@juno-ai/bind 4.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 +182 -25
- package/completion/defects.d.ts +138 -0
- package/completion/defects.js +130 -0
- package/completion/index.d.ts +3 -0
- package/completion/index.js +3 -0
- package/completion/tool-calls.d.ts +90 -0
- package/completion/tool-calls.js +67 -0
- package/completion/watchdog.d.ts +165 -0
- package/completion/watchdog.js +211 -0
- package/index.d.ts +9 -6
- package/index.js +9 -6
- package/loop/tool-loop.js +17 -5
- package/package.json +6 -2
- package/routing/attempt-errors.d.ts +122 -0
- package/routing/attempt-errors.js +176 -0
- package/routing/executor.js +16 -1
- package/routing/index.d.ts +1 -0
- package/routing/index.js +1 -0
- package/run/children.d.ts +55 -3
- package/run/children.js +43 -10
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming-completion idle watchdog.
|
|
3
|
+
*
|
|
4
|
+
* A chat-completions SDK's `timeout` bounds *establishing* the request and (for
|
|
5
|
+
* a non-streaming call) the whole response — but once a stream has started
|
|
6
|
+
* yielding, nothing bounds the gap between chunks. A provider that opens the
|
|
7
|
+
* stream and then stalls leaves the consumer's `for await` awaiting the next
|
|
8
|
+
* chunk forever, holding its worker slot until some outer deadline fires, or
|
|
9
|
+
* never. Downstream that is the agent that says "thinking…" and never answers.
|
|
10
|
+
*
|
|
11
|
+
* The budget is split into three because the phases of a streaming completion
|
|
12
|
+
* have very different normal latencies, and one number cannot serve all of them:
|
|
13
|
+
*
|
|
14
|
+
* - **Time to first token** — from opening the stream to the first *answer*
|
|
15
|
+
* token. Generous: a high-reasoning model can legitimately think for a while
|
|
16
|
+
* before emitting anything, and even once reasoning deltas are flowing it can
|
|
17
|
+
* sit silent for seconds between finishing its reasoning and emitting the
|
|
18
|
+
* first content/tool token. This budget therefore covers the reasoning phase
|
|
19
|
+
* AND the reasoning→answer gap — {@link ChunkOutput} `"reasoning"` does NOT
|
|
20
|
+
* switch to the tight budget. Arming the tight budget on a reasoning token is
|
|
21
|
+
* a real observed failure: the normal reasoning→content pause of a frontier
|
|
22
|
+
* reasoning model routinely approaches several seconds and tripped
|
|
23
|
+
* "no chunk mid-stream" on healthy turns.
|
|
24
|
+
* - **Inter-chunk** — the gap between chunks once the model's ANSWER output
|
|
25
|
+
* (content / tool-call arguments / refusal) is flowing. Tight: answer chunks
|
|
26
|
+
* normally arrive sub-second, so a multi-second silence mid-answer is already
|
|
27
|
+
* abnormal, and a shorter budget recovers a mid-stream stall far sooner.
|
|
28
|
+
* - **Overall cap** — an absolute wall-clock ceiling, armed at construction and
|
|
29
|
+
* never re-armed. Defense in depth, not a duplicate of the two idle budgets:
|
|
30
|
+
* the idle watchdog only bounds the *gap* between chunks, so a provider that
|
|
31
|
+
* drip-feeds a chunk just under the idle budget forever (a reasoning delta a
|
|
32
|
+
* minute, never reaching an answer) never trips it. The proper bound on total
|
|
33
|
+
* work is the caller's own deadline signal, but not every caller has one, so
|
|
34
|
+
* this guarantees a single call cannot hold a slot indefinitely regardless.
|
|
35
|
+
*
|
|
36
|
+
* It is armed by the constructor rather than by {@link StreamWatchdog.open}
|
|
37
|
+
* because it is the guarantee of last resort: a host that forgets `open()`
|
|
38
|
+
* gets a watchdog that silently never fires, with no type error and no test
|
|
39
|
+
* failure, and the symptom appears only under a stalling provider. Counting
|
|
40
|
+
* the connection handshake against a ceiling this generous costs nothing;
|
|
41
|
+
* leaving the ceiling to an imperative call the host may skip costs a worker
|
|
42
|
+
* slot. The two *idle* budgets genuinely must wait for `open()` — arming them
|
|
43
|
+
* earlier would charge connection time to the first-token budget.
|
|
44
|
+
*
|
|
45
|
+
* The watchdog owns timers and abort signals and nothing else: it does not read
|
|
46
|
+
* the stream, does not know the wire format, and does not phrase the error. It
|
|
47
|
+
* reports a structured {@link StreamStall} and the host words it — the same
|
|
48
|
+
* split the routing modules use, since a stall message is usually product copy.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* What a streamed chunk carried, from the watchdog's point of view. The
|
|
52
|
+
* distinction that matters is `"reasoning"` vs `"answer"`: only answer output
|
|
53
|
+
* arms the tight inter-chunk budget. A chunk carrying both is `"answer"`.
|
|
54
|
+
* A role/metadata-only opening chunk is `"none"` — the model has not produced a
|
|
55
|
+
* token yet, so it must not shorten the budget either.
|
|
56
|
+
*/
|
|
57
|
+
export type ChunkOutput = "none" | "reasoning" | "answer";
|
|
58
|
+
/** Why the watchdog tore the stream down. */
|
|
59
|
+
export type StreamStall =
|
|
60
|
+
/** The absolute wall-clock ceiling elapsed before the stream finished. */
|
|
61
|
+
Readonly<{
|
|
62
|
+
kind: "overall_cap";
|
|
63
|
+
limitMs: number;
|
|
64
|
+
}>
|
|
65
|
+
/**
|
|
66
|
+
* No answer token within the generous budget. `sawReasoning` distinguishes
|
|
67
|
+
* "the provider never said anything" from "it streamed reasoning and never
|
|
68
|
+
* reached an answer" — different upstream faults with the same budget.
|
|
69
|
+
*/
|
|
70
|
+
| Readonly<{
|
|
71
|
+
kind: "time_to_first_token";
|
|
72
|
+
limitMs: number;
|
|
73
|
+
sawReasoning: boolean;
|
|
74
|
+
}>
|
|
75
|
+
/** Answer output was flowing and then stopped mid-stream. */
|
|
76
|
+
| Readonly<{
|
|
77
|
+
kind: "inter_chunk";
|
|
78
|
+
limitMs: number;
|
|
79
|
+
}>;
|
|
80
|
+
export interface StreamWatchdogOptions {
|
|
81
|
+
/** Generous first-answer-token budget. Default {@link DEFAULT_TIME_TO_FIRST_TOKEN_MS}. */
|
|
82
|
+
readonly timeToFirstTokenMs?: number;
|
|
83
|
+
/** Tight budget between chunks once answer output flows. Default {@link DEFAULT_INTER_CHUNK_MS}. */
|
|
84
|
+
readonly interChunkMs?: number;
|
|
85
|
+
/**
|
|
86
|
+
* Absolute ceiling on the whole call, measured from **watchdog
|
|
87
|
+
* construction** — not from {@link StreamWatchdog.open}, which only arms the
|
|
88
|
+
* idle budgets. Size it to include whatever the host does between
|
|
89
|
+
* constructing the watchdog and opening the stream (connecting, sending the
|
|
90
|
+
* request, waiting on response headers), since all of that is inside the
|
|
91
|
+
* budget. Default {@link DEFAULT_MAX_CALL_DURATION_MS}.
|
|
92
|
+
*/
|
|
93
|
+
readonly maxCallDurationMs?: number;
|
|
94
|
+
/**
|
|
95
|
+
* The caller's own abort signal (a run deadline, a cancellation). Composed
|
|
96
|
+
* into {@link StreamWatchdog.signal}, and — critically — consulted by
|
|
97
|
+
* {@link StreamWatchdog.stall}: when the caller aborted, the teardown is the
|
|
98
|
+
* caller's, not a stall, and must not be reclassified as a retriable
|
|
99
|
+
* upstream fault.
|
|
100
|
+
*/
|
|
101
|
+
readonly external?: AbortSignal | null;
|
|
102
|
+
/**
|
|
103
|
+
* Timer port, defaulting to the ambient globals. Inject a controllable clock
|
|
104
|
+
* to test budget behaviour exactly, rather than sleeping a real interval and
|
|
105
|
+
* hoping the machine keeps up — a watchdog test asserting "nothing fired yet"
|
|
106
|
+
* against a real timer is a flake waiting for a loaded CI box.
|
|
107
|
+
*
|
|
108
|
+
* The handle is opaque (`unknown`) so a fake can hand back whatever it likes;
|
|
109
|
+
* only {@link StreamTimers.clear} ever consumes it.
|
|
110
|
+
*/
|
|
111
|
+
readonly timers?: StreamTimers;
|
|
112
|
+
}
|
|
113
|
+
/** The subset of the timer API the watchdog needs. */
|
|
114
|
+
export interface StreamTimers {
|
|
115
|
+
set(callback: () => void, ms: number): unknown;
|
|
116
|
+
clear(handle: unknown): void;
|
|
117
|
+
}
|
|
118
|
+
export interface StreamWatchdog {
|
|
119
|
+
/** Hand this to the transport as the request's abort signal. */
|
|
120
|
+
readonly signal: AbortSignal;
|
|
121
|
+
/**
|
|
122
|
+
* The stream is open: arm the time-to-first-token budget. Call immediately
|
|
123
|
+
* after the transport returns the stream — arming before that would count
|
|
124
|
+
* connection time against the first-token budget. The absolute cap is already
|
|
125
|
+
* running (see {@link createStreamWatchdog}); this only starts the idle
|
|
126
|
+
* budgets. Idempotent, and a no-op after {@link dispose}.
|
|
127
|
+
*/
|
|
128
|
+
open(): void;
|
|
129
|
+
/**
|
|
130
|
+
* A chunk arrived: re-arm for the gap to the *next* one. Call for every
|
|
131
|
+
* chunk, including metadata-only ones — a chunk that carried nothing still
|
|
132
|
+
* proves the stream is alive.
|
|
133
|
+
*/
|
|
134
|
+
observedChunk(output: ChunkOutput): void;
|
|
135
|
+
/**
|
|
136
|
+
* Why the stream was torn down, or `null` if this watchdog did not do it.
|
|
137
|
+
* Returns `null` whenever the caller's own signal aborted, even if a budget
|
|
138
|
+
* also elapsed: a cancelled call is cancelled, not stalled.
|
|
139
|
+
*
|
|
140
|
+
* Call from the `catch` that saw the abort, before {@link dispose}.
|
|
141
|
+
*/
|
|
142
|
+
stall(): StreamStall | null;
|
|
143
|
+
/**
|
|
144
|
+
* Clear the timers, forget any budget that elapsed, and settle the composite
|
|
145
|
+
* signal. Idempotent; call from a `finally` on every path.
|
|
146
|
+
*
|
|
147
|
+
* Settling matters as much as clearing: the composite holds a listener on a
|
|
148
|
+
* possibly long-lived caller signal, and one per call across a run's many
|
|
149
|
+
* calls is a leak.
|
|
150
|
+
*
|
|
151
|
+
* Forgetting matters because a timer can fire in the moment between the last
|
|
152
|
+
* chunk and the stream ending — the abort loses the race, the read completes
|
|
153
|
+
* normally, and nothing was torn down. If that stale flag survived, a *later*
|
|
154
|
+
* failure on the same call (a defect found while assembling the message, a
|
|
155
|
+
* billing read) would reach {@link stall} and be reported as an upstream
|
|
156
|
+
* stall it had nothing to do with. Call `stall()` before `dispose()`, which
|
|
157
|
+
* is the documented order and the only order in which a real stall is
|
|
158
|
+
* observable anyway.
|
|
159
|
+
*/
|
|
160
|
+
dispose(): void;
|
|
161
|
+
}
|
|
162
|
+
export declare const DEFAULT_TIME_TO_FIRST_TOKEN_MS = 120000;
|
|
163
|
+
export declare const DEFAULT_INTER_CHUNK_MS = 5000;
|
|
164
|
+
export declare const DEFAULT_MAX_CALL_DURATION_MS = 600000;
|
|
165
|
+
export declare function createStreamWatchdog(options?: StreamWatchdogOptions): StreamWatchdog;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming-completion idle watchdog.
|
|
3
|
+
*
|
|
4
|
+
* A chat-completions SDK's `timeout` bounds *establishing* the request and (for
|
|
5
|
+
* a non-streaming call) the whole response — but once a stream has started
|
|
6
|
+
* yielding, nothing bounds the gap between chunks. A provider that opens the
|
|
7
|
+
* stream and then stalls leaves the consumer's `for await` awaiting the next
|
|
8
|
+
* chunk forever, holding its worker slot until some outer deadline fires, or
|
|
9
|
+
* never. Downstream that is the agent that says "thinking…" and never answers.
|
|
10
|
+
*
|
|
11
|
+
* The budget is split into three because the phases of a streaming completion
|
|
12
|
+
* have very different normal latencies, and one number cannot serve all of them:
|
|
13
|
+
*
|
|
14
|
+
* - **Time to first token** — from opening the stream to the first *answer*
|
|
15
|
+
* token. Generous: a high-reasoning model can legitimately think for a while
|
|
16
|
+
* before emitting anything, and even once reasoning deltas are flowing it can
|
|
17
|
+
* sit silent for seconds between finishing its reasoning and emitting the
|
|
18
|
+
* first content/tool token. This budget therefore covers the reasoning phase
|
|
19
|
+
* AND the reasoning→answer gap — {@link ChunkOutput} `"reasoning"` does NOT
|
|
20
|
+
* switch to the tight budget. Arming the tight budget on a reasoning token is
|
|
21
|
+
* a real observed failure: the normal reasoning→content pause of a frontier
|
|
22
|
+
* reasoning model routinely approaches several seconds and tripped
|
|
23
|
+
* "no chunk mid-stream" on healthy turns.
|
|
24
|
+
* - **Inter-chunk** — the gap between chunks once the model's ANSWER output
|
|
25
|
+
* (content / tool-call arguments / refusal) is flowing. Tight: answer chunks
|
|
26
|
+
* normally arrive sub-second, so a multi-second silence mid-answer is already
|
|
27
|
+
* abnormal, and a shorter budget recovers a mid-stream stall far sooner.
|
|
28
|
+
* - **Overall cap** — an absolute wall-clock ceiling, armed at construction and
|
|
29
|
+
* never re-armed. Defense in depth, not a duplicate of the two idle budgets:
|
|
30
|
+
* the idle watchdog only bounds the *gap* between chunks, so a provider that
|
|
31
|
+
* drip-feeds a chunk just under the idle budget forever (a reasoning delta a
|
|
32
|
+
* minute, never reaching an answer) never trips it. The proper bound on total
|
|
33
|
+
* work is the caller's own deadline signal, but not every caller has one, so
|
|
34
|
+
* this guarantees a single call cannot hold a slot indefinitely regardless.
|
|
35
|
+
*
|
|
36
|
+
* It is armed by the constructor rather than by {@link StreamWatchdog.open}
|
|
37
|
+
* because it is the guarantee of last resort: a host that forgets `open()`
|
|
38
|
+
* gets a watchdog that silently never fires, with no type error and no test
|
|
39
|
+
* failure, and the symptom appears only under a stalling provider. Counting
|
|
40
|
+
* the connection handshake against a ceiling this generous costs nothing;
|
|
41
|
+
* leaving the ceiling to an imperative call the host may skip costs a worker
|
|
42
|
+
* slot. The two *idle* budgets genuinely must wait for `open()` — arming them
|
|
43
|
+
* earlier would charge connection time to the first-token budget.
|
|
44
|
+
*
|
|
45
|
+
* The watchdog owns timers and abort signals and nothing else: it does not read
|
|
46
|
+
* the stream, does not know the wire format, and does not phrase the error. It
|
|
47
|
+
* reports a structured {@link StreamStall} and the host words it — the same
|
|
48
|
+
* split the routing modules use, since a stall message is usually product copy.
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_TIME_TO_FIRST_TOKEN_MS = 120_000;
|
|
51
|
+
export const DEFAULT_INTER_CHUNK_MS = 5_000;
|
|
52
|
+
export const DEFAULT_MAX_CALL_DURATION_MS = 600_000;
|
|
53
|
+
/**
|
|
54
|
+
* The largest delay `setTimeout` can represent. Above this the delay overflows
|
|
55
|
+
* its 32-bit signed field and the timer fires almost immediately instead.
|
|
56
|
+
*/
|
|
57
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
58
|
+
/**
|
|
59
|
+
* A budget must be a delay `setTimeout` can actually honour.
|
|
60
|
+
*
|
|
61
|
+
* This is checked at construction, and it fails closed in the direction that
|
|
62
|
+
* actually bites. Every value rejected here would otherwise make the watchdog
|
|
63
|
+
* fire on the next tick and tear down every *healthy* stream, surfacing as a
|
|
64
|
+
* total inference outage that looks like a provider incident:
|
|
65
|
+
*
|
|
66
|
+
* - `NaN` / `Infinity`: `setTimeout` coerces a non-finite delay to 1ms rather
|
|
67
|
+
* than ignoring it, so a bad budget does not disable the watchdog — it makes
|
|
68
|
+
* it instantaneous.
|
|
69
|
+
* - Zero or negative: fires on the next tick by definition.
|
|
70
|
+
* - Above {@link MAX_TIMEOUT_MS}: overflows the 32-bit delay and, per spec,
|
|
71
|
+
* clamps to 1ms. This is the trap that reads as reasonable — `maxCallDuration`
|
|
72
|
+
* of 30 days is a plausible config value and the units are milliseconds, so
|
|
73
|
+
* it is one `* 1000` away from a healthy-looking number that disables the
|
|
74
|
+
* backstop entirely.
|
|
75
|
+
*
|
|
76
|
+
* Refusing the value at construction turns a silent outage into a stack trace
|
|
77
|
+
* at the call site that supplied it.
|
|
78
|
+
*/
|
|
79
|
+
function requirePositiveMs(value, field) {
|
|
80
|
+
if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMEOUT_MS) {
|
|
81
|
+
throw new RangeError(`StreamWatchdog ${field} must be a positive number of ms no greater than ${MAX_TIMEOUT_MS}, got ${String(value)}`);
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Call `unref()` on a timer when the runtime exposes it (Node/Bun) so a
|
|
87
|
+
* forgotten `dispose()` cannot hold the event loop open; a no-op under a DOM
|
|
88
|
+
* `number` timer or an injected fake. Feature-detected rather than `as`-cast to
|
|
89
|
+
* keep type safety.
|
|
90
|
+
*
|
|
91
|
+
* Duplicated from `../run/harness` deliberately: importing across module
|
|
92
|
+
* folders would make `@juno-ai/bind/completion` drag in the run mechanics for
|
|
93
|
+
* five lines, and a consumer that wants only the watchdog should get only the
|
|
94
|
+
* watchdog.
|
|
95
|
+
*/
|
|
96
|
+
function unrefTimer(timer) {
|
|
97
|
+
if (typeof timer === "object" &&
|
|
98
|
+
timer !== null &&
|
|
99
|
+
"unref" in timer &&
|
|
100
|
+
typeof timer.unref === "function") {
|
|
101
|
+
timer.unref();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const AMBIENT_TIMERS = {
|
|
105
|
+
set: (callback, ms) => setTimeout(callback, ms),
|
|
106
|
+
clear: (handle) => {
|
|
107
|
+
clearTimeout(handle);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
export function createStreamWatchdog(options = {}) {
|
|
111
|
+
const timeToFirstTokenMs = requirePositiveMs(options.timeToFirstTokenMs ?? DEFAULT_TIME_TO_FIRST_TOKEN_MS, "timeToFirstTokenMs");
|
|
112
|
+
const interChunkMs = requirePositiveMs(options.interChunkMs ?? DEFAULT_INTER_CHUNK_MS, "interChunkMs");
|
|
113
|
+
const maxCallDurationMs = requirePositiveMs(options.maxCallDurationMs ?? DEFAULT_MAX_CALL_DURATION_MS, "maxCallDurationMs");
|
|
114
|
+
const external = options.external ?? null;
|
|
115
|
+
const timers = options.timers ?? AMBIENT_TIMERS;
|
|
116
|
+
// Two controllers rather than one, so `stall()` can name which bound tripped
|
|
117
|
+
// without the caller having to correlate timers.
|
|
118
|
+
const idleController = new AbortController();
|
|
119
|
+
const overallController = new AbortController();
|
|
120
|
+
let idleFired = false;
|
|
121
|
+
let overallFired = false;
|
|
122
|
+
let disposed = false;
|
|
123
|
+
// True once any chunk carried model output; only phrases the diagnostic.
|
|
124
|
+
let sawOutput = false;
|
|
125
|
+
// True once ANSWER output flowed. Only this arms the tight budget.
|
|
126
|
+
let sawAnswerOutput = false;
|
|
127
|
+
let idleTimer = null;
|
|
128
|
+
let overallTimer = null;
|
|
129
|
+
function armIdle(ms) {
|
|
130
|
+
if (idleTimer !== null)
|
|
131
|
+
timers.clear(idleTimer);
|
|
132
|
+
idleTimer = timers.set(() => {
|
|
133
|
+
idleFired = true;
|
|
134
|
+
idleController.abort();
|
|
135
|
+
}, ms);
|
|
136
|
+
unrefTimer(idleTimer);
|
|
137
|
+
}
|
|
138
|
+
function clearTimers() {
|
|
139
|
+
if (idleTimer !== null) {
|
|
140
|
+
timers.clear(idleTimer);
|
|
141
|
+
idleTimer = null;
|
|
142
|
+
}
|
|
143
|
+
if (overallTimer !== null) {
|
|
144
|
+
timers.clear(overallTimer);
|
|
145
|
+
overallTimer = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const signal = AbortSignal.any(external
|
|
149
|
+
? [external, idleController.signal, overallController.signal]
|
|
150
|
+
: [idleController.signal, overallController.signal]);
|
|
151
|
+
// Armed here, not in `open()`, so the ceiling holds even for a host that
|
|
152
|
+
// never calls `open()`. See the module header.
|
|
153
|
+
overallTimer = timers.set(() => {
|
|
154
|
+
overallFired = true;
|
|
155
|
+
overallController.abort();
|
|
156
|
+
}, maxCallDurationMs);
|
|
157
|
+
unrefTimer(overallTimer);
|
|
158
|
+
return {
|
|
159
|
+
signal,
|
|
160
|
+
open() {
|
|
161
|
+
if (disposed)
|
|
162
|
+
return;
|
|
163
|
+
armIdle(timeToFirstTokenMs);
|
|
164
|
+
},
|
|
165
|
+
observedChunk(output) {
|
|
166
|
+
if (disposed)
|
|
167
|
+
return;
|
|
168
|
+
if (output !== "none")
|
|
169
|
+
sawOutput = true;
|
|
170
|
+
if (output === "answer")
|
|
171
|
+
sawAnswerOutput = true;
|
|
172
|
+
armIdle(sawAnswerOutput ? interChunkMs : timeToFirstTokenMs);
|
|
173
|
+
},
|
|
174
|
+
stall() {
|
|
175
|
+
// A caller abort wins over every budget: the stream may well have been
|
|
176
|
+
// idle when cancellation landed, but reporting that as an upstream stall
|
|
177
|
+
// would make a deliberate cancellation look retriable.
|
|
178
|
+
if (external?.aborted)
|
|
179
|
+
return null;
|
|
180
|
+
// Checked before the idle budget because the cap is the stronger claim:
|
|
181
|
+
// when both elapsed, the call ran past its absolute ceiling, and the
|
|
182
|
+
// trailing idle gap is a symptom of that rather than a separate fault.
|
|
183
|
+
if (overallFired) {
|
|
184
|
+
return { kind: "overall_cap", limitMs: maxCallDurationMs };
|
|
185
|
+
}
|
|
186
|
+
if (idleFired) {
|
|
187
|
+
if (sawAnswerOutput)
|
|
188
|
+
return { kind: "inter_chunk", limitMs: interChunkMs };
|
|
189
|
+
return {
|
|
190
|
+
kind: "time_to_first_token",
|
|
191
|
+
limitMs: timeToFirstTokenMs,
|
|
192
|
+
sawReasoning: sawOutput,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
},
|
|
197
|
+
dispose() {
|
|
198
|
+
disposed = true;
|
|
199
|
+
clearTimers();
|
|
200
|
+
// Drop any budget that elapsed. A timer that fired in the moment before
|
|
201
|
+
// the stream ended lost the race — it tore nothing down — and leaving the
|
|
202
|
+
// flag set would let it be blamed for a later, unrelated failure on the
|
|
203
|
+
// same call. See the `dispose` doc comment.
|
|
204
|
+
idleFired = false;
|
|
205
|
+
overallFired = false;
|
|
206
|
+
// Abort the idle controller (not the overall one) purely to settle the
|
|
207
|
+
// composite so it releases its listener on `external`.
|
|
208
|
+
idleController.abort();
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -6,16 +6,19 @@
|
|
|
6
6
|
* the harness that runs that chain.
|
|
7
7
|
*
|
|
8
8
|
* Current surface: the tool-calling turn kernel (`src/loop/` — the iteration
|
|
9
|
-
* engine itself), the deterministic LLM provider-routing core
|
|
10
|
-
* vocabulary, the run mechanics (deadline,
|
|
11
|
-
* classification, tool-batch pooling, child-run
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* engine itself), the deterministic LLM provider-routing core with its
|
|
10
|
+
* transport-error classifier, the turn vocabulary, the run mechanics (deadline,
|
|
11
|
+
* coalesced heartbeat, failure classification, tool-batch pooling, child-run
|
|
12
|
+
* lineage and admission), the streaming-completion watchdog and completion
|
|
13
|
+
* defect detection (`src/completion/`), transcript validation/healing, provider
|
|
14
|
+
* tool-schema sanitization, and the plugin/tool vocabulary with its registry and
|
|
15
|
+
* progressive-disclosure activation — generic over the host's invocation
|
|
16
|
+
* context. What is NOT here is
|
|
15
17
|
* the run driver: starting a run, recording what it did, and delivering its
|
|
16
18
|
* output. See the README for the rest of what is deliberately absent.
|
|
17
19
|
*/
|
|
18
20
|
export * from "./routing/index.js";
|
|
21
|
+
export * from "./completion/index.js";
|
|
19
22
|
export * from "./contracts/index.js";
|
|
20
23
|
export * from "./run/index.js";
|
|
21
24
|
export * from "./transcript/index.js";
|
package/index.js
CHANGED
|
@@ -6,16 +6,19 @@
|
|
|
6
6
|
* the harness that runs that chain.
|
|
7
7
|
*
|
|
8
8
|
* Current surface: the tool-calling turn kernel (`src/loop/` — the iteration
|
|
9
|
-
* engine itself), the deterministic LLM provider-routing core
|
|
10
|
-
* vocabulary, the run mechanics (deadline,
|
|
11
|
-
* classification, tool-batch pooling, child-run
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* engine itself), the deterministic LLM provider-routing core with its
|
|
10
|
+
* transport-error classifier, the turn vocabulary, the run mechanics (deadline,
|
|
11
|
+
* coalesced heartbeat, failure classification, tool-batch pooling, child-run
|
|
12
|
+
* lineage and admission), the streaming-completion watchdog and completion
|
|
13
|
+
* defect detection (`src/completion/`), transcript validation/healing, provider
|
|
14
|
+
* tool-schema sanitization, and the plugin/tool vocabulary with its registry and
|
|
15
|
+
* progressive-disclosure activation — generic over the host's invocation
|
|
16
|
+
* context. What is NOT here is
|
|
15
17
|
* the run driver: starting a run, recording what it did, and delivering its
|
|
16
18
|
* output. See the README for the rest of what is deliberately absent.
|
|
17
19
|
*/
|
|
18
20
|
export * from "./routing/index.js";
|
|
21
|
+
export * from "./completion/index.js";
|
|
19
22
|
export * from "./contracts/index.js";
|
|
20
23
|
export * from "./run/index.js";
|
|
21
24
|
export * from "./transcript/index.js";
|
package/loop/tool-loop.js
CHANGED
|
@@ -43,17 +43,29 @@ export async function runToolLoop(params) {
|
|
|
43
43
|
await ensureNotCancelled?.();
|
|
44
44
|
const tools = buildTools();
|
|
45
45
|
onStatus?.(iteration === 0 ? "thinking" : "thinking_with_tools");
|
|
46
|
-
// Stream live token progress: the per-call estimate is added to the
|
|
47
|
-
// from prior iterations so a caller's counter rises
|
|
48
|
-
// multi-iteration run. The real cumulative is published right after the
|
|
49
|
-
// returns (below), reconciling any estimate drift.
|
|
46
|
+
// Stream live token progress: the per-call estimate is added to the
|
|
47
|
+
// cumulative from prior iterations so a caller's counter rises across a
|
|
48
|
+
// multi-iteration run. The real cumulative is published right after the
|
|
49
|
+
// call returns (below), reconciling any estimate drift.
|
|
50
|
+
//
|
|
51
|
+
// Clamped to a per-call high-water mark because `callModel` may internally
|
|
52
|
+
// retry — a defect retry, another provider, the fallback model — and each
|
|
53
|
+
// attempt restarts its own estimate at zero. Unclamped, a caller's counter
|
|
54
|
+
// visibly runs backwards mid-turn ("1.2k tokens" → blank → "120 tokens"),
|
|
55
|
+
// which reads as lost work at exactly the moment the system is recovering
|
|
56
|
+
// from a fault. The mark is per-call, so the post-call reconciliation to
|
|
57
|
+
// the real total below is free to correct downward.
|
|
50
58
|
const baseOutputTokens = state.outputTokens;
|
|
59
|
+
let progressHighWater = baseOutputTokens;
|
|
51
60
|
const result = await callModel(state.messages, tools.length > 0 ? tools : undefined,
|
|
52
61
|
// Carry the cumulative tool count alongside the streamed token estimate so
|
|
53
62
|
// the pill shows both; no tools run *during* a model call, so the count is
|
|
54
63
|
// whatever has accumulated from prior iterations.
|
|
55
64
|
onProgressUpdate
|
|
56
|
-
? (estCallTokens) =>
|
|
65
|
+
? (estCallTokens) => {
|
|
66
|
+
progressHighWater = Math.max(progressHighWater, baseOutputTokens + estCallTokens);
|
|
67
|
+
onProgressUpdate(progressHighWater, state.toolCalls);
|
|
68
|
+
}
|
|
57
69
|
: undefined);
|
|
58
70
|
state.inputTokens += result.inputTokens;
|
|
59
71
|
state.outputTokens += result.outputTokens;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juno-ai/bind",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Agent harness: the tool-calling turn kernel, deterministic LLM provider routing, 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).",
|
|
3
|
+
"version": "6.0.0",
|
|
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",
|
|
7
7
|
"main": "./index.js",
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"types": "./routing/index.d.ts",
|
|
16
16
|
"import": "./routing/index.js"
|
|
17
17
|
},
|
|
18
|
+
"./completion": {
|
|
19
|
+
"types": "./completion/index.d.ts",
|
|
20
|
+
"import": "./completion/index.js"
|
|
21
|
+
},
|
|
18
22
|
"./contracts": {
|
|
19
23
|
"types": "./contracts/index.d.ts",
|
|
20
24
|
"import": "./contracts/index.js"
|