@aeye/core 0.6.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.
@@ -1,178 +1,179 @@
1
- # @aeye/core — Prompt
2
-
3
- `Prompt` (`src/prompt.ts`) generates an AI response from a Handlebars-templated message, optionally calls tools in a loop, and validates structured output against a Zod schema with automatic re-prompting.
4
-
5
- ```typescript
6
- class Prompt<TContext = {}, TMetadata = {}, TName extends string = string,
7
- TInput extends object = {}, TOutput extends object | string = string,
8
- TTools extends Tuple<ToolCompatible<TContext, TMetadata>> = [],
9
- TDecoded extends unknown = TOutput>
10
- implements Component<...>
11
- ```
12
-
13
- `TDecoded` is the decoded type a custom `parse` produces from the model's structured output — **any** value (a built AST/class instance, a number, an array, …), not just an object or string; it types `validate` and the decoded output. It defaults to the wire `TOutput` when no custom `parse` is supplied — `schema` always stays the wire type.
14
-
15
- A `Prompt` only does real work when the context supplies `execute` and/or `stream` (see [types doc](./aeye-core-types.md)). Without them, get/run yields nothing.
16
-
17
- ## `PromptInput` (constructor config)
18
-
19
- | Field | Type | Notes |
20
- |------|------|-------|
21
- | `name` | `string` | Unique identifier. |
22
- | `description` | `string` | Informational. |
23
- | `content` | `string` | Handlebars template. `{{tools}}` is auto-appended (wrapped in `<tools>`) when tools exist and you didn't include it. |
24
- | `input?` | `Fn<Record<string,any>, [TInput?, ctx]>` | Maps raw input → template variables. |
25
- | `schema?` | `Fn<ZodType<TOutput> \| false, [TInput?, ctx]>` | Output schema. `false` → plain-text output. Omitted → text. |
26
- | `parse?` | `(raw, ctx) => TDecoded \| Error \| Promise<…>` | **Custom output parser that REPLACES Zod.** Receives the structured value **already wire-decoded to the conceptual shape** — core first applies the request's `FormatDescriptor` decode (`decodeWire`: array-of-pairs→record, null→undefined for optionals, numeric-key→tuple, …) so your parser is provider-agnostic and never sees a model family's wire quirks — and fully owns turning it into the decoded `TDecoded`; return (or throw) an `Error` to reject it — routed through the same `outputRetries` channel a Zod failure would, surfacing the error's own `.message` (no Zod vocabulary, since Zod never runs). Lets a caller (e.g. `@aeye/query`'s parser) return a built AST plus compiler-style diagnostics. Only runs where Zod validation runs today (a structured, non-`ZodString` `schema` is present); `schema` is still required and still drives the model wire format. Absent ⇒ unchanged Zod path. |
27
- | `strict?` | `boolean \| number` | Strict-mode policy, default `1`. See [schema doc](./aeye-core-schema.md). |
28
- | `config?` | `Fn<Partial<Request> \| false, [TInput?, ctx]>` | Per-request overrides (temperature, model, toolChoice, …). `false` ⇒ prompt not compatible. |
29
- | `reconfig?` | `(stats: PromptReconfigInput, ctx) => PromptReconfig` | Adapt config after each iteration based on runtime stats. |
30
- | `tools?` | `TTools` | Tools the model may call. |
31
- | `toolExecution?` | `'sequential' \| 'parallel' \| 'immediate'` | Default `immediate`. |
32
- | `toolRetries?` | `number` | Retry failed tool calls. Default 2. |
33
- | `toolIterations?` | `number` | Max tool-calling loop iterations. Default 3. |
34
- | `toolsMax?` | `number` | Stop sending tools after this many successes. |
35
- | `toolsOnly?` | `boolean` | Only call tools, don't generate text. |
36
- | `toolsComplete?` | `boolean` | Auto-synthesize tool results for unpaired `tool_calls` (abort/interrupt). Default `true`. |
37
- | `outputRetries?` | `number` | Retries to get valid structured output. Defaults to ctx (2). |
38
- | `forgetRetries?` | `number` | Context-trimming retries. Defaults to ctx (1). |
39
- | `validationErrorMaxLength?` | `number` | Truncate validation errors fed back to the model. Default 4096. |
40
- | `retool?` | `Fn<RetoolResult, [TInput?, ctx]>` | Dynamically select tools (by name or object), or `false` if incompatible. |
41
- | `dynamic?` | `boolean` | Re-resolve input/content/config/schema/tools each iteration; `undefined` ends the loop. |
42
- | `excludeMessages?` | `boolean` | Don't include `ctx.messages` when rendering. |
43
- | `onToolResult?` | `(event: ToolResultEvent<TContext, TMetadata, TTools>) => unknown \| Promise<unknown>` | Intercept each tool's **success** result before the model sees it; the return value is what's presented (serialized like any tool result). Model-facing only. See [Tool result transformer](#tool-result-transformer). |
44
- | `metadata?` / `metadataFn?` | `TMetadata` / fn | Execution metadata (model, requirements). |
45
- | `validate?` | `(output, ctx) => void \| Promise<void>` | Post-parse hook; throw to re-prompt. |
46
- | `applicable?` | `(ctx) => boolean \| Promise<boolean>` | Availability check. |
47
-
48
- `PromptReconfigInput` stats include: `iteration`, `maxIterations`, `toolParseErrors`, `toolCallErrors`, `tools` (names), `toolSuccesses`, and remaining `toolRetries` / `outputRetries` / `forgetRetries`. `PromptReconfig` lets you return `{ config?, maxIterations?, toolRetries?, outputRetries?, forgetRetries? }`.
49
-
50
- Four semantics of that hook are load-bearing and are not visible in the types:
51
-
52
- - **`PromptReconfig.maxIterations` is RELATIVE, not absolute.** The loop assigns `maxIterations = iterations + value`, so returning `N` means "this round plus `N - 1` more". Return `budget - stats.iteration` to set an absolute ceiling. Returning `0` **breaks out immediately** — and with no result parsed yet the run then throws its own generic `failed without a specified error`, so `0` is not a graceful stop.
53
- - **`PromptReconfigInput.maxIterations` is the ceiling as it stood BEFORE this call** — whatever the previous round set. A decision that must track a ceiling you are raising in the same call has to use your own value, not this field.
54
- - **The error/success counters are cumulative for the run**, not per round: `toolSuccesses` / `toolParseErrors` / `toolCallErrors` only ever grow, so "did THIS round make progress?" is a delta you keep yourself. `toolRetries` / `outputRetries` / `forgetRetries` are the opposite — they are the *remaining* allowances and count down.
55
- - **`reconfig` runs at the END of an iteration**, after tool dispatch and output parsing and before the `dynamic` re-resolve — so anything it changes takes effect on the NEXT model call.
56
-
57
- ## Methods
58
-
59
- ```typescript
60
- get(mode?: PromptGetType, input?: TInput, ctx?: Context): PromptGet<mode, TOutput, TTools>
61
- run(input?: TInput, ctx?: Context): AsyncGenerator<PromptEvent<TOutput, TTools>, TOutput | undefined>
62
- applicable(ctx?: Context): Promise<boolean>
63
- metadata(input?, ctx?): TMetadata | Promise<TMetadata>
64
- ```
65
-
66
- ### `get()` modes
67
-
68
- | Mode (default `'result'`) | Returns |
69
- |------|---------|
70
- | `'result'` | `Promise<TOutput \| undefined>` — final structured output |
71
- | `'tools'` | `Promise<PromptToolOutput<TTools>[] \| undefined>` — `{ tool, result }[]` |
72
- | `'stream'` | `AsyncGenerator<PromptEvent, TOutput \| undefined>` — all events |
73
- | `'streamTools'` | `AsyncGenerator<PromptToolOutput, TOutput \| undefined>` |
74
- | `'streamContent'` | `AsyncGenerator<string, TOutput \| undefined>` — text deltas only |
75
-
76
- `run()` is equivalent to `get('stream', …)`.
77
-
78
- ```typescript
79
- // Structured result
80
- const r = await prompt.get('result', { text: 'hi' }, { execute, messages: [] });
81
-
82
- // Stream text deltas
83
- for await (const chunk of prompt.get('streamContent', { text: 'hi' }, { stream, messages: [] })) {
84
- process.stdout.write(chunk);
85
- }
86
-
87
- // Stream all events
88
- for await (const e of prompt.get('stream', { text: 'hi' }, { stream, messages: [] })) {
89
- if (e.type === 'textPartial') process.stdout.write(e.content);
90
- else if (e.type === 'toolStart') console.log('tool:', e.tool.name);
91
- else if (e.type === 'toolOutput') console.log('result:', e.result);
92
- else if (e.type === 'complete') console.log('output:', e.output);
93
- }
94
- ```
95
-
96
- ## `PromptEvent` union
97
-
98
- Every event carries `request: Request`. Types:
99
-
100
- - `request` (`+ iterations`), `textPartial`, `text`, `textComplete`, `textReset` (`+ reason?`)
101
- - `refusal`, `reason` / `reasonPartial` (reasoning trace)
102
- - `toolParseName`, `toolParseArguments` (`+ args`), `toolArgRepairAttempt` (`+ fields, success`)
103
- - tool lifecycle: `toolStart`, `toolOutput` (`+ result` raw, `+ toModel` presented value), `toolInterrupt`, `toolSuspend`, `toolError` (`+ error, rawArgs?`) — each carries `tool` + `args`
104
- - `message` (`+ message`), `suspend`, `complete` (`+ output`)
105
- - `requestUsage`, `responseTokens`, `usage` (`+ usage`)
106
-
107
- ## Prompt with tools
108
-
109
- ```typescript
110
- const advisor = new Prompt({
111
- name: 'travelAdvisor',
112
- description: 'Plans a trip',
113
- content: 'Plan a trip to {{destination}}. Use getWeather to check conditions.',
114
- input: (i: { destination: string }) => ({ destination: i.destination }),
115
- tools: [getWeather], // see aeye-core-tools.md
116
- toolExecution: 'parallel',
117
- toolIterations: 3,
118
- schema: z.object({
119
- recommendations: z.array(z.string()),
120
- packingList: z.array(z.string()),
121
- }),
122
- });
123
-
124
- const advice = await advisor.get('result', { destination: 'Paris' }, { execute, messages: [] });
125
- ```
126
-
127
- ## Tool result transformer
128
-
129
- `onToolResult` intercepts each tool's **successful** result *before* it's handed to the model and returns what the model sees instead. It's fully type-safe: `event` is a discriminated union over the prompt's tools, so **narrowing on `event.tool` types both `event.result` and `event.args`** for that tool. The default (unmatched) branch is the catch-all.
130
-
131
- ```typescript
132
- const prompt = new Prompt({
133
- name: 'searcher',
134
- description: 'Searches and answers',
135
- content: 'Answer using search.',
136
- tools: [searchTool, mathTool], // searchTool → { hits, ids }, mathTool → number
137
- onToolResult: (event) => {
138
- if (event.tool === 'search') {
139
- // event.result is the search result; event.args is { query }
140
- return `Found ${event.result.hits} hits for "${event.args.query}"`;
141
- }
142
- // catch-all: here event is the `math` member (event.result: number)
143
- return event.result; // pass-through
144
- },
145
- });
146
- ```
147
-
148
- - **The return value is what's presented.** It's serialized exactly like an untransformed result — a `string` is used verbatim, anything else is `JSON.stringify`-d — into the `role: 'tool'` message. To pass through unchanged, `return event.result`.
149
- - **Model-facing only.** `get('tools')`, `streamTools`, and the `toolOutput` event's `result` field always report the RAW result. The presented value is additionally exposed on the `toolOutput` event as `toModel` (equal to `result` when no handler is set).
150
- - **v1 = success results only.** Errored / suspended / interrupted / synthetic (`toolsComplete`) tool slots BYPASS the handler and keep their existing content. (A future v2 could add a `status: 'success' | 'error'` discriminant so handlers can transform errors too.)
151
- - **A handler that throws** is treated as a tool error for that slot — the model sees the error content — so the `tool_call` ↔ `role: 'tool'` pairing guarantee still holds. The raw result stays available on `get('tools')`/`streamTools`.
152
- - `async` handlers are awaited.
153
-
154
- Compile-time safety: referencing a non-existent tool name, or accessing a field not on the narrowed tool's `result`/`args`, fails to compile.
155
-
156
- ## Token / context-window management
157
-
158
- When the rendered messages plus reserved output tokens exceed the window, the prompt trims older messages and retries (`forgetRetries`). Supply `estimateUsage` for accuracy:
159
-
160
- ```typescript
161
- await prompt.get('result', { text }, {
162
- execute, messages: history,
163
- maxOutputTokens: 2048,
164
- estimateUsage: (m) => ({ text: { input: Math.ceil(String(m.content).length / 4) } }),
165
- });
166
- ```
167
-
168
- ## Suspend / resume (human-in-the-loop)
169
-
170
- A tool can `throw new PromptSuspend(...)` to stop the loop *without* writing a tool result. The caller receives a `suspend` event whose `request.messages` includes the assistant message with the pending tool call. Persist those messages, do the external work (approval, etc.), append the `role: 'tool'` result message, then re-run the prompt with the combined messages as `ctx.messages`. `PromptSuspend` is never auto-paired even with `toolsComplete: true`.
171
-
172
- ## Gotchas
173
-
174
- - Default `get` mode is `'result'`; pass the mode explicitly when streaming.
175
- - `schema` returning `false` disables structured output — output is the raw text string.
176
- - `config`/`retool` returning `false` marks the prompt incompatible with the context.
177
- - `toolsMax` is a soft cap unless `toolsOneAtATime` (request flag) is set — it stops *offering* tools after the threshold.
178
- - Validation errors (Zod / JSON / `validate`) are truncated to `validationErrorMaxLength` before being shown to the model.
1
+ # @aeye/core — Prompt
2
+
3
+ `Prompt` (`src/prompt.ts`) generates an AI response from a Handlebars-templated message, optionally calls tools in a loop, and validates structured output against a Zod schema with automatic re-prompting.
4
+
5
+ ```typescript
6
+ class Prompt<TContext = {}, TMetadata = {}, TName extends string = string,
7
+ TInput extends object = {}, TOutput extends object | string = string,
8
+ TTools extends Tuple<ToolCompatible<TContext, TMetadata>> = [],
9
+ TDecoded extends unknown = TOutput>
10
+ implements Component<...>
11
+ ```
12
+
13
+ `TDecoded` is the decoded type a custom `parse` produces from the model's structured output — **any** value (a built AST/class instance, a number, an array, …), not just an object or string; it types `validate` and the decoded output. It defaults to the wire `TOutput` when no custom `parse` is supplied — `schema` always stays the wire type.
14
+
15
+ A `Prompt` only does real work when the context supplies `execute` and/or `stream` (see [types doc](./aeye-core-types.md)). Without them, get/run yields nothing.
16
+
17
+ ## `PromptInput` (constructor config)
18
+
19
+ | Field | Type | Notes |
20
+ |------|------|-------|
21
+ | `name` | `string` | Unique identifier. |
22
+ | `description` | `string` | Informational. |
23
+ | `content` | `string` | Handlebars template, sent as the system message. `{{tools}}` is auto-appended (wrapped in `<tools>`) when tools exist and you didn't include it. |
24
+ | `userTurn?` | `string` | User message added after the system message when there's no conversation (Anthropic, Bedrock and some OpenRouter hosts require one). Defaults to `ctx.userTurn`, then `DEFAULT_USER_TURN` (`'Begin.'`). |
25
+ | `input?` | `Fn<Record<string,any>, [TInput?, ctx]>` | Maps raw input → template variables. |
26
+ | `schema?` | `Fn<ZodType<TOutput> \| false, [TInput?, ctx]>` | Output schema. `false` → plain-text output. Omitted → text. |
27
+ | `parse?` | `(raw, ctx) => TDecoded \| Error \| Promise<…>` | **Custom output parser that REPLACES Zod.** Receives the structured value **already wire-decoded to the conceptual shape** — core first applies the request's `FormatDescriptor` decode (`decodeWire`: array-of-pairs→record, null→undefined for optionals, numeric-key→tuple, …) so your parser is provider-agnostic and never sees a model family's wire quirks — and fully owns turning it into the decoded `TDecoded`; return (or throw) an `Error` to reject it — routed through the same `outputRetries` channel a Zod failure would, surfacing the error's own `.message` (no Zod vocabulary, since Zod never runs). Lets a caller (e.g. `@aeye/query`'s parser) return a built AST plus compiler-style diagnostics. Only runs where Zod validation runs today (a structured, non-`ZodString` `schema` is present); `schema` is still required and still drives the model wire format. Absent ⇒ unchanged Zod path. |
28
+ | `strict?` | `boolean \| number` | Strict-mode policy, default `1`. See [schema doc](./aeye-core-schema.md). |
29
+ | `config?` | `Fn<Partial<Request> \| false, [TInput?, ctx]>` | Per-request overrides (temperature, model, toolChoice, …). `false` ⇒ prompt not compatible. |
30
+ | `reconfig?` | `(stats: PromptReconfigInput, ctx) => PromptReconfig` | Adapt config after each iteration based on runtime stats. |
31
+ | `tools?` | `TTools` | Tools the model may call. |
32
+ | `toolExecution?` | `'sequential' \| 'parallel' \| 'immediate'` | Default `immediate`. |
33
+ | `toolRetries?` | `number` | Retry failed tool calls. Default 2. |
34
+ | `toolIterations?` | `number` | Max tool-calling loop iterations. Default 3. |
35
+ | `toolsMax?` | `number` | Stop sending tools after this many successes. |
36
+ | `toolsOnly?` | `boolean` | Only call tools, don't generate text. |
37
+ | `toolsComplete?` | `boolean` | Auto-synthesize tool results for unpaired `tool_calls` (abort/interrupt). Default `true`. |
38
+ | `outputRetries?` | `number` | Retries to get valid structured output. Defaults to ctx (2). |
39
+ | `forgetRetries?` | `number` | Context-trimming retries. Defaults to ctx (1). |
40
+ | `validationErrorMaxLength?` | `number` | Truncate validation errors fed back to the model. Default 4096. |
41
+ | `retool?` | `Fn<RetoolResult, [TInput?, ctx]>` | Dynamically select tools (by name or object), or `false` if incompatible. |
42
+ | `dynamic?` | `boolean` | Re-resolve input/content/config/schema/tools each iteration; `undefined` ends the loop. |
43
+ | `excludeMessages?` | `boolean` | Don't include `ctx.messages` when rendering. |
44
+ | `onToolResult?` | `(event: ToolResultEvent<TContext, TMetadata, TTools>) => unknown \| Promise<unknown>` | Intercept each tool's **success** result before the model sees it; the return value is what's presented (serialized like any tool result). Model-facing only. See [Tool result transformer](#tool-result-transformer). |
45
+ | `metadata?` / `metadataFn?` | `TMetadata` / fn | Execution metadata (model, requirements). |
46
+ | `validate?` | `(output, ctx) => void \| Promise<void>` | Post-parse hook; throw to re-prompt. |
47
+ | `applicable?` | `(ctx) => boolean \| Promise<boolean>` | Availability check. |
48
+
49
+ `PromptReconfigInput` stats include: `iteration`, `maxIterations`, `toolParseErrors`, `toolCallErrors`, `tools` (names), `toolSuccesses`, and remaining `toolRetries` / `outputRetries` / `forgetRetries`. `PromptReconfig` lets you return `{ config?, maxIterations?, toolRetries?, outputRetries?, forgetRetries? }`.
50
+
51
+ Four semantics of that hook are load-bearing and are not visible in the types:
52
+
53
+ - **`PromptReconfig.maxIterations` is RELATIVE, not absolute.** The loop assigns `maxIterations = iterations + value`, so returning `N` means "this round plus `N - 1` more". Return `budget - stats.iteration` to set an absolute ceiling. Returning `0` **breaks out immediately** — and with no result parsed yet the run then throws its own generic `failed without a specified error`, so `0` is not a graceful stop.
54
+ - **`PromptReconfigInput.maxIterations` is the ceiling as it stood BEFORE this call** — whatever the previous round set. A decision that must track a ceiling you are raising in the same call has to use your own value, not this field.
55
+ - **The error/success counters are cumulative for the run**, not per round: `toolSuccesses` / `toolParseErrors` / `toolCallErrors` only ever grow, so "did THIS round make progress?" is a delta you keep yourself. `toolRetries` / `outputRetries` / `forgetRetries` are the opposite — they are the *remaining* allowances and count down.
56
+ - **`reconfig` runs at the END of an iteration**, after tool dispatch and output parsing and before the `dynamic` re-resolve — so anything it changes takes effect on the NEXT model call.
57
+
58
+ ## Methods
59
+
60
+ ```typescript
61
+ get(mode?: PromptGetType, input?: TInput, ctx?: Context): PromptGet<mode, TOutput, TTools>
62
+ run(input?: TInput, ctx?: Context): AsyncGenerator<PromptEvent<TOutput, TTools>, TOutput | undefined>
63
+ applicable(ctx?: Context): Promise<boolean>
64
+ metadata(input?, ctx?): TMetadata | Promise<TMetadata>
65
+ ```
66
+
67
+ ### `get()` modes
68
+
69
+ | Mode (default `'result'`) | Returns |
70
+ |------|---------|
71
+ | `'result'` | `Promise<TOutput \| undefined>` — final structured output |
72
+ | `'tools'` | `Promise<PromptToolOutput<TTools>[] \| undefined>` — `{ tool, result }[]` |
73
+ | `'stream'` | `AsyncGenerator<PromptEvent, TOutput \| undefined>` — all events |
74
+ | `'streamTools'` | `AsyncGenerator<PromptToolOutput, TOutput \| undefined>` |
75
+ | `'streamContent'` | `AsyncGenerator<string, TOutput \| undefined>` — text deltas only |
76
+
77
+ `run()` is equivalent to `get('stream', …)`.
78
+
79
+ ```typescript
80
+ // Structured result
81
+ const r = await prompt.get('result', { text: 'hi' }, { execute, messages: [] });
82
+
83
+ // Stream text deltas
84
+ for await (const chunk of prompt.get('streamContent', { text: 'hi' }, { stream, messages: [] })) {
85
+ process.stdout.write(chunk);
86
+ }
87
+
88
+ // Stream all events
89
+ for await (const e of prompt.get('stream', { text: 'hi' }, { stream, messages: [] })) {
90
+ if (e.type === 'textPartial') process.stdout.write(e.content);
91
+ else if (e.type === 'toolStart') console.log('tool:', e.tool.name);
92
+ else if (e.type === 'toolOutput') console.log('result:', e.result);
93
+ else if (e.type === 'complete') console.log('output:', e.output);
94
+ }
95
+ ```
96
+
97
+ ## `PromptEvent` union
98
+
99
+ Every event carries `request: Request`. Types:
100
+
101
+ - `request` (`+ iterations`), `textPartial`, `text`, `textComplete`, `textReset` (`+ reason?`)
102
+ - `refusal`, `reason` / `reasonPartial` (reasoning trace)
103
+ - `toolParseName`, `toolParseArguments` (`+ args`), `toolArgRepairAttempt` (`+ fields, success`)
104
+ - tool lifecycle: `toolStart`, `toolOutput` (`+ result` raw, `+ toModel` presented value), `toolInterrupt`, `toolSuspend`, `toolError` (`+ error, rawArgs?`) — each carries `tool`, `toolCallId` (the model's call id, also on its `role: 'tool'` reply) and `args`. `onToolIntercept(tool, args, ctx, call)` gets the `ToolCall` too. A valid call left unrun because a sibling in the batch was invalid (an unknown tool) is answered `[not run: …; call it again]`
105
+ - `message` (`+ message`), `suspend`, `complete` (`+ output`)
106
+ - `requestUsage`, `responseTokens`, `usage` (`+ usage`)
107
+
108
+ ## Prompt with tools
109
+
110
+ ```typescript
111
+ const advisor = new Prompt({
112
+ name: 'travelAdvisor',
113
+ description: 'Plans a trip',
114
+ content: 'Plan a trip to {{destination}}. Use getWeather to check conditions.',
115
+ input: (i: { destination: string }) => ({ destination: i.destination }),
116
+ tools: [getWeather], // see aeye-core-tools.md
117
+ toolExecution: 'parallel',
118
+ toolIterations: 3,
119
+ schema: z.object({
120
+ recommendations: z.array(z.string()),
121
+ packingList: z.array(z.string()),
122
+ }),
123
+ });
124
+
125
+ const advice = await advisor.get('result', { destination: 'Paris' }, { execute, messages: [] });
126
+ ```
127
+
128
+ ## Tool result transformer
129
+
130
+ `onToolResult` intercepts each tool's **successful** result *before* it's handed to the model and returns what the model sees instead. It's fully type-safe: `event` is a discriminated union over the prompt's tools, so **narrowing on `event.tool` types both `event.result` and `event.args`** for that tool. The default (unmatched) branch is the catch-all.
131
+
132
+ ```typescript
133
+ const prompt = new Prompt({
134
+ name: 'searcher',
135
+ description: 'Searches and answers',
136
+ content: 'Answer using search.',
137
+ tools: [searchTool, mathTool], // searchTool → { hits, ids }, mathTool → number
138
+ onToolResult: (event) => {
139
+ if (event.tool === 'search') {
140
+ // event.result is the search result; event.args is { query }
141
+ return `Found ${event.result.hits} hits for "${event.args.query}"`;
142
+ }
143
+ // catch-all: here event is the `math` member (event.result: number)
144
+ return event.result; // pass-through
145
+ },
146
+ });
147
+ ```
148
+
149
+ - **The return value is what's presented.** It's serialized exactly like an untransformed result — a `string` is used verbatim, anything else is `JSON.stringify`-d — into the `role: 'tool'` message. To pass through unchanged, `return event.result`.
150
+ - **Model-facing only.** `get('tools')`, `streamTools`, and the `toolOutput` event's `result` field always report the RAW result. The presented value is additionally exposed on the `toolOutput` event as `toModel` (equal to `result` when no handler is set).
151
+ - **v1 = success results only.** Errored / suspended / interrupted / synthetic (`toolsComplete`) tool slots BYPASS the handler and keep their existing content. (A future v2 could add a `status: 'success' | 'error'` discriminant so handlers can transform errors too.)
152
+ - **A handler that throws** is treated as a tool error for that slot — the model sees the error content — so the `tool_call` ↔ `role: 'tool'` pairing guarantee still holds. The raw result stays available on `get('tools')`/`streamTools`.
153
+ - `async` handlers are awaited.
154
+
155
+ Compile-time safety: referencing a non-existent tool name, or accessing a field not on the narrowed tool's `result`/`args`, fails to compile.
156
+
157
+ ## Token / context-window management
158
+
159
+ When the rendered messages plus reserved output tokens exceed the window, the prompt trims older messages and retries (`forgetRetries`). It removes whole groups, oldest first: a message with the `role: 'tool'` replies after it, so tool calls never lose their replies. System messages, the last user message and the most recent group stay; an agent's tool loop after the last user message is trimmed too. `forget` is `protected`, so a subclass can summarize instead of drop. Supply `estimateUsage` for accuracy:
160
+
161
+ ```typescript
162
+ await prompt.get('result', { text }, {
163
+ execute, messages: history,
164
+ maxOutputTokens: 2048,
165
+ estimateUsage: (m) => ({ text: { input: Math.ceil(String(m.content).length / 4) } }),
166
+ });
167
+ ```
168
+
169
+ ## Suspend / resume (human-in-the-loop)
170
+
171
+ A tool can `throw new PromptSuspend(...)` to stop the loop *without* writing a tool result. The caller receives a `suspend` event whose `request.messages` includes the assistant message with the pending tool call. Persist those messages, do the external work (approval, etc.), append the `role: 'tool'` result message, then re-run the prompt with the combined messages as `ctx.messages`. `PromptSuspend` is never auto-paired even with `toolsComplete: true`.
172
+
173
+ ## Gotchas
174
+
175
+ - Default `get` mode is `'result'`; pass the mode explicitly when streaming.
176
+ - `schema` returning `false` disables structured output — output is the raw text string.
177
+ - `config`/`retool` returning `false` marks the prompt incompatible with the context.
178
+ - `toolsMax` is a soft cap unless `toolsOneAtATime` (request flag) is set — it stops *offering* tools after the threshold.
179
+ - Validation errors (Zod / JSON / `validate`) are truncated to `validationErrorMaxLength` before being shown to the model.
@@ -31,8 +31,9 @@ A descriptor (see `src/schema.ts` line ~26 for the fully documented interface) d
31
31
  | `allowAllOf` / `allowAnyOf` / `allowOneOf` | Combinator support. |
32
32
  | `allowRootRef` / `allowDefsRef` | Whether `$ref: '#'` / `$ref: '#/$defs/X'` are permitted. Under `allowDefsRef: false` the emitter never produces a `$defs` section at all: a shared node is **inlined** at each use site, and a non-root cycle takes the bounded placeholder (`supportsRecursion` alone does not license a `$ref` the descriptor cannot spell). |
33
33
  | `anyEncoding` | How `z.any()` / `z.unknown()` is emitted — see below. |
34
- | `supportedStringFormats` | `'all'` or a `Set<string>` whitelist. |
34
+ | `supportedStringFormats` | `'all'` (every format JSON Schema 2020-12 defines) or a `Set<string>` whitelist. |
35
35
  | `supportsRecursion` | Whether `$ref` self-reference works. |
36
+ | `structuredOutputAlwaysStrict` | The family has no non-strict structured output (Anthropic) — see [below](#structuredoutputalwaysstrict). |
36
37
  | `maxStrictTools` / `maxStrictOptionalParams` / `maxStrictUnionTypes` | Per-request slot budgets (`undefined` = no documented limit). |
37
38
  | `schemaSizeLimits` | Per-SCHEMA size ceilings — properties, nesting, total string characters, enum values (`undefined` = no documented limit) — see below. |
38
39
  | `maxEnumValues` | Opt-in ceiling on how many values ONE emitted `enum` may carry (`undefined` = no cap). `GOOGLE_STRICT`/`GOOGLE_NON_STRICT` declare 40, a MEASURED response to a real provider rejection; every other built-in declares none — see below. |
@@ -97,22 +98,42 @@ An open "any JSON value" slot has no single portable spelling, so each dialect p
97
98
  |------|------|------|
98
99
  | `'recursive-strict'` | `$defs/Any` with array-of-pairs records (OpenAI strict has no open objects) | `allowAnyOf`, `allowDefsRef`, `supportsRecursion` |
99
100
  | `'recursive-open'` | `$defs/Any` with `additionalProperties: <self>` | `allowAnyOf`, `allowDefsRef`, `supportsRecursion` |
100
- | `'flat'` | inline non-recursive `anyOf` over every JSON type | `allowAnyOf` |
101
+ | `'flat'` | inline non-recursive `anyOf` over every JSON type (xAI strict) | `allowAnyOf` |
101
102
  | `'unconstrained'` | the empty schema `{}` (plus any `description`) | nothing |
103
+ | `'none'` | no spelling exists (Anthropic strict) — the schema is not sent strict | nothing |
104
+
105
+ `'none'` is for a dialect that rejects every spelling of "any": Anthropic strict answers 400 to `{}` ("Empty schema … is not supported"), a typeless schema, `additionalProperties: true` and a recursive `$defs/Any` alike. A schema containing `z.any()` / `z.unknown()`, or a recursion cycle (whose placeholder bottoms out at "any"), is therefore not sendable under it: `SchemaBudget` degrades such a tool to the family's non-strict descriptor (even under `strict: true`), and `canExpress` answers `false` so such an output goes as prompt text.
102
106
 
103
107
  `'unconstrained'` is the only mode available to a dialect that forbids combinators **and** named `$defs` (Google). Per JSON Schema a schema with no assertion keywords validates every instance, which is exactly what "any value" means — so it is lossless as a constraint, and the source schema's `description` still rides along as the human/model-facing signal.
104
108
 
109
+ ### `structuredOutputAlwaysStrict`
110
+
111
+ Anthropic compiles every structured output to a grammar (`output_config.format` is strict by definition, and OpenRouter flags every `json_schema` bound for Claude), so an output "degraded" to the non-strict dialect still earns `too many optional parameters (30) … (limit: 24)`. A descriptor declaring `structuredOutputAlwaysStrict: true` (`ANTHROPIC_STRICT`) changes two things:
112
+
113
+ - `canExpress` also requires the output schema to fit the strict limits **on its own** — the per-schema size limits and the per-request optional-parameter and union budgets. An output that doesn't fit is delivered as prompt text under `schemaDelivery: 'auto'`.
114
+ - `SchemaBudget` never degrades an output to the non-strict variant; it keeps the strict dialect.
115
+
116
+ ## What every dialect emits
117
+
118
+ A few emitter rules hold under every descriptor, `LENIENT` included:
119
+
120
+ - **`z.nullable()` always admits `null`**, spelled as an `anyOf` branch (`anyOf: [T, { type: 'null' }]`) wherever the dialect has `anyOf`; a `["T", "null"]` type array is the spelling of last resort (o3's strict function validator rejects it). Earlier versions dropped the null outside OpenAI strict, so a grammar-constrained model could never answer it.
121
+ - **Only real JSON Schema formats are emitted.** `'all'` means the formats JSON Schema 2020-12 defines (`date-time`, `email`, `uri`, `uuid`, …); Zod's own check names that share the field — `regex` from `.regex()`, `cuid`, `starts_with` — are never emitted (DeepInfra answers `Unknown format: regex`). A whitelisted descriptor emits only its list.
122
+ - **Open records drop `propertyNames` for a plain string key**: it constrains nothing, and DeepInfra and Cloudflare reject the keyword (`Unimplemented keys: ["propertyNames"]`). A key schema with real constraints (an enum, a pattern) still emits it.
123
+ - **`strictify` handles `.meta({ id })`.** The strictified clone carries the id as `aid` (zod's global registry refuses a second schema with the same `id`), and `aid` names the same `$defs` entry.
124
+
105
125
  ## Built-in descriptors
106
126
 
107
- Seven ship frozen and pre-registered:
127
+ Nine ship frozen and pre-registered:
108
128
 
109
129
  | Export | Family / Strict | Notes |
110
130
  |------|------|-------|
111
131
  | `OPENAI_STRICT` | openai / strict | records→array-of-pairs, tuples→numeric-key objects, `optional → T \| null`, closed objects, restricted format whitelist. |
112
- | `ANTHROPIC_STRICT` | anthropic / strict | closed objects, no recursion, no length/range constraints; budgets 20 tools / 24 optional params / 16 unions. |
132
+ | `ANTHROPIC_STRICT` | anthropic / strict | closed objects, no recursion, no length/range constraints; budgets 20 tools / 24 optional params / 16 unions; format whitelist `date-time`, `time`, `date`, `duration`, `email`, `hostname`, `uri`, `ipv4`, `ipv6`, `uuid`; `anyEncoding: 'none'`; `structuredOutputAlwaysStrict`. |
113
133
  | `GOOGLE_STRICT` | google / strict | `prefixItems`, `$ref: '#'` recursion, `propertyOrdering`, restricted format whitelist, `z.any()` → `unconstrained` (Gemini forbids `anyOf` and named `$defs`). |
134
+ | `XAI_STRICT` | xai / strict | `anyOf` and `oneOf`, non-circular `$defs`, `prefixItems` tuples, open records, format whitelist incl. `uri`, `pattern`, length/item/numeric bounds; no `allOf`; `z.any()` → `flat`. |
114
135
  | `LENIENT` | lenient / non-strict | No rewrites; everything passes through. Default for unannotated models. |
115
- | `OPENAI_NON_STRICT`, `ANTHROPIC_NON_STRICT` | family / non-strict | Aliased to `LENIENT` but tagged with the family for diagnostics. |
136
+ | `OPENAI_NON_STRICT`, `ANTHROPIC_NON_STRICT`, `XAI_NON_STRICT` | family / non-strict | Aliased to `LENIENT` but tagged with the family for diagnostics. |
116
137
  | `GOOGLE_NON_STRICT` | google / non-strict | `LENIENT` **except** `anyEncoding: 'unconstrained'`. Gemini compiles a decoding grammar whenever a tool call is forced, and rejects a self-referencing `$defs/Any` when it does — which has nothing to do with a per-tool strict flag (Google's function-calling API has none), so the encoding belongs to the dialect. |
117
138
 
118
139
  ## Functions
@@ -130,6 +151,7 @@ hasDescriptorFamily(family: string | undefined): boolean
130
151
  resolveDescriptor(options: ToJSONSchemaOptions | boolean | FormatDescriptor): FormatDescriptor
131
152
  strictestOf(a, b): FormatDescriptor
132
153
  strictPriority(requested: boolean | number | undefined): number
154
+ canExpress(schema, descriptor): boolean // can it go out as structured output?
133
155
  ```
134
156
 
135
157
  - `toJSONSchema` accepts a descriptor, a `ToJSONSchemaOptions` object, or a boolean (back-compat: `true` ⇒ `OPENAI_STRICT`, `false` ⇒ `LENIENT`).
@@ -155,18 +177,18 @@ toJSONSchema(schema, false); // === LENIENT
155
177
 
156
178
  ## `SchemaBudget`
157
179
 
158
- `new SchemaBudget(descriptor)` allocates strict "slots" across a single request when a descriptor has per-request limits (e.g. Anthropic). Providers call:
180
+ `new SchemaBudget(tool, output = tool)` allocates strict "slots" across a single request when a descriptor has per-request limits (e.g. Anthropic). Tools are emitted through `tool` and the structured output through `output`; they differ when a model has strict structured output but no strict tools (gpt-audio, for one) or the reverse, so neither kind is sent `strict: true` the model can't take. Only the slot counters are shared, sized from the stricter of the two. `@aeye/ai`'s `buildSchemaBudget` builds it from the model's `'toolsStrict'` and `'structured'` capabilities. Providers call:
159
181
 
160
182
  ```typescript
161
183
  budget.allocateTool(schema, requested): FormatDescriptor // returns strict or the family's non-strict, decrements
162
184
  budget.allocateOutput(schema, requested): FormatDescriptor // output schema; consumes param/union budget, not a tool slot
163
185
  ```
164
186
 
165
- It falls back when `requested === false`, when the schema uses an unrepresentable feature, when the schema exceeds the dialect's `schemaSizeLimits`, or when a positive-number request can't fit the remaining budget (priority order; `true` always wins subject to *budget*, never subject to feasibility or size).
187
+ It falls back when `requested === false`, when the schema uses an unrepresentable feature, when the schema exceeds the dialect's `schemaSizeLimits`, when the schema's optional parameters or unions **alone** exceed the per-request budget (no other item can make it fit), or when a positive-number request can't fit the remaining budget (priority order; `true` always wins subject to *budget*, never subject to feasibility or size). An output under a `structuredOutputAlwaysStrict` descriptor is never degraded — `canExpress` already kept an unfitting one off the wire.
166
188
 
167
189
  **The fallback is the descriptor's own family, non-strict — never the family-blind `LENIENT`.** A dialect rule can outlive strict mode: `GOOGLE_NON_STRICT` exists for exactly one such rule, the `unconstrained` encoding of `z.any()`, because Gemini compiles a decoding grammar whenever a tool call is forced and no per-tool `strict` flag is involved. Degrading to `LENIENT` put the self-referencing `$defs/Any` that encoding was created to avoid straight back on the Google wire — and did so for *every* schema that degrades, which under `GOOGLE_STRICT` (`allowAnyOf: false`) is every recursive gin/query program schema there is. For the other families the change is behaviour-neutral by construction: `OPENAI_NON_STRICT` / `ANTHROPIC_NON_STRICT` are `LENIENT` with a family tag, and a family that registers no non-strict variant resolves to `LENIENT`. Only the `descriptor` id pinned on the tool differs, and it round-trips through `getDescriptorById`.
168
190
 
169
- "Unrepresentable" here means a **combinator the descriptor forbids** (`z.union` → `anyOf`, `z.intersection` → `allOf`), because nothing rewrites those away — a schema carrying one under `GOOGLE_STRICT` is a provider HTTP 400. **Recursion is not unrepresentable**: the emitter replaces any back-edge the descriptor cannot spell with a bounded placeholder, so a recursive schema stays strict (looser at the back-edge, never rejected). That is the difference between this check and `canExpress`, which additionally treats a widened back-edge as a reason to fall back — the right answer for a structured *output* (it can be delivered as prompt text instead) and the wrong one for a *tool* (it cannot).
191
+ "Unrepresentable" here means a **combinator the descriptor forbids** (`z.union` → `anyOf`, `z.intersection` → `allOf`), because nothing rewrites those away — a schema carrying one under `GOOGLE_STRICT` is a provider HTTP 400. **Recursion is not unrepresentable**: the emitter replaces any back-edge the descriptor cannot spell with a bounded placeholder, so a recursive schema stays strict (looser at the back-edge, never rejected). The exception is an `anyEncoding: 'none'` dialect (Anthropic strict): the placeholder bottoms out at "any", which it cannot spell, so a cycle — like `z.any()` / `z.unknown()` — degrades the tool there. That is the difference between this check and `canExpress`, which additionally treats a widened back-edge as a reason to fall back — the right answer for a structured *output* (it can be delivered as prompt text instead) and the wrong one for a *tool* (it cannot).
170
192
 
171
193
  ## Registering a custom descriptor
172
194
 
@@ -190,5 +212,6 @@ Once registered, `toJSONSchema(schema, MY_PROVIDER_STRICT)` / `strictify(...)` w
190
212
  ## Gotchas
191
213
 
192
214
  - `ToolDefinition.parameters` and `ResponseFormat.type` stay **raw Zod**. The matching `strictify` is applied *lazily* by the provider once it knows the model's descriptor, then pinned via `descriptor` id so the validator (`Tool.parse` / Prompt output validation) matches the exact wire shape the model saw.
193
- - Strict dialects drop features they can't represent (Anthropic: no recursion, no numeric ranges). If a feature matters, check `analyzeSchema` against the descriptor or force `strict: false`.
215
+ - Strict dialects drop features they can't represent (Anthropic: no numeric ranges, formats outside its whitelist). If a feature matters, check `analyzeSchema` against the descriptor or force `strict: false`.
216
+ - Under Anthropic strict, `z.any()`, `z.unknown()` and recursive schemas never go out strict: such a tool degrades to non-strict and such an output is sent as prompt text.
194
217
  - The `JSONSchema` / `JSONSchemaType` / `ToJSONSchemaOptions` / `DescriptorFamily` types are exported for tooling.