@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.
- package/README.md +178 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +461 -0
- package/dist/lib/assign.d.ts +122 -0
- package/dist/lib/assign.js +235 -0
- package/dist/lib/branch.d.ts +68 -0
- package/dist/lib/branch.js +76 -0
- package/dist/lib/canonical.d.ts +13 -0
- package/dist/lib/canonical.js +20 -0
- package/dist/lib/consensus.d.ts +58 -0
- package/dist/lib/consensus.js +99 -0
- package/dist/lib/deadline.d.ts +49 -0
- package/dist/lib/deadline.js +68 -0
- package/dist/lib/decision.d.ts +38 -0
- package/dist/lib/decision.js +107 -0
- package/dist/lib/errors.d.ts +77 -0
- package/dist/lib/errors.js +436 -0
- package/dist/lib/score.d.ts +50 -0
- package/dist/lib/score.js +56 -0
- package/dist/lib/sequence.d.ts +109 -0
- package/dist/lib/sequence.js +244 -0
- package/dist/lib/stall.d.ts +41 -0
- package/dist/lib/stall.js +124 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# @crewhaus/tool-flow
|
|
2
|
+
|
|
3
|
+
Deterministic control flow: the decisions a harness makes constantly and
|
|
4
|
+
should almost never pay a model to make.
|
|
5
|
+
|
|
6
|
+
Which arm to take. Whether an error is worth retrying. Whether the loop is
|
|
7
|
+
still moving. Whether there is time left for the thorough path. Which band a
|
|
8
|
+
score falls in. Whether N answers actually agreed. Which rep this lead
|
|
9
|
+
belongs to. What the flow can do next. None of these are judgement calls —
|
|
10
|
+
they all have right answers — and routing each one through a model costs
|
|
11
|
+
tokens, adds latency, and risks getting it wrong.
|
|
12
|
+
|
|
13
|
+
| Tool | Answers |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `Branch` | which arm of an if/switch a value takes |
|
|
16
|
+
| `DecisionTable` | what an operator's policy table says, and which rows said it |
|
|
17
|
+
| `ErrorClassify` | what kind of error this is and what to do next |
|
|
18
|
+
| `DeadlineCheck` | how much of the time budget is left, and whether one more step fits |
|
|
19
|
+
| `ConsensusVote` | what N answers settled on, and how much they agreed |
|
|
20
|
+
| `StallDetect` | whether the loop is progressing, repeating, or oscillating |
|
|
21
|
+
| `RuleScore` | what this record scores, which band it lands in, and why |
|
|
22
|
+
| `LeadAssign` | which owner a record routes to, and why it was not one of the others |
|
|
23
|
+
| `SequenceRun` | which steps of a declared flow can run now, and what the rest are waiting on |
|
|
24
|
+
|
|
25
|
+
## One condition grammar
|
|
26
|
+
|
|
27
|
+
`Branch`, `DecisionTable`, `RuleScore`, `LeadAssign` and `SequenceRun` all
|
|
28
|
+
evaluate the same `checks` that `@crewhaus/tool-schema`'s `Assert` tool does,
|
|
29
|
+
through the same evaluator:
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
when:
|
|
33
|
+
- { path: "order.total", op: greaterThan, expected: 1000 }
|
|
34
|
+
- { path: "customer.tier", op: oneOf, expected: [enterprise, strategic] }
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
There is deliberately no second vocabulary. An operator who learns
|
|
38
|
+
`startsWith` from `Assert` gets the same operator, with the same semantics,
|
|
39
|
+
in a branch and in a scoring rule.
|
|
40
|
+
|
|
41
|
+
## Determinism
|
|
42
|
+
|
|
43
|
+
Every library under `src/lib` is pure: the clock is a parameter, never a
|
|
44
|
+
call, and nothing here touches the filesystem, the network or a random
|
|
45
|
+
source. The same inputs give the same answer in a test, in a replay, and in
|
|
46
|
+
production.
|
|
47
|
+
|
|
48
|
+
Three tool wrappers read the real clock, because knowing the time is part of
|
|
49
|
+
their job:
|
|
50
|
+
|
|
51
|
+
- **`DeadlineCheck`** always — a model does not know what time it is, which
|
|
52
|
+
is the whole reason the tool exists.
|
|
53
|
+
- **`ErrorClassify`** only to resolve a `Retry-After` given as an HTTP-date.
|
|
54
|
+
- **`SequenceRun`** only to decide whether a step's `after` gate has passed.
|
|
55
|
+
|
|
56
|
+
All three take an explicit `now` that overrides it. That is the only impurity
|
|
57
|
+
in the package, and passing `now` removes it.
|
|
58
|
+
|
|
59
|
+
Timestamps must carry a UTC offset. `2026-01-01T00:00:00` is rejected with an
|
|
60
|
+
explanation rather than guessed at, because per ECMAScript an offset-less
|
|
61
|
+
date-time string is *local* time while the date-only form is UTC — so the
|
|
62
|
+
same spec would mean different instants on two machines.
|
|
63
|
+
|
|
64
|
+
An epoch outside the ±8.64e15 ms a `Date` can represent is rejected by name
|
|
65
|
+
for the same reason: the field that parsed it is the only place that still
|
|
66
|
+
knows which of a spec's several instants was the bad one, and left to reach a
|
|
67
|
+
consumer it surfaces as a bare `RangeError: Invalid Date`.
|
|
68
|
+
|
|
69
|
+
## What it refuses
|
|
70
|
+
|
|
71
|
+
Three shapes are errors rather than conveniences, because each one reads as
|
|
72
|
+
working and does not:
|
|
73
|
+
|
|
74
|
+
- **An arm, row or owner with no conditions.** Under `all`, an empty
|
|
75
|
+
condition list is vacuously true, so it silently swallows everything after
|
|
76
|
+
it while looking like a rule. A catch-all is spelled `otherwise`, or
|
|
77
|
+
`fallback` in `LeadAssign`. A `SequenceRun` step may omit `when` entirely —
|
|
78
|
+
steps are not alternatives, so an unconditional one swallows nothing — but
|
|
79
|
+
an explicitly empty list is still rejected, because it says "gated" and is
|
|
80
|
+
not.
|
|
81
|
+
- **Two arms, rows, owners or steps sharing a name.** The name is the thing
|
|
82
|
+
the caller routes on and the thing that appears in the audit trail.
|
|
83
|
+
- **A custom `ErrorClassify` rule with no conditions**, which would match
|
|
84
|
+
every error and mask every builtin pack behind it.
|
|
85
|
+
|
|
86
|
+
`DecisionTable`'s `unique` and `priority` policies report an ambiguity as a
|
|
87
|
+
conflict rather than picking a winner — that ambiguity is the policy bug the
|
|
88
|
+
strict policy exists to find. `LeadAssign` does the same with two equally
|
|
89
|
+
specific territories, and with a roster that is missing a fact its strategy
|
|
90
|
+
needs: an owner with a capacity but no load has not been shown to have room,
|
|
91
|
+
and one with no load at all is not on zero. Neither of those falls through to
|
|
92
|
+
`fallback`, because "nobody is eligible" and "I cannot work out who is" are
|
|
93
|
+
different answers, and only the first one has a right owner to route to. The
|
|
94
|
+
per-owner report says the same thing one level down: such an owner comes back
|
|
95
|
+
`eligible: null` with the missing fact as the reason, never `eligible: true`.
|
|
96
|
+
|
|
97
|
+
`specific` ranks a territory by the conditions it *requires*, not by the ones
|
|
98
|
+
a particular record happened to satisfy — an owner with `match: any` insists
|
|
99
|
+
on exactly one however many hold, so counting the holders would send two leads
|
|
100
|
+
in the same territory to two different reps over a field neither territory
|
|
101
|
+
asked for.
|
|
102
|
+
|
|
103
|
+
## What it does not do
|
|
104
|
+
|
|
105
|
+
- **It does not invoke anything.** These tools decide; they do not act on the
|
|
106
|
+
decision. `Branch` hands back the arm's declared `result`, and the caller
|
|
107
|
+
acts. `SequenceRun` is the same promise at flow scale: it returns the steps
|
|
108
|
+
that can run now, each with the parameters to run it with, and runs none of
|
|
109
|
+
them — invoking a tool needs the executor, which no tool package has.
|
|
110
|
+
Chaining tool calls without a model turn needs the `kind: tool` step, which
|
|
111
|
+
does not exist yet.
|
|
112
|
+
- **It does not persist anything.** `StallDetect` takes the history as an
|
|
113
|
+
argument rather than keeping one, `SequenceRun` takes `completed` and
|
|
114
|
+
`failed`, and `LeadAssign` takes the loads and hands the advanced rotation
|
|
115
|
+
cursor back. Durable cursors and counters are `@crewhaus/tool-state`.
|
|
116
|
+
- **It does not wait.** Nothing here sleeps, polls, or blocks. `ErrorClassify`
|
|
117
|
+
reports the wait a server asked for, and `SequenceRun` reports how long a
|
|
118
|
+
step has left before it is due; honouring either is the caller's job. There
|
|
119
|
+
is deliberately no `WaitForEvent`: blocking needs an event source to block
|
|
120
|
+
on and an abort signal to stop on, and a tool named for a wait that returned
|
|
121
|
+
immediately would be believed.
|
|
122
|
+
- **It does not ask a human.** Approvals and questions need a channel and a
|
|
123
|
+
resumable id, which belong to the harness runtime.
|
|
124
|
+
- **It does not validate against JSON Schema.** That is `JsonSchemaValidate`,
|
|
125
|
+
and the path-assertion gate is `Assert`, both in `@crewhaus/tool-schema`.
|
|
126
|
+
|
|
127
|
+
## Error classification
|
|
128
|
+
|
|
129
|
+
`ErrorClassify` maps any combination of status, exit code, signal, errno or
|
|
130
|
+
provider code, message text and `Retry-After` onto one of 19 stable classes
|
|
131
|
+
and one of 9 next actions. Precedence is: caller rules, then status, then
|
|
132
|
+
signal, then exit code, then code, then message text.
|
|
133
|
+
|
|
134
|
+
Two details worth knowing:
|
|
135
|
+
|
|
136
|
+
- **`retryable` follows the action, not the class.** An OOM kill is class
|
|
137
|
+
`capacity`, which is retryable in general — but that particular one
|
|
138
|
+
resolves to `escalate`, and a caller reading the boolean would otherwise
|
|
139
|
+
re-run the identical command and be killed identically.
|
|
140
|
+
- **Message matching uses substrings, not regular expressions.** That text
|
|
141
|
+
comes from a remote server, and a pattern with nested quantifiers there is
|
|
142
|
+
a denial of service waiting to happen. Caller-supplied rules may use a
|
|
143
|
+
regex; the builtin packs never do.
|
|
144
|
+
|
|
145
|
+
Exit code `1` is deliberately unclassified. It is the generic "it failed" and
|
|
146
|
+
says nothing about why, so claiming a class for it would be inventing
|
|
147
|
+
information.
|
|
148
|
+
|
|
149
|
+
## A known limitation: regex denial of service
|
|
150
|
+
|
|
151
|
+
The `matches` and `notMatches` ops compile a caller-supplied pattern and run
|
|
152
|
+
it against caller-supplied text. JavaScript's regex engine backtracks, so a
|
|
153
|
+
pattern with nested quantifiers against text that *nearly* matches can take
|
|
154
|
+
seconds of CPU:
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
^(([a-z])+.)+[A-Z]([a-z])+$ against 60 lowercase letters ~2.7s
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
This is a property of the shared check grammar in `@crewhaus/tool-schema`,
|
|
161
|
+
not of this package — the `Assert` tool has the same exposure, and both are
|
|
162
|
+
reachable with operator-written or model-written patterns matched against
|
|
163
|
+
text a harness fetched from somewhere else.
|
|
164
|
+
|
|
165
|
+
Two things reduce it, neither of which is a fix:
|
|
166
|
+
|
|
167
|
+
- `ErrorClassify` matches `message` and `code` **separately** rather than
|
|
168
|
+
joining them. A pattern anchored with `$` against a joined string can never
|
|
169
|
+
match, and a pattern that can never match is precisely the worst case for a
|
|
170
|
+
backtracking engine — joining the fields manufactured that for free.
|
|
171
|
+
- `ErrorClassify`'s builtin packs use substrings, never patterns, so the
|
|
172
|
+
default path compiles no regex at all.
|
|
173
|
+
|
|
174
|
+
A real fix has to live in the shared evaluator: reject nested-quantifier
|
|
175
|
+
patterns before compiling them, or match with an engine that does not
|
|
176
|
+
backtrack. Until then, treat `matches` as trusted-pattern-only, and prefer
|
|
177
|
+
`contains`, `startsWith` and `endsWith` — which are linear — where they will
|
|
178
|
+
do.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RegisteredTool } from "@crewhaus/tool-catalog";
|
|
2
|
+
import { type Check } from "@crewhaus/tool-schema";
|
|
3
|
+
export declare const branch: RegisteredTool;
|
|
4
|
+
export declare const decisionTable: RegisteredTool;
|
|
5
|
+
export declare const errorClassify: RegisteredTool;
|
|
6
|
+
export declare const deadlineCheck: RegisteredTool;
|
|
7
|
+
export declare const consensusVote: RegisteredTool;
|
|
8
|
+
export declare const stallDetect: RegisteredTool;
|
|
9
|
+
export declare const ruleScore: RegisteredTool;
|
|
10
|
+
export declare const leadAssign: RegisteredTool;
|
|
11
|
+
export declare const sequenceRun: RegisteredTool;
|
|
12
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
13
|
+
export declare const FLOW_TOOLS: ReadonlyArray<RegisteredTool>;
|
|
14
|
+
export type { Check };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @crewhaus/tool-flow — deterministic control flow.
|
|
3
|
+
*
|
|
4
|
+
* These are the decisions a harness makes constantly and should almost never
|
|
5
|
+
* pay a model to make: which arm to take, whether an error is worth
|
|
6
|
+
* retrying, whether the loop is still moving, whether there is time left,
|
|
7
|
+
* which band a score falls in, whether N answers actually agreed.
|
|
8
|
+
*
|
|
9
|
+
* Every library under `./lib` is pure — the clock is a parameter, never a
|
|
10
|
+
* call — so the same inputs give the same answer in a test, in a replay and
|
|
11
|
+
* in production. Three of the tool wrappers do read the real clock, because
|
|
12
|
+
* knowing the time is part of their job: `DeadlineCheck` always,
|
|
13
|
+
* `ErrorClassify` only to resolve a `Retry-After` given as a date, and
|
|
14
|
+
* `SequenceRun` only to decide whether a step's `after` gate has passed. All
|
|
15
|
+
* three take an explicit `now` that overrides it, and that is the only
|
|
16
|
+
* impurity in the package.
|
|
17
|
+
*
|
|
18
|
+
* The condition vocabulary is `@crewhaus/tool-schema`'s: `Branch`,
|
|
19
|
+
* `DecisionTable`, `RuleScore`, `LeadAssign` and `SequenceRun` all evaluate
|
|
20
|
+
* the same `checks` the `Assert` tool does, through the same evaluator. An
|
|
21
|
+
* operator learns one grammar.
|
|
22
|
+
*/
|
|
23
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
24
|
+
import { ASSERT_OPS } from "@crewhaus/tool-schema";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import { ASSIGN_STRATEGIES, assignOwner } from "./lib/assign";
|
|
27
|
+
import { evaluateBranches } from "./lib/branch";
|
|
28
|
+
import { VOTE_MODES, tallyVotes } from "./lib/consensus";
|
|
29
|
+
import { checkDeadline } from "./lib/deadline";
|
|
30
|
+
import { HIT_POLICIES, evaluateTable } from "./lib/decision";
|
|
31
|
+
import { ERROR_CLASSES, NEXT_ACTIONS, classifyError } from "./lib/errors";
|
|
32
|
+
import { scoreValue } from "./lib/score";
|
|
33
|
+
import { planSequence } from "./lib/sequence";
|
|
34
|
+
import { detectStall } from "./lib/stall";
|
|
35
|
+
/** Compact JSON — the reader is a model, and every byte is context. */
|
|
36
|
+
const json = (value) => JSON.stringify(value);
|
|
37
|
+
/** Ceilings that keep one pathological table from becoming a hang. */
|
|
38
|
+
const LIMITS = {
|
|
39
|
+
checks: 64,
|
|
40
|
+
arms: 64,
|
|
41
|
+
rows: 512,
|
|
42
|
+
rules: 256,
|
|
43
|
+
votes: 512,
|
|
44
|
+
history: 512,
|
|
45
|
+
signals: 64,
|
|
46
|
+
owners: 256,
|
|
47
|
+
steps: 256,
|
|
48
|
+
};
|
|
49
|
+
const checkSchema = z.object({
|
|
50
|
+
path: z
|
|
51
|
+
.string()
|
|
52
|
+
.optional()
|
|
53
|
+
.describe("dotted path into the value, e.g. 'order.items[0].sku'; omit for the value itself"),
|
|
54
|
+
op: z.enum(ASSERT_OPS),
|
|
55
|
+
expected: z.unknown().describe("the operand; its meaning depends on the op"),
|
|
56
|
+
flags: z.string().optional().describe("regex flags for matches/notMatches"),
|
|
57
|
+
message: z.string().optional().describe("replaces the generated reason when this check fails"),
|
|
58
|
+
});
|
|
59
|
+
const checksField = z.array(checkSchema).min(1).max(LIMITS.checks);
|
|
60
|
+
const matchField = z
|
|
61
|
+
.enum(["all", "any"])
|
|
62
|
+
.optional()
|
|
63
|
+
.describe("whether every check must hold, or any one of them; default all");
|
|
64
|
+
/**
|
|
65
|
+
* The largest epoch millisecond value a `Date` represents, per ECMAScript.
|
|
66
|
+
* Beyond it every `Date` is invalid, and `toISOString` throws.
|
|
67
|
+
*/
|
|
68
|
+
const MAX_TIME_VALUE = 8.64e15;
|
|
69
|
+
/**
|
|
70
|
+
* An instant, as epoch milliseconds or an ISO-8601 string.
|
|
71
|
+
*
|
|
72
|
+
* A string without an offset is rejected rather than guessed at. Per
|
|
73
|
+
* ECMAScript, `Date.parse("2026-01-01T00:00:00")` is *local* time while the
|
|
74
|
+
* date-only form is UTC, so the same spec would mean different instants on
|
|
75
|
+
* two machines — the exact class of bug this package exists to remove.
|
|
76
|
+
*
|
|
77
|
+
* This is the one place an instant is turned into a number, so it is also
|
|
78
|
+
* the one place that has the field name to put in the message. An epoch
|
|
79
|
+
* outside the representable range used to pass straight through and surface
|
|
80
|
+
* later as a bare `RangeError: Invalid Date` from whichever consumer
|
|
81
|
+
* rendered it — `DeadlineCheck`'s `deadline`, `SequenceRun`'s
|
|
82
|
+
* `not due until …` — naming neither the field nor the value, in a spec that
|
|
83
|
+
* may carry a dozen instants. The string form was already caught, because
|
|
84
|
+
* `Date.parse` returns NaN out there; only the numeric form got this far.
|
|
85
|
+
*/
|
|
86
|
+
function parseInstant(value, field) {
|
|
87
|
+
if (typeof value === "number") {
|
|
88
|
+
if (!Number.isFinite(value))
|
|
89
|
+
throw new Error(`${field} is not a finite epoch-millisecond value`);
|
|
90
|
+
if (Math.abs(value) > MAX_TIME_VALUE) {
|
|
91
|
+
throw new Error(`${field} (${value}) is outside the range a date can represent (±8.64e15 epoch milliseconds)`);
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
const text = value.trim();
|
|
96
|
+
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(text)) {
|
|
97
|
+
throw new Error(`${field} ("${text}") has no UTC offset — write it as e.g. 2026-01-01T00:00:00Z, because an offset-less string means local time and would differ between machines`);
|
|
98
|
+
}
|
|
99
|
+
const parsed = Date.parse(text);
|
|
100
|
+
if (Number.isNaN(parsed))
|
|
101
|
+
throw new Error(`${field} ("${text}") is not a valid ISO-8601 instant`);
|
|
102
|
+
return parsed;
|
|
103
|
+
}
|
|
104
|
+
const instantField = z.union([z.string(), z.number()]);
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
export const branch = buildTool({
|
|
107
|
+
name: "Branch",
|
|
108
|
+
description: "Take the first arm whose conditions hold, and hand back that arm's name and its declared result. Use it to route on a tool result — exit code, status, a field of a JSON response — without spending a model turn deciding which way to go. Conditions use the same vocabulary as Assert. An arm with no conditions is rejected, because it would silently swallow every later arm; a catch-all is spelled 'otherwise'.",
|
|
109
|
+
inputSchema: z.object({
|
|
110
|
+
value: z.unknown().describe("the value the arms are tested against"),
|
|
111
|
+
arms: z
|
|
112
|
+
.array(z.object({
|
|
113
|
+
name: z.string().min(1).describe("what the caller routes on; must be unique"),
|
|
114
|
+
when: checksField,
|
|
115
|
+
match: matchField,
|
|
116
|
+
result: z.unknown().describe("returned verbatim when this arm wins"),
|
|
117
|
+
}))
|
|
118
|
+
.min(1)
|
|
119
|
+
.max(LIMITS.arms),
|
|
120
|
+
otherwise: z
|
|
121
|
+
.object({ name: z.string().min(1), result: z.unknown() })
|
|
122
|
+
.optional()
|
|
123
|
+
.describe("used when no arm matches; without it a miss returns matched:false"),
|
|
124
|
+
verbose: z
|
|
125
|
+
.boolean()
|
|
126
|
+
.optional()
|
|
127
|
+
.describe("include the per-arm report of why earlier arms did not match"),
|
|
128
|
+
}),
|
|
129
|
+
readOnly: true,
|
|
130
|
+
concurrencySafe: true,
|
|
131
|
+
execute: async (input) => {
|
|
132
|
+
const outcome = evaluateBranches(input.value, input.arms, {
|
|
133
|
+
otherwise: input.otherwise,
|
|
134
|
+
});
|
|
135
|
+
const body = {
|
|
136
|
+
matched: outcome.matched,
|
|
137
|
+
name: outcome.name,
|
|
138
|
+
index: outcome.index,
|
|
139
|
+
fallback: outcome.fallback,
|
|
140
|
+
result: outcome.result,
|
|
141
|
+
...(input.verbose ? { evaluated: outcome.evaluated } : {}),
|
|
142
|
+
};
|
|
143
|
+
return json(body);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
export const decisionTable = buildTool({
|
|
147
|
+
name: "DecisionTable",
|
|
148
|
+
description: "Answer an operator's policy table — severity by tier to priority and owner, error class by attempt to action — returning the matched row ids, the outputs, and a digest of the table that produced them. Four hit policies: first match, exactly one match, highest priority, or collect them all. Use it to keep a policy in versioned data instead of in a prompt, and to get an audit trail for free.",
|
|
149
|
+
inputSchema: z.object({
|
|
150
|
+
value: z.unknown().describe("the value the rows are tested against"),
|
|
151
|
+
policy: z.enum(HIT_POLICIES).describe("first | unique | priority | collect"),
|
|
152
|
+
version: z.string().optional().describe("echoed into the answer, so it is attributable"),
|
|
153
|
+
rows: z
|
|
154
|
+
.array(z.object({
|
|
155
|
+
id: z.string().min(1).describe("the audit trail; must be unique"),
|
|
156
|
+
when: checksField,
|
|
157
|
+
match: matchField,
|
|
158
|
+
priority: z.number().optional().describe("used by the priority policy; higher wins"),
|
|
159
|
+
outputs: z.record(z.unknown()).describe("what this row decides"),
|
|
160
|
+
}))
|
|
161
|
+
.min(1)
|
|
162
|
+
.max(LIMITS.rows),
|
|
163
|
+
otherwise: z.record(z.unknown()).optional().describe("outputs when no row matches"),
|
|
164
|
+
}),
|
|
165
|
+
readOnly: true,
|
|
166
|
+
concurrencySafe: true,
|
|
167
|
+
execute: async (input) => json(evaluateTable(input.value, input)),
|
|
168
|
+
});
|
|
169
|
+
export const errorClassify = buildTool({
|
|
170
|
+
name: "ErrorClassify",
|
|
171
|
+
description: `Turn an error's signals — HTTP status, exit code, signal, errno or provider code, message text, Retry-After — into one of ${ERROR_CLASSES.length} stable classes and one of ${NEXT_ACTIONS.length} next actions, with the wait the server itself asked for. Use it instead of asking a model whether something is worth retrying: a 429 with Retry-After is not a judgement call, and neither is exit 127. Attempt and maxAttempts downgrade a retry to an escalation once the attempts run out.`,
|
|
172
|
+
inputSchema: z.object({
|
|
173
|
+
status: z.number().int().optional().describe("HTTP status code"),
|
|
174
|
+
exitCode: z.number().int().optional().describe("process exit code; 128+N is read as signal N"),
|
|
175
|
+
signal: z.string().optional().describe("POSIX signal name, e.g. SIGKILL"),
|
|
176
|
+
code: z
|
|
177
|
+
.string()
|
|
178
|
+
.optional()
|
|
179
|
+
.describe("errno (ECONNRESET) or provider code (insufficient_quota)"),
|
|
180
|
+
message: z.string().optional().describe("the error message, or the tail of stderr"),
|
|
181
|
+
retryAfter: z
|
|
182
|
+
.string()
|
|
183
|
+
.optional()
|
|
184
|
+
.describe("the raw Retry-After header: seconds or an HTTP-date"),
|
|
185
|
+
attempt: z.number().int().positive().optional().describe("1-based attempt number"),
|
|
186
|
+
maxAttempts: z.number().int().positive().optional(),
|
|
187
|
+
now: instantField
|
|
188
|
+
.optional()
|
|
189
|
+
.describe("reference instant for a date-form Retry-After; defaults to the real clock"),
|
|
190
|
+
rules: z
|
|
191
|
+
.array(z.object({
|
|
192
|
+
id: z.string().min(1),
|
|
193
|
+
contains: z.string().optional().describe("case-insensitive substring of message or code"),
|
|
194
|
+
matches: z.string().optional().describe("regex source, matched case-insensitively"),
|
|
195
|
+
status: z.number().int().optional(),
|
|
196
|
+
exitCode: z.number().int().optional(),
|
|
197
|
+
class: z.enum(ERROR_CLASSES),
|
|
198
|
+
action: z.enum(NEXT_ACTIONS),
|
|
199
|
+
waitMs: z.number().int().nonnegative().optional(),
|
|
200
|
+
}))
|
|
201
|
+
.max(LIMITS.rules)
|
|
202
|
+
.optional()
|
|
203
|
+
.describe("caller rules, tried in order before every builtin pack"),
|
|
204
|
+
}),
|
|
205
|
+
readOnly: true,
|
|
206
|
+
concurrencySafe: true,
|
|
207
|
+
execute: async (input) => {
|
|
208
|
+
const nowMs = input.now === undefined ? Date.now() : parseInstant(input.now, "now");
|
|
209
|
+
return json(classifyError(input, { rules: input.rules, nowMs }));
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
export const deadlineCheck = buildTool({
|
|
213
|
+
name: "DeadlineCheck",
|
|
214
|
+
description: "Report how much of a time budget is left, as milliseconds, a fraction, and a phase (ample, tight, critical, expired) a branch can switch on — and, given the cost of one more step, whether that step still fits. Use it because a model does not know what time it is: this is the authoritative clock for 'stop polling', 'skip the optional enrichment', 'take the fast path'.",
|
|
215
|
+
inputSchema: z
|
|
216
|
+
.object({
|
|
217
|
+
deadline: instantField.optional().describe("absolute end, ISO-8601 with offset or epoch ms"),
|
|
218
|
+
budgetMs: z.number().int().nonnegative().optional().describe("duration from startedAt"),
|
|
219
|
+
startedAt: instantField.optional().describe("when the budget started; defaults to now"),
|
|
220
|
+
now: instantField.optional().describe("overrides the real clock, for tests and replays"),
|
|
221
|
+
stepCostMs: z.number().int().positive().optional().describe("cost of one more unit of work"),
|
|
222
|
+
tightAt: z.number().min(0).max(1).optional().describe("fraction at or below which: tight"),
|
|
223
|
+
criticalAt: z
|
|
224
|
+
.number()
|
|
225
|
+
.min(0)
|
|
226
|
+
.max(1)
|
|
227
|
+
.optional()
|
|
228
|
+
.describe("...and critical; must be <= tightAt"),
|
|
229
|
+
})
|
|
230
|
+
.refine((v) => v.deadline !== undefined || v.budgetMs !== undefined, {
|
|
231
|
+
message: "give either deadline or budgetMs",
|
|
232
|
+
}),
|
|
233
|
+
readOnly: true,
|
|
234
|
+
concurrencySafe: true,
|
|
235
|
+
execute: async (input) => {
|
|
236
|
+
const nowMs = input.now === undefined ? Date.now() : parseInstant(input.now, "now");
|
|
237
|
+
return json(checkDeadline({
|
|
238
|
+
nowMs,
|
|
239
|
+
deadlineMs: input.deadline === undefined ? undefined : parseInstant(input.deadline, "deadline"),
|
|
240
|
+
budgetMs: input.budgetMs,
|
|
241
|
+
startedAtMs: input.startedAt === undefined ? undefined : parseInstant(input.startedAt, "startedAt"),
|
|
242
|
+
stepCostMs: input.stepCostMs,
|
|
243
|
+
tightAt: input.tightAt,
|
|
244
|
+
criticalAt: input.criticalAt,
|
|
245
|
+
}));
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
export const consensusVote = buildTool({
|
|
249
|
+
name: "ConsensusVote",
|
|
250
|
+
description: "Settle several answers into one by weighted plurality, and report how much they actually agreed. Compares exactly, numerically within a tolerance, or as sets by Jaccard overlap, after optional normalization. Use it to decide when N crew members or N samples agree enough to act, and when the disagreement means a judge or a human should look — the agreement fraction and the dissenters are the output that matters, not just the winner.",
|
|
251
|
+
inputSchema: z.object({
|
|
252
|
+
votes: z
|
|
253
|
+
.array(z.object({
|
|
254
|
+
value: z.unknown().describe("the answer cast"),
|
|
255
|
+
weight: z.number().nonnegative().optional().describe("default 1"),
|
|
256
|
+
voter: z.string().optional().describe("named in the dissent report"),
|
|
257
|
+
}))
|
|
258
|
+
.min(1)
|
|
259
|
+
.max(LIMITS.votes),
|
|
260
|
+
mode: z.enum(VOTE_MODES).optional().describe("exact | numeric | set; default exact"),
|
|
261
|
+
tolerance: z.number().nonnegative().optional().describe("numeric mode: absolute distance"),
|
|
262
|
+
overlap: z.number().min(0).max(1).optional().describe("set mode: minimum Jaccard similarity"),
|
|
263
|
+
threshold: z
|
|
264
|
+
.number()
|
|
265
|
+
.min(0)
|
|
266
|
+
.max(1)
|
|
267
|
+
.optional()
|
|
268
|
+
.describe("support needed to be decided; default 0.5"),
|
|
269
|
+
normalize: z
|
|
270
|
+
.object({
|
|
271
|
+
trim: z.boolean().optional().describe("default true"),
|
|
272
|
+
lowercase: z.boolean().optional(),
|
|
273
|
+
collapseWhitespace: z.boolean().optional(),
|
|
274
|
+
stripPunctuation: z.boolean().optional(),
|
|
275
|
+
})
|
|
276
|
+
.optional(),
|
|
277
|
+
}),
|
|
278
|
+
readOnly: true,
|
|
279
|
+
concurrencySafe: true,
|
|
280
|
+
execute: async (input) => json(tallyVotes(input.votes, input)),
|
|
281
|
+
});
|
|
282
|
+
export const stallDetect = buildTool({
|
|
283
|
+
name: "StallDetect",
|
|
284
|
+
description: "Given the last N turns of named progress signals — a failing-test fingerprint, a workspace diff hash, the last error, the tool call being made — say whether the loop is still moving, repeating itself, or oscillating between states. Use it to stop a fix-test-fail loop that has stopped making progress, which from inside any single turn is indistinguishable from one about to succeed. Also names the signals that have never changed.",
|
|
285
|
+
inputSchema: z.object({
|
|
286
|
+
history: z
|
|
287
|
+
.array(z.record(z.string()))
|
|
288
|
+
.min(1)
|
|
289
|
+
.max(LIMITS.history)
|
|
290
|
+
.describe("oldest first; each entry maps signal name to an opaque fingerprint"),
|
|
291
|
+
window: z
|
|
292
|
+
.number()
|
|
293
|
+
.int()
|
|
294
|
+
.min(2)
|
|
295
|
+
.optional()
|
|
296
|
+
.describe("identical snapshots to call it stalled; default 3"),
|
|
297
|
+
maxCycle: z
|
|
298
|
+
.number()
|
|
299
|
+
.int()
|
|
300
|
+
.positive()
|
|
301
|
+
.max(16)
|
|
302
|
+
.optional()
|
|
303
|
+
.describe("longest cycle to look for; default 3"),
|
|
304
|
+
signals: z
|
|
305
|
+
.array(z.string())
|
|
306
|
+
.max(LIMITS.signals)
|
|
307
|
+
.optional()
|
|
308
|
+
.describe("consider only these signals"),
|
|
309
|
+
}),
|
|
310
|
+
readOnly: true,
|
|
311
|
+
concurrencySafe: true,
|
|
312
|
+
execute: async (input) => json(detectStall(input.history, {
|
|
313
|
+
window: input.window,
|
|
314
|
+
maxCycle: input.maxCycle,
|
|
315
|
+
signals: input.signals,
|
|
316
|
+
})),
|
|
317
|
+
});
|
|
318
|
+
export const ruleScore = buildTool({
|
|
319
|
+
name: "RuleScore",
|
|
320
|
+
description: "Score a record against versioned additive rules and report the band it lands in, together with every rule that fired and what it contributed. Use it for lead qualification, risk, or triage priority: unlike a score a model produced, this one can be explained to the person it affects, re-run identically next quarter, and attributed to a rule version.",
|
|
321
|
+
inputSchema: z.object({
|
|
322
|
+
value: z.unknown().describe("the record being scored"),
|
|
323
|
+
version: z.string().optional().describe("echoed into the result, so a change is attributable"),
|
|
324
|
+
rules: z
|
|
325
|
+
.array(z.object({
|
|
326
|
+
id: z.string().min(1),
|
|
327
|
+
when: checksField,
|
|
328
|
+
points: z.number().describe("may be negative"),
|
|
329
|
+
label: z.string().optional().describe("the reason that goes on the record"),
|
|
330
|
+
}))
|
|
331
|
+
.min(1)
|
|
332
|
+
.max(LIMITS.rules),
|
|
333
|
+
bands: z
|
|
334
|
+
.array(z.object({ name: z.string().min(1), min: z.number() }))
|
|
335
|
+
.max(LIMITS.rules)
|
|
336
|
+
.optional()
|
|
337
|
+
.describe("highest band whose min is met wins, whatever order they are declared in"),
|
|
338
|
+
min: z.number().optional().describe("clamp the total"),
|
|
339
|
+
max: z.number().optional(),
|
|
340
|
+
includeMissed: z.boolean().optional().describe("also list the rules that did not fire"),
|
|
341
|
+
}),
|
|
342
|
+
readOnly: true,
|
|
343
|
+
concurrencySafe: true,
|
|
344
|
+
execute: async (input) => {
|
|
345
|
+
const result = scoreValue(input.value, input);
|
|
346
|
+
if (input.includeMissed)
|
|
347
|
+
return json(result);
|
|
348
|
+
const { missed: _missed, ...rest } = result;
|
|
349
|
+
return json(rest);
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
export const leadAssign = buildTool({
|
|
353
|
+
name: "LeadAssign",
|
|
354
|
+
description: "Route a record to an owner from a declared roster: territory and segment as the same conditions Assert and RuleScore use, plus capacity, an out-of-office flag, and a strategy — declaration order, most specific territory, least loaded, or round robin. Use it to keep lead routing in versioned rules instead of in a prompt, and to be able to tell a rep exactly why a lead went elsewhere. It decides only: the load figures come in and the advanced rotation cursor goes back out, because this package does not persist anything.",
|
|
355
|
+
inputSchema: z.object({
|
|
356
|
+
value: z.unknown().describe("the record being routed"),
|
|
357
|
+
strategy: z.enum(ASSIGN_STRATEGIES).describe("first | specific | least_loaded | round_robin"),
|
|
358
|
+
version: z.string().optional().describe("echoed into the answer, so it is attributable"),
|
|
359
|
+
owners: z
|
|
360
|
+
.array(z.object({
|
|
361
|
+
id: z.string().min(1).describe("what gets written on the record; must be unique"),
|
|
362
|
+
when: checksField,
|
|
363
|
+
match: matchField,
|
|
364
|
+
load: z
|
|
365
|
+
.number()
|
|
366
|
+
.nonnegative()
|
|
367
|
+
.optional()
|
|
368
|
+
.describe("assignments already held; required by least_loaded and by capacity"),
|
|
369
|
+
capacity: z.number().nonnegative().optional().describe("skip this owner at or above it"),
|
|
370
|
+
available: z
|
|
371
|
+
.boolean()
|
|
372
|
+
.optional()
|
|
373
|
+
.describe("false takes the owner out; absent means no out-of-office record"),
|
|
374
|
+
}))
|
|
375
|
+
.min(1)
|
|
376
|
+
.max(LIMITS.owners),
|
|
377
|
+
cursor: z
|
|
378
|
+
.number()
|
|
379
|
+
.int()
|
|
380
|
+
.nonnegative()
|
|
381
|
+
.optional()
|
|
382
|
+
.describe("round_robin: where the last call left off; the answer carries the next one, or null when nothing was picked from the roster and the old cursor still stands"),
|
|
383
|
+
fallback: z
|
|
384
|
+
.object({ id: z.string().min(1) })
|
|
385
|
+
.optional()
|
|
386
|
+
.describe("owner of last resort when nobody is eligible; never used for an undecidable roster"),
|
|
387
|
+
verbose: z
|
|
388
|
+
.boolean()
|
|
389
|
+
.optional()
|
|
390
|
+
.describe("include the per-owner report of why each was in, out, or undetermined (eligible: null)"),
|
|
391
|
+
}),
|
|
392
|
+
readOnly: true,
|
|
393
|
+
concurrencySafe: true,
|
|
394
|
+
execute: async (input) => {
|
|
395
|
+
const { considered, ...rest } = assignOwner(input.value, input);
|
|
396
|
+
return json(input.verbose ? { ...rest, considered } : rest);
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
export const sequenceRun = buildTool({
|
|
400
|
+
name: "SequenceRun",
|
|
401
|
+
description: "Work out what a declared multi-step sequence should do next — the steps whose prerequisites are complete, whose scheduled time has arrived and whose conditions hold, each with the parameters to run it with — plus what is still waiting, what can no longer happen because a step it needs failed or was ruled out, and what its own conditions ruled out. The flow's state is one of ready, blocked, finished or halted, so a flow that died is never reported as one that completed. It returns a plan and runs nothing: invoking a tool needs the executor, which no tool package has. Use it to drive a cadence or a fix-up flow from versioned data, with the caller executing each step the plan hands back.",
|
|
402
|
+
inputSchema: z.object({
|
|
403
|
+
value: z.unknown().describe("the context the step conditions are tested against"),
|
|
404
|
+
version: z.string().optional().describe("echoed into the plan, so a change is attributable"),
|
|
405
|
+
steps: z
|
|
406
|
+
.array(z.object({
|
|
407
|
+
id: z.string().min(1).describe("must be unique; what needs and progress refer to"),
|
|
408
|
+
when: checksField
|
|
409
|
+
.optional()
|
|
410
|
+
.describe("the step's own gate; omit it for a step that is always due"),
|
|
411
|
+
match: matchField,
|
|
412
|
+
needs: z
|
|
413
|
+
.array(z.string().min(1))
|
|
414
|
+
.max(LIMITS.steps)
|
|
415
|
+
.optional()
|
|
416
|
+
.describe("ids that must be completed first"),
|
|
417
|
+
params: z.unknown().describe("returned verbatim in the plan, for the caller to act on"),
|
|
418
|
+
after: instantField.optional().describe("not due before this instant"),
|
|
419
|
+
}))
|
|
420
|
+
.min(1)
|
|
421
|
+
.max(LIMITS.steps),
|
|
422
|
+
completed: z
|
|
423
|
+
.array(z.string().min(1))
|
|
424
|
+
.max(LIMITS.steps)
|
|
425
|
+
.optional()
|
|
426
|
+
.describe("ids already done; the caller keeps this, the tool does not"),
|
|
427
|
+
failed: z
|
|
428
|
+
.array(z.string().min(1))
|
|
429
|
+
.max(LIMITS.steps)
|
|
430
|
+
.optional()
|
|
431
|
+
.describe("ids that failed; their dependents are reported unreachable, not waiting"),
|
|
432
|
+
now: instantField.optional().describe("overrides the real clock, for tests and replays"),
|
|
433
|
+
}),
|
|
434
|
+
readOnly: true,
|
|
435
|
+
concurrencySafe: true,
|
|
436
|
+
execute: async (input) => {
|
|
437
|
+
const nowMs = input.now === undefined ? Date.now() : parseInstant(input.now, "now");
|
|
438
|
+
// Each `after` is parsed here and dropped, so the planner can only read
|
|
439
|
+
// the parsed number: a step can never end up gated on an offset-less
|
|
440
|
+
// string that means one instant on the operator's laptop and another in
|
|
441
|
+
// CI, because the string does not reach it.
|
|
442
|
+
const steps = input.steps.map(({ after, ...step }) => ({
|
|
443
|
+
...step,
|
|
444
|
+
afterMs: after === undefined ? undefined : parseInstant(after, `step "${step.id}" after`),
|
|
445
|
+
when: step.when,
|
|
446
|
+
}));
|
|
447
|
+
return json(planSequence(input.value, { version: input.version, steps }, { nowMs, completed: input.completed, failed: input.failed }));
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
451
|
+
export const FLOW_TOOLS = Object.freeze([
|
|
452
|
+
branch,
|
|
453
|
+
consensusVote,
|
|
454
|
+
deadlineCheck,
|
|
455
|
+
decisionTable,
|
|
456
|
+
errorClassify,
|
|
457
|
+
leadAssign,
|
|
458
|
+
ruleScore,
|
|
459
|
+
sequenceRun,
|
|
460
|
+
stallDetect,
|
|
461
|
+
]);
|