@juno-ai/bind 13.0.0 → 15.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 +123 -0
- package/loop/index.d.ts +1 -1
- package/loop/index.js +1 -1
- package/loop/tool-loop.d.ts +157 -0
- package/loop/tool-loop.js +178 -4
- package/package.json +1 -1
- package/tools/sanitize-schema.d.ts +69 -14
- package/tools/sanitize-schema.js +342 -27
package/README.md
CHANGED
|
@@ -35,6 +35,15 @@ constraints that will fail CI if you break them.
|
|
|
35
35
|
|
|
36
36
|
**Added**
|
|
37
37
|
|
|
38
|
+
- **Three loop ports for a host whose transcript is durable** —
|
|
39
|
+
`ToolLoopTurn.acceptMessage`, `ToolLoopParams.beforeToolMessageAccepted` and
|
|
40
|
+
`ToolLoopParams.onStepSettled` (plus `StepSettlement`,
|
|
41
|
+
`DiscardedTurnWithToolCallsError` and `InputBlockedBySuspendError`).
|
|
42
|
+
Together they let a host record each accepted message as the loop accepts
|
|
43
|
+
it, in the order it accepts it, and hand back messages it has committed.
|
|
44
|
+
All three are optional and unwired behaviour is unchanged. See "How to
|
|
45
|
+
commit a transcript as the loop builds it".
|
|
46
|
+
|
|
38
47
|
- **`@juno-ai/bind/skills`** — progressive disclosure for *instructions*, the
|
|
39
48
|
mirror of `@juno-ai/bind/plugins`. `createSkillRegistry` for what your source
|
|
40
49
|
ships, `partitionSkillCatalog` for the Tier-1 catalog and its token budget,
|
|
@@ -56,6 +65,27 @@ constraints that will fail CI if you break them.
|
|
|
56
65
|
|
|
57
66
|
**Breaking**
|
|
58
67
|
|
|
68
|
+
- **`sanitizeToolSchema` now emits a union for a nullable property** instead of
|
|
69
|
+
collapsing it. `{type:["string","null"], minLength:1}` comes out as
|
|
70
|
+
`{anyOf:[{type:"string",minLength:1},{type:"null"}]}`, with the node's
|
|
71
|
+
type-bearing keywords on the typed branch and its annotations left outside;
|
|
72
|
+
the old output was `{type:"string", minLength:1}` plus an "Accepts string or
|
|
73
|
+
null." note on the description. Nothing in the API changed, but the emitted
|
|
74
|
+
schema did — a consumer asserting on sanitizer output, or reading `.type` off
|
|
75
|
+
a sanitized node, needs updating.
|
|
76
|
+
|
|
77
|
+
The collapse was lossy in a way that broke callers. A model handed a
|
|
78
|
+
type-satisfying schema and a description that says "or send null" cannot
|
|
79
|
+
express "none" in the half of the declaration it treats as binding, so it
|
|
80
|
+
invents a value: one tool parameter authored `type:["string","null"],
|
|
81
|
+
minLength:1` received `"/"`, `". "` and `".000001"` for thousands of calls,
|
|
82
|
+
each refused by the downstream API and each retried. The union shape is
|
|
83
|
+
measured accepted on grok-4.3/4.5/4.6, gemini-3.5/3.6/3.7-flash and
|
|
84
|
+
gpt-5.6-terra/sol/luna. A genuine multi-type union
|
|
85
|
+
(`["string","number","boolean"]`) still collapses — no single shape is
|
|
86
|
+
accepted by every provider — and so does a nullable type at the parameters root, in
|
|
87
|
+
a branch of a root `anyOf`/`oneOf`, or in a composition branch whose
|
|
88
|
+
`required` resolves against the enclosing node.
|
|
59
89
|
- `runToolLoop` now returns `ToolLoopResult` (`{ stopReason, stats }`) instead
|
|
60
90
|
of `void`. A caller that ignores the return value is unchanged, but a
|
|
61
91
|
wrapper *annotated* `Promise<void>` no longer typechecks — widen it to
|
|
@@ -810,6 +840,84 @@ represent faithfully — a `Map`, a `Set`, a class instance, `NaN`, a cycle —
|
|
|
810
840
|
because a silent collision here reads as "same call" and skips a write that
|
|
811
841
|
never happened.
|
|
812
842
|
|
|
843
|
+
### How to commit a transcript as the loop builds it
|
|
844
|
+
|
|
845
|
+
`state.messages` is an in-memory array. If your host persists each message as it
|
|
846
|
+
lands — a Durable Object writing transitions, an event-sourced run that replays
|
|
847
|
+
after eviction — you need three things the array alone cannot give you: the
|
|
848
|
+
exact message at the moment it is accepted, its position among the results the
|
|
849
|
+
batch actually produced, and a point where you can hand back input your users
|
|
850
|
+
committed while the agent was working.
|
|
851
|
+
|
|
852
|
+
```ts
|
|
853
|
+
await runToolLoop({
|
|
854
|
+
// …
|
|
855
|
+
callModel: async (messages, tools) => {
|
|
856
|
+
const completion = await transport(messages, tools);
|
|
857
|
+
const empty = !completion.content.trim() && completion.toolCalls.length === 0;
|
|
858
|
+
if (empty) await settleReservation(turnId, "failed");
|
|
859
|
+
else await commit({ kind: "assistant_accepted", turnId, message: completion.message });
|
|
860
|
+
return { ...completion, acceptMessage: !empty };
|
|
861
|
+
},
|
|
862
|
+
beforeToolMessageAccepted: async (message, resultOrdinal) => {
|
|
863
|
+
// Throws → the message is NOT appended and the run fails.
|
|
864
|
+
await commit({ kind: "tool_result_accepted", message, resultOrdinal });
|
|
865
|
+
},
|
|
866
|
+
onStepSettled: async ({ wouldEnd }) => {
|
|
867
|
+
const pending = await claimCommittedInput({ wouldEnd });
|
|
868
|
+
return { messages: pending.messages, stop: pending.runWasCancelled };
|
|
869
|
+
},
|
|
870
|
+
});
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
**`acceptMessage: false` keeps a turn out of the transcript without hiding what
|
|
874
|
+
it cost.** The case is a tolerated empty completion: the provider returned
|
|
875
|
+
nothing, your host has already settled the slot it reserved, and appending an
|
|
876
|
+
empty assistant message would persist a turn that said nothing and re-send it on
|
|
877
|
+
every later call. Tokens and cost are still folded into `stats` and `state`. The
|
|
878
|
+
loop **refuses** the discard for a turn that requested tools — those results are
|
|
879
|
+
about to be appended and would have no request to pair with — and reports the
|
|
880
|
+
misuse through `onToolCallRejected` as a `DiscardedTurnWithToolCallsError`.
|
|
881
|
+
|
|
882
|
+
**`resultOrdinal` is dense over accepted results.** It is not the call's index
|
|
883
|
+
in `tool_calls`: an `answer`-suspend withholds its result, so the calls after it
|
|
884
|
+
close the gap, and the answer takes the ordinal it is accepted at when it
|
|
885
|
+
arrives. Reconstructing that order from `state.messages` afterwards is the one
|
|
886
|
+
thing that goes wrong for exactly the batch that suspended.
|
|
887
|
+
|
|
888
|
+
**`beforeToolMessageAccepted` covers results this loop produced, not messages
|
|
889
|
+
you hand back.** A `tool` message you return from `onStepSettled` skips the
|
|
890
|
+
port and takes no ordinal — you committed it before handing it over, so
|
|
891
|
+
reporting it back would ask you to record the same thing twice.
|
|
892
|
+
|
|
893
|
+
**A settled step is where input can join.** `onStepSettled` fires **at most
|
|
894
|
+
once per iteration**, and `wouldEnd` says which of the two shapes that
|
|
895
|
+
iteration took: `true` when the model asked for no tools (the run is about to
|
|
896
|
+
end), `false` when the batch's results are all in the transcript. Anywhere
|
|
897
|
+
else, an appended message lands between an assistant message and the results
|
|
898
|
+
it is waiting on, which is a transcript providers reject. Returned messages
|
|
899
|
+
are appended in order and the run continues; `stop` ends it as `aborted` with
|
|
900
|
+
them already appended, and a throw propagates out of `runToolLoop`
|
|
901
|
+
unconverted.
|
|
902
|
+
|
|
903
|
+
Two behaviours worth knowing before you wire it:
|
|
904
|
+
|
|
905
|
+
- At `wouldEnd: true`, `onStepSettled` runs **before** `onTurnWouldEnd`. Real
|
|
906
|
+
input outranks a nudge — a turn with more to answer was never stalled.
|
|
907
|
+
- A returned `role:"tool"` message carrying an **open suspend's `tool_call_id`
|
|
908
|
+
clears the suspension**, so a host that already has the answer keeps going
|
|
909
|
+
instead of pausing for a reply it has been handed. A `wake`-suspend from
|
|
910
|
+
another call in the same batch still ends the run: the answer says nothing
|
|
911
|
+
about it.
|
|
912
|
+
- **While a suspend is open, only `tool` messages are accepted.** A `user`
|
|
913
|
+
message delivered ahead of the answer is refused and reported as an
|
|
914
|
+
`InputBlockedBySuspendError`: appending it is how a resumed run ends up
|
|
915
|
+
sending `assistant(tool_calls) → user → tool`, which providers reject. Put
|
|
916
|
+
the answering result first in the array and the rest is accepted after it.
|
|
917
|
+
- **`shouldStop` outranks delivered input.** The messages are kept — your host
|
|
918
|
+
committed them — and the run ends as `aborted` rather than making another
|
|
919
|
+
model call.
|
|
920
|
+
|
|
813
921
|
### How to stream tokens to a user without breaking fallback
|
|
814
922
|
|
|
815
923
|
Three granularities reach you, and the third is the one with a retry problem.
|
|
@@ -1048,6 +1156,15 @@ property; a boolean `additionalProperties: false` on a nested object; and a
|
|
|
1048
1156
|
parameter literally named `properties`. Run it on third-party (e.g. MCP) tool
|
|
1049
1157
|
schemas too — those are where the violations usually come from.
|
|
1050
1158
|
|
|
1159
|
+
One transform is worth knowing about even if no provider forced it. A **nullable
|
|
1160
|
+
property** — `{type:["string","null"], minLength:1}` — is rewritten to
|
|
1161
|
+
`{anyOf:[{type:"string",minLength:1},{type:"null"}]}` rather than reduced to its
|
|
1162
|
+
non-null type with a note on the description. Reducing it is what a strict
|
|
1163
|
+
provider needs, but it leaves the model unable to say "none" in the part of the
|
|
1164
|
+
declaration it treats as binding, so it invents a value that satisfies the type:
|
|
1165
|
+
`"/"`, `" "`, `"null"`, `"undefined"`. If you author nullable parameters, you do
|
|
1166
|
+
not need to hand-write the union — the sanitizer produces it.
|
|
1167
|
+
|
|
1051
1168
|
### How to repair a transcript before sending it
|
|
1052
1169
|
|
|
1053
1170
|
```ts
|
|
@@ -1434,6 +1551,12 @@ what happens to the tool message:
|
|
|
1434
1551
|
`request` is an opaque render/route payload, validated by the consumer and never
|
|
1435
1552
|
inspected by the loop.
|
|
1436
1553
|
|
|
1554
|
+
An open `"answer"` suspend can also be resolved **within the same step**: a
|
|
1555
|
+
`role:"tool"` message returned from `onStepSettled` that carries the open call's
|
|
1556
|
+
`tool_call_id` clears the suspension and the run continues, for a host that
|
|
1557
|
+
already holds the reply. A `"wake"` suspend from another call in the same batch
|
|
1558
|
+
still ends the run — see "How to commit a transcript as the loop builds it".
|
|
1559
|
+
|
|
1437
1560
|
---
|
|
1438
1561
|
|
|
1439
1562
|
## Usage scenarios
|
package/loop/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export type { StopReason, RunStats } from "../contracts/turn.js";
|
|
2
|
-
export { runToolLoop, MissingActivationPortError, type ToolLoopParams, type ToolLoopResult, type ToolLoopState, type ToolLoopTurn, type ToolCallOutcome, type CompactionApplied, type RunStatus, } from "./tool-loop.js";
|
|
2
|
+
export { runToolLoop, MissingActivationPortError, DiscardedTurnWithToolCallsError, InputBlockedBySuspendError, type ToolLoopParams, type ToolLoopResult, type ToolLoopState, type ToolLoopTurn, type ToolCallOutcome, type StepSettlement, type CompactionApplied, type RunStatus, } from "./tool-loop.js";
|
package/loop/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { runToolLoop, MissingActivationPortError, } from "./tool-loop.js";
|
|
1
|
+
export { runToolLoop, MissingActivationPortError, DiscardedTurnWithToolCallsError, InputBlockedBySuspendError, } from "./tool-loop.js";
|
package/loop/tool-loop.d.ts
CHANGED
|
@@ -40,6 +40,30 @@ export interface ToolLoopTurn {
|
|
|
40
40
|
* simply does not count this turn, rather than counting it as a zero.
|
|
41
41
|
*/
|
|
42
42
|
cachedInputTokens?: number | null;
|
|
43
|
+
/**
|
|
44
|
+
* Set `false` to keep this completion OUT of the transcript while still
|
|
45
|
+
* accounting for what it cost. The turn happened — it is folded into
|
|
46
|
+
* `stats` and `state` either way — but `state.messages` never grows an
|
|
47
|
+
* assistant entry for it.
|
|
48
|
+
*
|
|
49
|
+
* The case it exists for is a **tolerated empty completion**: a provider
|
|
50
|
+
* answered with no text and no tool calls, the host decided that is a
|
|
51
|
+
* nothing rather than a failure, and the host had already reserved a
|
|
52
|
+
* transcript slot for the message it never got. Appending the empty
|
|
53
|
+
* assistant message would persist a turn saying nothing and re-send it on
|
|
54
|
+
* every later call; dropping it leaves the transcript exactly as the model
|
|
55
|
+
* found it, so the next turn is a clean retry of the same question.
|
|
56
|
+
*
|
|
57
|
+
* **Only a turn with no tool calls may be dropped.** Withholding a
|
|
58
|
+
* tool-calling assistant message orphans every `tool` result the batch is
|
|
59
|
+
* about to append, which is an invalid transcript the next model call
|
|
60
|
+
* rejects. The loop refuses that rather than building it — it reports the
|
|
61
|
+
* misuse through `onToolCallRejected` (one report per orphaned call, the
|
|
62
|
+
* same channel a missing activation port uses) and keeps the message.
|
|
63
|
+
*
|
|
64
|
+
* Defaults to accepting: omit it, or pass `true`, and nothing changes.
|
|
65
|
+
*/
|
|
66
|
+
acceptMessage?: boolean;
|
|
43
67
|
}
|
|
44
68
|
/**
|
|
45
69
|
* Outcome of running one tool call inside an assistant `tool_calls` batch.
|
|
@@ -155,6 +179,44 @@ export declare class MissingActivationPortError extends Error {
|
|
|
155
179
|
readonly name = "MissingActivationPortError";
|
|
156
180
|
constructor(port: "activatePlugins" | "activateSkills");
|
|
157
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* A turn returned `acceptMessage: false` while also requesting tools.
|
|
184
|
+
*
|
|
185
|
+
* Reported through `onToolCallRejected` rather than thrown, and the discard is
|
|
186
|
+
* refused rather than honoured: the tool calls are about to run, and a
|
|
187
|
+
* transcript carrying their results without the assistant message that asked
|
|
188
|
+
* for them is one the next provider call rejects outright. Keeping the message
|
|
189
|
+
* costs an assistant entry the host did not want; dropping it costs the run.
|
|
190
|
+
*
|
|
191
|
+
* Match on `error.name === "DiscardedTurnWithToolCallsError"` rather than
|
|
192
|
+
* `instanceof` — see {@link MissingActivationPortError} for why.
|
|
193
|
+
*/
|
|
194
|
+
export declare class DiscardedTurnWithToolCallsError extends Error {
|
|
195
|
+
readonly toolCallCount: number;
|
|
196
|
+
readonly name = "DiscardedTurnWithToolCallsError";
|
|
197
|
+
constructor(toolCallCount: number);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* A settled step delivered a message that cannot be appended yet: a call is
|
|
201
|
+
* still awaiting its answer, and the provider rule is that nothing comes
|
|
202
|
+
* between an assistant message and the `tool` results it is waiting on.
|
|
203
|
+
*
|
|
204
|
+
* Appending it anyway builds `assistant(tool_calls) → user → tool` the moment
|
|
205
|
+
* the answer is threaded back on resume, which providers reject outright — so
|
|
206
|
+
* the message is refused and reported (through `onToolCallRejected`, against
|
|
207
|
+
* the open call) rather than silently corrupting the transcript. Deliver the
|
|
208
|
+
* answering `tool` result first, in the same settlement, and everything after
|
|
209
|
+
* it is accepted normally.
|
|
210
|
+
*
|
|
211
|
+
* Match on `error.name === "InputBlockedBySuspendError"` rather than
|
|
212
|
+
* `instanceof` — see {@link MissingActivationPortError} for why.
|
|
213
|
+
*/
|
|
214
|
+
export declare class InputBlockedBySuspendError extends Error {
|
|
215
|
+
readonly openToolCallId: string;
|
|
216
|
+
readonly refusedRole: string;
|
|
217
|
+
readonly name = "InputBlockedBySuspendError";
|
|
218
|
+
constructor(openToolCallId: string, refusedRole: string);
|
|
219
|
+
}
|
|
158
220
|
/**
|
|
159
221
|
* What a completed loop reports back.
|
|
160
222
|
*
|
|
@@ -198,6 +260,17 @@ export interface ToolLoopResult {
|
|
|
198
260
|
*/
|
|
199
261
|
readonly stats: RunStats;
|
|
200
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* What a host hands back from {@link ToolLoopParams.onStepSettled}: the
|
|
265
|
+
* messages to append at this settled step, and whether the run should end
|
|
266
|
+
* after appending them.
|
|
267
|
+
*/
|
|
268
|
+
export interface StepSettlement {
|
|
269
|
+
/** Appended to the transcript in this order. Empty means "nothing queued". */
|
|
270
|
+
messages: OpenAI.ChatCompletionMessageParam[];
|
|
271
|
+
/** End the run as `aborted` once the messages above are appended. */
|
|
272
|
+
stop: boolean;
|
|
273
|
+
}
|
|
201
274
|
export interface ToolLoopParams {
|
|
202
275
|
state: ToolLoopState;
|
|
203
276
|
/**
|
|
@@ -217,6 +290,90 @@ export interface ToolLoopParams {
|
|
|
217
290
|
buildTools: () => OpenAI.ChatCompletionTool[];
|
|
218
291
|
/** Execute one tool call → the `tool` message + control signals. */
|
|
219
292
|
runToolCall: (toolCall: OpenAI.ChatCompletionMessageToolCall) => Promise<ToolCallOutcome>;
|
|
293
|
+
/**
|
|
294
|
+
* Record the exact `tool` message the loop is about to append, immediately
|
|
295
|
+
* before it appends it. For a host that persists the transcript as it is
|
|
296
|
+
* built, this is the only point where "what was accepted" and "what was
|
|
297
|
+
* recorded" cannot drift: a batch's outcomes are produced concurrently and
|
|
298
|
+
* an `answer`-suspend withholds one of them, so a host reconstructing the
|
|
299
|
+
* accepted set afterwards has to re-derive a decision the loop already made.
|
|
300
|
+
*
|
|
301
|
+
* `resultOrdinal` counts **accepted results in this batch, densely from 0**
|
|
302
|
+
* — not the call's index in `tool_calls`. A withheld suspend leaves no
|
|
303
|
+
* ordinal behind, so the results that follow it close the gap, and the
|
|
304
|
+
* answer that arrives later takes the ordinal it is accepted at rather than
|
|
305
|
+
* the one its call was issued at. Ordering within the batch is otherwise
|
|
306
|
+
* the model's original call order.
|
|
307
|
+
*
|
|
308
|
+
* **A throw aborts acceptance**: the message is not appended and the error
|
|
309
|
+
* propagates out of `runToolLoop`, unconverted. That is deliberate — this
|
|
310
|
+
* port exists for hosts that must record a result durably before the model
|
|
311
|
+
* may see it, and a host that cannot record one must not be handed a
|
|
312
|
+
* transcript claiming it did. Wire `isFatalToolError` the same way for the
|
|
313
|
+
* dispatch half of that rule.
|
|
314
|
+
*
|
|
315
|
+
* **Scope: results this loop produced.** A `tool` message the host itself
|
|
316
|
+
* returns from `onStepSettled` does not fire this port and takes no ordinal
|
|
317
|
+
* — the host committed that message before handing it over, so reporting it
|
|
318
|
+
* back would ask for a second record of the same thing. `resultOrdinal` is
|
|
319
|
+
* therefore a position within the batch, and a host that journals both
|
|
320
|
+
* sources keys them by where they came from rather than by one counter.
|
|
321
|
+
*/
|
|
322
|
+
beforeToolMessageAccepted?: (message: OpenAI.ChatCompletionToolMessageParam, resultOrdinal: number) => Promise<void> | void;
|
|
323
|
+
/**
|
|
324
|
+
* Deliver host-committed messages at a **settled step** — a point where the
|
|
325
|
+
* model turn and its whole tool batch are complete and the transcript is
|
|
326
|
+
* coherent, so appending a `user` (or a withheld `tool`) message cannot
|
|
327
|
+
* split an assistant message from the results it is waiting on.
|
|
328
|
+
*
|
|
329
|
+
* Called **at most once per iteration** — the two points below are the two
|
|
330
|
+
* shapes an iteration can take, not two calls in one — and `wouldEnd` says
|
|
331
|
+
* which one this is:
|
|
332
|
+
*
|
|
333
|
+
* - **`wouldEnd: true`** — the model asked for no tools and the loop is
|
|
334
|
+
* about to finish. Returning messages CONTINUES the run with them
|
|
335
|
+
* appended, ahead of `onTurnWouldEnd` (a host that has real input queued
|
|
336
|
+
* delivers it rather than nudging a turn that was not actually stalled).
|
|
337
|
+
* - **`wouldEnd: false`** — the batch ran and its results are in the
|
|
338
|
+
* transcript. Returning messages appends them before the next model call.
|
|
339
|
+
*
|
|
340
|
+
* Return `stop: true` to end the run as `aborted`, with whatever messages
|
|
341
|
+
* came back already appended. Returning `{ messages: [], stop: false }` —
|
|
342
|
+
* or leaving the port unwired — changes nothing.
|
|
343
|
+
*
|
|
344
|
+
* **A delivered `tool` message answers an open suspend.** If the run is
|
|
345
|
+
* waiting on an `answer`-suspend and a returned message carries that call's
|
|
346
|
+
* `tool_call_id`, the loop clears the suspension and keeps going rather than
|
|
347
|
+
* pausing for a reply it has just been handed. It still ends the run if some
|
|
348
|
+
* *other* call in the same batch asked for a `wake`-suspend, which is a
|
|
349
|
+
* separate request that a delivered answer says nothing about.
|
|
350
|
+
*
|
|
351
|
+
* **While a suspend is open, only `tool` messages are accepted.** Nothing
|
|
352
|
+
* may come between an assistant message and the results it is waiting on, so
|
|
353
|
+
* a `user` message delivered ahead of the answer is refused and reported as
|
|
354
|
+
* an {@link InputBlockedBySuspendError} — appending it is how a resumed run
|
|
355
|
+
* ends up sending `assistant(tool_calls) → user → tool`, which providers
|
|
356
|
+
* reject. Return the answering result first and the rest is accepted after
|
|
357
|
+
* it, in order.
|
|
358
|
+
*
|
|
359
|
+
* **A host that asked to stop still stops.** Delivered input says the turn
|
|
360
|
+
* is not over; `shouldStop` says the run is, and the run wins — the messages
|
|
361
|
+
* are kept and the loop ends as `aborted` rather than paying for another
|
|
362
|
+
* model call.
|
|
363
|
+
*
|
|
364
|
+
* Messages are otherwise appended in the order returned, and the loop does
|
|
365
|
+
* not validate them further: this is the host's transcript, committed on the
|
|
366
|
+
* host's side, arriving at the one place the loop can take it.
|
|
367
|
+
*
|
|
368
|
+
* **A throw propagates out of `runToolLoop` unconverted**, like
|
|
369
|
+
* `beforeToolMessageAccepted` and for the same reason: a host that cannot
|
|
370
|
+
* say what it has committed cannot be told the run went on without it.
|
|
371
|
+
*/
|
|
372
|
+
onStepSettled?: (step: {
|
|
373
|
+
assistantMessage: OpenAI.ChatCompletionMessage;
|
|
374
|
+
cumulativeToolCalls: number;
|
|
375
|
+
wouldEnd: boolean;
|
|
376
|
+
}) => StepSettlement | Promise<StepSettlement>;
|
|
220
377
|
/**
|
|
221
378
|
* Activate newly loaded plugins (mutate the catalog/active set). Optional:
|
|
222
379
|
* a host with a fixed tool surface has nothing to activate, and requiring an
|
package/loop/tool-loop.js
CHANGED
|
@@ -29,6 +29,56 @@ export class MissingActivationPortError extends Error {
|
|
|
29
29
|
this.port = port;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* A turn returned `acceptMessage: false` while also requesting tools.
|
|
34
|
+
*
|
|
35
|
+
* Reported through `onToolCallRejected` rather than thrown, and the discard is
|
|
36
|
+
* refused rather than honoured: the tool calls are about to run, and a
|
|
37
|
+
* transcript carrying their results without the assistant message that asked
|
|
38
|
+
* for them is one the next provider call rejects outright. Keeping the message
|
|
39
|
+
* costs an assistant entry the host did not want; dropping it costs the run.
|
|
40
|
+
*
|
|
41
|
+
* Match on `error.name === "DiscardedTurnWithToolCallsError"` rather than
|
|
42
|
+
* `instanceof` — see {@link MissingActivationPortError} for why.
|
|
43
|
+
*/
|
|
44
|
+
export class DiscardedTurnWithToolCallsError extends Error {
|
|
45
|
+
toolCallCount;
|
|
46
|
+
name = "DiscardedTurnWithToolCallsError";
|
|
47
|
+
constructor(toolCallCount) {
|
|
48
|
+
super(`A turn asked the loop to drop its assistant message, but the message ` +
|
|
49
|
+
`requests ${toolCallCount} tool call(s) whose results would be left ` +
|
|
50
|
+
`unpaired. The message was kept. Only a tool-less turn may be dropped.`);
|
|
51
|
+
this.toolCallCount = toolCallCount;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A settled step delivered a message that cannot be appended yet: a call is
|
|
56
|
+
* still awaiting its answer, and the provider rule is that nothing comes
|
|
57
|
+
* between an assistant message and the `tool` results it is waiting on.
|
|
58
|
+
*
|
|
59
|
+
* Appending it anyway builds `assistant(tool_calls) → user → tool` the moment
|
|
60
|
+
* the answer is threaded back on resume, which providers reject outright — so
|
|
61
|
+
* the message is refused and reported (through `onToolCallRejected`, against
|
|
62
|
+
* the open call) rather than silently corrupting the transcript. Deliver the
|
|
63
|
+
* answering `tool` result first, in the same settlement, and everything after
|
|
64
|
+
* it is accepted normally.
|
|
65
|
+
*
|
|
66
|
+
* Match on `error.name === "InputBlockedBySuspendError"` rather than
|
|
67
|
+
* `instanceof` — see {@link MissingActivationPortError} for why.
|
|
68
|
+
*/
|
|
69
|
+
export class InputBlockedBySuspendError extends Error {
|
|
70
|
+
openToolCallId;
|
|
71
|
+
refusedRole;
|
|
72
|
+
name = "InputBlockedBySuspendError";
|
|
73
|
+
constructor(openToolCallId, refusedRole) {
|
|
74
|
+
super(`A settled step delivered a \`${refusedRole}\` message while tool call ` +
|
|
75
|
+
`${openToolCallId} is still awaiting its answer. It was refused: the ` +
|
|
76
|
+
`answer has to reach the transcript first, or the resumed run sends ` +
|
|
77
|
+
`a \`tool\` result the message has already split from its call.`);
|
|
78
|
+
this.openToolCallId = openToolCallId;
|
|
79
|
+
this.refusedRole = refusedRole;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
32
82
|
/**
|
|
33
83
|
* The key a tool call is measured under in {@link RunStats.toolTimeBreakdownMs}.
|
|
34
84
|
*
|
|
@@ -67,7 +117,7 @@ function toolCallStatsKey(tc) {
|
|
|
67
117
|
* run's conclusion, which only exists once the loop is over.
|
|
68
118
|
*/
|
|
69
119
|
export async function runToolLoop(params) {
|
|
70
|
-
const { state, maxIterations, callModel, buildTools, runToolCall, activatePlugins, activateSkills, now = Date.now, ensureNotCancelled, throwIfTimedOut, onStatus, signal, onThinking, onAssistantMessage, flushProgress, onProgressUpdate, shouldStop, onTurnWouldEnd, drainInterrupts, onInterruptReceived, needsCompaction, applyCompaction, runsSerially, isFatalToolError, onToolCallRejected, } = params;
|
|
120
|
+
const { state, maxIterations, callModel, buildTools, runToolCall, activatePlugins, activateSkills, now = Date.now, ensureNotCancelled, throwIfTimedOut, onStatus, signal, onThinking, onAssistantMessage, flushProgress, onProgressUpdate, shouldStop, onTurnWouldEnd, drainInterrupts, onInterruptReceived, needsCompaction, applyCompaction, runsSerially, isFatalToolError, onToolCallRejected, beforeToolMessageAccepted, onStepSettled, } = params;
|
|
71
121
|
let stats = emptyRunStats();
|
|
72
122
|
// The default is the outcome of falling out of the `for` — every other exit
|
|
73
123
|
// assigns before it breaks. Seeding it here rather than at each `break` means
|
|
@@ -116,6 +166,55 @@ export async function runToolLoop(params) {
|
|
|
116
166
|
// Nothing useful to do — the reporting channel is the thing that broke.
|
|
117
167
|
}
|
|
118
168
|
};
|
|
169
|
+
// Ask the host what it has committed for this settled step. Unwired, the
|
|
170
|
+
// answer is "nothing, keep going" — the one shape every call site below
|
|
171
|
+
// reads, so neither of them branches on whether the port exists.
|
|
172
|
+
const settleStep = async (assistantMessage, wouldEnd) => {
|
|
173
|
+
const settlement = await onStepSettled?.({
|
|
174
|
+
assistantMessage,
|
|
175
|
+
cumulativeToolCalls: state.toolCalls,
|
|
176
|
+
wouldEnd,
|
|
177
|
+
});
|
|
178
|
+
// Read field by field rather than trusting the object whole: the types say
|
|
179
|
+
// both are present, and a JavaScript host (this package is published, and
|
|
180
|
+
// was reimplemented once already as a patch) that returns `{ stop: true }`
|
|
181
|
+
// would otherwise crash the run inside the loop rather than stop it.
|
|
182
|
+
return {
|
|
183
|
+
messages: settlement?.messages ?? [],
|
|
184
|
+
stop: settlement?.stop === true,
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
// Append a settlement's messages, honouring the one ordering rule an open
|
|
188
|
+
// `answer`-suspend imposes: until that call has its result, nothing else may
|
|
189
|
+
// come between it and the assistant message that asked for it. A delivered
|
|
190
|
+
// result for the open call clears the pause (the answer arrived in the same
|
|
191
|
+
// breath as the question); anything that is not a `tool` message is refused
|
|
192
|
+
// while the pause is open, because appending it is how a resumed run ends up
|
|
193
|
+
// sending `assistant(tool_calls) → user → tool`.
|
|
194
|
+
//
|
|
195
|
+
// Shared by both settle sites rather than living in the batch arm alone: a
|
|
196
|
+
// caller may seed `state.suspended` when it resumes a run, and the rule is
|
|
197
|
+
// about the transcript, not about which branch produced it.
|
|
198
|
+
const applySettledMessages = (messages) => {
|
|
199
|
+
let appended = 0;
|
|
200
|
+
let answeredSuspend = false;
|
|
201
|
+
for (const message of messages) {
|
|
202
|
+
const openSuspension = state.suspended;
|
|
203
|
+
if (openSuspension) {
|
|
204
|
+
if (message.role !== "tool") {
|
|
205
|
+
reportRejection(openSuspension.toolCallId, new InputBlockedBySuspendError(openSuspension.toolCallId, message.role));
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (message.tool_call_id === openSuspension.toolCallId) {
|
|
209
|
+
state.suspended = undefined;
|
|
210
|
+
answeredSuspend = true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
state.messages.push(message);
|
|
214
|
+
appended += 1;
|
|
215
|
+
}
|
|
216
|
+
return { appended, answeredSuspend };
|
|
217
|
+
};
|
|
119
218
|
// Swap in a freshly-compacted transcript and reset the live counts. The
|
|
120
219
|
// provider count no longer reflects the compacted array, so drop it — the
|
|
121
220
|
// auto-compaction check skips while it's 0 (preventing an immediate
|
|
@@ -209,7 +308,20 @@ export async function runToolLoop(params) {
|
|
|
209
308
|
state.lastOutputTokens = result.outputTokens;
|
|
210
309
|
state.hasFreshTokenCount = true;
|
|
211
310
|
const assistantMessage = result.message;
|
|
212
|
-
|
|
311
|
+
// `acceptMessage: false` keeps a tolerated empty completion out of the
|
|
312
|
+
// transcript — the turn is still accounted for above. Refused for a
|
|
313
|
+
// tool-calling turn: the batch below appends results, and results whose
|
|
314
|
+
// request is missing are a transcript the next provider call rejects.
|
|
315
|
+
const requestedToolCalls = assistantMessage.tool_calls ?? [];
|
|
316
|
+
if (result.acceptMessage === false && requestedToolCalls.length > 0) {
|
|
317
|
+
const misuse = new DiscardedTurnWithToolCallsError(requestedToolCalls.length);
|
|
318
|
+
for (const tc of requestedToolCalls) {
|
|
319
|
+
reportRejection(tc.id, misuse);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (result.acceptMessage !== false || requestedToolCalls.length > 0) {
|
|
323
|
+
state.messages.push(assistantMessage);
|
|
324
|
+
}
|
|
213
325
|
if (assistantMessage.content && assistantMessage.tool_calls?.length) {
|
|
214
326
|
onThinking?.(assistantMessage.content);
|
|
215
327
|
}
|
|
@@ -244,6 +356,32 @@ export async function runToolLoop(params) {
|
|
|
244
356
|
state.toolCalls += toolCalls.length;
|
|
245
357
|
onProgressUpdate?.(state.outputTokens, state.toolCalls);
|
|
246
358
|
if (!toolCalls || toolCalls.length === 0) {
|
|
359
|
+
// The step is settled: no batch to run, so the transcript is coherent
|
|
360
|
+
// right here. Ask the host for anything it has committed since the turn
|
|
361
|
+
// began — real input outranks a nudge, so this runs BEFORE
|
|
362
|
+
// `onTurnWouldEnd`. A host that delivers a message is saying the turn is
|
|
363
|
+
// not over, so the loop continues with it appended rather than asking
|
|
364
|
+
// whether a turn that has more to answer looked stalled.
|
|
365
|
+
const settlement = await settleStep(assistantMessage, true);
|
|
366
|
+
const settledInput = applySettledMessages(settlement.messages);
|
|
367
|
+
if (settlement.stop) {
|
|
368
|
+
stopReason = "aborted";
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
if (settledInput.appended > 0) {
|
|
372
|
+
// Delivered input says the turn is not over; a host that asked to stop
|
|
373
|
+
// says the RUN is. The stop wins, exactly as it does over a nudge
|
|
374
|
+
// below — otherwise `shouldStop` ("stop after the current turn") pays
|
|
375
|
+
// for another model call and answers after it was cancelled. The
|
|
376
|
+
// messages stay: the host committed them, and dropping them here would
|
|
377
|
+
// leave its ledger and this transcript disagreeing.
|
|
378
|
+
if (stopRequested) {
|
|
379
|
+
onProgressUpdate?.(state.outputTokens, state.toolCalls);
|
|
380
|
+
stopReason = "aborted";
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
247
385
|
// The model stopped calling tools — normally the turn is done. Give a
|
|
248
386
|
// caller a chance to push it forward instead: if `onTurnWouldEnd` returns
|
|
249
387
|
// text, inject it as a synthetic user message and keep looping. The hook
|
|
@@ -398,6 +536,17 @@ export async function runToolLoop(params) {
|
|
|
398
536
|
}
|
|
399
537
|
let compactionRequested = false;
|
|
400
538
|
let suspendRequested = false;
|
|
539
|
+
// Dense over ACCEPTED results, so a withheld suspend leaves no gap — see
|
|
540
|
+
// `beforeToolMessageAccepted`. Reset per batch: it is a position within
|
|
541
|
+
// this step, not a running total.
|
|
542
|
+
let acceptedResultOrdinal = 0;
|
|
543
|
+
const acceptToolMessage = async (message) => {
|
|
544
|
+
// A throw propagates: a host that could not record the result must not
|
|
545
|
+
// be handed a transcript that says the model already saw it.
|
|
546
|
+
await beforeToolMessageAccepted?.(message, acceptedResultOrdinal);
|
|
547
|
+
state.messages.push(message);
|
|
548
|
+
acceptedResultOrdinal += 1;
|
|
549
|
+
};
|
|
401
550
|
for (const outcome of outcomes) {
|
|
402
551
|
// An `answer`-suspend WITHHOLDS its tool message — the result is the
|
|
403
552
|
// future answer, threaded back on resume. At most one may be open: the
|
|
@@ -412,6 +561,14 @@ export async function runToolLoop(params) {
|
|
|
412
561
|
// Rebuild with the literal "answer" kind so the assignment matches
|
|
413
562
|
// `ToolLoopState.suspended` (always answer — see its type).
|
|
414
563
|
state.suspended = { ...outcome.suspend, resumeKind: "answer" };
|
|
564
|
+
// The message is withheld; the call's OTHER signals are not. A
|
|
565
|
+
// compaction asked for by the same outcome is skipped while the
|
|
566
|
+
// pause is open (the arm below checks `state.suspended`) and runs
|
|
567
|
+
// if a same-step answer clears it — whereas dropping the flag here
|
|
568
|
+
// loses it for good, which is only invisible while an answered
|
|
569
|
+
// suspend could not continue the run.
|
|
570
|
+
if (outcome.requestCompaction)
|
|
571
|
+
compactionRequested = true;
|
|
415
572
|
continue; // withhold this call's tool message
|
|
416
573
|
}
|
|
417
574
|
// Through the shared encoder, and carrying a `kind`, like every
|
|
@@ -420,7 +577,7 @@ export async function runToolLoop(params) {
|
|
|
420
577
|
// in one transcript — the exact thing `toolResultMessage` exists to
|
|
421
578
|
// prevent. `conflict` because it is a concurrency loss: the call was
|
|
422
579
|
// refused only because another question is already open.
|
|
423
|
-
|
|
580
|
+
await acceptToolMessage(toolResultMessage(outcome.suspend.toolCallId, {
|
|
424
581
|
success: false,
|
|
425
582
|
kind: "conflict",
|
|
426
583
|
error: "This question was not asked — you already have one waiting " +
|
|
@@ -433,7 +590,7 @@ export async function runToolLoop(params) {
|
|
|
433
590
|
// `wake` (e.g. sleep_until): fall through and push the tool message, then
|
|
434
591
|
// end the run — it re-enters via a prompt, not a threaded answer.
|
|
435
592
|
}
|
|
436
|
-
|
|
593
|
+
await acceptToolMessage(outcome.toolMessage);
|
|
437
594
|
if (outcome.requestCompaction)
|
|
438
595
|
compactionRequested = true;
|
|
439
596
|
}
|
|
@@ -469,6 +626,23 @@ export async function runToolLoop(params) {
|
|
|
469
626
|
stopReason = "aborted";
|
|
470
627
|
break;
|
|
471
628
|
}
|
|
629
|
+
// The step is settled: every result the batch produced is in the
|
|
630
|
+
// transcript and nothing is half-applied, so this is where host-committed
|
|
631
|
+
// input can join without splitting a call from its result.
|
|
632
|
+
const settlement = await settleStep(assistantMessage, false);
|
|
633
|
+
const settledInput = applySettledMessages(settlement.messages);
|
|
634
|
+
// The answer arrived in the same breath as the question, so the run is no
|
|
635
|
+
// longer waiting on a person. A `wake`-suspend from another call in the
|
|
636
|
+
// same batch is a separate request the answer says nothing about, so it
|
|
637
|
+
// still ends the run — recomputed from the outcomes rather than left set,
|
|
638
|
+
// because `suspendRequested` is true for the answered call too.
|
|
639
|
+
if (settledInput.answeredSuspend) {
|
|
640
|
+
suspendRequested = outcomes.some((outcome) => outcome.suspend?.resumeKind === "wake");
|
|
641
|
+
}
|
|
642
|
+
if (settlement.stop) {
|
|
643
|
+
stopReason = "aborted";
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
472
646
|
// A tool asked to end the run (it scheduled its own resume, or recorded an
|
|
473
647
|
// open call awaiting an answer). The tool results are already in
|
|
474
648
|
// state.messages above; stop now so the run doesn't keep going. Mark it as
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juno-ai/bind",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "15.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, the plugin/tool vocabulary, and the skill vocabulary (`./skills`) for progressive knowledge disclosure. 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",
|
|
@@ -32,13 +32,64 @@
|
|
|
32
32
|
* The transforms only remove or normalize constructs that carry no real
|
|
33
33
|
* constraint for a model's tool call. Each was verified against live Gemini:
|
|
34
34
|
*
|
|
35
|
-
* 1.
|
|
36
|
-
* `type` and
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
35
|
+
* 1. Resolve a `type` ARRAY, which Gemini rejects outright (it requires a
|
|
36
|
+
* scalar `type`). Two cases, and they are not the same kind of loss:
|
|
37
|
+
*
|
|
38
|
+
* a. NULLABLE single type — exactly one non-`"null"` member plus `"null"`,
|
|
39
|
+
* in either order, duplicates tolerated. Rewritten to
|
|
40
|
+
* `anyOf: [ <T branch>, {type:"null"} ]`, where the T branch carries
|
|
41
|
+
* the node's type-bearing keywords (`minLength`, `enum`, `properties`,
|
|
42
|
+
* `items`, bounds, …) under `type: T` and the node keeps its
|
|
43
|
+
* annotations. The branch is re-entered through this same walk, so
|
|
44
|
+
* every per-node transform applies to it — #12 retypes it to `"object"`
|
|
45
|
+
* when it carries `properties`, #13 drops an unsupported `pattern` into
|
|
46
|
+
* prose ON the branch, #2 filters its `enum`, #3 resolves its
|
|
47
|
+
* `required` against its own `properties`.
|
|
48
|
+
*
|
|
49
|
+
* Collapsing this case instead was lossy in a way that broke callers in
|
|
50
|
+
* production. A model handed `{type:"string", minLength:1,
|
|
51
|
+
* description:"… or send null …"}` cannot express "none" in the half of
|
|
52
|
+
* the declaration it treats as binding, so it INVENTS a value that
|
|
53
|
+
* satisfies the type: `slack_send_message.thread_ts` (authored
|
|
54
|
+
* `type:["string","null"], minLength:1`) received `"/"`, `". "`,
|
|
55
|
+
* `".000001"` and the like for thousands of calls, each refused by
|
|
56
|
+
* Slack and each retried; a Drive pagination cursor had already been
|
|
57
|
+
* seen taking `"x"`, `" "`, `"undefined"`, `"null"` and `"/"`. Prose
|
|
58
|
+
* loses to the schema every time. The `anyOf` shape is not a guess: it
|
|
59
|
+
* passes under `properties` on grok-4.3/4.5/4.6,
|
|
60
|
+
* gemini-3.5/3.6/3.7-flash and gpt-5.6-terra/sol/luna, and it ships
|
|
61
|
+
* today on every pagination cursor of six connectors. No "Accepts T or
|
|
62
|
+
* null." note is added — the schema now says it — and an `enum` that
|
|
63
|
+
* listed `null` contributes only its non-null members to the branch,
|
|
64
|
+
* with no "May also be null." note, since the null branch carries that.
|
|
65
|
+
* A sibling `enum`/`const` that EXCLUDES one arm keeps case (b)
|
|
66
|
+
* instead: those keywords are AND-ed with `type`, so
|
|
67
|
+
* `{type:["string","null"], enum:["x"]}` means "must be \"x\"", and a
|
|
68
|
+
* union whose null branch the enum never reaches would admit a value
|
|
69
|
+
* the node forbade.
|
|
70
|
+
*
|
|
71
|
+
* Four shapes opt out. The parameters ROOT, which has to be an object
|
|
72
|
+
* for every provider (transform #9 owns it). A branch of a ROOT
|
|
73
|
+
* `anyOf`/`oneOf`, because a root union branch carrying its OWN `anyOf`
|
|
74
|
+
* is 0/3 on Grok — rewriting there would turn a usable branch into one
|
|
75
|
+
* rule (a) drops, taking the whole keyword and every shape it
|
|
76
|
+
* advertised with it. And a COMPOSITION BRANCH whose `required` reaches
|
|
77
|
+
* the ENCLOSING node's `properties` — the extra scope transform #8
|
|
78
|
+
* gives it — because nesting such a name inside an `anyOf` puts it two
|
|
79
|
+
* levels from that scope, where transform #3 prunes it away entirely
|
|
80
|
+
* (a branch whose `required` its own `properties` cover needs no
|
|
81
|
+
* exception). All three keep case (b). (One residue is known and accepted: a `$defs` target
|
|
82
|
+
* rewritten this way, then inlined into a root union branch by
|
|
83
|
+
* transform #14, is unusable for the same 0/3 reason and still drops
|
|
84
|
+
* the keyword — the same narrow class #14 already documents.)
|
|
85
|
+
*
|
|
86
|
+
* b. Everything else — a genuine MULTI-type union, which has no shape
|
|
87
|
+
* every provider accepts. Collapsed to the first non-`"null"` member
|
|
88
|
+
* (`["string","number","boolean"]` → `"string"`) with the alternatives
|
|
89
|
+
* folded into `description`; an array of only `"null"` drops the
|
|
90
|
+
* `type`. The lost alternatives are advisory — arguments are still
|
|
91
|
+
* validated at dispatch against the tool's zod schema and by the remote
|
|
92
|
+
* MCP server.
|
|
42
93
|
* 2. Constrain `enum` to Gemini's rule: it is accepted ONLY as a list of
|
|
43
94
|
* strings on a string-typed (or type-less) property. So drop `enum`
|
|
44
95
|
* entirely when the node has an explicit non-string type (boolean,
|
|
@@ -202,9 +253,11 @@
|
|
|
202
253
|
*
|
|
203
254
|
* A root union of `$ref` branches is no longer a loss — transform #14 below
|
|
204
255
|
* inlines the targets so the union survives. What remains is the narrow
|
|
205
|
-
* residue it cannot inline: a target that is not a plain object schema
|
|
206
|
-
*
|
|
207
|
-
*
|
|
256
|
+
* residue it cannot inline: a target that is not a plain object schema
|
|
257
|
+
* (a union of its own included, which is what a `$defs` entry transform
|
|
258
|
+
* #1a rewrote becomes), one whose expanded size exceeds the copy-down
|
|
259
|
+
* ceiling, and a self-referential root pointer. Those branches stay
|
|
260
|
+
* unusable and still drop the keyword.
|
|
208
261
|
*
|
|
209
262
|
* Rule (b) covers EVERY root, not only composition roots. A property sweep
|
|
210
263
|
* over recursive shapes (`__tests__/tool-schema/property/`) found the
|
|
@@ -291,11 +344,13 @@
|
|
|
291
344
|
* one. A branch whose target is not a plain object schema stays unusable
|
|
292
345
|
* and still falls through to the drop.
|
|
293
346
|
*
|
|
294
|
-
* When (
|
|
295
|
-
* union
|
|
296
|
-
* node's `description` as prose ("Accepts string, number, or boolean.",
|
|
347
|
+
* When (1b) or (2) discards information the model could use — a collapsed
|
|
348
|
+
* multi-type union, or a wholly-dropped `enum` — that constraint is folded into
|
|
349
|
+
* the node's `description` as prose ("Accepts string, number, or boolean.",
|
|
297
350
|
* "Allowed values: true.") so the model still sees it. `description` is a free
|
|
298
|
-
* string every provider accepts, so this is always safe.
|
|
351
|
+
* string every provider accepts, so this is always safe. Prose is the fallback,
|
|
352
|
+
* never the first choice: where a shape every provider accepts exists, the
|
|
353
|
+
* schema says it instead — which is the whole of (1a).
|
|
299
354
|
*
|
|
300
355
|
* It deliberately leaves meaningful validation keywords (`format`,
|
|
301
356
|
* `pattern`, object- or `true`-valued `additionalProperties`, `const`, string
|
package/tools/sanitize-schema.js
CHANGED
|
@@ -32,13 +32,64 @@
|
|
|
32
32
|
* The transforms only remove or normalize constructs that carry no real
|
|
33
33
|
* constraint for a model's tool call. Each was verified against live Gemini:
|
|
34
34
|
*
|
|
35
|
-
* 1.
|
|
36
|
-
* `type` and
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
35
|
+
* 1. Resolve a `type` ARRAY, which Gemini rejects outright (it requires a
|
|
36
|
+
* scalar `type`). Two cases, and they are not the same kind of loss:
|
|
37
|
+
*
|
|
38
|
+
* a. NULLABLE single type — exactly one non-`"null"` member plus `"null"`,
|
|
39
|
+
* in either order, duplicates tolerated. Rewritten to
|
|
40
|
+
* `anyOf: [ <T branch>, {type:"null"} ]`, where the T branch carries
|
|
41
|
+
* the node's type-bearing keywords (`minLength`, `enum`, `properties`,
|
|
42
|
+
* `items`, bounds, …) under `type: T` and the node keeps its
|
|
43
|
+
* annotations. The branch is re-entered through this same walk, so
|
|
44
|
+
* every per-node transform applies to it — #12 retypes it to `"object"`
|
|
45
|
+
* when it carries `properties`, #13 drops an unsupported `pattern` into
|
|
46
|
+
* prose ON the branch, #2 filters its `enum`, #3 resolves its
|
|
47
|
+
* `required` against its own `properties`.
|
|
48
|
+
*
|
|
49
|
+
* Collapsing this case instead was lossy in a way that broke callers in
|
|
50
|
+
* production. A model handed `{type:"string", minLength:1,
|
|
51
|
+
* description:"… or send null …"}` cannot express "none" in the half of
|
|
52
|
+
* the declaration it treats as binding, so it INVENTS a value that
|
|
53
|
+
* satisfies the type: `slack_send_message.thread_ts` (authored
|
|
54
|
+
* `type:["string","null"], minLength:1`) received `"/"`, `". "`,
|
|
55
|
+
* `".000001"` and the like for thousands of calls, each refused by
|
|
56
|
+
* Slack and each retried; a Drive pagination cursor had already been
|
|
57
|
+
* seen taking `"x"`, `" "`, `"undefined"`, `"null"` and `"/"`. Prose
|
|
58
|
+
* loses to the schema every time. The `anyOf` shape is not a guess: it
|
|
59
|
+
* passes under `properties` on grok-4.3/4.5/4.6,
|
|
60
|
+
* gemini-3.5/3.6/3.7-flash and gpt-5.6-terra/sol/luna, and it ships
|
|
61
|
+
* today on every pagination cursor of six connectors. No "Accepts T or
|
|
62
|
+
* null." note is added — the schema now says it — and an `enum` that
|
|
63
|
+
* listed `null` contributes only its non-null members to the branch,
|
|
64
|
+
* with no "May also be null." note, since the null branch carries that.
|
|
65
|
+
* A sibling `enum`/`const` that EXCLUDES one arm keeps case (b)
|
|
66
|
+
* instead: those keywords are AND-ed with `type`, so
|
|
67
|
+
* `{type:["string","null"], enum:["x"]}` means "must be \"x\"", and a
|
|
68
|
+
* union whose null branch the enum never reaches would admit a value
|
|
69
|
+
* the node forbade.
|
|
70
|
+
*
|
|
71
|
+
* Four shapes opt out. The parameters ROOT, which has to be an object
|
|
72
|
+
* for every provider (transform #9 owns it). A branch of a ROOT
|
|
73
|
+
* `anyOf`/`oneOf`, because a root union branch carrying its OWN `anyOf`
|
|
74
|
+
* is 0/3 on Grok — rewriting there would turn a usable branch into one
|
|
75
|
+
* rule (a) drops, taking the whole keyword and every shape it
|
|
76
|
+
* advertised with it. And a COMPOSITION BRANCH whose `required` reaches
|
|
77
|
+
* the ENCLOSING node's `properties` — the extra scope transform #8
|
|
78
|
+
* gives it — because nesting such a name inside an `anyOf` puts it two
|
|
79
|
+
* levels from that scope, where transform #3 prunes it away entirely
|
|
80
|
+
* (a branch whose `required` its own `properties` cover needs no
|
|
81
|
+
* exception). All three keep case (b). (One residue is known and accepted: a `$defs` target
|
|
82
|
+
* rewritten this way, then inlined into a root union branch by
|
|
83
|
+
* transform #14, is unusable for the same 0/3 reason and still drops
|
|
84
|
+
* the keyword — the same narrow class #14 already documents.)
|
|
85
|
+
*
|
|
86
|
+
* b. Everything else — a genuine MULTI-type union, which has no shape
|
|
87
|
+
* every provider accepts. Collapsed to the first non-`"null"` member
|
|
88
|
+
* (`["string","number","boolean"]` → `"string"`) with the alternatives
|
|
89
|
+
* folded into `description`; an array of only `"null"` drops the
|
|
90
|
+
* `type`. The lost alternatives are advisory — arguments are still
|
|
91
|
+
* validated at dispatch against the tool's zod schema and by the remote
|
|
92
|
+
* MCP server.
|
|
42
93
|
* 2. Constrain `enum` to Gemini's rule: it is accepted ONLY as a list of
|
|
43
94
|
* strings on a string-typed (or type-less) property. So drop `enum`
|
|
44
95
|
* entirely when the node has an explicit non-string type (boolean,
|
|
@@ -202,9 +253,11 @@
|
|
|
202
253
|
*
|
|
203
254
|
* A root union of `$ref` branches is no longer a loss — transform #14 below
|
|
204
255
|
* inlines the targets so the union survives. What remains is the narrow
|
|
205
|
-
* residue it cannot inline: a target that is not a plain object schema
|
|
206
|
-
*
|
|
207
|
-
*
|
|
256
|
+
* residue it cannot inline: a target that is not a plain object schema
|
|
257
|
+
* (a union of its own included, which is what a `$defs` entry transform
|
|
258
|
+
* #1a rewrote becomes), one whose expanded size exceeds the copy-down
|
|
259
|
+
* ceiling, and a self-referential root pointer. Those branches stay
|
|
260
|
+
* unusable and still drop the keyword.
|
|
208
261
|
*
|
|
209
262
|
* Rule (b) covers EVERY root, not only composition roots. A property sweep
|
|
210
263
|
* over recursive shapes (`__tests__/tool-schema/property/`) found the
|
|
@@ -291,11 +344,13 @@
|
|
|
291
344
|
* one. A branch whose target is not a plain object schema stays unusable
|
|
292
345
|
* and still falls through to the drop.
|
|
293
346
|
*
|
|
294
|
-
* When (
|
|
295
|
-
* union
|
|
296
|
-
* node's `description` as prose ("Accepts string, number, or boolean.",
|
|
347
|
+
* When (1b) or (2) discards information the model could use — a collapsed
|
|
348
|
+
* multi-type union, or a wholly-dropped `enum` — that constraint is folded into
|
|
349
|
+
* the node's `description` as prose ("Accepts string, number, or boolean.",
|
|
297
350
|
* "Allowed values: true.") so the model still sees it. `description` is a free
|
|
298
|
-
* string every provider accepts, so this is always safe.
|
|
351
|
+
* string every provider accepts, so this is always safe. Prose is the fallback,
|
|
352
|
+
* never the first choice: where a shape every provider accepts exists, the
|
|
353
|
+
* schema says it instead — which is the whole of (1a).
|
|
299
354
|
*
|
|
300
355
|
* It deliberately leaves meaningful validation keywords (`format`,
|
|
301
356
|
* `pattern`, object- or `true`-valued `additionalProperties`, `const`, string
|
|
@@ -370,6 +425,93 @@ const SUBSCHEMA_ARRAY_KEYS = new Set([
|
|
|
370
425
|
* `prefixItems` is in `SUBSCHEMA_ARRAY_KEYS` but NOT here: its elements are the
|
|
371
426
|
* item schemas of a tuple, a fresh scope, not branches of the enclosing object. */
|
|
372
427
|
const COMPOSITION_KEYS = new Set(["allOf", "anyOf", "oneOf"]);
|
|
428
|
+
/**
|
|
429
|
+
* Transform #1, nullable case: the JSON Schema type names that may stand
|
|
430
|
+
* opposite `"null"` in a `type` array for the `anyOf` rewrite to fire. An
|
|
431
|
+
* unrecognized name is left to the collapse — rewriting `{type:["foo","null"]}`
|
|
432
|
+
* into a two-branch union would emit an unmeasured shape built around a type no
|
|
433
|
+
* provider knows, where the collapse at least degrades to prose.
|
|
434
|
+
*/
|
|
435
|
+
const NULLABLE_UNION_TYPES = new Set([
|
|
436
|
+
"string",
|
|
437
|
+
"number",
|
|
438
|
+
"integer",
|
|
439
|
+
"boolean",
|
|
440
|
+
"object",
|
|
441
|
+
"array",
|
|
442
|
+
]);
|
|
443
|
+
/**
|
|
444
|
+
* Keywords whose presence on a node disqualifies it from the nullable rewrite.
|
|
445
|
+
* Each is a construct the emitted `anyOf` would have to be evaluated ALONGSIDE
|
|
446
|
+
* (an `anyOf` sibling to an `oneOf`, an `if`/`then` keyed off the node's type,
|
|
447
|
+
* …), and no such combination has been measured against a provider. A node
|
|
448
|
+
* carrying one keeps transform #1's collapse.
|
|
449
|
+
*/
|
|
450
|
+
const NULLABLE_REWRITE_BLOCKERS = [
|
|
451
|
+
"anyOf",
|
|
452
|
+
"oneOf",
|
|
453
|
+
"allOf",
|
|
454
|
+
"not",
|
|
455
|
+
"if",
|
|
456
|
+
"then",
|
|
457
|
+
"else",
|
|
458
|
+
];
|
|
459
|
+
/**
|
|
460
|
+
* The type-bearing keywords that move from a nullable node onto its typed
|
|
461
|
+
* branch, where they constrain the non-null value they were written for. Every
|
|
462
|
+
* one of them is meaningless against `null`, so leaving them on the outer node
|
|
463
|
+
* beside the union would say something the author never meant.
|
|
464
|
+
*
|
|
465
|
+
* The object-structure keywords move as a GROUP, and that is load-bearing
|
|
466
|
+
* rather than tidy. `additionalProperties` is defined against the keys that
|
|
467
|
+
* `properties` and `patternProperties` did NOT match *in the same schema
|
|
468
|
+
* object*, so moving two of the three and stranding the third silently changes
|
|
469
|
+
* what the schema says: for `{properties:{a}, patternProperties:{"^x":…},
|
|
470
|
+
* additionalProperties:{…}}`, a key `x1` was exempt from
|
|
471
|
+
* `additionalProperties` and, split across the union boundary, stops being
|
|
472
|
+
* exempt. `dependencies` / `dependentRequired` / `dependentSchemas` travel with
|
|
473
|
+
* them because they describe the same object form.
|
|
474
|
+
*
|
|
475
|
+
* Two groups deliberately stay OUTSIDE. Annotations (`description`, `title`,
|
|
476
|
+
* `default`, `examples`, `deprecated`, `readOnly`, `writeOnly`, `$comment`)
|
|
477
|
+
* describe the property as a whole, including its null case, and duplicating a
|
|
478
|
+
* description into the branch would show the model the same sentence twice. And
|
|
479
|
+
* `unevaluatedProperties` / `unevaluatedItems` are defined over the annotations
|
|
480
|
+
* every applicable schema produced — the `anyOf` branches included — so the
|
|
481
|
+
* outer node is exactly where they belong; moving one inside would narrow it to
|
|
482
|
+
* the branch's own keywords. Anything else the node carries (`$defs`, `$id`, an
|
|
483
|
+
* unknown keyword) stays outside too, keeping the treatment it has today.
|
|
484
|
+
*/
|
|
485
|
+
const NULLABLE_BRANCH_KEYS = new Set([
|
|
486
|
+
"minLength",
|
|
487
|
+
"maxLength",
|
|
488
|
+
"pattern",
|
|
489
|
+
"format",
|
|
490
|
+
"enum",
|
|
491
|
+
"const",
|
|
492
|
+
"minimum",
|
|
493
|
+
"maximum",
|
|
494
|
+
"exclusiveMinimum",
|
|
495
|
+
"exclusiveMaximum",
|
|
496
|
+
"multipleOf",
|
|
497
|
+
"items",
|
|
498
|
+
"prefixItems",
|
|
499
|
+
"minItems",
|
|
500
|
+
"maxItems",
|
|
501
|
+
"uniqueItems",
|
|
502
|
+
"properties",
|
|
503
|
+
"patternProperties",
|
|
504
|
+
"required",
|
|
505
|
+
"additionalProperties",
|
|
506
|
+
"propertyNames",
|
|
507
|
+
"minProperties",
|
|
508
|
+
"maxProperties",
|
|
509
|
+
"dependencies",
|
|
510
|
+
"dependentRequired",
|
|
511
|
+
"dependentSchemas",
|
|
512
|
+
"contentMediaType",
|
|
513
|
+
"contentEncoding",
|
|
514
|
+
]);
|
|
373
515
|
/**
|
|
374
516
|
* Ceiling on how many property subschemas transform #8 may copy down across ALL
|
|
375
517
|
* branches of one root composition.
|
|
@@ -786,20 +928,154 @@ function sanitizeCompositionBranch(branch, depth, parentProperties, budget) {
|
|
|
786
928
|
// Arrays and boolean/primitive schemas carry no `required` to resolve.
|
|
787
929
|
if (!isPlainObject(branch))
|
|
788
930
|
return sanitizeSubschema(branch, depth);
|
|
789
|
-
const sanitized = sanitizeSchemaNode(branch, depth, parentProperties);
|
|
790
931
|
// A non-null budget marks a ROOT composition — the only place the
|
|
791
|
-
// self-sufficiency repair applies
|
|
932
|
+
// self-sufficiency repair applies, and the only place transform #1's nullable
|
|
933
|
+
// rewrite stands down (see the module header).
|
|
934
|
+
const sanitized = sanitizeSchemaNode(branch, depth, parentProperties, budget !== null);
|
|
792
935
|
return budget
|
|
793
936
|
? makeBranchSelfSufficient(sanitized, parentProperties, budget)
|
|
794
937
|
: sanitized;
|
|
795
938
|
}
|
|
939
|
+
/**
|
|
940
|
+
* Transform #1, nullable case: decide whether this node is a nullable single
|
|
941
|
+
* type — `["string","null"]` and friends — that should be REWRITTEN to an
|
|
942
|
+
* `anyOf` union instead of collapsed. Returns the non-null type name, or `null`
|
|
943
|
+
* when the node keeps today's collapse.
|
|
944
|
+
*
|
|
945
|
+
* Order of the members does not matter and duplicates are tolerated
|
|
946
|
+
* (`["null","string"]`, `["string","null","null"]`); what must hold is that
|
|
947
|
+
* exactly one distinct non-`"null"` type is present, alongside at least one
|
|
948
|
+
* `"null"`.
|
|
949
|
+
*
|
|
950
|
+
* Four shapes opt out, each for a measured or structural reason:
|
|
951
|
+
* - The ROOT and a branch of a ROOT composition (`isRootCompositionBranch`).
|
|
952
|
+
* The root must be an object for every provider, and a root union branch
|
|
953
|
+
* carrying its OWN `anyOf` is 0/3 on Grok — so rewriting there would turn a
|
|
954
|
+
* usable branch into one transform #9 rule (a) drops, taking the whole
|
|
955
|
+
* keyword (and with it every shape the union advertised) with it.
|
|
956
|
+
* - A sibling `enum`/`const` that excludes one of the two arms. These are
|
|
957
|
+
* AND-ed with `type`, so `{type:["string","null"], enum:["x"]}` means "must
|
|
958
|
+
* be \"x\"" and `{…, enum:[null]}` means "must be null". The rewrite moves
|
|
959
|
+
* the keyword onto the TYPED branch, leaving `{type:"null"}` unconstrained
|
|
960
|
+
* by it — so emitting the union would admit a value the node forbade. Both
|
|
961
|
+
* directions keep the collapse; `const: null` is the exception that still
|
|
962
|
+
* rewrites, because there the typed branch is unsatisfiable and the union
|
|
963
|
+
* reduces to "must be null", which is what the node says (collapsing it
|
|
964
|
+
* would give the unsatisfiable `{type:"string", const:null}` instead).
|
|
965
|
+
* - A COMPOSITION BRANCH whose `required` reaches the ENCLOSING node's
|
|
966
|
+
* `properties` — the extra scope transform #8 gives it. Moving such a name
|
|
967
|
+
* inside a nested `anyOf` puts it two levels from that scope, which is
|
|
968
|
+
* exactly the dangling `required` transform #3 exists to prevent on Gemini.
|
|
969
|
+
* A branch whose `required` is covered by its OWN `properties` needs no
|
|
970
|
+
* exception and is rewritten normally.
|
|
971
|
+
*/
|
|
972
|
+
function nullableUnionType(node, parentProperties, isRootCompositionBranch) {
|
|
973
|
+
if (isRootCompositionBranch)
|
|
974
|
+
return null;
|
|
975
|
+
const declared = node.type;
|
|
976
|
+
if (!Array.isArray(declared))
|
|
977
|
+
return null;
|
|
978
|
+
let typed = null;
|
|
979
|
+
let sawNull = false;
|
|
980
|
+
for (const member of declared) {
|
|
981
|
+
if (typeof member !== "string")
|
|
982
|
+
return null;
|
|
983
|
+
if (member === "null") {
|
|
984
|
+
sawNull = true;
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
if (!NULLABLE_UNION_TYPES.has(member))
|
|
988
|
+
return null;
|
|
989
|
+
if (typed !== null && typed !== member)
|
|
990
|
+
return null;
|
|
991
|
+
typed = member;
|
|
992
|
+
}
|
|
993
|
+
if (!sawNull || typed === null)
|
|
994
|
+
return null;
|
|
995
|
+
if (NULLABLE_REWRITE_BLOCKERS.some((key) => hasOwn(node, key)))
|
|
996
|
+
return null;
|
|
997
|
+
// A sibling `enum`/`const` is AND-ed with `type`, so it decides whether the
|
|
998
|
+
// two arms the `type` array advertises are BOTH actually reachable — and the
|
|
999
|
+
// rewrite may only emit an arm the input really allows. Moving the keyword
|
|
1000
|
+
// onto the typed branch leaves the `{type:"null"}` branch unconstrained by
|
|
1001
|
+
// it, so emitting that branch when the enum excludes `null` would admit a
|
|
1002
|
+
// value the author forbade (`{type:["string","null"], enum:["x"]}` means
|
|
1003
|
+
// "must be \"x\""), and emitting the typed branch when only `null` is allowed
|
|
1004
|
+
// would admit every string. Either way the union would say more than the node
|
|
1005
|
+
// did, so both keep the collapse.
|
|
1006
|
+
//
|
|
1007
|
+
// `const: null` is the one shape that still rewrites: the typed branch is
|
|
1008
|
+
// then unsatisfiable and the union reduces to "must be null", exactly what
|
|
1009
|
+
// the node means — where collapsing it would produce the unsatisfiable
|
|
1010
|
+
// `{type:"string", const:null}` instead, which is strictly worse.
|
|
1011
|
+
if (hasOwn(node, "const") && node.const !== null)
|
|
1012
|
+
return null;
|
|
1013
|
+
if (hasOwn(node, "enum")) {
|
|
1014
|
+
if (!Array.isArray(node.enum))
|
|
1015
|
+
return null;
|
|
1016
|
+
const permitsNull = node.enum.some((member) => member === null);
|
|
1017
|
+
const permitsNonNull = node.enum.some((member) => member !== null);
|
|
1018
|
+
if (!permitsNull || !permitsNonNull)
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
// Only a `required` name that actually REACHES the parent scope forces the
|
|
1022
|
+
// collapse. A branch whose `required` is satisfied by its own `properties`
|
|
1023
|
+
// loses nothing by being rewritten, and declining there would keep the
|
|
1024
|
+
// invented-value failure for a node that has no need of the exception. Key
|
|
1025
|
+
// names survive sanitization unchanged, so the raw map answers this.
|
|
1026
|
+
if (parentProperties != null && Array.isArray(node.required)) {
|
|
1027
|
+
const own = isPlainObject(node.properties) ? node.properties : null;
|
|
1028
|
+
const reachesParentScope = node.required.some((name) => typeof name === "string" && !(own !== null && hasOwn(own, name)));
|
|
1029
|
+
if (reachesParentScope)
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
return typed;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Build the typed branch of a nullable rewrite: the node's own type-bearing
|
|
1036
|
+
* keywords (`NULLABLE_BRANCH_KEYS`) under a scalar `type`, with `null` removed
|
|
1037
|
+
* from an `enum` because the sibling `{type:"null"}` branch already carries
|
|
1038
|
+
* that fact — and leaving it would make transform #2 emit a "May also be null."
|
|
1039
|
+
* note the schema no longer needs.
|
|
1040
|
+
*
|
|
1041
|
+
* The branch is run back through `sanitizeSchemaNode` rather than assembled by
|
|
1042
|
+
* hand so every per-node transform applies to it exactly as it would to any
|
|
1043
|
+
* scalar-typed node: transform #12 retypes it to `"object"` when it carries
|
|
1044
|
+
* `properties`, transform #13 drops an unsupported `pattern` into prose on the
|
|
1045
|
+
* branch, transform #3 resolves its `required` against its own `properties`,
|
|
1046
|
+
* and transform #2 filters its `enum`.
|
|
1047
|
+
*
|
|
1048
|
+
* Sanitized at THIS node's depth, not one deeper. The branch is a
|
|
1049
|
+
* re-expression of the node, so its children sit exactly where they sat before
|
|
1050
|
+
* the rewrite, and the depth budget they are charged is unchanged. No
|
|
1051
|
+
* `parentProperties` is passed: the only branch that could have used one — a
|
|
1052
|
+
* composition branch carrying `required` — is excluded by `nullableUnionType`.
|
|
1053
|
+
*/
|
|
1054
|
+
function buildNullableBranch(node, typeName, depth) {
|
|
1055
|
+
const branch = { type: typeName };
|
|
1056
|
+
for (const [key, value] of Object.entries(node)) {
|
|
1057
|
+
if (!NULLABLE_BRANCH_KEYS.has(key))
|
|
1058
|
+
continue;
|
|
1059
|
+
if (key === "enum" && Array.isArray(value)) {
|
|
1060
|
+
branch.enum = value.filter((member) => member !== null);
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
safeSet(branch, key, value);
|
|
1064
|
+
}
|
|
1065
|
+
return sanitizeSchemaNode(branch, depth);
|
|
1066
|
+
}
|
|
796
1067
|
/**
|
|
797
1068
|
* @param parentProperties When this node is a composition branch, the enclosing
|
|
798
1069
|
* node's SANITIZED `properties` map — the extra scope its `required` may name
|
|
799
1070
|
* (transform #8). `undefined`/`null` for every other node, which keeps
|
|
800
1071
|
* transform #3's own-properties-only rule exactly as it was.
|
|
1072
|
+
* @param isRootCompositionBranch True when this node is an element of a
|
|
1073
|
+
* composition on the parameters ROOT. Only transform #1's nullable rewrite
|
|
1074
|
+
* reads it, and only to decline: such a branch is judged individually by
|
|
1075
|
+
* transform #9 rule (a), which rejects any branch carrying its own
|
|
1076
|
+
* `anyOf`/`oneOf`.
|
|
801
1077
|
*/
|
|
802
|
-
function sanitizeSchemaNode(node, depth, parentProperties) {
|
|
1078
|
+
function sanitizeSchemaNode(node, depth, parentProperties, isRootCompositionBranch = false) {
|
|
803
1079
|
// Depth guard: stop walking absurdly nested input rather than overflowing
|
|
804
1080
|
// the stack. Real tool schemas are a few levels deep; anything past
|
|
805
1081
|
// MAX_DEPTH is adversarial, so the subtree is replaced with the
|
|
@@ -809,10 +1085,27 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
|
|
|
809
1085
|
if (depth > MAX_DEPTH)
|
|
810
1086
|
return {};
|
|
811
1087
|
const childDepth = depth + 1;
|
|
1088
|
+
// Transform #1, nullable case. A NON-root `{type:["T","null"], …}` is emitted
|
|
1089
|
+
// as `anyOf: [<T branch>, {type:"null"}]` rather than collapsed to `T` with a
|
|
1090
|
+
// prose note, because prose loses to the schema: a model reading
|
|
1091
|
+
// `{type:"string", minLength:1}` cannot express "none" in the half of the
|
|
1092
|
+
// declaration it treats as binding, so it invents a value that satisfies the
|
|
1093
|
+
// type instead (see the module header). Decided first because everything
|
|
1094
|
+
// below — the `properties` pre-pass, the type resolution, the enum rule, the
|
|
1095
|
+
// notes — belongs to the BRANCH when the rewrite fires, not to the node that
|
|
1096
|
+
// now carries only the union and its annotations.
|
|
1097
|
+
const nullableType = depth > 0
|
|
1098
|
+
? nullableUnionType(node, parentProperties, isRootCompositionBranch)
|
|
1099
|
+
: null;
|
|
1100
|
+
const nullableBranch = nullableType === null
|
|
1101
|
+
? null
|
|
1102
|
+
: buildNullableBranch(node, nullableType, depth);
|
|
812
1103
|
// Sanitized `properties`, computed ahead of the key walk because transform #8
|
|
813
1104
|
// needs it before the walk reaches whichever of `properties` / `oneOf` comes
|
|
814
1105
|
// first in key order. Emitted verbatim when the walk reaches `properties`.
|
|
815
|
-
|
|
1106
|
+
// Skipped entirely under a nullable rewrite, where `properties` has moved
|
|
1107
|
+
// onto the typed branch and was sanitized there.
|
|
1108
|
+
const sanitizedProperties = nullableType === null && hasOwn(node, "properties")
|
|
816
1109
|
? sanitizePropertiesKeyword(node.properties, childDepth)
|
|
817
1110
|
: undefined;
|
|
818
1111
|
const ownProperties = isPlainObject(sanitizedProperties)
|
|
@@ -836,16 +1129,25 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
|
|
|
836
1129
|
const declaresProperty = (name) => (ownProperties !== null && hasOwn(ownProperties, name)) ||
|
|
837
1130
|
(parentProperties != null && hasOwn(parentProperties, name));
|
|
838
1131
|
// Resolve the node's effective single `type`, collapsing a JSON Schema
|
|
839
|
-
// `type` ARRAY (a union, e.g. `["string","number","boolean"]`
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
//
|
|
1132
|
+
// `type` ARRAY (a MULTI-type union, e.g. `["string","number","boolean"]`) to
|
|
1133
|
+
// the first non-"null" member. Gemini's function-declaration schema requires
|
|
1134
|
+
// a single `type` and hard-rejects a type array, which manifests as a
|
|
1135
|
+
// misleading downstream error. Computed up front (not in key order) because
|
|
1136
|
+
// the `enum` decision below depends on it. The nullable case
|
|
1137
|
+
// (`["string","null"]`) took the `anyOf` rewrite above and never gets here —
|
|
1138
|
+
// except at the root and the two branch positions that decline it, which
|
|
1139
|
+
// collapse like any other union.
|
|
844
1140
|
const notesFromRetype = [];
|
|
845
1141
|
let typeToEmit = node.type;
|
|
846
1142
|
let emitType = "type" in node;
|
|
847
1143
|
let singleType;
|
|
848
|
-
if (
|
|
1144
|
+
if (nullableType !== null) {
|
|
1145
|
+
// The `type` key's slot is taken by the `anyOf` emitted in the walk below,
|
|
1146
|
+
// so nothing resolves a scalar type here and no note is manufactured: the
|
|
1147
|
+
// union states the nullability the note used to approximate.
|
|
1148
|
+
emitType = false;
|
|
1149
|
+
}
|
|
1150
|
+
else if (Array.isArray(node.type)) {
|
|
849
1151
|
const nonNull = node.type.filter((t) => t !== "null");
|
|
850
1152
|
if (nonNull.length > 0) {
|
|
851
1153
|
typeToEmit = nonNull[0];
|
|
@@ -894,7 +1196,7 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
|
|
|
894
1196
|
// which every provider accepts) so the model still sees it. Computed up
|
|
895
1197
|
// front so it can be appended wherever `description` appears in key order.
|
|
896
1198
|
const notes = [...notesFromRetype];
|
|
897
|
-
if (Array.isArray(node.type)) {
|
|
1199
|
+
if (nullableType === null && Array.isArray(node.type)) {
|
|
898
1200
|
const typeNames = node.type.filter((t) => typeof t === "string");
|
|
899
1201
|
if (typeNames.length > 1) {
|
|
900
1202
|
notes.push(`Accepts ${humanJoin(typeNames)}.`);
|
|
@@ -904,7 +1206,7 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
|
|
|
904
1206
|
notes.push("Must be null.");
|
|
905
1207
|
}
|
|
906
1208
|
}
|
|
907
|
-
if (Array.isArray(node.enum)) {
|
|
1209
|
+
if (nullableType === null && Array.isArray(node.enum)) {
|
|
908
1210
|
const stringMembers = node.enum.filter((v) => typeof v === "string");
|
|
909
1211
|
if (dropEnum || stringMembers.length === 0) {
|
|
910
1212
|
// The whole enum is dropped (non-string type, or no string members
|
|
@@ -920,7 +1222,8 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
|
|
|
920
1222
|
}
|
|
921
1223
|
// Transform #13, decided up front so its note joins the others before
|
|
922
1224
|
// `descNote` is frozen and the loop below can simply skip the keyword.
|
|
923
|
-
const dropPattern =
|
|
1225
|
+
const dropPattern = nullableType === null &&
|
|
1226
|
+
typeof node.pattern === "string" &&
|
|
924
1227
|
usesUnsupportedRegexConstruct(node.pattern);
|
|
925
1228
|
if (dropPattern) {
|
|
926
1229
|
const pattern = node.pattern;
|
|
@@ -940,7 +1243,19 @@ function sanitizeSchemaNode(node, depth, parentProperties) {
|
|
|
940
1243
|
// by those names are handled safely inside `sanitizeSchemaMap`.)
|
|
941
1244
|
if (PROTO_KEYS.has(key))
|
|
942
1245
|
continue;
|
|
1246
|
+
// Transform #1, nullable case: every type-bearing keyword has moved onto
|
|
1247
|
+
// the typed branch, which was sanitized as a node in its own right. Only
|
|
1248
|
+
// the node's annotations (and any keyword outside both sets) stay here.
|
|
1249
|
+
if (nullableType !== null && NULLABLE_BRANCH_KEYS.has(key))
|
|
1250
|
+
continue;
|
|
943
1251
|
if (key === "type") {
|
|
1252
|
+
if (nullableBranch !== null) {
|
|
1253
|
+
// Emitted in the `type` key's own position so key order stays
|
|
1254
|
+
// deterministic — which is what lets a second pass, finding an `anyOf`
|
|
1255
|
+
// it leaves alone, reproduce this object exactly.
|
|
1256
|
+
out.anyOf = [nullableBranch, { type: "null" }];
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
944
1259
|
if (emitType)
|
|
945
1260
|
out.type = typeToEmit;
|
|
946
1261
|
continue;
|