@juno-ai/bind 5.0.0 → 6.0.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 +74 -7
- package/completion/defects.js +5 -3
- package/completion/index.d.ts +1 -0
- package/completion/index.js +1 -0
- package/completion/tool-calls.d.ts +90 -0
- package/completion/tool-calls.js +67 -0
- package/package.json +1 -1
- package/run/children.d.ts +55 -3
- package/run/children.js +43 -10
package/README.md
CHANGED
|
@@ -428,6 +428,41 @@ cancellation is never reported as a retriable upstream stall. Call it in the
|
|
|
428
428
|
(`setTimeout` coerces a non-finite delay to ~1ms — it does not disable the
|
|
429
429
|
timer).
|
|
430
430
|
|
|
431
|
+
### How to read the arguments of a tool call the model asked for
|
|
432
|
+
|
|
433
|
+
`JSON.parse(toolCall.function.arguments)` is the obvious implementation and it
|
|
434
|
+
is wrong for the commonest tool there is. Providers send `""` for a
|
|
435
|
+
**zero-argument** call as readily as `"{}"`, so the obvious version kills a
|
|
436
|
+
perfectly good call as a JSON syntax error and burns a recovery turn on a turn
|
|
437
|
+
that was never broken.
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
import { parseToolCallArguments } from "@juno-ai/bind/completion";
|
|
441
|
+
|
|
442
|
+
const read = parseToolCallArguments(toolCall);
|
|
443
|
+
switch (read.kind) {
|
|
444
|
+
case "parsed":
|
|
445
|
+
return dispatch(toolCall.function.name, read.arguments);
|
|
446
|
+
case "unsupported_type":
|
|
447
|
+
return toolMessage(toolCall.id, `Unsupported tool call type: ${read.type}`);
|
|
448
|
+
case "unparseable":
|
|
449
|
+
// Put `detail` in front of the MODEL, not only in a log — its next turn is
|
|
450
|
+
// the only thing that can correct the arguments.
|
|
451
|
+
return toolMessage(toolCall.id, `Invalid tool arguments: ${read.detail}`);
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Every outcome is a value, not a throw, because every outcome has to end with a
|
|
456
|
+
`tool` message carrying this call's id — a transcript where an assistant asked
|
|
457
|
+
for a tool and nothing answered it is rejected by the provider on the *next*
|
|
458
|
+
request, so "give up on this call" was never an option.
|
|
459
|
+
|
|
460
|
+
Valid JSON that is not an object — `null`, `[]`, `42` — is refused rather than
|
|
461
|
+
dispatched. Tool arguments are a named parameter bag by definition, and handing
|
|
462
|
+
a tool an array where it expects fields turns a clear failure here into a
|
|
463
|
+
confusing one inside the tool, after any side effect it performs before its own
|
|
464
|
+
validation.
|
|
465
|
+
|
|
431
466
|
### How to map your transport errors onto the routing taxonomy
|
|
432
467
|
|
|
433
468
|
`failureDisposition` decides what the router does with a classified failure, but
|
|
@@ -608,7 +643,7 @@ keeps a consumer who only wants routing from pulling in the rest.
|
|
|
608
643
|
| Import | Owns | Reach for it when |
|
|
609
644
|
|---|---|---|
|
|
610
645
|
| `@juno-ai/bind/routing` | Route plans, the planner, the failure taxonomy and the classifier that maps your errors onto it, the plan executor, the circuit breaker, billing-basis arithmetic, config degradation | You call more than one provider or model, or you want retries and fallback governed by one table |
|
|
611
|
-
| `@juno-ai/bind/completion` | Streaming idle watchdog (time-to-first-token, inter-chunk, absolute cap), completion-defect detection, and the structured-output retry predicates | You read a streamed completion |
|
|
646
|
+
| `@juno-ai/bind/completion` | Streaming idle watchdog (time-to-first-token, inter-chunk, absolute cap), completion-defect detection, the tool-call argument read (`parseToolCallArguments`), and the structured-output retry predicates | You read a streamed completion |
|
|
612
647
|
| `@juno-ai/bind/contracts` | Turn vocabulary — `TurnFn`, `ModelTurnResult`, `StopReason`, `RunStats` and its folds | You want one seam between your loop and any LLM client, and comparable per-run metrics |
|
|
613
648
|
| `@juno-ai/bind/loop` | `runToolLoop` — the iteration engine: model turn, two-phase tool batch, activation, compaction, interrupts, suspend | You want the agent loop itself, not just the pieces to build one |
|
|
614
649
|
| `@juno-ai/bind/run` | Wall-clock deadline, failure classification, coalesced heartbeat, tool-batch pooling, child-run lineage and admission, poll backoff | A run must be bounded, observable, and able to say *why* it stopped — or it can spawn runs of its own |
|
|
@@ -650,6 +685,22 @@ its runs are on the queue is not bounded, it is billed. A rule whose measurement
|
|
|
650
685
|
is `NaN` or `Infinity` refuses rather than admits — every comparison is false
|
|
651
686
|
against `NaN`, so the naive reading of a broken count is an unbounded chain.
|
|
652
687
|
|
|
688
|
+
**Run — "could not measure" is an omission, never a sentinel.** The refusal
|
|
689
|
+
above is for a count you *did* supply and that came back broken. A bound you
|
|
690
|
+
could not measure at all — the query threw, the counter was unreachable — is
|
|
691
|
+
expressed by leaving that rule out of the array, which admits. The two are
|
|
692
|
+
opposite answers to opposite questions, and passing `NaN` for "unknown" turns a
|
|
693
|
+
transient database blip into every agent in your system refusing to run. Which
|
|
694
|
+
of the two a given failure is is yours to decide; the harness only judges what
|
|
695
|
+
it was handed.
|
|
696
|
+
|
|
697
|
+
**Completion — the zero-argument rule has two halves and they must agree.**
|
|
698
|
+
`detectCompletionDefect` decides a tool call with *empty* arguments is
|
|
699
|
+
legitimate and passes it through; `parseToolCallArguments` is what then reads
|
|
700
|
+
it as the zero-argument call it is. Both read one `toolCallArgumentsAbsent`, so
|
|
701
|
+
adopting only the detection half means independently reinventing the matching
|
|
702
|
+
parse — and getting it wrong the way everyone does, with a bare `JSON.parse`.
|
|
703
|
+
|
|
653
704
|
**Run — chain lineage is "null means me".** A root run's `rootRunId` and
|
|
654
705
|
`parentRunId` are both `null`, because a chain's origin has no id to point at
|
|
655
706
|
until its own row exists. Read any chain's root as `chain.rootRunId ?? runId`;
|
|
@@ -1124,13 +1175,15 @@ import { admitChildRun, descendChain, type ChainRule } from "@juno-ai/bind/run";
|
|
|
1124
1175
|
const rules: ChainRule[] = [
|
|
1125
1176
|
{ kind: "depth", parentDepth: chain.depth, maxDepth: 5 },
|
|
1126
1177
|
{ kind: "chain_budget", runsInChain: await countRunsInChain(chain), maxRuns: 50 },
|
|
1178
|
+
{ kind: "pair_seen", alreadyPaired: await hasPairedInChain(chain, targetId) },
|
|
1127
1179
|
{ kind: "pair_cooldown", msSinceLastSpawn: await msSinceLastSpawn(runId), cooldownMs: 30_000 },
|
|
1180
|
+
{ kind: "tenant_rate", runsInWindow: await countRecentRuns(tenantId), maxRuns: 100, windowMs: 30_000 },
|
|
1128
1181
|
{ kind: "tenant_ceiling", activeRuns: await countActiveRuns(tenantId), maxActiveRuns: 200 },
|
|
1129
1182
|
];
|
|
1130
1183
|
|
|
1131
1184
|
const admission = admitChildRun(rules);
|
|
1132
1185
|
if (!admission.admitted) {
|
|
1133
|
-
log.warn("child run refused", { rule: admission.rule });
|
|
1186
|
+
log.warn("child run refused", { rule: admission.rule, retryable: admission.retryable });
|
|
1134
1187
|
return { success: false, kind: "validation", error: admission.reason };
|
|
1135
1188
|
}
|
|
1136
1189
|
```
|
|
@@ -1138,11 +1191,25 @@ if (!admission.admitted) {
|
|
|
1138
1191
|
Rules are evaluated in order and the first refusal wins, so you choose which
|
|
1139
1192
|
reason the model sees. Pick the set against your own cost model: depth caps
|
|
1140
1193
|
runaway recursion, a chain budget caps a chain that stays shallow but keeps
|
|
1141
|
-
fanning out,
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1194
|
+
fanning out, `pair_seen` allows a given pair to work together once per chain
|
|
1195
|
+
and a `pair_cooldown` merely spaces them out, a tenant rate limit bounds a burst
|
|
1196
|
+
over a rolling window, and a tenant ceiling bounds what is running *right now* —
|
|
1197
|
+
the last two covering what no chain rule can, someone starting a thousand
|
|
1198
|
+
independent chains.
|
|
1199
|
+
|
|
1200
|
+
A refusal carries `rule` (which bound fired) and `retryable`, which separates a
|
|
1201
|
+
bound that clears on its own — a cooldown, a rate window rolling — from one that
|
|
1202
|
+
never will, so a caller can choose between waiting and giving up. Only a
|
|
1203
|
+
refusal carries them; `retryable === undefined` means there was nothing to
|
|
1204
|
+
retry, not "not retryable".
|
|
1205
|
+
|
|
1206
|
+
Two failure modes get opposite treatment, and the difference is the part to get
|
|
1207
|
+
right. A **broken measurement** (`NaN`, `Infinity`, a negative count) refuses:
|
|
1208
|
+
every comparison is false against `NaN`, so the naive reading would turn a
|
|
1209
|
+
broken count into an unbounded chain. A bound you **could not measure at all**
|
|
1210
|
+
— the count query threw — is expressed by *omitting the rule*, which admits.
|
|
1211
|
+
Reaching for `NaN` to mean "unknown" collapses the two and makes a transient
|
|
1212
|
+
database blip refuse every child run you have.
|
|
1146
1213
|
|
|
1147
1214
|
`descendChain` handles the lineage arithmetic, including the root-id fallback
|
|
1148
1215
|
that is easy to get backwards — a first-generation child adopts its parent's
|
package/completion/defects.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* any SDK's message class, so a host assembling chunks by hand and a host
|
|
19
19
|
* handing over an `openai` message both fit without a cast.
|
|
20
20
|
*/
|
|
21
|
+
import { toolCallArgumentsAbsent } from "./tool-calls.js";
|
|
21
22
|
/**
|
|
22
23
|
* Additional attempts when a structured-output call returns content that will
|
|
23
24
|
* not parse as JSON: the initial call plus this many retries. Providers
|
|
@@ -79,10 +80,11 @@ export function detectCompletionDefect(completion, cutByTokenLimit) {
|
|
|
79
80
|
const args = toolCall?.function?.arguments;
|
|
80
81
|
if (typeof args !== "string")
|
|
81
82
|
return false;
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
// The same predicate the dispatcher reads, so the two halves of the
|
|
84
|
+
// zero-argument rule cannot drift — see `tool-calls.ts`.
|
|
85
|
+
if (toolCallArgumentsAbsent(args))
|
|
84
86
|
return cutByTokenLimit;
|
|
85
|
-
return !jsonParses(
|
|
87
|
+
return !jsonParses(args.trim());
|
|
86
88
|
});
|
|
87
89
|
if (truncated !== undefined) {
|
|
88
90
|
return { kind: "truncated_tool_call", toolCall: truncated };
|
package/completion/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { createStreamWatchdog, DEFAULT_TIME_TO_FIRST_TOKEN_MS, DEFAULT_INTER_CHUNK_MS, DEFAULT_MAX_CALL_DURATION_MS, type ChunkOutput, type StreamStall, type StreamWatchdog, type StreamWatchdogOptions, } from "./watchdog.js";
|
|
2
2
|
export { detectCompletionDefect, expectsJsonOutput, jsonParses, structuredOutputParses, DEFAULT_STRUCTURED_OUTPUT_MAX_RETRIES, DEFAULT_COMPLETION_DEFECT_MAX_RETRIES, type AssembledCompletion, type CompletionDefect, type CompletionOutputs, type ResponseFormatShape, type StreamedToolCall, } from "./defects.js";
|
|
3
|
+
export { parseToolCallArguments, toolCallArgumentsAbsent, type DispatchableToolCall, type ToolCallArguments, } from "./tool-calls.js";
|
package/completion/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { createStreamWatchdog, DEFAULT_TIME_TO_FIRST_TOKEN_MS, DEFAULT_INTER_CHUNK_MS, DEFAULT_MAX_CALL_DURATION_MS, } from "./watchdog.js";
|
|
2
2
|
export { detectCompletionDefect, expectsJsonOutput, jsonParses, structuredOutputParses, DEFAULT_STRUCTURED_OUTPUT_MAX_RETRIES, DEFAULT_COMPLETION_DEFECT_MAX_RETRIES, } from "./defects.js";
|
|
3
|
+
export { parseToolCallArguments, toolCallArgumentsAbsent, } from "./tool-calls.js";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a tool call the model asked for.
|
|
3
|
+
*
|
|
4
|
+
* This is the dispatch half of a rule whose other half lives in
|
|
5
|
+
* `defects.ts`, and the two only work if they agree. `detectCompletionDefect`
|
|
6
|
+
* decides that a tool call with *empty* arguments is legitimate — the normal
|
|
7
|
+
* zero-argument shape, not a truncation — and passes it through to be run.
|
|
8
|
+
* Something then has to run it, and that something has to reach the same
|
|
9
|
+
* conclusion: empty arguments mean no arguments, not malformed JSON.
|
|
10
|
+
*
|
|
11
|
+
* Splitting those halves across a package boundary is how the original bug
|
|
12
|
+
* happened. Providers send `""` for a zero-arg call as readily as `"{}"`, the
|
|
13
|
+
* dispatcher fed `""` to `JSON.parse`, and a perfectly good call died as
|
|
14
|
+
* "Invalid JSON in tool arguments" — a whole recovery turn spent on a turn
|
|
15
|
+
* that was never broken. A host adopting only the detection half inherits the
|
|
16
|
+
* permissive decision and has to independently invent the matching parse.
|
|
17
|
+
*
|
|
18
|
+
* So both halves read {@link toolCallArgumentsAbsent}. One definition, and a
|
|
19
|
+
* change to what "absent" means cannot update one side and miss the other.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* The shape this module reads. Structural rather than tied to an SDK's class,
|
|
23
|
+
* and `type` is optional because a host assembling calls by hand may not carry
|
|
24
|
+
* one — its absence is treated as the ordinary function call it almost always
|
|
25
|
+
* is, while a *present* non-function type is refused.
|
|
26
|
+
*/
|
|
27
|
+
export interface DispatchableToolCall {
|
|
28
|
+
readonly type?: string | undefined;
|
|
29
|
+
readonly function?: {
|
|
30
|
+
readonly name?: string | undefined;
|
|
31
|
+
readonly arguments?: string | undefined;
|
|
32
|
+
} | undefined;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether a tool call carried no arguments at all.
|
|
36
|
+
*
|
|
37
|
+
* Whitespace counts as absent: a provider that pads its zero-arg payload is
|
|
38
|
+
* still saying "no arguments", and treating `" "` as content sends it to a
|
|
39
|
+
* JSON parse that can only fail.
|
|
40
|
+
*/
|
|
41
|
+
export declare function toolCallArgumentsAbsent(rawArguments: string): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* What a tool call's arguments turned out to be.
|
|
44
|
+
*
|
|
45
|
+
* A tagged union rather than a throw, because every outcome here has to end
|
|
46
|
+
* with the host emitting a `tool` message for this call's id. A transcript
|
|
47
|
+
* where an assistant asked for a tool and no `tool` message answers it is
|
|
48
|
+
* rejected by the provider on the *next* request, so "give up on this call"
|
|
49
|
+
* is never an option — only "dispatch it" or "answer it with an error".
|
|
50
|
+
*/
|
|
51
|
+
export type ToolCallArguments =
|
|
52
|
+
/** Dispatch with these. `{}` for a legitimate zero-argument call. */
|
|
53
|
+
{
|
|
54
|
+
kind: "parsed";
|
|
55
|
+
arguments: Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Not a function call, so there is nothing to dispatch. Present in the
|
|
59
|
+
* OpenAI union (`type: "custom"`) and in whatever a future provider adds.
|
|
60
|
+
*/
|
|
61
|
+
| {
|
|
62
|
+
kind: "unsupported_type";
|
|
63
|
+
type: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Arguments that cannot be used: either not JSON at all, or JSON that is not
|
|
67
|
+
* an object.
|
|
68
|
+
*
|
|
69
|
+
* The two have different histories, which matters for how the host words the
|
|
70
|
+
* error. A *syntax* failure by this point is a real error rather than a
|
|
71
|
+
* truncation — a cut-off stream is caught upstream by
|
|
72
|
+
* `detectCompletionDefect` and retried, so anything still unparseable here
|
|
73
|
+
* survived that. A payload that parsed but is not an object was never
|
|
74
|
+
* eligible for that retry (it parses fine), so the model's next turn is its
|
|
75
|
+
* only correction — which is why {@link detail} names what was wrong, and
|
|
76
|
+
* why a host should put it in front of the model rather than only in a log.
|
|
77
|
+
*/
|
|
78
|
+
| {
|
|
79
|
+
kind: "unparseable";
|
|
80
|
+
detail: string;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Read a tool call's arguments, or say why it cannot be dispatched.
|
|
84
|
+
*
|
|
85
|
+
* A parsed value that is not a JSON object — `"null"`, `"[]"`, `"42"`, all
|
|
86
|
+
* valid JSON — is refused rather than passed on. Tool arguments are a named
|
|
87
|
+
* parameter bag by definition, and handing a tool an array where it expects
|
|
88
|
+
* fields turns a clear failure here into a confusing one inside the tool.
|
|
89
|
+
*/
|
|
90
|
+
export declare function parseToolCallArguments(toolCall: DispatchableToolCall): ToolCallArguments;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a tool call the model asked for.
|
|
3
|
+
*
|
|
4
|
+
* This is the dispatch half of a rule whose other half lives in
|
|
5
|
+
* `defects.ts`, and the two only work if they agree. `detectCompletionDefect`
|
|
6
|
+
* decides that a tool call with *empty* arguments is legitimate — the normal
|
|
7
|
+
* zero-argument shape, not a truncation — and passes it through to be run.
|
|
8
|
+
* Something then has to run it, and that something has to reach the same
|
|
9
|
+
* conclusion: empty arguments mean no arguments, not malformed JSON.
|
|
10
|
+
*
|
|
11
|
+
* Splitting those halves across a package boundary is how the original bug
|
|
12
|
+
* happened. Providers send `""` for a zero-arg call as readily as `"{}"`, the
|
|
13
|
+
* dispatcher fed `""` to `JSON.parse`, and a perfectly good call died as
|
|
14
|
+
* "Invalid JSON in tool arguments" — a whole recovery turn spent on a turn
|
|
15
|
+
* that was never broken. A host adopting only the detection half inherits the
|
|
16
|
+
* permissive decision and has to independently invent the matching parse.
|
|
17
|
+
*
|
|
18
|
+
* So both halves read {@link toolCallArgumentsAbsent}. One definition, and a
|
|
19
|
+
* change to what "absent" means cannot update one side and miss the other.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Whether a tool call carried no arguments at all.
|
|
23
|
+
*
|
|
24
|
+
* Whitespace counts as absent: a provider that pads its zero-arg payload is
|
|
25
|
+
* still saying "no arguments", and treating `" "` as content sends it to a
|
|
26
|
+
* JSON parse that can only fail.
|
|
27
|
+
*/
|
|
28
|
+
export function toolCallArgumentsAbsent(rawArguments) {
|
|
29
|
+
return rawArguments.trim().length === 0;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read a tool call's arguments, or say why it cannot be dispatched.
|
|
33
|
+
*
|
|
34
|
+
* A parsed value that is not a JSON object — `"null"`, `"[]"`, `"42"`, all
|
|
35
|
+
* valid JSON — is refused rather than passed on. Tool arguments are a named
|
|
36
|
+
* parameter bag by definition, and handing a tool an array where it expects
|
|
37
|
+
* fields turns a clear failure here into a confusing one inside the tool.
|
|
38
|
+
*/
|
|
39
|
+
export function parseToolCallArguments(toolCall) {
|
|
40
|
+
if (toolCall.type !== undefined && toolCall.type !== "function") {
|
|
41
|
+
return { kind: "unsupported_type", type: toolCall.type };
|
|
42
|
+
}
|
|
43
|
+
const raw = toolCall.function?.arguments;
|
|
44
|
+
if (typeof raw !== "string" || toolCallArgumentsAbsent(raw)) {
|
|
45
|
+
// The empty case and the missing case are the same call: the model named a
|
|
46
|
+
// tool and gave it nothing, which is what a zero-argument tool looks like.
|
|
47
|
+
return { kind: "parsed", arguments: {} };
|
|
48
|
+
}
|
|
49
|
+
let value;
|
|
50
|
+
try {
|
|
51
|
+
value = JSON.parse(raw);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return {
|
|
55
|
+
kind: "unparseable",
|
|
56
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
60
|
+
// `typeof` alone is not a usable name here: it reports both `null` and an
|
|
61
|
+
// array as "object", so the two payloads most likely to arrive would be
|
|
62
|
+
// described as the very thing they were rejected for not being.
|
|
63
|
+
const got = value === null ? "null" : Array.isArray(value) ? "an array" : typeof value;
|
|
64
|
+
return { kind: "unparseable", detail: `expected a JSON object, got ${got}` };
|
|
65
|
+
}
|
|
66
|
+
return { kind: "parsed", arguments: value };
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juno-ai/bind",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"description": "Agent harness: the tool-calling turn kernel, deterministic LLM provider routing with transport-error classification, the streaming-completion watchdog, run mechanics, sub-agent lineage and admission, transcript healing, tool-schema sanitization, and the plugin/tool vocabulary. MIT-licensed; published to npm from the canonical repo via scripts/publish-bind.ts (docs/bind.md).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/run/children.d.ts
CHANGED
|
@@ -86,6 +86,26 @@ export type ChainRule =
|
|
|
86
86
|
msSinceLastSpawn: number | null;
|
|
87
87
|
cooldownMs: number;
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Whether this parent→child pair has *already* run, at all, within whatever
|
|
91
|
+
* scope the host measured over. The stronger sibling of
|
|
92
|
+
* `pair_cooldown`: a cooldown lets a pair repeat once enough time passes,
|
|
93
|
+
* while this permits the pair exactly once and never again.
|
|
94
|
+
*
|
|
95
|
+
* Reach for it where a repeat is not slow but wrong — two agents that answer
|
|
96
|
+
* each other are a loop whose every hop looks individually reasonable, and a
|
|
97
|
+
* time-based bound only makes such a loop cheaper per hour rather than
|
|
98
|
+
* ending it.
|
|
99
|
+
*
|
|
100
|
+
* The scope is the host's to choose and is deliberately not modelled here:
|
|
101
|
+
* "already paired in this chain" and "already paired in this conversation"
|
|
102
|
+
* are the same rule over different measurements, and naming either one would
|
|
103
|
+
* put a product's containment vocabulary into the harness.
|
|
104
|
+
*/
|
|
105
|
+
| {
|
|
106
|
+
kind: "pair_seen";
|
|
107
|
+
alreadyPaired: boolean;
|
|
108
|
+
}
|
|
89
109
|
/**
|
|
90
110
|
* A ceiling on concurrent runs for the whole tenant. Worth having alongside
|
|
91
111
|
* the chain rules: depth and chain budgets constrain one lineage, and neither
|
|
@@ -95,6 +115,26 @@ export type ChainRule =
|
|
|
95
115
|
kind: "tenant_ceiling";
|
|
96
116
|
activeRuns: number;
|
|
97
117
|
maxActiveRuns: number;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* How many runs a tenant may *start* within a rolling window, as opposed to
|
|
121
|
+
* how many may be in flight at once (`tenant_ceiling`).
|
|
122
|
+
*
|
|
123
|
+
* The two bound different abuses and neither implies the other. A ceiling
|
|
124
|
+
* caps concurrency, so a caller that starts and finishes runs quickly slips
|
|
125
|
+
* under it indefinitely — a message flood, or one fan-out across a large
|
|
126
|
+
* group, is exactly that shape. A rate caps total starts, so it bounds spend
|
|
127
|
+
* where the ceiling bounds load.
|
|
128
|
+
*
|
|
129
|
+
* `windowMs` is carried only so the refusal can say how long the wait is;
|
|
130
|
+
* the rule does not roll the window itself. The host measures over whatever
|
|
131
|
+
* window it chose and passes both.
|
|
132
|
+
*/
|
|
133
|
+
| {
|
|
134
|
+
kind: "tenant_rate";
|
|
135
|
+
runsInWindow: number;
|
|
136
|
+
maxRuns: number;
|
|
137
|
+
windowMs: number;
|
|
98
138
|
};
|
|
99
139
|
export type ChildAdmission = {
|
|
100
140
|
admitted: true;
|
|
@@ -105,9 +145,9 @@ export type ChildAdmission = {
|
|
|
105
145
|
/**
|
|
106
146
|
* Could waiting change this answer?
|
|
107
147
|
*
|
|
108
|
-
* The
|
|
109
|
-
* not separate them: a cooldown clears on its own, a
|
|
110
|
-
* never does. Without this a model reads "refused" and has to guess
|
|
148
|
+
* The rules are not all the same kind of refusal, and prose alone does
|
|
149
|
+
* not separate them: a cooldown or a rolling window clears on its own, a
|
|
150
|
+
* spent chain budget or a repeated pair never does. Without this a model reads "refused" and has to guess
|
|
111
151
|
* between waiting and giving up — and guessing wrong either wastes the
|
|
112
152
|
* run on retries into the same wall or abandons work it could have done
|
|
113
153
|
* a moment later.
|
|
@@ -127,6 +167,18 @@ export type ChildAdmission = {
|
|
|
127
167
|
* reason the model sees when several apply. An empty rule list admits — this
|
|
128
168
|
* function bounds what it is given and claims nothing about what it is not.
|
|
129
169
|
*
|
|
170
|
+
* **When a measurement fails, omit the rule — do not pass a sentinel.** The
|
|
171
|
+
* two failure postures here answer different questions and are easy to
|
|
172
|
+
* conflate. A rule whose number arrived broken (`NaN`, negative) refuses,
|
|
173
|
+
* because a nonsense count is not evidence of safety. A rule you could not
|
|
174
|
+
* measure at all — the count query threw, the store was unreachable — is
|
|
175
|
+
* absent, and absence admits. That is a deliberate seam, not an oversight:
|
|
176
|
+
* whether a transient infrastructure failure should stop all work or let it
|
|
177
|
+
* through is an availability judgement about a specific product, and this
|
|
178
|
+
* function has no standing to make it. Decide it at the call site, in a
|
|
179
|
+
* `catch`, by choosing whether to append the rule. Passing `NaN` or `-1` to
|
|
180
|
+
* mean "unknown" inverts the answer you almost certainly want.
|
|
181
|
+
*
|
|
130
182
|
* **Call before enqueuing, never after.** A chain that is bounded only once its
|
|
131
183
|
* runs are already queued is not bounded; it is billed.
|
|
132
184
|
*
|
package/run/children.js
CHANGED
|
@@ -48,6 +48,18 @@ parent) {
|
|
|
48
48
|
* reason the model sees when several apply. An empty rule list admits — this
|
|
49
49
|
* function bounds what it is given and claims nothing about what it is not.
|
|
50
50
|
*
|
|
51
|
+
* **When a measurement fails, omit the rule — do not pass a sentinel.** The
|
|
52
|
+
* two failure postures here answer different questions and are easy to
|
|
53
|
+
* conflate. A rule whose number arrived broken (`NaN`, negative) refuses,
|
|
54
|
+
* because a nonsense count is not evidence of safety. A rule you could not
|
|
55
|
+
* measure at all — the count query threw, the store was unreachable — is
|
|
56
|
+
* absent, and absence admits. That is a deliberate seam, not an oversight:
|
|
57
|
+
* whether a transient infrastructure failure should stop all work or let it
|
|
58
|
+
* through is an availability judgement about a specific product, and this
|
|
59
|
+
* function has no standing to make it. Decide it at the call site, in a
|
|
60
|
+
* `catch`, by choosing whether to append the rule. Passing `NaN` or `-1` to
|
|
61
|
+
* mean "unknown" inverts the answer you almost certainly want.
|
|
62
|
+
*
|
|
51
63
|
* **Call before enqueuing, never after.** A chain that is bounded only once its
|
|
52
64
|
* runs are already queued is not bounded; it is billed.
|
|
53
65
|
*
|
|
@@ -83,8 +95,8 @@ function evaluateRule(rule) {
|
|
|
83
95
|
const childDepth = rule.parentDepth + 1;
|
|
84
96
|
return childDepth >= rule.maxDepth
|
|
85
97
|
? refuse(rule, false, `chain depth limit reached (${childDepth}/${rule.maxDepth}) — ` +
|
|
86
|
-
`
|
|
87
|
-
`
|
|
98
|
+
`as far as work may be handed on, and waiting will not change ` +
|
|
99
|
+
`that; do this work here rather than delegating again`)
|
|
88
100
|
: null;
|
|
89
101
|
}
|
|
90
102
|
case "chain_budget": {
|
|
@@ -94,8 +106,8 @@ function evaluateRule(rule) {
|
|
|
94
106
|
return broken;
|
|
95
107
|
return rule.runsInChain >= rule.maxRuns
|
|
96
108
|
? refuse(rule, false, `chain run budget exhausted (${rule.runsInChain}/${rule.maxRuns}) — ` +
|
|
97
|
-
`
|
|
98
|
-
`
|
|
109
|
+
`spent for this whole chain and it does not refill, so do this ` +
|
|
110
|
+
`work here rather than delegating again`)
|
|
99
111
|
: null;
|
|
100
112
|
}
|
|
101
113
|
case "pair_cooldown": {
|
|
@@ -106,10 +118,19 @@ function evaluateRule(rule) {
|
|
|
106
118
|
if (broken)
|
|
107
119
|
return broken;
|
|
108
120
|
return rule.msSinceLastSpawn < rule.cooldownMs
|
|
109
|
-
? refuse(rule, true, `
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
121
|
+
? refuse(rule, true, `handoff cooldown active — retry in ` +
|
|
122
|
+
`${rule.cooldownMs - rule.msSinceLastSpawn}ms, or do this work ` +
|
|
123
|
+
`here (${rule.msSinceLastSpawn}ms since the last handoff, ` +
|
|
124
|
+
`${rule.cooldownMs}ms required)`)
|
|
125
|
+
: null;
|
|
126
|
+
}
|
|
127
|
+
case "pair_seen": {
|
|
128
|
+
// A boolean carries no measurement to validate — the host either
|
|
129
|
+
// observed the prior pairing or it did not.
|
|
130
|
+
return rule.alreadyPaired
|
|
131
|
+
? refuse(rule, false, `repeat handoff refused — these two have already worked together ` +
|
|
132
|
+
`here and are allowed to only once, so waiting will not change ` +
|
|
133
|
+
`that; do this work here instead`)
|
|
113
134
|
: null;
|
|
114
135
|
}
|
|
115
136
|
case "tenant_ceiling": {
|
|
@@ -119,8 +140,20 @@ function evaluateRule(rule) {
|
|
|
119
140
|
return broken;
|
|
120
141
|
return rule.activeRuns >= rule.maxActiveRuns
|
|
121
142
|
? refuse(rule, true, `concurrent run ceiling reached (${rule.activeRuns}/` +
|
|
122
|
-
`${rule.maxActiveRuns}) —
|
|
123
|
-
`
|
|
143
|
+
`${rule.maxActiveRuns}) — clears as other runs finish, so retry ` +
|
|
144
|
+
`shortly or do this work here`)
|
|
145
|
+
: null;
|
|
146
|
+
}
|
|
147
|
+
case "tenant_rate": {
|
|
148
|
+
const broken = unmeasurable(rule, rule.runsInWindow, "runsInWindow") ??
|
|
149
|
+
unmeasurable(rule, rule.maxRuns, "maxRuns") ??
|
|
150
|
+
unmeasurable(rule, rule.windowMs, "windowMs");
|
|
151
|
+
if (broken)
|
|
152
|
+
return broken;
|
|
153
|
+
return rule.runsInWindow >= rule.maxRuns
|
|
154
|
+
? refuse(rule, true, `start-rate limit reached (${rule.runsInWindow}/${rule.maxRuns} ` +
|
|
155
|
+
`runs in the last ${rule.windowMs}ms) — the window rolls, so ` +
|
|
156
|
+
`wait up to ${rule.windowMs}ms and retry, or do this work here`)
|
|
124
157
|
: null;
|
|
125
158
|
}
|
|
126
159
|
default: {
|