@namzu/sdk 5.2.0 → 6.1.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/CHANGELOG.md +237 -0
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +13 -0
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/gateway/__tests__/completion-inbox.test.d.ts +2 -0
- package/dist/gateway/__tests__/completion-inbox.test.d.ts.map +1 -0
- package/dist/gateway/__tests__/completion-inbox.test.js +258 -0
- package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -0
- package/dist/gateway/completion-inbox.d.ts +113 -0
- package/dist/gateway/completion-inbox.d.ts.map +1 -0
- package/dist/gateway/completion-inbox.js +211 -0
- package/dist/gateway/completion-inbox.js.map +1 -0
- package/dist/provider/__tests__/strict-schema.test.js +50 -2
- package/dist/provider/__tests__/strict-schema.test.js.map +1 -1
- package/dist/provider/__tests__/vendor-detail.test.d.ts +2 -0
- package/dist/provider/__tests__/vendor-detail.test.d.ts.map +1 -0
- package/dist/provider/__tests__/vendor-detail.test.js +89 -0
- package/dist/provider/__tests__/vendor-detail.test.js.map +1 -0
- package/dist/provider/errors.d.ts +38 -5
- package/dist/provider/errors.d.ts.map +1 -1
- package/dist/provider/errors.js +107 -5
- package/dist/provider/errors.js.map +1 -1
- package/dist/provider/strict-schema.d.ts.map +1 -1
- package/dist/provider/strict-schema.js +64 -8
- package/dist/provider/strict-schema.js.map +1 -1
- package/dist/public-runtime.d.ts +4 -0
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +10 -0
- package/dist/public-runtime.js.map +1 -1
- package/dist/registry/tool/__tests__/dialect.test.d.ts +2 -0
- package/dist/registry/tool/__tests__/dialect.test.d.ts.map +1 -0
- package/dist/registry/tool/__tests__/dialect.test.js +146 -0
- package/dist/registry/tool/__tests__/dialect.test.js.map +1 -0
- package/dist/registry/tool/dialect.d.ts +50 -0
- package/dist/registry/tool/dialect.d.ts.map +1 -0
- package/dist/registry/tool/dialect.js +138 -0
- package/dist/registry/tool/dialect.js.map +1 -0
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +34 -0
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/registry/toolset/catalog.d.ts.map +1 -1
- package/dist/registry/toolset/catalog.js +10 -5
- package/dist/registry/toolset/catalog.js.map +1 -1
- package/dist/runtime/query/__tests__/completion-notification.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/completion-notification.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/completion-notification.test.js +168 -0
- package/dist/runtime/query/__tests__/completion-notification.test.js.map +1 -0
- package/dist/runtime/query/__tests__/step-allow-list.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/step-allow-list.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/step-allow-list.test.js +171 -0
- package/dist/runtime/query/__tests__/step-allow-list.test.js.map +1 -0
- package/dist/runtime/query/__tests__/stream-recovery.test.js +6 -0
- package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
- package/dist/runtime/query/executor.d.ts +14 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +20 -1
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts +9 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +1 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +74 -1
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/result.d.ts.map +1 -1
- package/dist/runtime/query/result.js +6 -0
- package/dist/runtime/query/result.js.map +1 -1
- package/dist/tools/coordinator/__tests__/completion-delivery.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/completion-delivery.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/completion-delivery.test.js +255 -0
- package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -0
- package/dist/tools/coordinator/__tests__/task-list.test.js +13 -7
- package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts +35 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +222 -16
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/types/provider/error.d.ts +20 -4
- package/dist/types/provider/error.d.ts.map +1 -1
- package/dist/types/tool/index.d.ts +13 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/package.json +1 -1
- package/src/agents/SupervisorAgent.ts +14 -0
- package/src/gateway/__tests__/completion-inbox.test.ts +320 -0
- package/src/gateway/completion-inbox.ts +227 -0
- package/src/provider/__tests__/strict-schema.test.ts +58 -2
- package/src/provider/__tests__/vendor-detail.test.ts +107 -0
- package/src/provider/errors.ts +106 -5
- package/src/provider/strict-schema.ts +65 -8
- package/src/public-runtime.ts +11 -0
- package/src/registry/tool/__tests__/dialect.test.ts +200 -0
- package/src/registry/tool/dialect.ts +143 -0
- package/src/registry/tool/execute.ts +35 -0
- package/src/registry/toolset/catalog.ts +10 -5
- package/src/runtime/query/__tests__/completion-notification.test.ts +196 -0
- package/src/runtime/query/__tests__/step-allow-list.test.ts +205 -0
- package/src/runtime/query/__tests__/stream-recovery.test.ts +6 -0
- package/src/runtime/query/executor.ts +21 -1
- package/src/runtime/query/index.ts +11 -0
- package/src/runtime/query/iteration/index.ts +77 -1
- package/src/runtime/query/iteration/phases/context.ts +10 -0
- package/src/runtime/query/result.ts +6 -0
- package/src/tools/coordinator/__tests__/completion-delivery.test.ts +347 -0
- package/src/tools/coordinator/__tests__/task-list.test.ts +13 -7
- package/src/tools/coordinator/index.ts +250 -20
- package/src/types/provider/error.ts +20 -4
- package/src/types/tool/index.ts +13 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { providerHttpError, redactSecrets, vendorDetail } from '../errors.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The provider's own account of what was wrong, kept — and scrubbed.
|
|
7
|
+
*
|
|
8
|
+
* `ProviderRequestErrorInit` declared `detail` from the beginning and the
|
|
9
|
+
* constructor never read it, so the field existed and carried nothing. The
|
|
10
|
+
* body was read to classify and then dropped, deliberately, because an error
|
|
11
|
+
* body can echo a request and a request can carry a key.
|
|
12
|
+
*
|
|
13
|
+
* The cost of that trade showed up in production: the wire had been saying
|
|
14
|
+
* `tools.0.custom.input_schema: … must match JSON Schema draft 2020-12` and
|
|
15
|
+
* the SDK deleted the sentence, so diagnosing it took seven eliminated
|
|
16
|
+
* hypotheses and a day of downtime. Keeping the sentence and scrubbing the
|
|
17
|
+
* credential shapes is the trade that was actually available.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
describe('the sentence that names the broken field survives', () => {
|
|
21
|
+
it('lifts the structured message out of a vendor body', () => {
|
|
22
|
+
const body = JSON.stringify({
|
|
23
|
+
type: 'error',
|
|
24
|
+
error: {
|
|
25
|
+
type: 'invalid_request_error',
|
|
26
|
+
message:
|
|
27
|
+
'tools.0.custom.input_schema: JSON schema is invalid. It must match JSON Schema draft 2020-12',
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
expect(vendorDetail(body)).toBe(
|
|
32
|
+
'tools.0.custom.input_schema: JSON schema is invalid. It must match JSON Schema draft 2020-12',
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('reaches the error a caller actually catches', () => {
|
|
37
|
+
const err = providerHttpError({
|
|
38
|
+
providerId: 'anthropic',
|
|
39
|
+
status: 400,
|
|
40
|
+
body: JSON.stringify({ error: { message: "Schema type 'oneOf' is not supported" } }),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(err.detail).toContain('oneOf')
|
|
44
|
+
// …and the message too, so a log line that prints only the message is
|
|
45
|
+
// still enough to act on.
|
|
46
|
+
expect(err.message).toContain('oneOf')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('falls back to the raw text when the body is not JSON', () => {
|
|
50
|
+
expect(vendorDetail('upstream connect error, transport failure')).toBe(
|
|
51
|
+
'upstream connect error, transport failure',
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('says nothing rather than something empty', () => {
|
|
56
|
+
expect(vendorDetail(undefined)).toBeUndefined()
|
|
57
|
+
expect(vendorDetail(null)).toBeUndefined()
|
|
58
|
+
expect(vendorDetail(' ')).toBeUndefined()
|
|
59
|
+
expect(vendorDetail({})).toBeUndefined()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('truncates a body that is not a sentence', () => {
|
|
63
|
+
const detail = vendorDetail('x'.repeat(5_000))
|
|
64
|
+
expect(detail?.length).toBeLessThanOrEqual(401)
|
|
65
|
+
expect(detail?.endsWith('…')).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('a credential never rides along', () => {
|
|
70
|
+
it.each([
|
|
71
|
+
['sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv', 'anthropic-style key'],
|
|
72
|
+
['npm_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345', 'npm token'],
|
|
73
|
+
['ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345', 'github token'],
|
|
74
|
+
['AKIAIOSFODNN7EXAMPLE', 'aws access key id'],
|
|
75
|
+
])('scrubs %s (%s)', (secret) => {
|
|
76
|
+
const scrubbed = redactSecrets(`upstream rejected token ${secret} for this request`)
|
|
77
|
+
expect(scrubbed).not.toContain(secret)
|
|
78
|
+
expect(scrubbed).toContain('[redacted]')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('scrubs a bearer header the vendor echoed back', () => {
|
|
82
|
+
const scrubbed = redactSecrets('bad header: Authorization: Bearer abcdef0123456789ABCDEF')
|
|
83
|
+
expect(scrubbed).not.toContain('abcdef0123456789ABCDEF')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('scrubs a credential-named JSON field without eating the rest', () => {
|
|
87
|
+
const scrubbed = redactSecrets('{"api_key":"sk-live-9999","model":"the-model-that-failed"}')
|
|
88
|
+
expect(scrubbed).not.toContain('sk-live-9999')
|
|
89
|
+
// The surrounding sentence is the whole point — scrubbing must not
|
|
90
|
+
// degrade into deleting the message.
|
|
91
|
+
expect(scrubbed).toContain('the-model-that-failed')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('scrubs on the real path, not only in the helper', () => {
|
|
95
|
+
const err = providerHttpError({
|
|
96
|
+
providerId: 'anthropic',
|
|
97
|
+
status: 401,
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
error: { message: 'invalid key sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv supplied' },
|
|
100
|
+
}),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(err.detail).not.toContain('AbCdEfGhIjKlMnOpQrStUv')
|
|
104
|
+
expect(err.detail).toContain('[redacted]')
|
|
105
|
+
expect(err.message).not.toContain('AbCdEfGhIjKlMnOpQrStUv')
|
|
106
|
+
})
|
|
107
|
+
})
|
package/src/provider/errors.ts
CHANGED
|
@@ -11,11 +11,19 @@
|
|
|
11
11
|
*
|
|
12
12
|
* So the contract here is deliberately narrow:
|
|
13
13
|
*
|
|
14
|
-
* - the message is built from the STATUS LINE
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* survives every logger that serializes an error chain,
|
|
18
|
-
* point.
|
|
14
|
+
* - the message is built from the STATUS LINE, the classified `kind`, and the
|
|
15
|
+
* provider's own complaint in `detail` — truncated and scrubbed of anything
|
|
16
|
+
* credential-shaped. The raw body is never re-thrown and never attached as
|
|
17
|
+
* `cause`; a `cause` survives every logger that serializes an error chain,
|
|
18
|
+
* which defeats the point.
|
|
19
|
+
*
|
|
20
|
+
* The body used to be dropped entirely. That was over-corrected: a provider
|
|
21
|
+
* rejecting a request names the exact offending field, and deleting that
|
|
22
|
+
* sentence turned a one-line diagnosis into hypothesis elimination against a
|
|
23
|
+
* live API — once at the cost of a day of production downtime, while the
|
|
24
|
+
* wire had been saying `tools.0.custom.input_schema: … must match JSON
|
|
25
|
+
* Schema draft 2020-12` the entire time. Scrubbing what looks like a
|
|
26
|
+
* credential keeps the safety and returns the sentence.
|
|
19
27
|
* - `retryAfterMs` is DATA. Nothing in this module sleeps, backs off or
|
|
20
28
|
* retries. A retry loop inside a driver burns the run's wall clock and hides
|
|
21
29
|
* the failure from the layer that should decide.
|
|
@@ -50,6 +58,22 @@ export class ProviderRequestError extends Error {
|
|
|
50
58
|
public readonly providerId: string
|
|
51
59
|
public readonly status?: number
|
|
52
60
|
public readonly retryAfterMs?: number
|
|
61
|
+
/**
|
|
62
|
+
* What the provider said was wrong, truncated and redacted.
|
|
63
|
+
*
|
|
64
|
+
* `ProviderRequestErrorInit` has declared this field all along and the
|
|
65
|
+
* constructor never read it, so every caller that set it was writing to
|
|
66
|
+
* nothing. That is not a cosmetic gap: a provider rejecting a request
|
|
67
|
+
* usually names the exact offending field, and losing that sentence turns
|
|
68
|
+
* a one-line diagnosis into hypothesis elimination against a live API. It
|
|
69
|
+
* did — a tool schema in the wrong JSON Schema dialect cost a day of
|
|
70
|
+
* production downtime while the wire had been saying
|
|
71
|
+
* `tools.0.custom.input_schema: … must match JSON Schema draft 2020-12`
|
|
72
|
+
* the whole time.
|
|
73
|
+
*
|
|
74
|
+
* See {@link vendorDetail} for what is kept and what is scrubbed.
|
|
75
|
+
*/
|
|
76
|
+
public readonly detail?: string
|
|
53
77
|
|
|
54
78
|
constructor(init: ProviderRequestErrorInit) {
|
|
55
79
|
super(buildProviderErrorMessage(init))
|
|
@@ -58,7 +82,80 @@ export class ProviderRequestError extends Error {
|
|
|
58
82
|
this.providerId = init.providerId
|
|
59
83
|
if (init.status !== undefined) this.status = init.status
|
|
60
84
|
if (init.retryAfterMs !== undefined) this.retryAfterMs = init.retryAfterMs
|
|
85
|
+
if (init.detail !== undefined) this.detail = init.detail
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Longest detail worth carrying. A provider's complaint is a sentence. */
|
|
90
|
+
const DETAIL_MAX = 400
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Credential shapes to scrub before a provider's words are kept.
|
|
94
|
+
*
|
|
95
|
+
* The original decision to discard the body outright was not paranoia — an
|
|
96
|
+
* error body can echo the request, and a request can carry a key. The answer
|
|
97
|
+
* is to scrub what looks like a credential rather than to throw away the
|
|
98
|
+
* sentence that names the broken field.
|
|
99
|
+
*/
|
|
100
|
+
const SECRET_PATTERNS: readonly RegExp[] = [
|
|
101
|
+
/\b(?:sk|pk|rk)[-_][A-Za-z0-9_-]{12,}/g,
|
|
102
|
+
/\bnpm_[A-Za-z0-9]{20,}/g,
|
|
103
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/g,
|
|
104
|
+
/\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
|
|
105
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
106
|
+
/("(?:api[_-]?key|authorization|token|secret|password)"\s*:\s*)"[^"]*"/gi,
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The provider's own account of what was wrong, safe to log.
|
|
111
|
+
*
|
|
112
|
+
* Prefers the structured `error.message` a JSON body carries, because that is
|
|
113
|
+
* the field vendors put the actionable sentence in and it is bounded; falls
|
|
114
|
+
* back to the raw text. Truncated, and every credential shape replaced.
|
|
115
|
+
*/
|
|
116
|
+
export function vendorDetail(body: unknown): string | undefined {
|
|
117
|
+
if (body === undefined || body === null) return undefined
|
|
118
|
+
|
|
119
|
+
let text: string
|
|
120
|
+
if (typeof body === 'string') {
|
|
121
|
+
try {
|
|
122
|
+
text = pickMessage(JSON.parse(body)) ?? body
|
|
123
|
+
} catch {
|
|
124
|
+
text = body
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
text = pickMessage(body) ?? ''
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const cleaned = redactSecrets(text.trim())
|
|
131
|
+
if (cleaned.length === 0) return undefined
|
|
132
|
+
return cleaned.length > DETAIL_MAX ? `${cleaned.slice(0, DETAIL_MAX)}…` : cleaned
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function redactSecrets(text: string): string {
|
|
136
|
+
let out = text
|
|
137
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
138
|
+
out = out.replace(pattern, (_match, prefix?: string) =>
|
|
139
|
+
prefix === undefined ? '[redacted]' : `${prefix}"[redacted]"`,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function pickMessage(value: unknown): string | undefined {
|
|
146
|
+
if (typeof value === 'string') return value
|
|
147
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
148
|
+
const node = value as Record<string, unknown>
|
|
149
|
+
// `{ error: { message } }` is what every wire in this tree uses; the rest
|
|
150
|
+
// are the shapes seen in the drivers' own fixtures.
|
|
151
|
+
const nested = node.error
|
|
152
|
+
if (typeof nested === 'string') return nested
|
|
153
|
+
if (typeof nested === 'object' && nested !== null) {
|
|
154
|
+
const message = (nested as Record<string, unknown>).message
|
|
155
|
+
if (typeof message === 'string') return message
|
|
61
156
|
}
|
|
157
|
+
if (typeof node.message === 'string') return node.message
|
|
158
|
+
return undefined
|
|
62
159
|
}
|
|
63
160
|
|
|
64
161
|
/** Is this a classified provider failure, whichever SDK copy threw it? */
|
|
@@ -312,11 +409,13 @@ export function providerVendorError(input: {
|
|
|
312
409
|
input.retryAfter ?? vendorRetryAfter(error),
|
|
313
410
|
input.now ?? Date.now(),
|
|
314
411
|
)
|
|
412
|
+
const detail = vendorDetail(message)
|
|
315
413
|
return new ProviderRequestError({
|
|
316
414
|
kind,
|
|
317
415
|
providerId: input.providerId,
|
|
318
416
|
...(status !== undefined ? { status } : {}),
|
|
319
417
|
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
418
|
+
...(detail !== undefined ? { detail } : {}),
|
|
320
419
|
})
|
|
321
420
|
}
|
|
322
421
|
|
|
@@ -335,10 +434,12 @@ export function providerHttpError(input: {
|
|
|
335
434
|
}): ProviderRequestError {
|
|
336
435
|
const kind = classifyProviderHttpStatus(input.status, input.body)
|
|
337
436
|
const retryAfterMs = parseRetryAfterMs(input.retryAfter, input.now ?? Date.now())
|
|
437
|
+
const detail = vendorDetail(input.body)
|
|
338
438
|
return new ProviderRequestError({
|
|
339
439
|
kind,
|
|
340
440
|
providerId: input.providerId,
|
|
341
441
|
status: input.status,
|
|
342
442
|
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
443
|
+
...(detail !== undefined ? { detail } : {}),
|
|
343
444
|
})
|
|
344
445
|
}
|
|
@@ -28,12 +28,21 @@
|
|
|
28
28
|
* A deny-list rather than an allow-list, deliberately. An allow-list would have
|
|
29
29
|
* to enumerate every annotation a schema may carry — `description`, `title`,
|
|
30
30
|
* `examples`, vendor extensions — and would refuse a schema for saying
|
|
31
|
-
* something harmless.
|
|
32
|
-
*
|
|
31
|
+
* something harmless. Each entry names what to write instead.
|
|
32
|
+
*
|
|
33
|
+
* MEASURED against the live API, not read off a page. The first version of this
|
|
34
|
+
* list was derived from documentation and was wrong in both directions: it
|
|
35
|
+
* refused `minLength`/`maxLength`, which the wire accepts, and it permitted
|
|
36
|
+
* `prefixItems`, which the wire rejects. A deny-list nobody probed is a guess
|
|
37
|
+
* with a confident tone, and this one would have refused working tools while
|
|
38
|
+
* still letting a broken one through.
|
|
39
|
+
*
|
|
40
|
+
* The probe lives in the live contract test; run it against a new model before
|
|
41
|
+
* trusting this list on that model.
|
|
33
42
|
*/
|
|
34
43
|
const NO_CONDITIONALS = 'strict mode has no conditional schemas; flatten the object'
|
|
35
44
|
const NO_NUMERIC_BOUNDS = 'numeric bounds are not in the subset; enforce at execution'
|
|
36
|
-
const
|
|
45
|
+
const NO_ARRAY_BOUNDS = 'array bounds are not in the subset; enforce at execution'
|
|
37
46
|
|
|
38
47
|
// A Map rather than an object literal, because one of the keys is `then`: an
|
|
39
48
|
// ordinary object carrying a `then` property is a thenable, and awaiting it
|
|
@@ -50,17 +59,40 @@ const UNSUPPORTED: ReadonlyMap<string, string> = new Map([
|
|
|
50
59
|
['exclusiveMinimum', NO_NUMERIC_BOUNDS],
|
|
51
60
|
['exclusiveMaximum', NO_NUMERIC_BOUNDS],
|
|
52
61
|
['multipleOf', NO_NUMERIC_BOUNDS],
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
// `minLength`/`maxLength` are NOT here. The first version of this list put
|
|
63
|
+
// them here on documentation alone, and measurement says the wire accepts
|
|
64
|
+
// both — so the list was refusing tools that would have worked. A deny-list
|
|
65
|
+
// derived from prose and never probed is a guess with a confident tone.
|
|
66
|
+
// `minItems` is NOT a flat denial — see MIN_ITEMS_ALLOWED below. The wire
|
|
67
|
+
// accepts 0 and 1 and refuses everything above, naming the value in the
|
|
68
|
+
// error, so a blanket entry here would refuse `.nonempty()` on a schema the
|
|
69
|
+
// wire would have taken.
|
|
70
|
+
['maxItems', NO_ARRAY_BOUNDS],
|
|
71
|
+
['uniqueItems', NO_ARRAY_BOUNDS],
|
|
72
|
+
// The other direction of the same mistake: this was missing, and it is the
|
|
73
|
+
// one that interacts with the dialect conversion. A tuple becomes
|
|
74
|
+
// `prefixItems` for the 2020-12 wire — and strict rejects `prefixItems`
|
|
75
|
+
// outright, so a tool that is BOTH strict AND tuple-shaped cannot be
|
|
76
|
+
// expressed at all. Better to say that at registration than to convert a
|
|
77
|
+
// schema into a different rejection.
|
|
78
|
+
['prefixItems', 'strict arrays take one `items` schema; a tuple cannot be expressed'],
|
|
58
79
|
['patternProperties', 'name the properties explicitly'],
|
|
59
80
|
['propertyNames', 'name the properties explicitly'],
|
|
60
81
|
['dependentSchemas', 'flatten the object and validate at execution'],
|
|
61
82
|
['dependentRequired', 'flatten the object and validate at execution'],
|
|
62
83
|
])
|
|
63
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The only `minItems` values the strict subset admits.
|
|
87
|
+
*
|
|
88
|
+
* Measured: `minItems: 0` and `minItems: 1` are accepted, `minItems: 2` comes
|
|
89
|
+
* back *"For 'array' type, 'minItems' values other than 0 or 1 are not
|
|
90
|
+
* supported"*. So the constraint is on the VALUE, not the keyword, and that is
|
|
91
|
+
* the whole difference between refusing a required-non-empty array — the
|
|
92
|
+
* ordinary spelling of `z.array(...).nonempty()` — and letting it through.
|
|
93
|
+
*/
|
|
94
|
+
const MIN_ITEMS_ALLOWED = new Set([0, 1])
|
|
95
|
+
|
|
64
96
|
export interface StrictSchemaViolation {
|
|
65
97
|
/** Dotted path to the offending keyword, e.g. `properties.insertLine.oneOf`. */
|
|
66
98
|
readonly path: string
|
|
@@ -100,6 +132,31 @@ export function findStrictSchemaViolations(schema: unknown, path = ''): StrictSc
|
|
|
100
132
|
})
|
|
101
133
|
continue
|
|
102
134
|
}
|
|
135
|
+
// A tuple in the OTHER spelling. This check runs at registration, on the
|
|
136
|
+
// schema as rendered — which is draft-07, where a tuple is `items: [a,
|
|
137
|
+
// b]` — while the wire sees the 2020-12 `prefixItems` the driver
|
|
138
|
+
// converts it to. So a `prefixItems` entry alone never fires on the
|
|
139
|
+
// path that produces tuples, and the entry added to catch this case was
|
|
140
|
+
// dead for exactly the case it was added for.
|
|
141
|
+
//
|
|
142
|
+
// Both spellings mean the same thing and strict admits neither, so this
|
|
143
|
+
// names the tuple rather than the dialect it happens to be written in.
|
|
144
|
+
if (keyword === 'items' && Array.isArray(value)) {
|
|
145
|
+
found.push({
|
|
146
|
+
path: here,
|
|
147
|
+
keyword,
|
|
148
|
+
remedy: 'strict arrays take one `items` schema; a tuple cannot be expressed',
|
|
149
|
+
})
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
if (keyword === 'minItems' && typeof value === 'number' && !MIN_ITEMS_ALLOWED.has(value)) {
|
|
153
|
+
found.push({
|
|
154
|
+
path: here,
|
|
155
|
+
keyword,
|
|
156
|
+
remedy: 'strict accepts `minItems` of 0 or 1 only; enforce a larger bound at execution',
|
|
157
|
+
})
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
103
160
|
found.push(...findStrictSchemaViolations(value, here))
|
|
104
161
|
}
|
|
105
162
|
return found
|
package/src/public-runtime.ts
CHANGED
|
@@ -78,6 +78,13 @@ export { modelVersionAtLeast, parseVersionedModelId } from './provider/model-ver
|
|
|
78
78
|
// Strict tool input is a SUBSET of JSON Schema, and a keyword outside it makes
|
|
79
79
|
// the vendor reject the whole request rather than degrade one field.
|
|
80
80
|
export { assertStrictSchema, findStrictSchemaViolations } from './provider/strict-schema.js'
|
|
81
|
+
// A tool has one schema; what changes per provider is the DIALECT the wire
|
|
82
|
+
// parses, which is the wire's property. Rendered once, converted at the driver.
|
|
83
|
+
export { findDraft07Only, toSchemaDialect } from './registry/tool/dialect.js'
|
|
84
|
+
export type { JsonSchemaDialect } from './registry/tool/dialect.js'
|
|
85
|
+
// The renderer itself, so a driver or a contract test can ask what a tool will
|
|
86
|
+
// actually put on the wire without reaching into the registry.
|
|
87
|
+
export { renderToolSchema } from './registry/tool/schema.js'
|
|
81
88
|
export type { StrictSchemaViolation } from './provider/strict-schema.js'
|
|
82
89
|
export type { ModelIdGrammar, ModelVersion } from './provider/model-version.js'
|
|
83
90
|
export { drainQuery, query } from './runtime/query/index.js'
|
|
@@ -225,6 +232,10 @@ export {
|
|
|
225
232
|
export { InMemoryThreadStore } from './store/thread/memory.js'
|
|
226
233
|
|
|
227
234
|
export { LocalTaskGateway } from './gateway/local.js'
|
|
235
|
+
// Exported because `buildCoordinatorTools` is: a host that builds the
|
|
236
|
+
// coordinator surface itself needs the same inbox the loop drains, or its
|
|
237
|
+
// abandoned completions go unheard exactly as they did before.
|
|
238
|
+
export { CompletionInbox, formatCompletionNotification } from './gateway/completion-inbox.js'
|
|
228
239
|
|
|
229
240
|
// ─── providers, sandbox, vault ───────────────────────────────────────────
|
|
230
241
|
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
import { findDraft07Only, toSchemaDialect } from '../dialect.js'
|
|
5
|
+
import { renderToolSchema } from '../schema.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The mechanism, tested in the kernel that owns it.
|
|
9
|
+
*
|
|
10
|
+
* The drivers each have their own test proving the conversion reaches their
|
|
11
|
+
* wire. This one is about the conversion itself: what it rewrites, what it
|
|
12
|
+
* deliberately leaves alone, and the two properties the prompt cache depends
|
|
13
|
+
* on — a stable reference and a frozen result.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
describe('saying a schema in the dialect a wire parses', () => {
|
|
17
|
+
it('moves a tuple from `items` to `prefixItems`', () => {
|
|
18
|
+
const draft07 = {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
range: {
|
|
22
|
+
type: 'array',
|
|
23
|
+
items: [{ type: 'integer' }, { type: 'integer' }],
|
|
24
|
+
minItems: 2,
|
|
25
|
+
maxItems: 2,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
expect(toSchemaDialect(draft07, '2020-12')).toEqual({
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
range: {
|
|
34
|
+
type: 'array',
|
|
35
|
+
prefixItems: [{ type: 'integer' }, { type: 'integer' }],
|
|
36
|
+
minItems: 2,
|
|
37
|
+
maxItems: 2,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('leaves a homogeneous array alone, where `items` means the same thing', () => {
|
|
44
|
+
// The distinction the whole conversion turns on: `items` is only a
|
|
45
|
+
// tuple when it holds an ARRAY of schemas. One schema means "every
|
|
46
|
+
// element", which both dialects spell identically.
|
|
47
|
+
const schema = { type: 'array', items: { type: 'string' } }
|
|
48
|
+
|
|
49
|
+
expect(toSchemaDialect(schema, '2020-12')).toEqual(schema)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('turns `additionalItems` into the 2020-12 `items`', () => {
|
|
53
|
+
// `additionalItems` only ever qualified an array-form `items` — it says
|
|
54
|
+
// what the elements AFTER the tuple look like. 2020-12 gave that job to
|
|
55
|
+
// `items` once `prefixItems` holds the positional schemas.
|
|
56
|
+
const converted = toSchemaDialect(
|
|
57
|
+
{
|
|
58
|
+
type: 'array',
|
|
59
|
+
items: [{ type: 'integer' }],
|
|
60
|
+
additionalItems: { type: 'string' },
|
|
61
|
+
},
|
|
62
|
+
'2020-12',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
expect(converted).toEqual({
|
|
66
|
+
type: 'array',
|
|
67
|
+
prefixItems: [{ type: 'integer' }],
|
|
68
|
+
items: { type: 'string' },
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('keeps a closed tuple closed', () => {
|
|
73
|
+
// This assertion used to be its own opposite, and the comment under it
|
|
74
|
+
// was wrong about the dialect: it claimed a closed tuple is 2020-12's
|
|
75
|
+
// default once `prefixItems` is set. It is not — with no `items`,
|
|
76
|
+
// elements past the tuple are UNCONSTRAINED. Dropping the `false`
|
|
77
|
+
// turned a schema written to forbid a third element into one that
|
|
78
|
+
// allows any, which is a silent widening rather than a saved byte.
|
|
79
|
+
expect(
|
|
80
|
+
toSchemaDialect(
|
|
81
|
+
{ type: 'array', items: [{ type: 'integer' }], additionalItems: false },
|
|
82
|
+
'2020-12',
|
|
83
|
+
),
|
|
84
|
+
).toEqual({ type: 'array', prefixItems: [{ type: 'integer' }], items: false })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('ignores `additionalItems` with no tuple to qualify', () => {
|
|
88
|
+
// Meaningless in draft-07 too, so carrying it forward would be inventing
|
|
89
|
+
// a constraint the author did not write.
|
|
90
|
+
expect(
|
|
91
|
+
toSchemaDialect(
|
|
92
|
+
{ type: 'array', items: { type: 'string' }, additionalItems: { type: 'integer' } },
|
|
93
|
+
'2020-12',
|
|
94
|
+
),
|
|
95
|
+
).toEqual({ type: 'array', items: { type: 'string' } })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('converts a tuple nested inside another tuple', () => {
|
|
99
|
+
const converted = toSchemaDialect(
|
|
100
|
+
{ type: 'array', items: [{ type: 'array', items: [{ type: 'integer' }] }] },
|
|
101
|
+
'2020-12',
|
|
102
|
+
) as Record<string, Record<string, unknown>[]>
|
|
103
|
+
|
|
104
|
+
expect(converted.prefixItems?.[0]).toEqual({
|
|
105
|
+
type: 'array',
|
|
106
|
+
prefixItems: [{ type: 'integer' }],
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('hands back the very same object for draft-07', () => {
|
|
111
|
+
// Not an equal object — the SAME one. The tools block sits at position 0
|
|
112
|
+
// of the prompt-cache prefix, so a driver that speaks draft-07 must not
|
|
113
|
+
// pay an allocation or risk a differently-ordered copy per request.
|
|
114
|
+
const schema = { type: 'object' }
|
|
115
|
+
|
|
116
|
+
expect(toSchemaDialect(schema, 'draft-07')).toBe(schema)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('returns the same converted object every time it is asked', () => {
|
|
120
|
+
// Same reason. Conversion runs once per schema per dialect; a fresh
|
|
121
|
+
// object each iteration would invalidate the cache for the whole run
|
|
122
|
+
// even though the bytes were equal.
|
|
123
|
+
const schema = { type: 'array', items: [{ type: 'integer' }] }
|
|
124
|
+
|
|
125
|
+
expect(toSchemaDialect(schema, '2020-12')).toBe(toSchemaDialect(schema, '2020-12'))
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('freezes what it hands out, all the way down', () => {
|
|
129
|
+
// A caller that mutates a cached schema would poison every later render,
|
|
130
|
+
// and the symptom would be a silently invalidated prompt cache rather
|
|
131
|
+
// than an error. Freezing turns that into a throw at the mutation site.
|
|
132
|
+
const converted = toSchemaDialect(
|
|
133
|
+
{ type: 'object', properties: { a: { type: 'array', items: [{ type: 'integer' }] } } },
|
|
134
|
+
'2020-12',
|
|
135
|
+
) as { properties: { a: { prefixItems: unknown[] } } }
|
|
136
|
+
|
|
137
|
+
expect(Object.isFrozen(converted)).toBe(true)
|
|
138
|
+
expect(Object.isFrozen(converted.properties.a)).toBe(true)
|
|
139
|
+
expect(Object.isFrozen(converted.properties.a.prefixItems)).toBe(true)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('finding what a 2020-12 wire will refuse', () => {
|
|
144
|
+
it('names the path to an array-form `items`', () => {
|
|
145
|
+
expect(
|
|
146
|
+
findDraft07Only({
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: { range: { type: 'array', items: [{ type: 'integer' }] } },
|
|
149
|
+
}),
|
|
150
|
+
).toEqual(['properties.range.items'])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('names `additionalItems` too', () => {
|
|
154
|
+
expect(findDraft07Only({ additionalItems: false })).toEqual(['additionalItems'])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('walks into arrays, indexing the branch', () => {
|
|
158
|
+
expect(
|
|
159
|
+
findDraft07Only({
|
|
160
|
+
anyOf: [{ type: 'string' }, { type: 'array', items: [{ type: 'integer' }] }],
|
|
161
|
+
}),
|
|
162
|
+
).toEqual(['anyOf[1].items'])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('does not descend into a tuple it has already reported', () => {
|
|
166
|
+
// Reporting the tuple and then each of its positional schemas would
|
|
167
|
+
// turn one fixable finding into a list nobody reads.
|
|
168
|
+
expect(
|
|
169
|
+
findDraft07Only({ type: 'array', items: [{ type: 'integer' }, { type: 'string' }] }),
|
|
170
|
+
).toEqual(['items'])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('says nothing about a schema that is already 2020-12', () => {
|
|
174
|
+
expect(
|
|
175
|
+
findDraft07Only({
|
|
176
|
+
type: 'array',
|
|
177
|
+
prefixItems: [{ type: 'integer' }],
|
|
178
|
+
items: { type: 'string' },
|
|
179
|
+
}),
|
|
180
|
+
).toEqual([])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('tolerates the leaves', () => {
|
|
184
|
+
expect(findDraft07Only(null)).toEqual([])
|
|
185
|
+
expect(findDraft07Only('a string')).toEqual([])
|
|
186
|
+
expect(findDraft07Only(42)).toEqual([])
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('the round trip a real tool takes', () => {
|
|
191
|
+
it('renders a Zod tuple as draft-07 and converts it clean', () => {
|
|
192
|
+
// The actual defect, end to end: this is what `read.readRange` is.
|
|
193
|
+
const rendered = renderToolSchema(
|
|
194
|
+
z.object({ readRange: z.tuple([z.number(), z.number()]).optional() }),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
expect(findDraft07Only(rendered)).not.toEqual([])
|
|
198
|
+
expect(findDraft07Only(toSchemaDialect(rendered, '2020-12'))).toEqual([])
|
|
199
|
+
})
|
|
200
|
+
})
|