@jigging/agent-method 0.0.0 → 0.1.0-alpha.4
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/AGENTS.md +98 -0
- package/FLOW.contract.json +241 -0
- package/FLOW.meta.json +8 -0
- package/FLOW.ts +3 -0
- package/LICENSE +373 -0
- package/README.md +369 -0
- package/THIRD_PARTY_NOTICES +9 -0
- package/contracts/acp-public-updates.json +75 -0
- package/contracts/agent-commands.json +88 -0
- package/contracts/agent-replies.json +202 -0
- package/contracts/http-request/contract.json +37 -0
- package/dist/api.d.ts +11 -0
- package/dist/api.js +164 -0
- package/dist/conversation.d.ts +68 -0
- package/dist/conversation.js +346 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +8 -0
- package/dist/flow.d.ts +3 -0
- package/dist/flow.js +2784 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +220 -0
- package/dist/json.d.ts +21 -0
- package/dist/json.js +409 -0
- package/dist/schema.d.ts +7 -0
- package/dist/schema.js +180 -0
- package/dist/skills.d.ts +3 -0
- package/dist/skills.js +132 -0
- package/dist/values.d.ts +11 -0
- package/dist/values.js +65 -0
- package/justfile +32 -0
- package/licenses/flow.LICENSE +202 -0
- package/package.json +45 -5
- package/settings.schema.json +12 -0
- package/skills/answer-check/SKILL.md +5 -0
- package/src/api.ts +191 -0
- package/src/conversation.ts +387 -0
- package/src/errors.ts +11 -0
- package/src/flow.ts +66 -0
- package/src/index.ts +325 -0
- package/src/json.ts +406 -0
- package/src/schema.ts +230 -0
- package/src/skills.ts +138 -0
- package/src/values.ts +77 -0
- package/test/api.test.ts +326 -0
- package/test/conversation-fixture.ts +73 -0
- package/test/conversation.test.ts +328 -0
- package/test/json.test.ts +88 -0
- package/test/method.test.ts +308 -0
- package/test/pack.test.ts +81 -0
- package/test/result.test.ts +103 -0
- package/test/skills-flow.test.ts +252 -0
- package/tsconfig.json +17 -0
package/src/json.ts
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
export type JsonScalar = null | boolean | number | string
|
|
2
|
+
|
|
3
|
+
export type JsonValue = JsonScalar | readonly JsonValue[] | { readonly [key: string]: JsonValue }
|
|
4
|
+
|
|
5
|
+
export type JsonObject = Readonly<Record<string, JsonValue>>
|
|
6
|
+
|
|
7
|
+
export const JSON_1_LIMITS = Object.freeze({
|
|
8
|
+
bytes: 16_777_216,
|
|
9
|
+
depth: 128,
|
|
10
|
+
nodes: 262_144,
|
|
11
|
+
containerEntries: 65_536,
|
|
12
|
+
stringBytes: 8_388_608,
|
|
13
|
+
memberNameBytes: 1_024,
|
|
14
|
+
numberTokenBytes: 128,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const encoder = new TextEncoder()
|
|
18
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
|
|
19
|
+
|
|
20
|
+
export class Json1Error extends Error {
|
|
21
|
+
constructor(message: string) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = 'Json1Error'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class Parser {
|
|
28
|
+
private position = 0
|
|
29
|
+
private nodes = 0
|
|
30
|
+
|
|
31
|
+
constructor(private readonly source: string) {}
|
|
32
|
+
|
|
33
|
+
parse(): JsonValue {
|
|
34
|
+
this.skipWhitespace()
|
|
35
|
+
const value = this.parseValue(1)
|
|
36
|
+
this.skipWhitespace()
|
|
37
|
+
if (this.position !== this.source.length) this.fail('unexpected trailing characters')
|
|
38
|
+
return value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private parseValue(depth: number): JsonValue {
|
|
42
|
+
if (depth > JSON_1_LIMITS.depth) this.fail('maximum value depth exceeded')
|
|
43
|
+
this.nodes += 1
|
|
44
|
+
if (this.nodes > JSON_1_LIMITS.nodes) this.fail('maximum value nodes exceeded')
|
|
45
|
+
|
|
46
|
+
const char = this.source[this.position]
|
|
47
|
+
if (char === '"') return this.parseString(false)
|
|
48
|
+
if (char === '{') return this.parseObject(depth)
|
|
49
|
+
if (char === '[') return this.parseArray(depth)
|
|
50
|
+
if (char === 't') return this.parseLiteral('true', true)
|
|
51
|
+
if (char === 'f') return this.parseLiteral('false', false)
|
|
52
|
+
if (char === 'n') return this.parseLiteral('null', null)
|
|
53
|
+
if (char === '-' || (char !== undefined && char >= '0' && char <= '9')) {
|
|
54
|
+
return this.parseNumber()
|
|
55
|
+
}
|
|
56
|
+
return this.fail('expected a JSON value')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private parseObject(depth: number): JsonObject {
|
|
60
|
+
this.position += 1
|
|
61
|
+
const result: Record<string, JsonValue> = Object.create(null) as Record<string, JsonValue>
|
|
62
|
+
const keys = new Set<string>()
|
|
63
|
+
this.skipWhitespace()
|
|
64
|
+
if (this.consume('}')) return result
|
|
65
|
+
|
|
66
|
+
let members = 0
|
|
67
|
+
while (true) {
|
|
68
|
+
if (this.source[this.position] !== '"') this.fail('expected an object member name')
|
|
69
|
+
const key = this.parseString(true)
|
|
70
|
+
if (keys.has(key)) this.fail(`duplicate object member ${key}`)
|
|
71
|
+
keys.add(key)
|
|
72
|
+
members += 1
|
|
73
|
+
if (members > JSON_1_LIMITS.containerEntries) {
|
|
74
|
+
this.fail('maximum object members exceeded')
|
|
75
|
+
}
|
|
76
|
+
this.skipWhitespace()
|
|
77
|
+
if (!this.consume(':')) this.fail("expected ':' after member name")
|
|
78
|
+
this.skipWhitespace()
|
|
79
|
+
result[key] = this.parseValue(depth + 1)
|
|
80
|
+
this.skipWhitespace()
|
|
81
|
+
if (this.consume('}')) return result
|
|
82
|
+
if (!this.consume(',')) this.fail("expected ',' or '}'")
|
|
83
|
+
this.skipWhitespace()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private parseArray(depth: number): readonly JsonValue[] {
|
|
88
|
+
this.position += 1
|
|
89
|
+
const result: JsonValue[] = []
|
|
90
|
+
this.skipWhitespace()
|
|
91
|
+
if (this.consume(']')) return result
|
|
92
|
+
while (true) {
|
|
93
|
+
if (result.length >= JSON_1_LIMITS.containerEntries) {
|
|
94
|
+
this.fail('maximum array items exceeded')
|
|
95
|
+
}
|
|
96
|
+
result.push(this.parseValue(depth + 1))
|
|
97
|
+
this.skipWhitespace()
|
|
98
|
+
if (this.consume(']')) return result
|
|
99
|
+
if (!this.consume(',')) this.fail("expected ',' or ']'")
|
|
100
|
+
this.skipWhitespace()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private parseString(memberName: boolean): string {
|
|
105
|
+
this.position += 1
|
|
106
|
+
const parts: string[] = []
|
|
107
|
+
let segmentStart = this.position
|
|
108
|
+
while (this.position < this.source.length) {
|
|
109
|
+
const char = this.source[this.position]
|
|
110
|
+
this.position += 1
|
|
111
|
+
if (char === '"') {
|
|
112
|
+
parts.push(this.source.slice(segmentStart, this.position - 1))
|
|
113
|
+
const value = parts.join('')
|
|
114
|
+
const limit = memberName ? JSON_1_LIMITS.memberNameBytes : JSON_1_LIMITS.stringBytes
|
|
115
|
+
if (encoder.encode(value).byteLength > limit) this.fail('maximum string bytes exceeded')
|
|
116
|
+
return value
|
|
117
|
+
}
|
|
118
|
+
if (char === '\\') {
|
|
119
|
+
parts.push(this.source.slice(segmentStart, this.position - 1))
|
|
120
|
+
parts.push(this.parseEscape())
|
|
121
|
+
segmentStart = this.position
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (char === undefined || char.charCodeAt(0) <= 0x1f) {
|
|
125
|
+
this.fail('invalid character in string')
|
|
126
|
+
}
|
|
127
|
+
const code = char.charCodeAt(0)
|
|
128
|
+
if (code >= 0xd800 && code <= 0xdfff) {
|
|
129
|
+
const next = this.source[this.position]
|
|
130
|
+
if (
|
|
131
|
+
code > 0xdbff ||
|
|
132
|
+
next === undefined ||
|
|
133
|
+
next.charCodeAt(0) < 0xdc00 ||
|
|
134
|
+
next.charCodeAt(0) > 0xdfff
|
|
135
|
+
)
|
|
136
|
+
this.fail('lone Unicode surrogate')
|
|
137
|
+
this.position += 1
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return this.fail('unterminated string')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private parseEscape(): string {
|
|
144
|
+
const escaped = this.source[this.position]
|
|
145
|
+
this.position += 1
|
|
146
|
+
switch (escaped) {
|
|
147
|
+
case '"':
|
|
148
|
+
case '\\':
|
|
149
|
+
case '/':
|
|
150
|
+
return escaped
|
|
151
|
+
case 'b':
|
|
152
|
+
return '\b'
|
|
153
|
+
case 'f':
|
|
154
|
+
return '\f'
|
|
155
|
+
case 'n':
|
|
156
|
+
return '\n'
|
|
157
|
+
case 'r':
|
|
158
|
+
return '\r'
|
|
159
|
+
case 't':
|
|
160
|
+
return '\t'
|
|
161
|
+
case 'u': {
|
|
162
|
+
const first = this.parseHexCodeUnit()
|
|
163
|
+
if (first >= 0xd800 && first <= 0xdbff) {
|
|
164
|
+
if (this.source.slice(this.position, this.position + 2) !== '\\u') {
|
|
165
|
+
this.fail('high surrogate without low surrogate')
|
|
166
|
+
}
|
|
167
|
+
this.position += 2
|
|
168
|
+
const second = this.parseHexCodeUnit()
|
|
169
|
+
if (second < 0xdc00 || second > 0xdfff) {
|
|
170
|
+
this.fail('high surrogate without low surrogate')
|
|
171
|
+
}
|
|
172
|
+
return String.fromCharCode(first, second)
|
|
173
|
+
}
|
|
174
|
+
if (first >= 0xdc00 && first <= 0xdfff) this.fail('lone low surrogate')
|
|
175
|
+
return String.fromCharCode(first)
|
|
176
|
+
}
|
|
177
|
+
default:
|
|
178
|
+
return this.fail('invalid string escape')
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private parseHexCodeUnit(): number {
|
|
183
|
+
const token = this.source.slice(this.position, this.position + 4)
|
|
184
|
+
if (!/^[0-9A-Fa-f]{4}$/.test(token)) this.fail('invalid Unicode escape')
|
|
185
|
+
this.position += 4
|
|
186
|
+
return Number.parseInt(token, 16)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private parseNumber(): number {
|
|
190
|
+
const start = this.position
|
|
191
|
+
this.consume('-')
|
|
192
|
+
if (this.consume('0')) {
|
|
193
|
+
const next = this.source[this.position]
|
|
194
|
+
if (next !== undefined && next >= '0' && next <= '9') {
|
|
195
|
+
this.fail('leading zero in number')
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
if (!this.consumeDigit('1', '9')) this.fail('invalid number')
|
|
199
|
+
while (this.consumeDigit('0', '9')) {}
|
|
200
|
+
}
|
|
201
|
+
if (this.consume('.')) {
|
|
202
|
+
if (!this.consumeDigit('0', '9')) this.fail('fraction requires a digit')
|
|
203
|
+
while (this.consumeDigit('0', '9')) {}
|
|
204
|
+
}
|
|
205
|
+
const exponent = this.source[this.position]
|
|
206
|
+
if (exponent === 'e' || exponent === 'E') {
|
|
207
|
+
this.position += 1
|
|
208
|
+
const sign = this.source[this.position]
|
|
209
|
+
if (sign === '+' || sign === '-') this.position += 1
|
|
210
|
+
if (!this.consumeDigit('0', '9')) this.fail('exponent requires a digit')
|
|
211
|
+
while (this.consumeDigit('0', '9')) {}
|
|
212
|
+
}
|
|
213
|
+
const token = this.source.slice(start, this.position)
|
|
214
|
+
if (token.length > JSON_1_LIMITS.numberTokenBytes) {
|
|
215
|
+
this.fail('maximum number token bytes exceeded')
|
|
216
|
+
}
|
|
217
|
+
const value = Number(token)
|
|
218
|
+
if (!Number.isFinite(value)) this.fail('non-finite number')
|
|
219
|
+
if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
|
|
220
|
+
this.fail('integral number outside safe range')
|
|
221
|
+
}
|
|
222
|
+
return value
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private parseLiteral<T extends JsonValue>(token: string, value: T): T {
|
|
226
|
+
if (this.source.slice(this.position, this.position + token.length) !== token) {
|
|
227
|
+
this.fail(`invalid literal ${token}`)
|
|
228
|
+
}
|
|
229
|
+
this.position += token.length
|
|
230
|
+
return value
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private consume(expected: string): boolean {
|
|
234
|
+
if (this.source[this.position] !== expected) return false
|
|
235
|
+
this.position += 1
|
|
236
|
+
return true
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private consumeDigit(minimum: string, maximum: string): boolean {
|
|
240
|
+
const char = this.source[this.position]
|
|
241
|
+
if (char === undefined || char < minimum || char > maximum) return false
|
|
242
|
+
this.position += 1
|
|
243
|
+
return true
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private skipWhitespace(): void {
|
|
247
|
+
while (true) {
|
|
248
|
+
const char = this.source[this.position]
|
|
249
|
+
if (char !== ' ' && char !== '\n' && char !== '\r' && char !== '\t') return
|
|
250
|
+
this.position += 1
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private fail(message: string): never {
|
|
255
|
+
throw new Json1Error(`${message} at character ${this.position}`)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function decodeJson1(bytes: Uint8Array): JsonValue {
|
|
260
|
+
if (bytes.byteLength === 0) throw new Json1Error('empty JSON/0 document')
|
|
261
|
+
if (bytes.byteLength > JSON_1_LIMITS.bytes) throw new Json1Error('maximum bytes exceeded')
|
|
262
|
+
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
263
|
+
throw new Json1Error('UTF-8 BOM is not allowed')
|
|
264
|
+
}
|
|
265
|
+
let text: string
|
|
266
|
+
try {
|
|
267
|
+
text = decoder.decode(bytes)
|
|
268
|
+
} catch {
|
|
269
|
+
throw new Json1Error('invalid UTF-8')
|
|
270
|
+
}
|
|
271
|
+
return new Parser(text).parse()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function validateJson1(value: unknown): asserts value is JsonValue {
|
|
275
|
+
visitJson(value, 1, { nodes: 0, encodedBytes: 0, active: new WeakSet<object>() }, false)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface VisitState {
|
|
279
|
+
nodes: number
|
|
280
|
+
encodedBytes: number
|
|
281
|
+
readonly active: WeakSet<object>
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function visitJson(value: unknown, depth: number, state: VisitState, memberName: boolean): void {
|
|
285
|
+
if (depth > JSON_1_LIMITS.depth) throw new Json1Error('maximum value depth exceeded')
|
|
286
|
+
state.nodes += 1
|
|
287
|
+
if (state.nodes > JSON_1_LIMITS.nodes) throw new Json1Error('maximum value nodes exceeded')
|
|
288
|
+
if (value === null) {
|
|
289
|
+
chargeEncodedBytes(state, 4)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
if (typeof value === 'boolean') {
|
|
293
|
+
chargeEncodedBytes(state, value ? 4 : 5)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
if (typeof value === 'number') {
|
|
297
|
+
if (!Number.isFinite(value)) throw new Json1Error('non-finite number')
|
|
298
|
+
if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
|
|
299
|
+
throw new Json1Error('integral number outside safe range')
|
|
300
|
+
}
|
|
301
|
+
chargeEncodedBytes(state, (Object.is(value, -0) ? '0' : JSON.stringify(value)).length)
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
if (typeof value === 'string') {
|
|
305
|
+
validateString(value, memberName ? JSON_1_LIMITS.memberNameBytes : JSON_1_LIMITS.stringBytes)
|
|
306
|
+
chargeEncodedBytes(state, encodedJsonStringBytes(value))
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
if (typeof value !== 'object') throw new Json1Error('value is not JSON/0')
|
|
310
|
+
if (state.active.has(value)) throw new Json1Error('cyclic value')
|
|
311
|
+
state.active.add(value)
|
|
312
|
+
try {
|
|
313
|
+
if (Array.isArray(value)) {
|
|
314
|
+
if (value.length > JSON_1_LIMITS.containerEntries) {
|
|
315
|
+
throw new Json1Error('maximum array items exceeded')
|
|
316
|
+
}
|
|
317
|
+
if (
|
|
318
|
+
Object.getPrototypeOf(value) !== Array.prototype ||
|
|
319
|
+
Reflect.ownKeys(value).length !== value.length + 1
|
|
320
|
+
) {
|
|
321
|
+
throw new Json1Error('array must contain only ordinary indexed data')
|
|
322
|
+
}
|
|
323
|
+
chargeEncodedBytes(state, 2 + Math.max(0, value.length - 1))
|
|
324
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
325
|
+
const field = Object.getOwnPropertyDescriptor(value, String(index))
|
|
326
|
+
if (field === undefined || !('value' in field) || !field.enumerable) {
|
|
327
|
+
throw new Json1Error('array items must be ordinary data')
|
|
328
|
+
}
|
|
329
|
+
visitJson(field.value, depth + 1, state, false)
|
|
330
|
+
}
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
const object = value as Record<string, unknown>
|
|
334
|
+
const prototype = Object.getPrototypeOf(object)
|
|
335
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
336
|
+
throw new Json1Error('object must be an ordinary record')
|
|
337
|
+
}
|
|
338
|
+
const keys = Object.keys(object)
|
|
339
|
+
if (Reflect.ownKeys(object).length !== keys.length) {
|
|
340
|
+
throw new Json1Error('object members must be enumerable string data')
|
|
341
|
+
}
|
|
342
|
+
if (keys.length > JSON_1_LIMITS.containerEntries) {
|
|
343
|
+
throw new Json1Error('maximum object members exceeded')
|
|
344
|
+
}
|
|
345
|
+
chargeEncodedBytes(state, 2 + keys.length + Math.max(0, keys.length - 1))
|
|
346
|
+
for (const key of keys) {
|
|
347
|
+
validateString(key, JSON_1_LIMITS.memberNameBytes)
|
|
348
|
+
chargeEncodedBytes(state, encodedJsonStringBytes(key))
|
|
349
|
+
const field = Object.getOwnPropertyDescriptor(object, key)!
|
|
350
|
+
if (!('value' in field)) throw new Json1Error('object members must be ordinary data')
|
|
351
|
+
visitJson(field.value, depth + 1, state, false)
|
|
352
|
+
}
|
|
353
|
+
} finally {
|
|
354
|
+
state.active.delete(value)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function chargeEncodedBytes(state: VisitState, bytes: number): void {
|
|
359
|
+
if (bytes > JSON_1_LIMITS.bytes - state.encodedBytes) {
|
|
360
|
+
throw new Json1Error('maximum encoded bytes exceeded')
|
|
361
|
+
}
|
|
362
|
+
state.encodedBytes += bytes
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function encodedJsonStringBytes(value: string): number {
|
|
366
|
+
return encoder.encode(JSON.stringify(value)).byteLength
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function validateString(value: string, byteLimit: number): void {
|
|
370
|
+
if (encoder.encode(value).byteLength > byteLimit) {
|
|
371
|
+
throw new Json1Error('maximum string bytes exceeded')
|
|
372
|
+
}
|
|
373
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
374
|
+
const code = value.charCodeAt(index)
|
|
375
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
376
|
+
const next = value.charCodeAt(index + 1)
|
|
377
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) throw new Json1Error('lone Unicode surrogate')
|
|
378
|
+
index += 1
|
|
379
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
380
|
+
throw new Json1Error('lone Unicode surrogate')
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** RFC 8785 canonical bytes for an already validated JSON/0 value. */
|
|
386
|
+
export function canonicalJson(value: JsonValue): Uint8Array {
|
|
387
|
+
validateJson1(value)
|
|
388
|
+
return encoder.encode(canonicalText(value))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function canonicalText(value: JsonValue): string {
|
|
392
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string') {
|
|
393
|
+
return JSON.stringify(value)
|
|
394
|
+
}
|
|
395
|
+
if (typeof value === 'number') return Object.is(value, -0) ? '0' : JSON.stringify(value)
|
|
396
|
+
if (Array.isArray(value)) return `[${value.map(canonicalText).join(',')}]`
|
|
397
|
+
const object = value as JsonObject
|
|
398
|
+
const members = Object.keys(object)
|
|
399
|
+
.sort(compareUtf16)
|
|
400
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalText(object[key]!)}`)
|
|
401
|
+
return `{${members.join(',')}}`
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function compareUtf16(left: string, right: string): number {
|
|
405
|
+
return left < right ? -1 : left > right ? 1 : 0
|
|
406
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { AgentMethodError } from './errors.js'
|
|
2
|
+
import { canonicalJson, type JsonObject, type JsonValue, validateJson1 } from './json.js'
|
|
3
|
+
import { exactKeys, ordinaryRecord } from './values.js'
|
|
4
|
+
|
|
5
|
+
const MAX_RESPONSE_SCHEMA_DEPTH = 8
|
|
6
|
+
const MAX_PROPERTIES_PER_OBJECT = 32
|
|
7
|
+
const MAX_RESPONSE_SCHEMA_PROPERTIES = 128
|
|
8
|
+
const MAX_ARRAY_ITEMS = 256
|
|
9
|
+
const MAX_RESPONSE_SCHEMA_ENUM_VALUES = 256
|
|
10
|
+
const MAX_RESPONSE_SCHEMA_SYMBOL_CHARACTERS = 120_000
|
|
11
|
+
const MAX_LARGE_ENUM_CHARACTERS = 15_000
|
|
12
|
+
|
|
13
|
+
interface ResponseSchemaProfileState {
|
|
14
|
+
properties: number
|
|
15
|
+
enumValues: number
|
|
16
|
+
symbolCharacters: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Validate Jig's deliberately bounded recursive Agent structured-output profile. */
|
|
20
|
+
export function assertResponseSchema(schema: JsonObject): void {
|
|
21
|
+
requireSchemaJson(schema)
|
|
22
|
+
if (schema.$schema !== 'https://flow.jig.md/schemas/schema-0.json') invalidSchemaProfile()
|
|
23
|
+
const state: ResponseSchemaProfileState = {
|
|
24
|
+
properties: 0,
|
|
25
|
+
enumValues: 0,
|
|
26
|
+
symbolCharacters: 0,
|
|
27
|
+
}
|
|
28
|
+
assertResponseSchemaNode(schema, 1, true, state)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assertResponseSchemaNode(
|
|
32
|
+
value: unknown,
|
|
33
|
+
depth: number,
|
|
34
|
+
root: boolean,
|
|
35
|
+
state: ResponseSchemaProfileState,
|
|
36
|
+
): void {
|
|
37
|
+
const schema = ordinaryRecord(value)
|
|
38
|
+
if (schema === undefined || depth > MAX_RESPONSE_SCHEMA_DEPTH) invalidSchemaProfile()
|
|
39
|
+
|
|
40
|
+
if (schema.type === 'object') {
|
|
41
|
+
assertClosedResponseObject(schema, depth, root, state)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if (root || Object.hasOwn(schema, '$schema')) invalidSchemaProfile()
|
|
45
|
+
|
|
46
|
+
if (schema.type === 'array') {
|
|
47
|
+
const baseExpected = Object.hasOwn(schema, 'minItems')
|
|
48
|
+
? ['items', 'maxItems', 'minItems', 'type']
|
|
49
|
+
: ['items', 'maxItems', 'type']
|
|
50
|
+
if (
|
|
51
|
+
!exactProfileKeys(schema, baseExpected) ||
|
|
52
|
+
!boundedNonnegativeInteger(schema.maxItems, MAX_ARRAY_ITEMS) ||
|
|
53
|
+
(Object.hasOwn(schema, 'minItems') &&
|
|
54
|
+
(!boundedNonnegativeInteger(schema.minItems, MAX_ARRAY_ITEMS) ||
|
|
55
|
+
(schema.minItems as number) > (schema.maxItems as number)))
|
|
56
|
+
) {
|
|
57
|
+
invalidSchemaProfile()
|
|
58
|
+
}
|
|
59
|
+
assertResponseSchemaNode(schema.items, depth + 1, false, state)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (schema.type === 'integer' || isNullableType(schema.type, 'integer')) {
|
|
64
|
+
if (!exactProfileKeys(schema, ['type'])) invalidSchemaProfile()
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (schema.type === 'string' || isNullableType(schema.type, 'string')) {
|
|
68
|
+
assertResponseString(schema, isNullableType(schema.type, 'string'), state)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
invalidSchemaProfile()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function assertClosedResponseObject(
|
|
75
|
+
schema: Record<string, unknown>,
|
|
76
|
+
depth: number,
|
|
77
|
+
root: boolean,
|
|
78
|
+
state: ResponseSchemaProfileState,
|
|
79
|
+
): void {
|
|
80
|
+
const expected = root
|
|
81
|
+
? ['$schema', 'additionalProperties', 'properties', 'required', 'type']
|
|
82
|
+
: ['additionalProperties', 'properties', 'required', 'type']
|
|
83
|
+
const properties = ordinaryRecord(schema.properties)
|
|
84
|
+
if (
|
|
85
|
+
!exactProfileKeys(schema, expected) ||
|
|
86
|
+
schema.additionalProperties !== false ||
|
|
87
|
+
properties === undefined ||
|
|
88
|
+
!Array.isArray(schema.required)
|
|
89
|
+
) {
|
|
90
|
+
invalidSchemaProfile()
|
|
91
|
+
}
|
|
92
|
+
const names = Object.keys(properties)
|
|
93
|
+
if (
|
|
94
|
+
names.length === 0 ||
|
|
95
|
+
names.length > MAX_PROPERTIES_PER_OBJECT ||
|
|
96
|
+
schema.required.length !== names.length ||
|
|
97
|
+
new Set(schema.required).size !== names.length ||
|
|
98
|
+
schema.required.some((name) => typeof name !== 'string' || !Object.hasOwn(properties, name))
|
|
99
|
+
) {
|
|
100
|
+
invalidSchemaProfile()
|
|
101
|
+
}
|
|
102
|
+
state.properties += names.length
|
|
103
|
+
if (state.properties > MAX_RESPONSE_SCHEMA_PROPERTIES) invalidSchemaProfile()
|
|
104
|
+
for (const name of names) {
|
|
105
|
+
state.symbolCharacters += unicodeScalarLength(name)
|
|
106
|
+
if (state.symbolCharacters > MAX_RESPONSE_SCHEMA_SYMBOL_CHARACTERS) invalidSchemaProfile()
|
|
107
|
+
assertResponseSchemaNode(properties[name], depth + 1, false, state)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function assertResponseString(
|
|
112
|
+
schema: Record<string, unknown>,
|
|
113
|
+
nullable: boolean,
|
|
114
|
+
state: ResponseSchemaProfileState,
|
|
115
|
+
): void {
|
|
116
|
+
if (!Object.hasOwn(schema, 'enum')) {
|
|
117
|
+
if (!exactProfileKeys(schema, ['type'])) invalidSchemaProfile()
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if (
|
|
121
|
+
!exactProfileKeys(schema, ['enum', 'type']) ||
|
|
122
|
+
!Array.isArray(schema.enum) ||
|
|
123
|
+
schema.enum.length === 0 ||
|
|
124
|
+
schema.enum.length > MAX_RESPONSE_SCHEMA_ENUM_VALUES ||
|
|
125
|
+
schema.enum.some((item) => typeof item !== 'string' && (!nullable || item !== null)) ||
|
|
126
|
+
new Set(schema.enum).size !== schema.enum.length ||
|
|
127
|
+
(nullable &&
|
|
128
|
+
(!schema.enum.includes(null) || !schema.enum.some((item) => typeof item === 'string')))
|
|
129
|
+
) {
|
|
130
|
+
invalidSchemaProfile()
|
|
131
|
+
}
|
|
132
|
+
state.enumValues += schema.enum.length
|
|
133
|
+
const enumCharacters = schema.enum.reduce(
|
|
134
|
+
(total, item) => total + (typeof item === 'string' ? unicodeScalarLength(item) : 0),
|
|
135
|
+
0,
|
|
136
|
+
)
|
|
137
|
+
if (schema.enum.length > 250 && enumCharacters > MAX_LARGE_ENUM_CHARACTERS) {
|
|
138
|
+
invalidSchemaProfile()
|
|
139
|
+
}
|
|
140
|
+
state.symbolCharacters += enumCharacters
|
|
141
|
+
if (state.enumValues > MAX_RESPONSE_SCHEMA_ENUM_VALUES) invalidSchemaProfile()
|
|
142
|
+
if (state.symbolCharacters > MAX_RESPONSE_SCHEMA_SYMBOL_CHARACTERS) invalidSchemaProfile()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isNullableType(value: unknown, base: 'integer' | 'string'): boolean {
|
|
146
|
+
return (
|
|
147
|
+
Array.isArray(value) &&
|
|
148
|
+
value.length === 2 &&
|
|
149
|
+
new Set(value).size === 2 &&
|
|
150
|
+
value.includes(base) &&
|
|
151
|
+
value.includes('null')
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function exactProfileKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
|
|
156
|
+
if (Object.hasOwn(value, 'description') && typeof value.description !== 'string') return false
|
|
157
|
+
return exactKeys(
|
|
158
|
+
value,
|
|
159
|
+
Object.hasOwn(value, 'description') ? [...expected, 'description'] : expected,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function boundedNonnegativeInteger(value: unknown, maximum: number): boolean {
|
|
164
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= maximum
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function unicodeScalarLength(value: string): number {
|
|
168
|
+
return [...value].length
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function invalidSchemaProfile(): never {
|
|
172
|
+
throw new AgentMethodError(
|
|
173
|
+
'INVALID_INPUT',
|
|
174
|
+
'Agent responseSchema must use the bounded structured-output profile',
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function requireSchemaJson(schema: unknown): void {
|
|
179
|
+
try {
|
|
180
|
+
validateJson1(schema)
|
|
181
|
+
if (ordinaryRecord(schema) === undefined) invalidSchemaProfile()
|
|
182
|
+
if (canonicalJson(schema).byteLength > 256 * 1024) {
|
|
183
|
+
throw new AgentMethodError('RESOURCE_EXHAUSTED', 'Agent responseSchema exceeds 256 KiB')
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error instanceof AgentMethodError) throw error
|
|
187
|
+
throw new AgentMethodError('INVALID_INPUT', 'Agent responseSchema must be valid JSON/0')
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Remove only the FLOW dialect declaration from a fresh provider-facing copy. */
|
|
192
|
+
export function projectResponseSchema(schema: JsonObject): JsonObject {
|
|
193
|
+
assertResponseSchema(schema)
|
|
194
|
+
const copy = JSON.parse(new TextDecoder().decode(canonicalJson(schema))) as Record<
|
|
195
|
+
string,
|
|
196
|
+
JsonValue
|
|
197
|
+
>
|
|
198
|
+
delete copy.$schema
|
|
199
|
+
return copy
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Check a value against the already asserted closed structured-output profile. */
|
|
203
|
+
export function matchesResponseSchema(schema: JsonObject, value: JsonValue): boolean {
|
|
204
|
+
if (value === null && Array.isArray(schema.type) && schema.type.includes('null')) {
|
|
205
|
+
return !Array.isArray(schema.enum) || schema.enum.includes(null)
|
|
206
|
+
}
|
|
207
|
+
if (schema.type === 'object') {
|
|
208
|
+
const record = ordinaryRecord(value)
|
|
209
|
+
if (record === undefined) return false
|
|
210
|
+
const properties = schema.properties as JsonObject
|
|
211
|
+
return (
|
|
212
|
+
exactKeys(record, Object.keys(properties)) &&
|
|
213
|
+
Object.entries(properties).every(([name, child]) =>
|
|
214
|
+
matchesResponseSchema(child as JsonObject, record[name] as JsonValue),
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
if (schema.type === 'array') {
|
|
219
|
+
return (
|
|
220
|
+
Array.isArray(value) &&
|
|
221
|
+
value.length <= (schema.maxItems as number) &&
|
|
222
|
+
value.length >= ((schema.minItems as number | undefined) ?? 0) &&
|
|
223
|
+
value.every((item) => matchesResponseSchema(schema.items as JsonObject, item))
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
if (schema.type === 'integer' || isNullableType(schema.type, 'integer')) {
|
|
227
|
+
return typeof value === 'number' && Number.isSafeInteger(value)
|
|
228
|
+
}
|
|
229
|
+
return typeof value === 'string' && (!Array.isArray(schema.enum) || schema.enum.includes(value))
|
|
230
|
+
}
|