prefix-safe-json 0.4.5 → 0.5.1
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/LICENSE +27 -4
- package/README.md +195 -428
- package/dist/ajv.d.ts +2 -0
- package/dist/ajv.d.ts.map +1 -0
- package/dist/ajv.js +19 -0
- package/dist/ajv.js.map +1 -0
- package/dist/conformance/runner.d.ts +19 -0
- package/dist/conformance/runner.d.ts.map +1 -0
- package/dist/conformance/runner.js +78 -0
- package/dist/conformance/runner.js.map +1 -0
- package/dist/conformance/types.d.ts +115 -0
- package/dist/conformance/types.d.ts.map +1 -0
- package/dist/conformance/types.js +2 -0
- package/dist/conformance/types.js.map +1 -0
- package/dist/conformance.d.ts +3 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +11 -0
- package/dist/conformance.js.map +1 -0
- package/dist/coordinator/coordinator.d.ts +25 -11
- package/dist/coordinator/coordinator.d.ts.map +1 -1
- package/dist/coordinator/coordinator.js +140 -25
- package/dist/coordinator/coordinator.js.map +1 -1
- package/dist/coordinator/types.d.ts +3 -2
- package/dist/coordinator/types.d.ts.map +1 -1
- package/dist/gate/gate.d.ts +2 -2
- package/dist/gate/gate.d.ts.map +1 -1
- package/dist/gate/gate.js +4 -4
- package/dist/gate/gate.js.map +1 -1
- package/dist/guard/provider-guard.js +1 -1
- package/dist/guard/provider-guard.js.map +1 -1
- package/dist/guard/types.d.ts +11 -4
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/standard-schema.d.ts +2 -0
- package/dist/standard-schema.d.ts.map +1 -0
- package/dist/standard-schema.js +11 -0
- package/dist/standard-schema.js.map +1 -0
- package/dist/validation/ajv-validator.d.ts +43 -0
- package/dist/validation/ajv-validator.d.ts.map +1 -0
- package/dist/validation/ajv-validator.js +63 -0
- package/dist/validation/ajv-validator.js.map +1 -0
- package/dist/validation/standard-schema-validator.d.ts +41 -0
- package/dist/validation/standard-schema-validator.d.ts.map +1 -0
- package/dist/validation/standard-schema-validator.js +131 -0
- package/dist/validation/standard-schema-validator.js.map +1 -0
- package/dist/validation/types.d.ts +63 -0
- package/dist/validation/types.d.ts.map +1 -0
- package/dist/validation/types.js +131 -0
- package/dist/validation/types.js.map +1 -0
- package/package.json +21 -4
package/README.md
CHANGED
|
@@ -1,483 +1,250 @@
|
|
|
1
1
|
# prefix-safe-json
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/prefix-safe-json)
|
|
4
|
+
[](https://github.com/canblmz1/prefix-safe-json/actions/workflows/ci.yml)
|
|
5
|
+
[](#license)
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
terminal state before caller-owned side effects.**
|
|
7
|
+
An incremental JSON parser and execution gate for **streamed LLM tool calls**.
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
files, run commands, send requests, or perform another irreversible operation.
|
|
12
|
-
It distinguishes complete, unfabricated raw arguments from truncated or
|
|
13
|
-
unconfirmed input, including SDK-projected or repaired values that merely look
|
|
14
|
-
complete. JSON validity is not execution authority.
|
|
9
|
+
`prefix-safe-json` keeps incomplete, conflicting, or unconfirmed streamed arguments from being treated as final tool input. It is designed for callers that need to distinguish:
|
|
15
10
|
|
|
16
|
-
|
|
11
|
+
1. JSON that can be parsed right now,
|
|
12
|
+
2. values that are stable enough to expose, and
|
|
13
|
+
3. arguments that the provider actually completed and confirmed.
|
|
14
|
+
|
|
15
|
+
It does not execute tools itself.
|
|
17
16
|
|
|
18
17
|
```bash
|
|
19
|
-
pnpm add prefix-safe-json ai
|
|
20
|
-
# or: npm install prefix-safe-json ai
|
|
18
|
+
pnpm add prefix-safe-json ai zod
|
|
19
|
+
# or: npm install prefix-safe-json ai zod
|
|
21
20
|
```
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
development and release jobs remain on Node 22/24 because the development
|
|
25
|
-
toolchain and the exact `ai@7.0.77` lifecycle proof require newer Node; those
|
|
26
|
-
requirements do not apply to the package's runtime dependency graph.
|
|
27
|
-
|
|
28
|
-
## Supply-chain verification
|
|
29
|
-
|
|
30
|
-
Verify releases independently rather than relying on publisher claims. The
|
|
31
|
-
`0.4.2` npm artifact has npm provenance from this repository's GitHub Actions
|
|
32
|
-
publish workflow, and its tarball can be rebuilt and compared with the release
|
|
33
|
-
tag by running `npm run verify:published-release -- 0.4.2` from a clone with
|
|
34
|
-
tags fetched. `0.4.2` has no npm `gitHead` (see
|
|
35
|
-
[`RELEASE_INTEGRITY.md`](docs/RELEASE_INTEGRITY.md) for why); the verifier
|
|
36
|
-
establishes its release commit through verified provenance instead, failing
|
|
37
|
-
closed if that provenance is missing or disagrees. See the command-driven
|
|
38
|
-
[maintainer audit](docs/MAINTAINER_AUDIT.md),
|
|
39
|
-
the exact [release/hash mapping](docs/RELEASE_INTEGRITY.md), the
|
|
40
|
-
[runtime dependency graph](docs/RUNTIME_DEPENDENCIES.md), and the
|
|
41
|
-
[execution-critical source map](docs/EXECUTION_AUDIT_SURFACE.md).
|
|
42
|
-
|
|
43
|
-
## Recommended AI SDK boundary
|
|
44
|
-
|
|
45
|
-
Lock the local tool definitions before the AI SDK sees them, feed the real
|
|
46
|
-
`fullStream` into the guard, and keep the irreversible operation in caller-owned
|
|
47
|
-
manual dispatch:
|
|
22
|
+
## Safe execution example
|
|
48
23
|
|
|
49
24
|
```javascript
|
|
50
|
-
import {
|
|
25
|
+
import { writeFile } from "node:fs/promises";
|
|
26
|
+
import { streamText } from "ai";
|
|
27
|
+
import { z } from "zod";
|
|
51
28
|
import {
|
|
52
29
|
createAiSdkExecutionGuard,
|
|
53
30
|
createAiSdkExecutionLock,
|
|
54
31
|
} from "prefix-safe-json";
|
|
32
|
+
import { fromStandardSchema } from "prefix-safe-json/standard-schema";
|
|
55
33
|
|
|
56
|
-
const writeFileSchema = {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
34
|
+
const writeFileSchema = z.object({ path: z.string(), content: z.string() });
|
|
35
|
+
|
|
36
|
+
const tools = createAiSdkExecutionLock({
|
|
37
|
+
write_file: {
|
|
38
|
+
description: "Write a UTF-8 text file",
|
|
39
|
+
inputSchema: writeFileSchema,
|
|
61
40
|
},
|
|
62
|
-
|
|
63
|
-
additionalProperties: false,
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
export async function runToolCall(model, prompt, callerOwnedSideEffect) {
|
|
67
|
-
const lockedTools = createAiSdkExecutionLock({
|
|
68
|
-
write_file: {
|
|
69
|
-
description: "Write a UTF-8 text file",
|
|
70
|
-
inputSchema: jsonSchema(writeFileSchema),
|
|
71
|
-
},
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
const result = streamText({ model, prompt, tools: lockedTools });
|
|
75
|
-
const guard = createAiSdkExecutionGuard({
|
|
76
|
-
schemas: { write_file: writeFileSchema },
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
for await (const part of result.fullStream) {
|
|
80
|
-
guard.push(part);
|
|
81
|
-
}
|
|
41
|
+
});
|
|
82
42
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
43
|
+
const guard = createAiSdkExecutionGuard({
|
|
44
|
+
validators: { write_file: fromStandardSchema(writeFileSchema) },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const result = streamText({ model, prompt, tools });
|
|
48
|
+
for await (const part of result.fullStream) {
|
|
49
|
+
guard.push(part);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { decisions, diagnostics } = guard.finish();
|
|
53
|
+
for (const decision of decisions) {
|
|
54
|
+
const authority = guard.takeDecision(decision.internalId);
|
|
55
|
+
if (authority) {
|
|
56
|
+
await writeFile(authority.value.path, authority.value.content);
|
|
57
|
+
} else {
|
|
58
|
+
console.warn(`skipped ${decision.name}: ${decision.action} (${decision.reason})`);
|
|
87
59
|
}
|
|
88
60
|
}
|
|
61
|
+
if (decisions.length === 0 && diagnostics.length > 0) {
|
|
62
|
+
console.warn("no tool call was executed:", diagnostics);
|
|
63
|
+
}
|
|
89
64
|
```
|
|
90
65
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
- Never execute `chunk.input` or an SDK-projected/repaired value. Dispatch only
|
|
94
|
-
the `value` returned once by `takeDecision(internalId)`.
|
|
95
|
-
- `finish()` remains replayable diagnostic state. `takeDecision()` returns a
|
|
96
|
-
call's executable authority at most once and never consumes another call.
|
|
97
|
-
- Provider-executed tools are outside the local guarantee.
|
|
98
|
-
- Mutating or reconstructing a locked definition after locking voids the
|
|
99
|
-
guarantee; pass `lockedTools` through unchanged.
|
|
100
|
-
- Application-level authorization and idempotency remain caller-owned. The
|
|
101
|
-
one-shot method prevents accidental replay through this guard instance; it
|
|
102
|
-
is not a distributed transaction or idempotency system.
|
|
103
|
-
- `prefix-safe-json` returns decisions; it never hides or performs execution.
|
|
104
|
-
|
|
105
|
-
[`examples/ai-sdk-lifecycle-proof.mjs`](examples/ai-sdk-lifecycle-proof.mjs)
|
|
106
|
-
runs deterministic execution-ownership proofs through the real `streamText()`
|
|
107
|
-
lifecycle of the exact pins `ai@5.0.244`, `ai@6.0.264`, and `ai@7.0.77`, with
|
|
108
|
-
no API key, network request, or paid model call. Run it with
|
|
109
|
-
`pnpm run example:ai-sdk-lifecycle-proof`. This is not a claim that every
|
|
110
|
-
version in those majors is tested. See [Compatibility](docs/COMPATIBILITY.md).
|
|
111
|
-
|
|
112
|
-
**What the decisions mean**
|
|
113
|
-
|
|
114
|
-
Every tool call gets exactly one verdict:
|
|
115
|
-
|
|
116
|
-
- **`execute`** — complete, unfabricated, and (if a schema is registered)
|
|
117
|
-
schema-valid. `decision.value` is safe to pass to your tool.
|
|
118
|
-
- **`retry`** — nothing is wrong with what arrived; there just isn't a
|
|
119
|
-
trustworthy complete value yet. Continue generation.
|
|
120
|
-
- **`reject`** — the data itself is the problem (malformed JSON, a schema
|
|
121
|
-
mismatch, a resource limit, a provider error, a content-policy
|
|
122
|
-
termination). Retrying the same input won't help.
|
|
123
|
-
|
|
124
|
-
**Why the model's finish reason matters as much as the JSON's shape**
|
|
125
|
-
|
|
126
|
-
A tool call cut short by a token limit, a provider error, or a content
|
|
127
|
-
filter can still contain syntactically complete, schema-valid JSON — the
|
|
128
|
-
model was simply stopped before finishing the *rest* of its response, not
|
|
129
|
-
necessarily mid-argument. Executing on JSON shape alone means trusting data
|
|
130
|
-
nobody, including the model, confirmed as final. This is exactly the failure
|
|
131
|
-
mode independently reported and reproduced in
|
|
132
|
-
[vercel/ai#19063](https://github.com/vercel/ai/issues/19063): tool calls
|
|
133
|
-
executing regardless of an unsafe finish reason, across AI SDK v5, v6, and
|
|
134
|
-
v7, with fixes landing across all three release lines.
|
|
135
|
-
`prefix-safe-json` does not patch or depend on that fix — it's a
|
|
136
|
-
provider-independent guard that requires positive proof a stream ended
|
|
137
|
-
safely before treating any tool call as final, regardless of which SDK,
|
|
138
|
-
provider, or framework hands it the data. See
|
|
139
|
-
`test/guard/ai-sdk-compatibility.test.ts` for the cross-version test
|
|
140
|
-
evidence.
|
|
141
|
-
|
|
142
|
-
**Execution ownership: what must never also happen**
|
|
143
|
-
|
|
144
|
-
The guard's decisions only mean something if they're the sole thing deciding
|
|
145
|
-
whether your tool function runs. The recommended pattern above prevents the
|
|
146
|
-
AI SDK's own tool-calling loop from running caller callbacks first —
|
|
147
|
-
in order from strongest guarantee to weakest:
|
|
148
|
-
|
|
149
|
-
- **Strongest — `createAiSdkExecutionLock()`** — wrap your tool definitions
|
|
150
|
-
with it before passing them to `streamText()`/`generateText()`. It removes
|
|
151
|
-
every AI SDK-invoked callback capable of running your code before this
|
|
152
|
-
library's decision — not just `execute`, but also `onInputStart`,
|
|
153
|
-
`onInputDelta`, and `onInputAvailable` (verified directly against
|
|
154
|
-
`ai@5`/`ai@6`/`ai@7`'s own real source: all three fire unconditionally,
|
|
155
|
-
independent of `needsApproval`/approval status — `needsApproval: true`
|
|
156
|
-
alone does **not** stop them, which is exactly the gap this function
|
|
157
|
-
closes). It also forces `needsApproval: true`, which still closes the
|
|
158
|
-
`execute` gap specifically on `ai@6`+ via the SDK's own approval
|
|
159
|
-
mechanism. It also rejects a **function-valued `description`**
|
|
160
|
-
(`ai@7`+ invokes it during tool preparation, before the model call even
|
|
161
|
-
begins — arbitrary caller code on the same pre-decision timeline as the
|
|
162
|
-
callback trio; a string `description` is unaffected on every major), and
|
|
163
|
-
any **provider tool shape whose real execution location it cannot
|
|
164
|
-
verify**: `isProviderExecuted: true` (real operation runs entirely on the
|
|
165
|
-
model provider's remote infrastructure), `ai@6`'s discriminator-less
|
|
166
|
-
`{ type: "provider" }`, and `ai@5`'s discriminator-less
|
|
167
|
-
`{ type: "provider-defined" }` are all rejected rather than silently
|
|
168
|
-
accepted; `ai@7`'s `{ type: "provider", isProviderExecuted: false }` is
|
|
169
|
-
accepted (verified to have no `execute` field at all on that shape, so the
|
|
170
|
-
SDK can never auto-run it). The guarantee is precisely about **this
|
|
171
|
-
function's own output, unchanged, on a supported local tool definition**:
|
|
172
|
-
it says nothing about a tool that bypasses this function, one
|
|
173
|
-
mutated/reconstructed after this function returns it, or a rejected
|
|
174
|
-
shape. Still dispatch manually from
|
|
175
|
-
`guard.takeDecision(observed.internalId)`, exactly as shown above; this only closes the
|
|
176
|
-
door on the SDK (or your own callbacks) doing it *for* you. See
|
|
177
|
-
[`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md#execution-ownership-tool-resulttool-error-as-evidence).
|
|
178
|
-
- **Safe, supported pattern (all majors, including `ai@5`)** — define your
|
|
179
|
-
tools *without* an AI SDK-native `execute` callback at all. Not even a
|
|
180
|
-
no-op one: the guard treats any `tool-result`/`tool-error` it observes as
|
|
181
|
-
proof the SDK's tool loop already invoked that call, and a no-op callback
|
|
182
|
-
still produces one — omitting `execute` entirely is what keeps the SDK
|
|
183
|
-
from running the tool itself in the first place. Consume `fullStream`
|
|
184
|
-
yourself, call `guard.finish()` for diagnostic state, and dispatch manually
|
|
185
|
-
only from `guard.takeDecision(observed.internalId)`, exactly as shown above. `createAiSdkExecutionLock()`
|
|
186
|
-
above gets you this same shape automatically (it drops `execute` too) plus
|
|
187
|
-
the SDK-enforced backstop where that backstop exists.
|
|
188
|
-
- **Unprotected / misuse pattern** — attach the real, irreversible operation
|
|
189
|
-
(or any side-effecting code at all) directly as `execute`, `onInputStart`,
|
|
190
|
-
`onInputDelta`, or `onInputAvailable` on a tool definition that bypasses
|
|
191
|
-
`createAiSdkExecutionLock()` entirely, *and* separately run the guard on
|
|
192
|
-
the same stream. All four are callbacks the SDK invokes on its own
|
|
193
|
-
schedule, independent of and often before this guard ever reaches a
|
|
194
|
-
decision — `onInputStart`/`onInputDelta`/`onInputAvailable` in particular
|
|
195
|
-
run regardless of finish reason, schema validity, or approval status, on
|
|
196
|
-
every major including `ai@5`. If that happens, the side effect has
|
|
197
|
-
already run — `prefix-safe-json` cannot retroactively undo it, and on
|
|
198
|
-
`ai@5` specifically nothing in this library can prevent any of these four
|
|
199
|
-
callbacks from firing at all (verified directly, not assumed — see
|
|
200
|
-
`test/integration/ai-sdk-lifecycle/ai-v5.real.test.ts`).
|
|
201
|
-
|
|
202
|
-
The guard does defend against the second pattern in one specific way, and
|
|
203
|
-
only for `execute` specifically: a `tool-result` or `tool-error` part on
|
|
204
|
-
`fullStream` is direct evidence the SDK's own loop already invoked *some*
|
|
205
|
-
call's `execute`. When it names a specific call (a real `toolCallId`), only
|
|
206
|
-
that call is disqualified; when it doesn't, the guard cannot tell which
|
|
207
|
-
in-flight call was affected and fails closed for **every** call in that
|
|
208
|
-
stream rather than guess. Either way the guard never again reports
|
|
209
|
-
`action: "execute"` for the affected call(s)
|
|
210
|
-
(`reason: "sdk_execution_observed"`, which takes priority over every other
|
|
211
|
-
rejection reason), so a caller who also runs the documented manual-dispatch
|
|
212
|
-
loop will not invoke the tool function a *second* time. There is no
|
|
213
|
-
equivalent detection for a bypassed `onInputStart`/`onInputDelta`/
|
|
214
|
-
`onInputAvailable` — those callbacks produce no observable `fullStream`
|
|
215
|
-
evidence of having run at all, so a misuse of any of the three is invisible
|
|
216
|
-
to the guard entirely; the only defense is calling
|
|
217
|
-
`createAiSdkExecutionLock()` in the first place. It does not undo
|
|
218
|
-
whatever the SDK's own callback already did. See
|
|
219
|
-
[`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md#execution-ownership-tool-resulttool-error-as-evidence)
|
|
220
|
-
for the full behavior and test matrix.
|
|
221
|
-
|
|
222
|
-
**Supported providers**: OpenAI (legacy `function_call` and Responses API),
|
|
223
|
-
Anthropic, Gemini, OpenRouter, generic OpenAI-compatible endpoints, and the
|
|
224
|
-
Vercel AI SDK. High-level guards currently ship for the AI SDK; every
|
|
225
|
-
provider has a public low-level adapter (see below) that composes with
|
|
226
|
-
`createToolCallExecutionGate()` the same way.
|
|
227
|
-
|
|
228
|
-
Gemini's adapter exposes its structured argument projection for inspection
|
|
229
|
-
and validation, but it never grants strict execute authority because Gemini
|
|
230
|
-
does not provide raw argument text at this seam. OpenAI-compatible and
|
|
231
|
-
OpenRouter events require a non-negative integer `choice.index`; missing,
|
|
232
|
-
invalid, or duplicate choice identity fails closed instead of guessing zero.
|
|
233
|
-
|
|
234
|
-
`AiSdkStreamAdapter`/`createAiSdkExecutionGuard()` target the public
|
|
235
|
-
`streamText()`/`generateText()` `fullStream` surface, whose `finishReason`
|
|
236
|
-
is a plain unified string (`"stop"`, `"length"`, ...). They are not
|
|
237
|
-
currently an adapter for the lower-level `@ai-sdk/provider` `doStream()`
|
|
238
|
-
boundary (`LanguageModelV3`/`V4`), where finish reason is
|
|
239
|
-
`{ unified, raw }`-object-shaped — building your own provider against that
|
|
240
|
-
interface directly needs different handling of that field.
|
|
241
|
-
|
|
242
|
-
Full semantics, the decision table, provider finish-reason mapping, and the
|
|
243
|
-
high-level guard API: [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md).
|
|
244
|
-
|
|
245
|
-
## The lower-level problem this is built on
|
|
246
|
-
|
|
247
|
-
LLM providers (OpenAI, Anthropic, Gemini, OpenRouter) stream tool-call arguments as small JSON chunks. During streaming, the JSON is frequently incomplete: strings split mid-word, UTF-8 characters split mid-byte, containers left unclosed, numbers terminated at chunk boundaries.
|
|
248
|
-
|
|
249
|
-
This library parses each chunk incrementally and provides:
|
|
250
|
-
|
|
251
|
-
- **Committed values**: Fields whose values are definitively complete
|
|
252
|
-
- **Pending tokens**: Partially received values awaiting more data
|
|
253
|
-
- **Monotonic semantic events**: JSON Pointer-based events that are never retracted
|
|
254
|
-
- **Parse diagnostics**: Structured error reporting with severity and recoverability
|
|
255
|
-
- **Deterministic repair**: When the stream ends, a clear accounting of what was repaired and why
|
|
256
|
-
- **Execution safety**: Whether the parsed result is safe to use for tool invocation
|
|
257
|
-
|
|
258
|
-
## Core Principle
|
|
259
|
-
|
|
260
|
-
> The parser never fabricates missing data. It clearly distinguishes what is definite, what is pending, what was deterministically repaired, and what is lost.
|
|
261
|
-
|
|
262
|
-
> **Valid JSON is not safe generation completion. Safe generation completion is not permission to execute a side effect.** Structural validity, confirmed completeness, and execution authority are three separate questions — the gate layer (below) answers all three before ever returning `execute`, not just the first. See [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) for the full guarantee/non-guarantee list.
|
|
263
|
-
|
|
264
|
-
## Why not a general "partial JSON" parser?
|
|
265
|
-
|
|
266
|
-
Most streaming JSON helpers (including ones already used in the wild for
|
|
267
|
-
LLM tool-call arguments) are designed to show *something* as early as
|
|
268
|
-
possible, even if that means guessing. Concretely, given a stream that has
|
|
269
|
-
so far delivered `{"city":"Tok` (the model is still typing "Tokyo"):
|
|
270
|
-
|
|
271
|
-
| | `city` field after this chunk |
|
|
272
|
-
|---|---|
|
|
273
|
-
| [vercel/ai's `fixJson`](https://github.com/vercel/ai/blob/main/packages/ai/src/util/fix-json.ts) | `"Tok"` — closes the open string as-is and reports a successful parse |
|
|
274
|
-
| [langchain's `parsePartialJson`](https://github.com/langchain-ai/langchainjs/blob/main/libs/langchain-core/src/utils/json.ts) | `"Tok"` — its recursive-descent parser returns whatever string content it collected before running out of input |
|
|
275
|
-
| **this library** (`snapshot().stableValue`) | *(absent — `city` is not committed yet)* |
|
|
276
|
-
|
|
277
|
-
Both of those are real, current, well-engineered implementations — verified
|
|
278
|
-
by cloning `vercel/ai` (commit `fbb154a0`) and `langchain-ai/langchainjs`
|
|
279
|
-
(commit `555d6f14`) and running their actual code side-by-side with this
|
|
280
|
-
library. Neither is a bug: they're built for progressively rendering a
|
|
281
|
-
value in a UI, where showing `"Tok"` and then `"Tokyo"` a moment later is
|
|
282
|
-
good UX. This library is built for the different question — *is it safe to
|
|
283
|
-
act on this value yet* — where treating `"Tok"` as the city would be
|
|
284
|
-
wrong. It's why `city` only appears in `stableValue` once its closing
|
|
285
|
-
quote genuinely arrives, and why `executable` only becomes `true` once the
|
|
286
|
-
whole document is unambiguously, definitively complete.
|
|
287
|
-
|
|
288
|
-
If you need live "filling in..." UI text, a partial-JSON renderer is the
|
|
289
|
-
right tool. If you need to know precisely when it's safe to execute a tool
|
|
290
|
-
call with the parsed arguments, that's what this library is for.
|
|
291
|
-
|
|
292
|
-
### A concrete case where this matters: silent execution on truncated data
|
|
293
|
-
|
|
294
|
-
The risk above isn't hypothetical. [Cline](https://github.com/cline/cline)
|
|
295
|
-
(a coding agent that writes files and runs terminal commands from LLM tool
|
|
296
|
-
calls — verified by cloning it, commit `81cce3d70e1`) wires a JSON-repair
|
|
297
|
-
step directly into tool execution:
|
|
298
|
-
[`sdk/packages/llms/src/providers/ai-sdk.ts:1332`](https://github.com/cline/cline/blob/81cce3d70e10244cdde40dbd0eb0bb711c93006d/sdk/packages/llms/src/providers/ai-sdk.ts#L1332)
|
|
299
|
-
registers `repairMalformedToolCall` as the Vercel AI SDK's
|
|
300
|
-
`experimental_repairToolCall` hook — whatever it returns *is* the tool call
|
|
301
|
-
that runs. That function's own docstring names its target: "truncated
|
|
302
|
-
payloads ... common with weaker models." Its repair path
|
|
303
|
-
([`sdk/packages/shared/src/parse/json.ts:42`](https://github.com/cline/cline/blob/81cce3d70e10244cdde40dbd0eb0bb711c93006d/sdk/packages/shared/src/parse/json.ts#L42))
|
|
304
|
-
runs the raw argument text through
|
|
305
|
-
[`jsonrepair`](https://github.com/josdejong/jsonrepair), which — like the
|
|
306
|
-
two implementations above — closes an unterminated string as-is rather
|
|
307
|
-
than rejecting it.
|
|
308
|
-
|
|
309
|
-
Feeding `parseJsonStream` (copied verbatim from that file) and this
|
|
310
|
-
library the same truncated `write_file` tool call — a `content` argument
|
|
311
|
-
cut off mid-value, the exact scenario the docstring describes:
|
|
66
|
+
`model` and `prompt` are yours. The lock stops the SDK from running `write_file` itself; the guard checks each streamed call against the same schema and hands out a one-shot decision. The caller owns dispatch, and a call the guard doesn't hand out never runs.
|
|
312
67
|
|
|
313
|
-
|
|
314
|
-
|
|
68
|
+
Using `jsonSchema()` from `ai` instead of Zod? Give the guard the raw schema inside it: `schemas: { write_file: writeFileSchema.jsonSchema }`. The wrapper itself is refused at construction, because it would otherwise validate nothing.
|
|
69
|
+
|
|
70
|
+
## Why parse success is not enough
|
|
315
71
|
|
|
316
|
-
|
|
317
|
-
{ "path": "config/database.yml",
|
|
318
|
-
"content": "production:\n host: db.prod.internal\n password: correct-horse-battery-sta" }
|
|
319
|
-
No error, no warning. This is what gets written to disk.
|
|
72
|
+
Tool-call arguments often arrive as partial JSON. A renderer may reasonably repair or close an unfinished prefix so it can show progressive UI. That is useful for display, but it answers a different question from whether a side effect should run.
|
|
320
73
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
74
|
+
Given the prefix:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
{"city":"Tok
|
|
324
78
|
```
|
|
325
79
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
-
|
|
381
|
-
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
`createToolCallStreamCoordinator()` optionally accepts a third argument: a map of tool name → JSON Schema (draft-07). When a call for a registered tool reaches a structurally complete outcome, its `stableValue` is validated against that schema.
|
|
388
|
-
|
|
389
|
-
This is a genuinely different check from prefix-safety. `executable` from the core parser only means *"this value is not truncated or fabricated."* It says nothing about whether the value matches what the tool actually needs — a model can finish generating a syntactically complete object that's still missing a required field, or has the wrong type for one. Schema validation catches that separate class of problem. The coordinator's own `executable` (on `tool_call_finished` and `ToolCallState.parser`) is only `true` when *both* hold.
|
|
80
|
+
some partial-JSON helpers can expose `"Tok"` immediately. `prefix-safe-json` keeps that field uncommitted until the stream provides enough evidence that the value is complete.
|
|
81
|
+
|
|
82
|
+
The core distinction is simple:
|
|
83
|
+
|
|
84
|
+
> **Parse completion is not execution authority.**
|
|
85
|
+
|
|
86
|
+
See [`docs/REAL_WORLD_FAILURES.md`](docs/REAL_WORLD_FAILURES.md) for independently reproduced examples and [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md) for the decision model.
|
|
87
|
+
|
|
88
|
+
## Decisions
|
|
89
|
+
|
|
90
|
+
Every observed tool call ends in one of three actions:
|
|
91
|
+
|
|
92
|
+
- **`execute`** — arguments are complete and, when configured, validator/schema checks pass.
|
|
93
|
+
- **`retry`** — no trustworthy complete value exists yet.
|
|
94
|
+
- **`reject`** — the stream contains malformed, conflicting, invalid, or terminally unsafe evidence.
|
|
95
|
+
|
|
96
|
+
The full reason matrix lives in [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md).
|
|
97
|
+
|
|
98
|
+
## Scope and non-goals
|
|
99
|
+
|
|
100
|
+
The library covers streamed argument evidence, call identity, lifecycle completion, truncation detection, conflicting evidence, validation composition, and one-shot execution decisions.
|
|
101
|
+
|
|
102
|
+
It does **not** provide:
|
|
103
|
+
|
|
104
|
+
- application authorization,
|
|
105
|
+
- human approval workflows,
|
|
106
|
+
- sandboxing,
|
|
107
|
+
- prompt-injection defense,
|
|
108
|
+
- distributed exactly-once semantics,
|
|
109
|
+
- a guarantee that the model selected the correct tool.
|
|
110
|
+
|
|
111
|
+
An `execute` decision means the tool arguments were observed as complete under the configured boundary. Your application still owns permissions, policy, validation, and the side effect itself.
|
|
112
|
+
|
|
113
|
+
See [`docs/PRODUCT_POSITIONING.md`](docs/PRODUCT_POSITIONING.md) and [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) for the detailed boundary.
|
|
114
|
+
|
|
115
|
+
## Vercel AI SDK integration
|
|
116
|
+
|
|
117
|
+
For `streamText()` / `generateText()`, the recommended pattern is:
|
|
118
|
+
|
|
119
|
+
1. wrap tool definitions with `createAiSdkExecutionLock()`,
|
|
120
|
+
2. consume `fullStream`,
|
|
121
|
+
3. feed stream parts into `createAiSdkExecutionGuard()`, and
|
|
122
|
+
4. dispatch manually only from `takeDecision()`.
|
|
123
|
+
|
|
124
|
+
`createAiSdkExecutionLock()` removes SDK-invoked callbacks that could execute application code before the guard reaches a final decision. A tool definition that bypasses that lock and performs side effects directly is outside the protection boundary.
|
|
125
|
+
|
|
126
|
+
The adapter targets the public AI SDK `fullStream` surface, not the lower-level `@ai-sdk/provider` `doStream()` boundary.
|
|
127
|
+
|
|
128
|
+
Detailed lifecycle behavior and version-specific tests are documented in [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md).
|
|
129
|
+
|
|
130
|
+
## Provider adapters
|
|
131
|
+
|
|
132
|
+
Low-level adapters are included for:
|
|
133
|
+
|
|
134
|
+
- OpenAI legacy `function_call`,
|
|
135
|
+
- OpenAI Responses API,
|
|
136
|
+
- Anthropic,
|
|
137
|
+
- Gemini,
|
|
138
|
+
- OpenRouter,
|
|
139
|
+
- generic OpenAI-compatible endpoints,
|
|
140
|
+
- Vercel AI SDK.
|
|
390
141
|
|
|
391
142
|
```typescript
|
|
392
|
-
import {
|
|
143
|
+
import {
|
|
144
|
+
createToolCallExecutionGate,
|
|
145
|
+
OpenAIStreamAdapter,
|
|
146
|
+
} from "prefix-safe-json";
|
|
147
|
+
|
|
148
|
+
const adapter = new OpenAIStreamAdapter();
|
|
149
|
+
const gate = createToolCallExecutionGate();
|
|
150
|
+
|
|
151
|
+
for (const rawChunk of stream) {
|
|
152
|
+
for (const event of adapter.push(rawChunk)) {
|
|
153
|
+
gate.push(event);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
393
156
|
|
|
394
|
-
const
|
|
157
|
+
for (const event of adapter.finish({ reason: "complete" })) {
|
|
158
|
+
gate.push(event);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const { decisions } = gate.finish();
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Gemini exposes structured argument projections at this seam rather than raw argument text, so its adapter does not grant the same strict raw-text execution authority. Provider-specific semantics are documented in [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md).
|
|
165
|
+
|
|
166
|
+
## Validation
|
|
167
|
+
|
|
168
|
+
Validation is optional and validator-agnostic. You can use JSON Schema/Ajv, Standard Schema-compatible validators, Zod, TypeBox, Valibot, or a custom validator.
|
|
169
|
+
|
|
170
|
+
`schemas` takes plain JSON Schema documents (TypeBox schemas count). Zod and other Standard Schema libraries go through `validators` with `fromStandardSchema()` from `prefix-safe-json/standard-schema`. Handing `schemas` a Zod schema or an AI SDK `jsonSchema()` wrapper throws at construction instead of silently validating nothing.
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
const gate = createToolCallExecutionGate(undefined, undefined, undefined, {
|
|
395
174
|
write_file: {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
},
|
|
401
|
-
required: ["path", "content"],
|
|
175
|
+
validate: (value) =>
|
|
176
|
+
WriteFileSchema.safeParse(value).success
|
|
177
|
+
? { valid: true }
|
|
178
|
+
: { valid: false },
|
|
402
179
|
},
|
|
403
180
|
});
|
|
181
|
+
```
|
|
404
182
|
|
|
405
|
-
|
|
183
|
+
See [`docs/VALIDATION.md`](docs/VALIDATION.md).
|
|
406
184
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
185
|
+
## Conformance corpus
|
|
186
|
+
|
|
187
|
+
[`conformance/`](conformance/) contains a provider-neutral fixture format and deterministic runner for this problem class. Projects can use the fixtures without adopting this package as their runtime implementation.
|
|
410
188
|
|
|
411
|
-
|
|
189
|
+
See [`docs/CONFORMANCE.md`](docs/CONFORMANCE.md).
|
|
412
190
|
|
|
413
|
-
##
|
|
191
|
+
## Compatibility
|
|
414
192
|
|
|
415
|
-
ESM only
|
|
416
|
-
Node `>=18.0.0`.
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
[`docs/COMPATIBILITY.md`](docs/COMPATIBILITY.md) for the full
|
|
420
|
-
runtime-versus-toolchain distinction, exact AI SDK pin requirements, and the
|
|
421
|
-
per-provider compatibility matrix.
|
|
193
|
+
- ESM only. CommonJS code can `require()` it on Node 20.19+ / 22.12+ (Node's `require(esm)`); older runtimes need `import()`.
|
|
194
|
+
- Runtime: Node `>=18.0.0`.
|
|
195
|
+
- Repository development/release tooling uses newer Node versions.
|
|
196
|
+
- CI exercises pinned Vercel AI SDK v5, v6, and v7 integration paths.
|
|
422
197
|
|
|
423
|
-
|
|
198
|
+
| AI SDK major | CI integration check |
|
|
199
|
+
| --- | --- |
|
|
200
|
+
| v5 | yes |
|
|
201
|
+
| v6 | yes |
|
|
202
|
+
| v7 | yes |
|
|
424
203
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
etc.) are equally public — see [`docs/EXECUTION_GATE.md`](docs/EXECUTION_GATE.md#example).
|
|
429
|
-
The `createParser()` API both are built on is also fully public, for anyone who
|
|
430
|
-
needs direct access to parser state:
|
|
204
|
+
This is not a claim that every patch release inside each major is tested. Exact pins and provider notes live in [`docs/COMPATIBILITY.md`](docs/COMPATIBILITY.md).
|
|
205
|
+
|
|
206
|
+
## Low-level parser API
|
|
431
207
|
|
|
432
208
|
```typescript
|
|
433
209
|
import { createParser } from "prefix-safe-json";
|
|
434
210
|
|
|
435
211
|
const parser = createParser();
|
|
436
|
-
|
|
437
|
-
// Feed chunks as they arrive from the LLM stream
|
|
438
212
|
parser.push('{"tool":"calc",');
|
|
439
213
|
parser.push('"args":{"x":42}}');
|
|
440
214
|
|
|
441
|
-
|
|
442
|
-
const snap = parser.snapshot();
|
|
443
|
-
console.log(snap.stableValue);
|
|
215
|
+
console.log(parser.snapshot().stableValue);
|
|
444
216
|
|
|
445
|
-
// Drain semantic events
|
|
446
|
-
const events = parser.drainEvents();
|
|
447
|
-
|
|
448
|
-
// Signal stream completion
|
|
449
217
|
const result = parser.finish({ reason: "complete" });
|
|
450
|
-
console.log(result.executable);
|
|
218
|
+
console.log(result.executable);
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Development
|
|
222
|
+
|
|
223
|
+
Implemented components include the incremental UTF-8 decoder, lexical/grammar state tracking, semantic events, duplicate-key rejection, configurable resource limits, provider adapters, concurrent call coordination, validation hooks, and execution-gate APIs.
|
|
224
|
+
|
|
225
|
+
Not currently provided: a standalone CLI or network/SSE client.
|
|
226
|
+
|
|
227
|
+
Runnable examples:
|
|
228
|
+
|
|
229
|
+
- [`examples/anthropic-truncation-safety.mjs`](examples/anthropic-truncation-safety.mjs)
|
|
230
|
+
- [`examples/ai-sdk-execution-gate.mjs`](examples/ai-sdk-execution-gate.mjs)
|
|
231
|
+
- [`examples/ai-sdk-lifecycle-proof.mjs`](examples/ai-sdk-lifecycle-proof.mjs)
|
|
232
|
+
|
|
233
|
+
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development workflow.
|
|
234
|
+
|
|
235
|
+
## Release verification
|
|
236
|
+
|
|
237
|
+
Published packages use npm provenance from this repository's GitHub Actions release workflow. A release tarball can also be rebuilt and compared with its tag using:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
npm run verify:published-release -- <version>
|
|
451
241
|
```
|
|
452
242
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
[`examples/anthropic-truncation-safety.mjs`](examples/anthropic-truncation-safety.mjs)
|
|
459
|
-
(`node examples/anthropic-truncation-safety.mjs` after `pnpm run build`).
|
|
460
|
-
It feeds `AnthropicStreamAdapter` a realistic Anthropic Messages API SSE
|
|
461
|
-
sequence — including one cut off mid-argument by `max_tokens`, matching
|
|
462
|
-
Anthropic's real streaming shape — through `createToolCallStreamCoordinator()`,
|
|
463
|
-
and asserts the truncated call is never reported executable while a
|
|
464
|
-
genuinely complete one is. This example runs in CI on every push, so it
|
|
465
|
-
can't silently rot into a stale claim.
|
|
466
|
-
|
|
467
|
-
For the exact-major AI SDK ownership proof, run
|
|
468
|
-
`pnpm run example:ai-sdk-lifecycle-proof` after `pnpm run build`. It asserts
|
|
469
|
-
for each exact pin that an unlocked control invokes caller code before guard
|
|
470
|
-
authority, locked unsafe calls perform no operation, and locked safe calls are
|
|
471
|
-
manually dispatched exactly once. CI and the release workflow run this same
|
|
472
|
-
command.
|
|
473
|
-
|
|
474
|
-
For the same raw-evidence demonstration through the low-level execution gate and the
|
|
475
|
-
[Vercel AI SDK](https://ai-sdk.dev)'s `fullStream` shape instead, see
|
|
476
|
-
[`examples/ai-sdk-execution-gate.mjs`](examples/ai-sdk-execution-gate.mjs)
|
|
477
|
-
(`node examples/ai-sdk-execution-gate.mjs` after `pnpm run build`) — also
|
|
478
|
-
run in CI on every push. [`examples/ai-sdk-guard.mjs`](examples/ai-sdk-guard.mjs)
|
|
479
|
-
is a lower-level wire-shape guard demonstration; it is not the canonical
|
|
480
|
-
execution-ownership example. Both remain CI-checked.
|
|
243
|
+
Additional release and dependency documentation is available in:
|
|
244
|
+
|
|
245
|
+
- [`docs/MAINTAINER_AUDIT.md`](docs/MAINTAINER_AUDIT.md)
|
|
246
|
+
- [`docs/RELEASE_INTEGRITY.md`](docs/RELEASE_INTEGRITY.md)
|
|
247
|
+
- [`docs/RUNTIME_DEPENDENCIES.md`](docs/RUNTIME_DEPENDENCIES.md)
|
|
481
248
|
|
|
482
249
|
## License
|
|
483
250
|
|