@crewhaus/tool-flow 0.7.0

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.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Settle N answers into one, and say how much they actually agreed.
3
+ *
4
+ * The agreement number is the point. A plurality winner with 40% support is
5
+ * not the same as a unanimous one, and a caller that only reads the winner
6
+ * cannot tell them apart — which is exactly when a judge or a human should
7
+ * be brought in.
8
+ */
9
+ import { canonicalize } from "./canonical";
10
+ export const VOTE_MODES = ["exact", "numeric", "set"];
11
+ export function normalizeValue(value, options = {}) {
12
+ if (typeof value !== "string")
13
+ return value;
14
+ let text = value;
15
+ if (options.trim !== false)
16
+ text = text.trim();
17
+ if (options.lowercase)
18
+ text = text.toLowerCase();
19
+ if (options.stripPunctuation)
20
+ text = text.replace(/[^\p{L}\p{N}\s]/gu, "");
21
+ if (options.collapseWhitespace)
22
+ text = text.replace(/\s+/g, " ").trim();
23
+ return text;
24
+ }
25
+ /** Jaccard similarity of two arrays treated as sets of stringified members. */
26
+ export function jaccard(a, b) {
27
+ const left = new Set(a.map(canonicalize));
28
+ const right = new Set(b.map(canonicalize));
29
+ if (left.size === 0 && right.size === 0)
30
+ return 1;
31
+ let shared = 0;
32
+ for (const item of left)
33
+ if (right.has(item))
34
+ shared += 1;
35
+ return shared / (left.size + right.size - shared);
36
+ }
37
+ /** Whether two already-normalized values count as the same answer. */
38
+ function same(a, b, options) {
39
+ const mode = options.mode ?? "exact";
40
+ if (mode === "numeric") {
41
+ if (typeof a !== "number" || typeof b !== "number")
42
+ return false;
43
+ if (!Number.isFinite(a) || !Number.isFinite(b))
44
+ return false;
45
+ return Math.abs(a - b) <= (options.tolerance ?? 0);
46
+ }
47
+ if (mode === "set") {
48
+ if (!Array.isArray(a) || !Array.isArray(b))
49
+ return false;
50
+ return jaccard(a, b) >= (options.overlap ?? 1);
51
+ }
52
+ return canonicalize(a) === canonicalize(b);
53
+ }
54
+ export function tallyVotes(votes, options = {}) {
55
+ if (votes.length === 0)
56
+ throw new Error("no votes were given");
57
+ const groups = [];
58
+ let total = 0;
59
+ for (const [index, vote] of votes.entries()) {
60
+ const weight = vote.weight ?? 1;
61
+ if (!Number.isFinite(weight) || weight < 0) {
62
+ throw new Error(`vote ${index} has weight ${vote.weight}, which must be a non-negative number`);
63
+ }
64
+ const value = normalizeValue(vote.value, options.normalize);
65
+ total += weight;
66
+ const voter = vote.voter ?? `#${index}`;
67
+ // Grouping is by first-match against existing groups, which is what makes
68
+ // `numeric` and `set` work at all: they are not transitive, so there is no
69
+ // canonical key to bucket by. Declared order therefore decides which
70
+ // group a borderline value joins, and that is why it is documented.
71
+ const existing = groups.find((g) => same(g.value, value, options));
72
+ if (existing) {
73
+ existing.weight += weight;
74
+ existing.voters.push(voter);
75
+ }
76
+ else {
77
+ groups.push({ value, weight, voters: [voter], order: index });
78
+ }
79
+ }
80
+ const ranked = [...groups].sort((a, b) => b.weight - a.weight || a.order - b.order);
81
+ const top = ranked[0];
82
+ const runnerUp = ranked[1];
83
+ const tie = runnerUp !== undefined && runnerUp.weight === top.weight;
84
+ const agreement = total === 0 ? 0 : top.weight / total;
85
+ const threshold = options.threshold ?? 0.5;
86
+ return {
87
+ // A tie is never decided, however high the fraction: two answers at 50%
88
+ // each would otherwise both clear a 0.5 threshold.
89
+ decided: !tie && agreement >= threshold,
90
+ winner: top.value,
91
+ support: top.weight,
92
+ total,
93
+ agreement,
94
+ unanimous: ranked.length === 1,
95
+ tie,
96
+ groups: ranked.map((g) => ({ value: g.value, weight: g.weight, voters: g.voters })),
97
+ dissenters: ranked.slice(1).flatMap((g) => g.voters),
98
+ };
99
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Time budgets.
3
+ *
4
+ * A model does not know what time it is, and a harness that asks one to
5
+ * decide "do I still have time for the thorough path?" is paying a token
6
+ * price for arithmetic. This does the arithmetic.
7
+ *
8
+ * The clock is an input, not a call. Every function here takes `nowMs`, so
9
+ * the same inputs give the same answer in a test, in a replay and in a
10
+ * production run; the tool wrapper is the one place that reads the real
11
+ * clock, and even there the caller may override it.
12
+ */
13
+ /** How much of the budget is left, as a label a branch can switch on. */
14
+ export declare const BUDGET_PHASES: readonly ["ample", "tight", "critical", "expired"];
15
+ export type BudgetPhase = (typeof BUDGET_PHASES)[number];
16
+ export type DeadlineInput = {
17
+ readonly nowMs: number;
18
+ /** An absolute end, as epoch ms. Supply this or `budgetMs`. */
19
+ readonly deadlineMs?: number;
20
+ /** A duration, measured from `startedAtMs` (default: now). */
21
+ readonly budgetMs?: number;
22
+ readonly startedAtMs?: number;
23
+ /**
24
+ * What one more unit of work is expected to cost. When it does not fit in
25
+ * the remaining time, `fits` is false and the caller can skip it.
26
+ */
27
+ readonly stepCostMs?: number;
28
+ /** Fraction remaining at or below which the phase becomes `tight`. */
29
+ readonly tightAt?: number;
30
+ /** ...and `critical`. Must be below `tightAt`. */
31
+ readonly criticalAt?: number;
32
+ };
33
+ export type DeadlineReport = {
34
+ readonly nowMs: number;
35
+ readonly deadlineMs: number;
36
+ readonly remainingMs: number;
37
+ readonly elapsedMs: number;
38
+ readonly totalMs: number;
39
+ /** Remaining over total, clamped to 0..1. 0 when the budget was empty. */
40
+ readonly fractionRemaining: number;
41
+ readonly phase: BudgetPhase;
42
+ readonly expired: boolean;
43
+ /** Null when no `stepCostMs` was given. */
44
+ readonly fits: boolean | null;
45
+ /** How many more steps of `stepCostMs` fit. Null without a step cost. */
46
+ readonly stepsRemaining: number | null;
47
+ readonly deadline: string;
48
+ };
49
+ export declare function checkDeadline(input: DeadlineInput): DeadlineReport;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Time budgets.
3
+ *
4
+ * A model does not know what time it is, and a harness that asks one to
5
+ * decide "do I still have time for the thorough path?" is paying a token
6
+ * price for arithmetic. This does the arithmetic.
7
+ *
8
+ * The clock is an input, not a call. Every function here takes `nowMs`, so
9
+ * the same inputs give the same answer in a test, in a replay and in a
10
+ * production run; the tool wrapper is the one place that reads the real
11
+ * clock, and even there the caller may override it.
12
+ */
13
+ /** How much of the budget is left, as a label a branch can switch on. */
14
+ export const BUDGET_PHASES = ["ample", "tight", "critical", "expired"];
15
+ const DEFAULT_TIGHT = 0.5;
16
+ const DEFAULT_CRITICAL = 0.2;
17
+ export function checkDeadline(input) {
18
+ const tightAt = input.tightAt ?? DEFAULT_TIGHT;
19
+ const criticalAt = input.criticalAt ?? DEFAULT_CRITICAL;
20
+ if (criticalAt > tightAt) {
21
+ throw new Error(`criticalAt (${criticalAt}) must not be above tightAt (${tightAt})`);
22
+ }
23
+ const startedAtMs = input.startedAtMs ?? input.nowMs;
24
+ let deadlineMs;
25
+ let totalMs;
26
+ if (input.deadlineMs !== undefined) {
27
+ deadlineMs = input.deadlineMs;
28
+ totalMs = Math.max(0, deadlineMs - startedAtMs);
29
+ }
30
+ else if (input.budgetMs !== undefined) {
31
+ totalMs = Math.max(0, input.budgetMs);
32
+ deadlineMs = startedAtMs + totalMs;
33
+ }
34
+ else {
35
+ throw new Error("give either deadlineMs or budgetMs");
36
+ }
37
+ const remainingMs = deadlineMs - input.nowMs;
38
+ const elapsedMs = input.nowMs - startedAtMs;
39
+ const expired = remainingMs <= 0;
40
+ // A zero-length budget is spent by definition; dividing by it would give
41
+ // NaN, which would then compare false against every threshold and read as
42
+ // "ample" — the most dangerous possible answer.
43
+ const fractionRemaining = totalMs === 0 ? 0 : Math.min(1, Math.max(0, remainingMs / totalMs));
44
+ const phase = expired
45
+ ? "expired"
46
+ : fractionRemaining <= criticalAt
47
+ ? "critical"
48
+ : fractionRemaining <= tightAt
49
+ ? "tight"
50
+ : "ample";
51
+ const stepCost = input.stepCostMs;
52
+ const stepsRemaining = stepCost === undefined || stepCost <= 0
53
+ ? null
54
+ : Math.max(0, Math.floor(Math.max(0, remainingMs) / stepCost));
55
+ return {
56
+ nowMs: input.nowMs,
57
+ deadlineMs,
58
+ remainingMs,
59
+ elapsedMs,
60
+ totalMs,
61
+ fractionRemaining,
62
+ phase,
63
+ expired,
64
+ fits: stepCost === undefined ? null : !expired && remainingMs >= stepCost,
65
+ stepsRemaining,
66
+ deadline: new Date(deadlineMs).toISOString(),
67
+ };
68
+ }
@@ -0,0 +1,38 @@
1
+ import { type Check } from "@crewhaus/tool-schema";
2
+ import type { MatchMode } from "./branch";
3
+ export declare const HIT_POLICIES: readonly ["first", "unique", "priority", "collect"];
4
+ export type HitPolicy = (typeof HIT_POLICIES)[number];
5
+ export type DecisionRow = {
6
+ readonly id: string;
7
+ readonly when: ReadonlyArray<Check>;
8
+ readonly match?: MatchMode;
9
+ /** Used by the `priority` policy; higher wins. Defaults to 0. */
10
+ readonly priority?: number;
11
+ readonly outputs: Readonly<Record<string, unknown>>;
12
+ };
13
+ export type DecisionTable = {
14
+ /** Echoed into every answer, so a decision is attributable to a revision. */
15
+ readonly version?: string;
16
+ readonly policy: HitPolicy;
17
+ readonly rows: ReadonlyArray<DecisionRow>;
18
+ /** Used when no row matches. Without it, a miss is `matched: false`. */
19
+ readonly otherwise?: Readonly<Record<string, unknown>>;
20
+ };
21
+ export type DecisionResult = {
22
+ readonly ok: boolean;
23
+ readonly matched: boolean;
24
+ readonly policy: HitPolicy;
25
+ readonly version: string | null;
26
+ /** Ids of every row that matched, in declared order. */
27
+ readonly matchedIds: ReadonlyArray<string>;
28
+ /** One row's outputs; for `collect`, one entry per matched row. */
29
+ readonly outputs: Readonly<Record<string, unknown>> | ReadonlyArray<Record<string, unknown>> | null;
30
+ readonly fallback: boolean;
31
+ /** Set when the table could not answer: ambiguity under `unique`/`priority`. */
32
+ readonly conflict: string | null;
33
+ /** Stable digest of the table, so a changed policy is visible in a log. */
34
+ readonly tableHash: string;
35
+ };
36
+ /** Digest of the rows and policy — not the version string, which is a label. */
37
+ export declare function hashTable(table: DecisionTable): string;
38
+ export declare function evaluateTable(value: unknown, table: DecisionTable): DecisionResult;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Decision tables: an operator's policy written once, answered the same way
3
+ * every time, with the matched row ids as the audit trail.
4
+ *
5
+ * The difference from {@link ./branch} is the hit policy. A branch is always
6
+ * "first one wins". A table can also require exactly one row to match, rank
7
+ * by priority, or collect every match — and each of those catches a class of
8
+ * policy bug that "first wins" hides.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { runChecks } from "@crewhaus/tool-schema";
12
+ import { canonicalize } from "./canonical";
13
+ export const HIT_POLICIES = ["first", "unique", "priority", "collect"];
14
+ /** Digest of the rows and policy — not the version string, which is a label. */
15
+ export function hashTable(table) {
16
+ const material = canonicalize({ policy: table.policy, rows: table.rows });
17
+ return createHash("sha256").update(material).digest("hex").slice(0, 16);
18
+ }
19
+ export function evaluateTable(value, table) {
20
+ if (table.rows.length === 0)
21
+ throw new Error("the decision table has no rows");
22
+ const seen = new Set();
23
+ for (const row of table.rows) {
24
+ if (row.when.length === 0) {
25
+ throw new Error(`row "${row.id}" has no checks — a row with no checks matches everything; use "otherwise" for a default`);
26
+ }
27
+ if (seen.has(row.id))
28
+ throw new Error(`two rows share the id "${row.id}"`);
29
+ seen.add(row.id);
30
+ }
31
+ const tableHash = hashTable(table);
32
+ const version = table.version ?? null;
33
+ const base = { policy: table.policy, version, tableHash };
34
+ const matches = [];
35
+ for (const row of table.rows) {
36
+ const report = runChecks(value, row.when);
37
+ const ok = (row.match ?? "all") === "all" ? report.ok : report.passed > 0;
38
+ if (ok) {
39
+ matches.push(row);
40
+ // `first` needs no further rows; the others need to see them all.
41
+ if (table.policy === "first")
42
+ break;
43
+ }
44
+ }
45
+ const matchedIds = matches.map((r) => r.id);
46
+ if (matches.length === 0) {
47
+ if (table.otherwise) {
48
+ return {
49
+ ...base,
50
+ ok: true,
51
+ matched: true,
52
+ matchedIds: [],
53
+ outputs: table.otherwise,
54
+ fallback: true,
55
+ conflict: null,
56
+ };
57
+ }
58
+ return {
59
+ ...base,
60
+ ok: true,
61
+ matched: false,
62
+ matchedIds: [],
63
+ outputs: null,
64
+ fallback: false,
65
+ conflict: null,
66
+ };
67
+ }
68
+ const answer = (outputs, conflict) => ({
69
+ ...base,
70
+ ok: conflict === null,
71
+ matched: conflict === null,
72
+ matchedIds,
73
+ outputs: conflict === null ? outputs : null,
74
+ fallback: false,
75
+ conflict,
76
+ });
77
+ switch (table.policy) {
78
+ case "first":
79
+ return answer(matches[0]?.outputs ?? null, null);
80
+ case "collect":
81
+ return answer(matches.map((r) => r.outputs), null);
82
+ case "unique":
83
+ // Two rows matching under `unique` is the bug the policy exists to
84
+ // find: the table's conditions were meant to be disjoint and are not.
85
+ return matches.length === 1
86
+ ? answer(matches[0]?.outputs ?? null, null)
87
+ : answer(null, `rows ${matchedIds.map((id) => `"${id}"`).join(", ")} all matched`);
88
+ case "priority": {
89
+ let best = matches[0];
90
+ let tied = [best];
91
+ for (const row of matches.slice(1)) {
92
+ const a = row.priority ?? 0;
93
+ const b = best.priority ?? 0;
94
+ if (a > b) {
95
+ best = row;
96
+ tied = [row];
97
+ }
98
+ else if (a === b) {
99
+ tied.push(row);
100
+ }
101
+ }
102
+ return tied.length === 1
103
+ ? answer(best.outputs, null)
104
+ : answer(null, `rows ${tied.map((r) => `"${r.id}"`).join(", ")} share the top priority ${best.priority ?? 0}`);
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Map an error to a stable class and a next action.
3
+ *
4
+ * Deciding "is this worth retrying?" is the single most common reason a
5
+ * harness spends a model turn on something that has a right answer. A 429
6
+ * with `Retry-After: 30` is not a judgement call, and neither is exit code
7
+ * 127. This turns the signals a failure actually carries — status, exit
8
+ * code, errno, the text — into one of a fixed set of classes and one of a
9
+ * fixed set of actions, so a loop can act on it directly.
10
+ *
11
+ * Nothing here reads the clock. `Retry-After` in its HTTP-date form is a
12
+ * point in time, and turning that into a wait needs a "now"; the caller
13
+ * supplies one or gets the date back unconverted. A classifier that quietly
14
+ * called `Date.now()` would return a different answer every second, which is
15
+ * exactly what this package promises not to do.
16
+ */
17
+ export declare const ERROR_CLASSES: readonly ["ok", "transient", "timeout", "unavailable", "rate_limited", "quota", "auth", "permission", "not_found", "bad_request", "conflict", "too_large", "unsupported", "capacity", "integrity", "cancelled", "redirect", "bug", "unknown"];
18
+ export type ErrorClass = (typeof ERROR_CLASSES)[number];
19
+ export declare const NEXT_ACTIONS: readonly ["continue", "retry", "retry_after", "backoff", "reauth", "reduce_input", "escalate", "skip", "fail"];
20
+ export type NextAction = (typeof NEXT_ACTIONS)[number];
21
+ /** Everything a failure might carry. All optional; more is better. */
22
+ export type ErrorSignal = {
23
+ readonly status?: number;
24
+ readonly exitCode?: number;
25
+ readonly signal?: string;
26
+ /** A Node errno (`ECONNRESET`) or a provider code (`insufficient_quota`). */
27
+ readonly code?: string;
28
+ /** The error message, or the tail of stderr. */
29
+ readonly message?: string;
30
+ /** The raw `Retry-After` header: delta-seconds or an HTTP-date. */
31
+ readonly retryAfter?: string;
32
+ /** 1-based attempt number and its ceiling, to decide when to stop. */
33
+ readonly attempt?: number;
34
+ readonly maxAttempts?: number;
35
+ };
36
+ /** A caller-supplied rule, tried before every builtin pack. */
37
+ export type ErrorRule = {
38
+ readonly id: string;
39
+ /** Case-insensitive substring of the message, or of `code`. */
40
+ readonly contains?: string;
41
+ /** Regex source matched against the message. Anchored by the caller. */
42
+ readonly matches?: string;
43
+ readonly status?: number;
44
+ readonly exitCode?: number;
45
+ readonly class: ErrorClass;
46
+ readonly action: NextAction;
47
+ readonly waitMs?: number;
48
+ };
49
+ export type Classification = {
50
+ readonly class: ErrorClass;
51
+ readonly action: NextAction;
52
+ readonly retryable: boolean;
53
+ /** How long to wait, when the error itself said so. Never invented. */
54
+ readonly waitMs: number | null;
55
+ /** The `Retry-After` date, when it was a date and no `now` was given. */
56
+ readonly retryAt: string | null;
57
+ /** Which rule decided this: a pack entry's id, or a caller rule's id. */
58
+ readonly matched: string;
59
+ readonly source: "custom" | "status" | "exit" | "signal" | "code" | "message" | "default";
60
+ /** True when attempts ran out, which downgrades a retry to an escalation. */
61
+ readonly exhausted: boolean;
62
+ };
63
+ /**
64
+ * Parse `Retry-After`. Two forms are legal: delta-seconds, and an HTTP-date.
65
+ * The date form needs a reference point to become a duration, so without
66
+ * `nowMs` it is returned as a date and `waitMs` stays null.
67
+ */
68
+ export declare function parseRetryAfter(raw: string, nowMs?: number): {
69
+ waitMs: number | null;
70
+ retryAt: string | null;
71
+ };
72
+ export type ClassifyOptions = {
73
+ readonly rules?: ReadonlyArray<ErrorRule>;
74
+ /** Epoch milliseconds, only used to turn an HTTP-date `Retry-After` into a wait. */
75
+ readonly nowMs?: number;
76
+ };
77
+ export declare function classifyError(signal: ErrorSignal, options?: ClassifyOptions): Classification;