expo-ai-kit 0.5.0 → 0.7.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,202 @@
1
+ import type { JSONSchema, JSONSchemaType } from './types';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Pure helpers for generateObject().
5
+ //
6
+ // This module is deliberately free of any native-module import so its logic can
7
+ // be unit-tested in plain Node. generateObject() (in index.ts) orchestrates the
8
+ // inference call + repair loop on top of these.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /** Build the instruction appended to the system prompt to elicit schema-shaped JSON. */
12
+ export function buildSchemaInstruction(schema: JSONSchema): string {
13
+ return [
14
+ 'You must respond with a single JSON value that strictly conforms to this JSON Schema:',
15
+ '',
16
+ JSON.stringify(schema),
17
+ '',
18
+ 'Rules:',
19
+ '- Output ONLY the JSON value. No prose, no explanation, no markdown code fences.',
20
+ '- Include every property listed under "required".',
21
+ '- Use the exact property names and value types defined by the schema.',
22
+ ].join('\n');
23
+ }
24
+
25
+ /** Follow-up prompt when the model returned something that could not be parsed as JSON. */
26
+ export const REPAIR_INVALID_JSON =
27
+ 'Your previous response was not valid JSON. Respond again with ONLY a single valid ' +
28
+ 'JSON value that conforms to the schema — no prose, no markdown code fences.';
29
+
30
+ /** Follow-up prompt when the model returned valid JSON that violated the schema. */
31
+ export function buildSchemaRepair(errors: string[]): string {
32
+ const detail = errors.slice(0, 8).join('; ');
33
+ return (
34
+ `Your previous JSON did not match the schema: ${detail}. ` +
35
+ 'Respond again with ONLY the corrected JSON value — no prose, no markdown code fences.'
36
+ );
37
+ }
38
+
39
+ export type ParseResult = { ok: true; value: unknown } | { ok: false };
40
+
41
+ /**
42
+ * Best-effort extraction of a JSON value from model output.
43
+ *
44
+ * On-device models often wrap JSON in a ```json fence or add a sentence of prose
45
+ * around it. We try, in order: a fenced block, the trimmed whole string, then the
46
+ * first balanced {...}/[...] block found anywhere in the text.
47
+ */
48
+ export function extractJson(text: string): ParseResult {
49
+ if (!text) return { ok: false };
50
+
51
+ const candidates: string[] = [];
52
+ const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
53
+ if (fence && fence[1]) candidates.push(fence[1]);
54
+ candidates.push(text);
55
+
56
+ for (const raw of candidates) {
57
+ const trimmed = raw.trim();
58
+ const direct = tryParse(trimmed);
59
+ if (direct.ok) return direct;
60
+
61
+ const sliced = sliceBalanced(trimmed);
62
+ if (sliced != null) {
63
+ const p = tryParse(sliced);
64
+ if (p.ok) return p;
65
+ }
66
+ }
67
+ return { ok: false };
68
+ }
69
+
70
+ function tryParse(s: string): ParseResult {
71
+ if (!s) return { ok: false };
72
+ try {
73
+ return { ok: true, value: JSON.parse(s) };
74
+ } catch {
75
+ return { ok: false };
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Return the first balanced `{...}` or `[...]` substring, or null if none.
81
+ * Tracks string state so braces inside JSON strings are not counted.
82
+ */
83
+ export function sliceBalanced(text: string): string | null {
84
+ const start = text.search(/[{[]/);
85
+ if (start < 0) return null;
86
+
87
+ const open = text[start];
88
+ const close = open === '{' ? '}' : ']';
89
+ let depth = 0;
90
+ let inStr = false;
91
+ let escaped = false;
92
+
93
+ for (let i = start; i < text.length; i++) {
94
+ const ch = text[i];
95
+ if (inStr) {
96
+ if (escaped) escaped = false;
97
+ else if (ch === '\\') escaped = true;
98
+ else if (ch === '"') inStr = false;
99
+ continue;
100
+ }
101
+ if (ch === '"') inStr = true;
102
+ else if (ch === open) depth++;
103
+ else if (ch === close) {
104
+ depth--;
105
+ if (depth === 0) return text.slice(start, i + 1);
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * Validate `value` against the supported subset of `schema`.
113
+ * Returns a list of human-readable error strings (empty ⇒ valid).
114
+ *
115
+ * Intentionally lenient: unknown keywords and extra object properties are
116
+ * allowed. The goal is to catch structural mistakes worth re-prompting over
117
+ * (wrong type, missing required field), not full JSON Schema conformance.
118
+ */
119
+ export function validateAgainstSchema(value: unknown, schema: JSONSchema): string[] {
120
+ const errors: string[] = [];
121
+ validateInto(value, schema, 'value', errors);
122
+ return errors;
123
+ }
124
+
125
+ function validateInto(
126
+ value: unknown,
127
+ schema: JSONSchema,
128
+ path: string,
129
+ errors: string[]
130
+ ): void {
131
+ if (!schema || typeof schema !== 'object') return;
132
+
133
+ // enum is authoritative — when present, the value must be one of its members.
134
+ if (Array.isArray(schema.enum)) {
135
+ if (!schema.enum.some((e) => jsonEqual(e, value))) {
136
+ errors.push(`${path} must be one of ${JSON.stringify(schema.enum)}`);
137
+ }
138
+ return;
139
+ }
140
+
141
+ const types = normalizeTypes(schema);
142
+ if (types && !types.some((t) => matchesType(value, t))) {
143
+ errors.push(`${path} must be of type ${types.join(' | ')}`);
144
+ return; // don't descend into a value of the wrong shape
145
+ }
146
+
147
+ if (isPlainObject(value)) {
148
+ if (Array.isArray(schema.required)) {
149
+ for (const key of schema.required) {
150
+ if (!(key in value)) errors.push(`${path}.${key} is required`);
151
+ }
152
+ }
153
+ if (schema.properties) {
154
+ for (const [key, sub] of Object.entries(schema.properties)) {
155
+ if (key in value) validateInto(value[key], sub, `${path}.${key}`, errors);
156
+ }
157
+ }
158
+ }
159
+
160
+ if (Array.isArray(value) && schema.items) {
161
+ value.forEach((item, i) => validateInto(item, schema.items as JSONSchema, `${path}[${i}]`, errors));
162
+ }
163
+ }
164
+
165
+ /** Resolve the declared type(s), inferring object/array from properties/items. */
166
+ function normalizeTypes(schema: JSONSchema): JSONSchemaType[] | null {
167
+ const t = schema.type;
168
+ if (typeof t === 'string') return [t];
169
+ if (Array.isArray(t)) return t;
170
+ if (schema.properties) return ['object'];
171
+ if (schema.items) return ['array'];
172
+ return null;
173
+ }
174
+
175
+ function matchesType(value: unknown, type: JSONSchemaType): boolean {
176
+ switch (type) {
177
+ case 'object':
178
+ return isPlainObject(value);
179
+ case 'array':
180
+ return Array.isArray(value);
181
+ case 'string':
182
+ return typeof value === 'string';
183
+ case 'number':
184
+ return typeof value === 'number' && Number.isFinite(value);
185
+ case 'integer':
186
+ return typeof value === 'number' && Number.isInteger(value);
187
+ case 'boolean':
188
+ return typeof value === 'boolean';
189
+ case 'null':
190
+ return value === null;
191
+ default:
192
+ return true;
193
+ }
194
+ }
195
+
196
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
197
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
198
+ }
199
+
200
+ function jsonEqual(a: unknown, b: unknown): boolean {
201
+ return a === b || JSON.stringify(a) === JSON.stringify(b);
202
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,126 @@
1
+ import type { JSONSchema, ToolSet } from './types';
2
+ import { extractJson } from './structured';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Pure helpers for generateText() tool calling.
6
+ //
7
+ // Like structured.ts, this module imports no native module so its logic can be
8
+ // unit-tested in plain Node. generateText() (in index.ts) drives the inference
9
+ // + tool-execution loop on top of these.
10
+ //
11
+ // On-device backends have no native tool-call channel, so we define a tiny text
12
+ // protocol: the model emits a JSON object `{"tool": "<name>", "arguments": {…}}`
13
+ // to request a call, or plain text to answer. We parse that back out tolerantly
14
+ // (reusing extractJson), validate the name + args, and run the tool in JS.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Build the instruction appended to the system prompt to enable tool calling. */
18
+ export function buildToolInstruction(tools: ToolSet): string {
19
+ const names = Object.keys(tools);
20
+ const lines: string[] = [
21
+ 'You have access to the following tools. Use one only when it helps answer the request:',
22
+ '',
23
+ ];
24
+ for (const name of names) {
25
+ const tool = tools[name];
26
+ lines.push(`- ${name}: ${tool.description}`);
27
+ lines.push(` arguments JSON Schema: ${JSON.stringify(tool.parameters)}`);
28
+ }
29
+ lines.push(
30
+ '',
31
+ 'To call a tool, respond with ONLY a JSON object of this exact form and nothing else:',
32
+ '{"tool": "<tool name>", "arguments": { ... }}',
33
+ '',
34
+ 'Rules:',
35
+ '- Call at most one tool per response.',
36
+ `- "tool" must be exactly one of: ${names.join(', ')}.`,
37
+ '- "arguments" must conform to that tool\'s arguments JSON Schema.',
38
+ '- If you do not need a tool, answer the user directly in plain text with no JSON.',
39
+ '- After you receive a tool result, use it to answer; do not repeat the same call.'
40
+ );
41
+ return lines.join('\n');
42
+ }
43
+
44
+ /**
45
+ * What {@link parseToolCall} found in a model response.
46
+ *
47
+ * - `tool`: a well-formed call to a known tool (args still need schema validation).
48
+ * - `unknown-tool`: looked like a tool call but the name isn't in the tool set.
49
+ * - `text`: no tool call — treat the response as the final answer.
50
+ */
51
+ export type ParsedToolCall =
52
+ | { kind: 'tool'; toolName: string; args: unknown }
53
+ | { kind: 'unknown-tool'; toolName: string }
54
+ | { kind: 'text' };
55
+
56
+ /**
57
+ * Detect a tool call in model output.
58
+ *
59
+ * A response is a tool call when it contains a JSON object (possibly wrapped in
60
+ * prose or a ```json fence) with a string `tool` field. We tolerate `arguments`
61
+ * or `args` for the payload. If `tool` names something not in `toolNames` it's
62
+ * reported as `unknown-tool` so the loop can re-prompt instead of leaking the
63
+ * raw JSON as an answer. Anything else is plain `text`.
64
+ */
65
+ export function parseToolCall(text: string, toolNames: string[]): ParsedToolCall {
66
+ const parsed = extractJson(text);
67
+ if (!parsed.ok) return { kind: 'text' };
68
+
69
+ const value = parsed.value;
70
+ if (!isPlainObject(value) || typeof value.tool !== 'string') {
71
+ return { kind: 'text' };
72
+ }
73
+
74
+ const toolName = value.tool;
75
+ if (!toolNames.includes(toolName)) {
76
+ return { kind: 'unknown-tool', toolName };
77
+ }
78
+
79
+ const rawArgs = 'arguments' in value ? value.arguments : value.args;
80
+ return { kind: 'tool', toolName, args: rawArgs ?? {} };
81
+ }
82
+
83
+ /** Follow-up prompt when the model named a tool that doesn't exist. */
84
+ export function buildUnknownToolRepair(toolName: string, toolNames: string[]): string {
85
+ return (
86
+ `The tool "${toolName}" does not exist. ` +
87
+ `Available tools are: ${toolNames.join(', ')}. ` +
88
+ 'Either call one of these tools using the required JSON form, or answer in plain text.'
89
+ );
90
+ }
91
+
92
+ /** Follow-up prompt when a tool's proposed arguments failed schema validation. */
93
+ export function buildToolArgsRepair(toolName: string, errors: string[]): string {
94
+ const detail = errors.slice(0, 8).join('; ');
95
+ return (
96
+ `The arguments for "${toolName}" did not match its schema: ${detail}. ` +
97
+ 'Respond again with ONLY the corrected {"tool": "' +
98
+ toolName +
99
+ '", "arguments": { ... }} JSON — no prose, no markdown code fences.'
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Render a tool result as the user-turn text fed back to the model.
105
+ * Non-string results are JSON-encoded; strings pass through as-is.
106
+ */
107
+ export function formatToolResult(toolName: string, result: unknown): string {
108
+ const body =
109
+ typeof result === 'string' ? result : safeStringify(result);
110
+ return `Result of calling the tool "${toolName}":\n${body}`;
111
+ }
112
+
113
+ function safeStringify(value: unknown): string {
114
+ try {
115
+ return JSON.stringify(value) ?? String(value);
116
+ } catch {
117
+ return String(value);
118
+ }
119
+ }
120
+
121
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
122
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
123
+ }
124
+
125
+ // Re-export for callers that want the schema type alongside tool helpers.
126
+ export type { JSONSchema };
package/src/types.ts CHANGED
@@ -141,6 +141,208 @@ export type LLMStreamEvent = {
141
141
  export type LLMStreamCallback = (event: LLMStreamEvent) => void;
142
142
 
143
143
 
144
+ // ============================================================================
145
+ // Structured Output (generateObject)
146
+ // ============================================================================
147
+
148
+ /**
149
+ * The set of JSON Schema primitive `type` values understood by
150
+ * {@link generateObject}'s local validator.
151
+ */
152
+ export type JSONSchemaType =
153
+ | 'object'
154
+ | 'array'
155
+ | 'string'
156
+ | 'number'
157
+ | 'integer'
158
+ | 'boolean'
159
+ | 'null';
160
+
161
+ /**
162
+ * A JSON Schema describing the shape you want {@link generateObject} to return.
163
+ *
164
+ * A pragmatic subset is enforced locally — `type`, `properties`, `required`,
165
+ * `items`, and `enum` — which covers most extraction shapes. Any other JSON
166
+ * Schema keywords you include (e.g. `description`, `minLength`) are still sent
167
+ * to the model to guide it, but are not validated on-device. Keep schemas small:
168
+ * on-device models follow flat, shallow shapes far more reliably than deeply
169
+ * nested ones.
170
+ */
171
+ export type JSONSchema = {
172
+ /** Expected JSON type (or a union of types). */
173
+ type?: JSONSchemaType | JSONSchemaType[];
174
+ /** Human-readable hint passed through to the model. */
175
+ description?: string;
176
+ /** For `object` schemas: the schema of each named property. */
177
+ properties?: Record<string, JSONSchema>;
178
+ /** For `object` schemas: property names that must be present. */
179
+ required?: string[];
180
+ /** For `array` schemas: the schema each element must satisfy. */
181
+ items?: JSONSchema;
182
+ /** Restrict the value to this set of literals. */
183
+ enum?: ReadonlyArray<string | number | boolean | null>;
184
+ /** Other JSON Schema keywords are accepted and forwarded to the model. */
185
+ [key: string]: unknown;
186
+ };
187
+
188
+ /**
189
+ * Options for {@link generateObject}.
190
+ */
191
+ export type GenerateObjectOptions = {
192
+ /**
193
+ * System prompt used when the messages array has no system message. Defaults
194
+ * to a structured-output-oriented instruction. If a system message is present
195
+ * in the array, this is ignored (the schema instruction is appended to it).
196
+ */
197
+ systemPrompt?: string;
198
+ /**
199
+ * Abort the request. Behaves like {@link LLMSendOptions.signal} — the returned
200
+ * promise rejects with an INFERENCE_CANCELLED {@link ModelError}.
201
+ */
202
+ signal?: AbortSignal;
203
+ /**
204
+ * How many times to re-prompt the model when its output is not valid JSON or
205
+ * does not match the schema. Each repair feeds the error back to the model.
206
+ * Defaults to 2 (i.e. up to 3 generations total).
207
+ */
208
+ maxRepairAttempts?: number;
209
+ };
210
+
211
+ /**
212
+ * Result of {@link generateObject}.
213
+ */
214
+ export type GenerateObjectResult<T = unknown> = {
215
+ /** The parsed value, validated against the schema. */
216
+ object: T;
217
+ /** The raw model output that produced `object` (useful for debugging). */
218
+ text: string;
219
+ };
220
+
221
+
222
+ // ============================================================================
223
+ // Tool / Function Calling (generateText)
224
+ // ============================================================================
225
+
226
+ /**
227
+ * A tool (function) the model may call to fetch data or take an action.
228
+ *
229
+ * The model never runs anything itself — it *proposes* a call (a name + JSON
230
+ * arguments), {@link generateText} validates the arguments against `parameters`,
231
+ * and only then invokes your `execute`. The result is fed back into the
232
+ * conversation so the model can use it to produce its final answer.
233
+ *
234
+ * @typeParam TArgs - Shape of the validated arguments passed to `execute`.
235
+ * @typeParam TResult - What `execute` returns (serialized back to the model).
236
+ */
237
+ export type Tool<TArgs = any, TResult = any> = {
238
+ /**
239
+ * What the tool does and when to use it. This is how the model decides
240
+ * whether a request matches this tool — make it specific and action-oriented.
241
+ */
242
+ description: string;
243
+ /**
244
+ * JSON Schema for the tool's arguments. The model is told to conform to it,
245
+ * and the args it proposes are validated against it (same pragmatic subset as
246
+ * {@link generateObject}) before `execute` runs. Keep it small and shallow.
247
+ */
248
+ parameters: JSONSchema;
249
+ /**
250
+ * Runs the tool with the validated arguments and returns a result.
251
+ *
252
+ * **Optional on purpose.** If you omit it, {@link generateText} does not run
253
+ * anything — it stops with `finishReason: 'tool-calls'` and hands you the
254
+ * proposed call so you can confirm, gate, or execute it yourself.
255
+ */
256
+ execute?: (args: TArgs) => TResult | Promise<TResult>;
257
+ };
258
+
259
+ /** A map of tool name → {@link Tool}, passed to {@link generateText}. */
260
+ export type ToolSet = Record<string, Tool>;
261
+
262
+ /** A tool invocation the model proposed (name + validated arguments). */
263
+ export type ToolCall = {
264
+ /** The tool's key in the {@link ToolSet}. */
265
+ toolName: string;
266
+ /** Arguments the model supplied, validated against the tool's `parameters`. */
267
+ args: unknown;
268
+ };
269
+
270
+ /** The outcome of running a {@link ToolCall} via its `execute`. */
271
+ export type ToolResult = {
272
+ /** The tool's key in the {@link ToolSet}. */
273
+ toolName: string;
274
+ /** The arguments that were passed to `execute`. */
275
+ args: unknown;
276
+ /** Whatever `execute` returned (or `{ error }` if it threw). */
277
+ result: unknown;
278
+ };
279
+
280
+ /** One model round-trip in the {@link generateText} loop. */
281
+ export type StepResult = {
282
+ /** Assistant text produced this step (empty when the step only called a tool). */
283
+ text: string;
284
+ /** Tool calls proposed this step (at most one in the current protocol). */
285
+ toolCalls: ToolCall[];
286
+ /** Results of the tool calls executed this step. */
287
+ toolResults: ToolResult[];
288
+ };
289
+
290
+ /**
291
+ * Why {@link generateText} stopped.
292
+ *
293
+ * - `'stop'`: the model produced a final text answer.
294
+ * - `'tool-calls'`: stopped because a proposed tool has no `execute` — the call
295
+ * is returned for you to handle (human-in-the-loop).
296
+ * - `'max-steps'`: hit the `maxSteps` cap while still calling tools. Raise the cap.
297
+ */
298
+ export type GenerateTextFinishReason = 'stop' | 'tool-calls' | 'max-steps';
299
+
300
+ /**
301
+ * Options for {@link generateText}.
302
+ */
303
+ export type GenerateTextOptions = {
304
+ /** Tools the model may call. Omit (or pass `{}`) for a plain text generation. */
305
+ tools?: ToolSet;
306
+ /**
307
+ * Maximum number of model round-trips (each call + tool execution is one step).
308
+ * Bounds the tool-calling chain so it can't run away. Defaults to 5.
309
+ */
310
+ maxSteps?: number;
311
+ /**
312
+ * System prompt used when the messages array has no system message. The tool
313
+ * instructions are appended to it (or to the array's system message if present).
314
+ */
315
+ systemPrompt?: string;
316
+ /**
317
+ * Abort the request. Behaves like {@link LLMSendOptions.signal} — the returned
318
+ * promise rejects with an INFERENCE_CANCELLED {@link ModelError}.
319
+ */
320
+ signal?: AbortSignal;
321
+ /**
322
+ * How many times to re-prompt within a step when the model emits a malformed
323
+ * tool call, an unknown tool name, or arguments that fail schema validation.
324
+ * Defaults to 2. If it still can't comply, `generateText` throws INFERENCE_FAILED.
325
+ */
326
+ maxRepairAttempts?: number;
327
+ };
328
+
329
+ /**
330
+ * Result of {@link generateText}.
331
+ */
332
+ export type GenerateTextResult = {
333
+ /** The final assistant text (empty if it stopped on a tool call without `execute`). */
334
+ text: string;
335
+ /** Every step taken, in order — useful for tracing or debugging. */
336
+ steps: StepResult[];
337
+ /** All tool calls across every step, flattened. */
338
+ toolCalls: ToolCall[];
339
+ /** All tool results across every step, flattened. */
340
+ toolResults: ToolResult[];
341
+ /** Why generation stopped. See {@link GenerateTextFinishReason}. */
342
+ finishReason: GenerateTextFinishReason;
343
+ };
344
+
345
+
144
346
  // ============================================================================
145
347
  // Model Types
146
348
  // ============================================================================