@tanstack-ai-browser/apfel 0.0.1 → 0.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.
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Tool-call repair. Apple's model picks the right tool but often gets its
3
+ * arguments wrong: it renames fields (`limit` for `numResults`, `site` for
4
+ * `includeDomains`) and drops optional ones it was asked to set. So after the
5
+ * model calls a tool, its arguments are written again with apfel's guided
6
+ * generation, which holds the model to the tool's JSON Schema:
7
+ *
8
+ * 1. For each optional field, a yes/no answer: does the request give it a
9
+ * value? (Asked for the arguments directly, the model leaves out optional
10
+ * numbers and arrays even when the request states them.)
11
+ * 2. The arguments, with the fields from step 1 required and the rest left
12
+ * out. Fields the model already got right (schema name, right type) are
13
+ * kept as it wrote them.
14
+ *
15
+ * Works from the schema alone, for any tool, MCP tools included. Through
16
+ * `chat()` with search, calendar, file and weather tools, the calls came out
17
+ * right 27 times in 32 with this, against 18 without; the misses left are
18
+ * the model not calling a tool at all, which this can't fix.
19
+ */
20
+
21
+ type JsonSchema = Record<string, unknown>
22
+
23
+ /** Keywords guided generation can enforce; apfel rejects others with 400. */
24
+ const KEPT = ["type", "description", "enum", "const"] as const
25
+
26
+ const isObject = (value: unknown): value is JsonSchema =>
27
+ !!value && typeof value === "object" && !Array.isArray(value)
28
+
29
+ /** The schema a local `$ref` (`#/$defs/X`, `#/definitions/X`) points to. */
30
+ function resolveRef(ref: string, root: JsonSchema): JsonSchema | undefined {
31
+ if (!ref.startsWith("#/")) return undefined
32
+ let node: unknown = root
33
+ for (const key of ref.slice(2).split("/")) {
34
+ node = isObject(node)
35
+ ? node[key.replace(/~1/g, "/").replace(/~0/g, "~")]
36
+ : undefined
37
+ }
38
+ return isObject(node) ? node : undefined
39
+ }
40
+
41
+ /**
42
+ * The schema in the subset guided generation enforces: types, properties,
43
+ * required, items, enum, const and descriptions. Local `$ref`s are inlined,
44
+ * `anyOf`/`oneOf`/type unions become their first non-null choice, and
45
+ * constraints it can't enforce (lengths, patterns, formats…) are dropped.
46
+ */
47
+ export function toGuidedSchema(
48
+ schema: unknown,
49
+ root: JsonSchema = isObject(schema) ? schema : {},
50
+ depth = 0
51
+ ): JsonSchema {
52
+ if (!isObject(schema) || depth > 12) return { type: "string" }
53
+ if (typeof schema.$ref === "string") {
54
+ const target = resolveRef(schema.$ref, root)
55
+ return target
56
+ ? toGuidedSchema(
57
+ { ...target, ...withoutKey(schema, "$ref") },
58
+ root,
59
+ depth + 1
60
+ )
61
+ : { type: "string" }
62
+ }
63
+ const choices = (schema.anyOf ?? schema.oneOf) as unknown
64
+ if (Array.isArray(choices)) {
65
+ const choice =
66
+ choices.find((c) => isObject(c) && c.type !== "null") ?? choices[0]
67
+ return toGuidedSchema(
68
+ {
69
+ ...(isObject(choice) ? choice : {}),
70
+ ...(schema.description !== undefined && {
71
+ description: schema.description
72
+ })
73
+ },
74
+ root,
75
+ depth + 1
76
+ )
77
+ }
78
+ if (Array.isArray(schema.allOf)) {
79
+ const merged = schema.allOf.reduce<JsonSchema>(
80
+ (all, part) => (isObject(part) ? mergeSchemas(all, part) : all),
81
+ withoutKey(schema, "allOf")
82
+ )
83
+ return toGuidedSchema(merged, root, depth + 1)
84
+ }
85
+
86
+ const out: JsonSchema = {}
87
+ for (const key of KEPT) {
88
+ if (schema[key] !== undefined) out[key] = schema[key]
89
+ }
90
+ if (Array.isArray(out.type)) {
91
+ out.type = out.type.find((t) => t !== "null") ?? "string"
92
+ }
93
+ if (out.type === undefined) {
94
+ if (isObject(schema.properties)) out.type = "object"
95
+ else if (schema.items !== undefined) out.type = "array"
96
+ else if (Array.isArray(out.enum)) out.type = typeof out.enum[0]
97
+ else if (out.const === undefined) out.type = "string"
98
+ }
99
+ if (out.type === "object") {
100
+ const properties = isObject(schema.properties) ? schema.properties : {}
101
+ out.properties = Object.fromEntries(
102
+ Object.entries(properties).map(([key, value]) => [
103
+ key,
104
+ toGuidedSchema(value, root, depth + 1)
105
+ ])
106
+ )
107
+ out.required = Array.isArray(schema.required)
108
+ ? schema.required.filter(
109
+ (key): key is string => typeof key === "string" && key in properties
110
+ )
111
+ : []
112
+ }
113
+ if (out.type === "array") {
114
+ out.items = toGuidedSchema(schema.items ?? {}, root, depth + 1)
115
+ }
116
+ return out
117
+ }
118
+
119
+ function withoutKey(schema: JsonSchema, key: string) {
120
+ const rest = { ...schema }
121
+ delete rest[key]
122
+ return rest
123
+ }
124
+
125
+ function mergeSchemas(a: JsonSchema, b: JsonSchema): JsonSchema {
126
+ const merged = { ...a, ...b }
127
+ if (isObject(a.properties) || isObject(b.properties)) {
128
+ merged.properties = {
129
+ ...(isObject(a.properties) ? a.properties : {}),
130
+ ...(isObject(b.properties) ? b.properties : {})
131
+ }
132
+ }
133
+ if (Array.isArray(a.required) || Array.isArray(b.required)) {
134
+ merged.required = [
135
+ ...new Set([
136
+ ...(Array.isArray(a.required) ? a.required : []),
137
+ ...(Array.isArray(b.required) ? b.required : [])
138
+ ])
139
+ ]
140
+ }
141
+ return merged
142
+ }
143
+
144
+ /** Whether `value` fits a guided schema's type and choices. */
145
+ function fits(value: unknown, schema: JsonSchema): boolean {
146
+ if (Array.isArray(schema.enum) && !schema.enum.includes(value)) return false
147
+ if (schema.const !== undefined) return value === schema.const
148
+ switch (schema.type) {
149
+ case "string":
150
+ return typeof value === "string"
151
+ case "integer":
152
+ return Number.isInteger(value)
153
+ case "number":
154
+ return typeof value === "number" && Number.isFinite(value)
155
+ case "boolean":
156
+ return typeof value === "boolean"
157
+ case "array":
158
+ return (
159
+ Array.isArray(value) &&
160
+ value.every((item) => fits(item, (schema.items ?? {}) as JsonSchema))
161
+ )
162
+ case "object":
163
+ return isObject(value)
164
+ default:
165
+ return true
166
+ }
167
+ }
168
+
169
+ /**
170
+ * The fields of the model's own arguments that already have a schema's
171
+ * name and a value of the right type. They're kept as they are: with several
172
+ * calls to one tool in a reply, they're what tells the calls apart.
173
+ */
174
+ function validDraftFields(
175
+ draft: string | undefined,
176
+ properties: Record<string, JsonSchema>
177
+ ) {
178
+ let parsed: unknown
179
+ try {
180
+ parsed = draft ? JSON.parse(draft) : undefined
181
+ } catch {
182
+ return {}
183
+ }
184
+ if (!isObject(parsed)) return {}
185
+ return Object.fromEntries(
186
+ Object.entries(parsed).filter(
187
+ ([key, value]) =>
188
+ Object.hasOwn(properties, key) &&
189
+ value !== null &&
190
+ fits(value, properties[key] as JsonSchema)
191
+ )
192
+ )
193
+ }
194
+
195
+ /** One line per field: name, type or choices, and description. */
196
+ function describeFields(properties: Record<string, JsonSchema>) {
197
+ return Object.entries(properties)
198
+ .map(([key, field]) => {
199
+ const kind = Array.isArray(field.enum)
200
+ ? field.enum.map((v) => JSON.stringify(v)).join(" | ")
201
+ : String(field.type ?? "any")
202
+ const about =
203
+ typeof field.description === "string" ? `: ${field.description}` : ""
204
+ return `- ${key} (${kind})${about}`
205
+ })
206
+ .join("\n")
207
+ }
208
+
209
+ /**
210
+ * The name of the tool the model meant, when it got the name slightly wrong
211
+ * (case, `-` for `_`…). Undefined if no tool matches.
212
+ */
213
+ export function matchToolName(name: string, toolNames: Iterable<string>) {
214
+ const names = [...toolNames]
215
+ if (names.includes(name)) return name
216
+ const plain = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
217
+ const matches = names.filter((n) => plain(n) === plain(name))
218
+ return matches.length === 1 ? matches[0] : undefined
219
+ }
220
+
221
+ /** A chat completion request apfel answers with JSON matching `schema`. */
222
+ export type GuidedRequest = {
223
+ /** Instructions, sent before the conversation. */
224
+ instructions: string
225
+ schema: JsonSchema
226
+ name: string
227
+ }
228
+
229
+ /** Sends a guided request; resolves to the model's JSON, parsed. */
230
+ export type GuidedComplete = (request: GuidedRequest) => Promise<unknown>
231
+
232
+ export type RepairableTool = {
233
+ name: string
234
+ description: string
235
+ inputSchema?: unknown
236
+ }
237
+
238
+ /**
239
+ * The tool call's arguments, written again under the tool's schema.
240
+ * Undefined if the tool takes no arguments or its schema isn't an object
241
+ * (the model's own arguments are used then).
242
+ */
243
+ export async function repairToolArguments(
244
+ tool: RepairableTool,
245
+ complete: GuidedComplete,
246
+ /** The model's own arguments, which may use the wrong field names. */
247
+ draft?: string
248
+ ): Promise<Record<string, unknown> | undefined> {
249
+ const schema = toGuidedSchema(tool.inputSchema)
250
+ const properties = (schema.properties ?? {}) as Record<string, JsonSchema>
251
+ if (schema.type !== "object" || Object.keys(properties).length === 0) {
252
+ return undefined
253
+ }
254
+ const required = schema.required as Array<string>
255
+ const optional = Object.keys(properties).filter(
256
+ (key) => !required.includes(key)
257
+ )
258
+ const about = `the tool ${tool.name}${tool.description ? ` (${tool.description})` : ""}`
259
+ // With several calls in one reply, the draft says which one this is.
260
+ const attempt =
261
+ draft && draft !== "{}"
262
+ ? `\nThe assistant's first attempt at this call, which may use wrong field names: ${draft.slice(0, 1000)}`
263
+ : ""
264
+
265
+ let chosen: Array<string> = []
266
+ if (optional.length > 0) {
267
+ const answer = await complete({
268
+ name: "fields",
269
+ instructions: `You are deciding the arguments of a call to ${about}, for the user's latest request. Its fields:\n${describeFields(properties)}\nFor each optional field, answer true only if the conversation states a value for it, otherwise false.`,
270
+ schema: {
271
+ type: "object",
272
+ properties: Object.fromEntries(
273
+ optional.map((key) => [
274
+ key,
275
+ {
276
+ type: "boolean",
277
+ description: `Does the request state a value for ${key}?`
278
+ }
279
+ ])
280
+ ),
281
+ required: optional
282
+ }
283
+ })
284
+ if (isObject(answer))
285
+ chosen = optional.filter((key) => answer[key] === true)
286
+ }
287
+
288
+ const kept = validDraftFields(draft, properties)
289
+ const fields = [
290
+ ...new Set([
291
+ ...required,
292
+ ...chosen,
293
+ ...optional.filter((key) => key in kept)
294
+ ])
295
+ ]
296
+ const args = await complete({
297
+ name: tool.name,
298
+ instructions: `Write the arguments for a call to ${about} that does what the user asks in their latest request.${attempt}`,
299
+ schema: {
300
+ type: "object",
301
+ properties: Object.fromEntries(
302
+ fields.map((key) => [key, properties[key]])
303
+ ),
304
+ required: fields
305
+ }
306
+ })
307
+ if (!isObject(args)) return undefined
308
+ // Optional fields left empty weren't really set.
309
+ const written = Object.fromEntries(
310
+ Object.entries(args).filter(
311
+ ([key, value]) =>
312
+ required.includes(key) ||
313
+ !(
314
+ value === "" ||
315
+ value === null ||
316
+ (Array.isArray(value) && value.length === 0)
317
+ )
318
+ )
319
+ )
320
+ return { ...written, ...kept }
321
+ }