@aeye/core 0.5.4 → 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.
Files changed (124) hide show
  1. package/README.md +790 -724
  2. package/aeye-core-prompts.md +179 -178
  3. package/aeye-core-schema.md +43 -16
  4. package/aeye-core-tools.md +179 -177
  5. package/aeye-core-types.md +1 -0
  6. package/dist/common.d.ts +30 -5
  7. package/dist/common.d.ts.map +1 -1
  8. package/dist/common.js +134 -129
  9. package/dist/common.js.map +1 -1
  10. package/dist/delivery.d.ts +39 -0
  11. package/dist/delivery.d.ts.map +1 -0
  12. package/dist/delivery.js +127 -0
  13. package/dist/delivery.js.map +1 -0
  14. package/dist/errors.d.ts +60 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/dist/errors.js +84 -0
  17. package/dist/errors.js.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/prompt.d.ts +134 -15
  23. package/dist/prompt.d.ts.map +1 -1
  24. package/dist/prompt.js +220 -164
  25. package/dist/prompt.js.map +1 -1
  26. package/dist/schema/analyze.d.ts +94 -0
  27. package/dist/schema/analyze.d.ts.map +1 -0
  28. package/dist/schema/analyze.js +221 -0
  29. package/dist/schema/analyze.js.map +1 -0
  30. package/dist/schema/any-value.d.ts +32 -0
  31. package/dist/schema/any-value.d.ts.map +1 -0
  32. package/dist/schema/any-value.js +77 -0
  33. package/dist/schema/any-value.js.map +1 -0
  34. package/dist/schema/budget.d.ts +114 -0
  35. package/dist/schema/budget.d.ts.map +1 -0
  36. package/dist/schema/budget.js +223 -0
  37. package/dist/schema/budget.js.map +1 -0
  38. package/dist/schema/check-bag.d.ts +28 -0
  39. package/dist/schema/check-bag.d.ts.map +1 -0
  40. package/dist/schema/check-bag.js +129 -0
  41. package/dist/schema/check-bag.js.map +1 -0
  42. package/dist/schema/consistency.d.ts +27 -0
  43. package/dist/schema/consistency.d.ts.map +1 -0
  44. package/dist/schema/consistency.js +110 -0
  45. package/dist/schema/consistency.js.map +1 -0
  46. package/dist/schema/cycle-breaker.d.ts +14 -0
  47. package/dist/schema/cycle-breaker.d.ts.map +1 -0
  48. package/dist/schema/cycle-breaker.js +157 -0
  49. package/dist/schema/cycle-breaker.js.map +1 -0
  50. package/dist/schema/decode.d.ts +31 -0
  51. package/dist/schema/decode.d.ts.map +1 -0
  52. package/dist/schema/decode.js +207 -0
  53. package/dist/schema/decode.js.map +1 -0
  54. package/dist/schema/descriptor.d.ts +300 -0
  55. package/dist/schema/descriptor.d.ts.map +1 -0
  56. package/dist/schema/descriptor.js +15 -0
  57. package/dist/schema/descriptor.js.map +1 -0
  58. package/dist/schema/dialects/anthropic.d.ts +34 -0
  59. package/dist/schema/dialects/anthropic.d.ts.map +1 -0
  60. package/dist/schema/dialects/anthropic.js +77 -0
  61. package/dist/schema/dialects/anthropic.js.map +1 -0
  62. package/dist/schema/dialects/google.d.ts +47 -0
  63. package/dist/schema/dialects/google.d.ts.map +1 -0
  64. package/dist/schema/dialects/google.js +91 -0
  65. package/dist/schema/dialects/google.js.map +1 -0
  66. package/dist/schema/dialects/lenient.d.ts +4 -0
  67. package/dist/schema/dialects/lenient.d.ts.map +1 -0
  68. package/dist/schema/dialects/lenient.js +26 -0
  69. package/dist/schema/dialects/lenient.js.map +1 -0
  70. package/dist/schema/dialects/openai.d.ts +40 -0
  71. package/dist/schema/dialects/openai.d.ts.map +1 -0
  72. package/dist/schema/dialects/openai.js +81 -0
  73. package/dist/schema/dialects/openai.js.map +1 -0
  74. package/dist/schema/dialects/xai.d.ts +23 -0
  75. package/dist/schema/dialects/xai.d.ts.map +1 -0
  76. package/dist/schema/dialects/xai.js +53 -0
  77. package/dist/schema/dialects/xai.js.map +1 -0
  78. package/dist/schema/enum-values.d.ts +44 -0
  79. package/dist/schema/enum-values.d.ts.map +1 -0
  80. package/dist/schema/enum-values.js +56 -0
  81. package/dist/schema/enum-values.js.map +1 -0
  82. package/dist/schema/expressible.d.ts +59 -0
  83. package/dist/schema/expressible.d.ts.map +1 -0
  84. package/dist/schema/expressible.js +161 -0
  85. package/dist/schema/expressible.js.map +1 -0
  86. package/dist/schema/index.d.ts +20 -0
  87. package/dist/schema/index.d.ts.map +1 -0
  88. package/dist/schema/index.js +20 -0
  89. package/dist/schema/index.js.map +1 -0
  90. package/dist/schema/json-schema.d.ts +64 -0
  91. package/dist/schema/json-schema.d.ts.map +1 -0
  92. package/dist/schema/json-schema.js +2 -0
  93. package/dist/schema/json-schema.js.map +1 -0
  94. package/dist/schema/registry.d.ts +73 -0
  95. package/dist/schema/registry.d.ts.map +1 -0
  96. package/dist/schema/registry.js +135 -0
  97. package/dist/schema/registry.js.map +1 -0
  98. package/dist/schema/strictify.d.ts +19 -0
  99. package/dist/schema/strictify.d.ts.map +1 -0
  100. package/dist/schema/strictify.js +237 -0
  101. package/dist/schema/strictify.js.map +1 -0
  102. package/dist/schema/to-json-schema.d.ts +29 -0
  103. package/dist/schema/to-json-schema.d.ts.map +1 -0
  104. package/dist/schema/to-json-schema.js +692 -0
  105. package/dist/schema/to-json-schema.js.map +1 -0
  106. package/dist/schema/walk-key.d.ts +24 -0
  107. package/dist/schema/walk-key.d.ts.map +1 -0
  108. package/dist/schema/walk-key.js +33 -0
  109. package/dist/schema/walk-key.js.map +1 -0
  110. package/dist/to.js +1 -1
  111. package/dist/to.js.map +1 -1
  112. package/dist/tool.d.ts +38 -4
  113. package/dist/tool.d.ts.map +1 -1
  114. package/dist/tool.js +52 -8
  115. package/dist/tool.js.map +1 -1
  116. package/dist/types.d.ts +265 -8
  117. package/dist/types.d.ts.map +1 -1
  118. package/dist/types.js +4 -1
  119. package/dist/types.js.map +1 -1
  120. package/package.json +2 -2
  121. package/dist/schema.d.ts +0 -840
  122. package/dist/schema.d.ts.map +0 -1
  123. package/dist/schema.js +0 -2478
  124. package/dist/schema.js.map +0 -1
@@ -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,11 +31,12 @@ 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
- | `maxEnumValues` | Opt-in ceiling on how many values ONE emitted `enum` may carry (`undefined` = no cap, and that is what every built-in declares) — see below. |
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. |
39
40
 
40
41
  ### `schemaSizeLimits` — how BIG one strict schema may be
41
42
 
@@ -60,7 +61,7 @@ Scope is Structured Outputs — a tool carrying `strict: true`, or a `json_schem
60
61
 
61
62
  The measurement is taken from the **Zod** schema rather than from the emitted JSON, so where an encoding is descriptor-dependent it counts the widest one (a record as its `{key, value}` pair form, a tuple as its numeric-key form). Over-counting costs strictness on a schema that would have fitted; under-counting costs an HTTP 400, so the rounding goes up. `$defs` entry names are not counted: they are generated at emit time, few, and short against a 120,000-character budget.
62
63
 
63
- ### `maxEnumValues` — an opt-in dialect ceiling that WIDENS, and why no built-in sets one
64
+ ### `maxEnumValues` — an opt-in dialect ceiling that WIDENS, and why only Google sets one
64
65
 
65
66
  Some providers may reject a schema whose `enum` is simply too long. That is **not** one of the per-request budgets above: those are strict-mode allowances the `SchemaBudget` spends across a whole request, degrading an item that no longer fits to the family's non-strict descriptor, whereas this one would fire whatever the strictness, so it is enforced structurally at the point an `enum` is emitted.
66
67
 
@@ -75,15 +76,19 @@ Truncating would make the values past the cap **unreachable** — the wire schem
75
76
 
76
77
  The trade is enforcement: a widened node no longer rejects a value structurally, so whatever validates the model's output afterwards must. `Tool.parse` does — it runs against the ORIGINAL zod schema, whose `enum` is untouched — so a hallucinated member comes back as a normal, re-promptable validation error.
77
78
 
78
- **No built-in descriptor declares a cap, and that is a measured result.** The field exists because a large `enum` was believed to make Gemini answer `400 INVALID_ARGUMENT`. Measured against `google/gemini-3-flash-preview` through OpenRouter (2026-08-23), it does not: single-parameter enums at 50/64/72/80/96/128/256/512/1024/**2048** values (52 KB of tool schema) all returned HTTP 200 with the expected tool call, under `tool_choice` both `required` (which is what makes Gemini compile a decoding grammar) and `auto` — as did 96 values with 80-character members, 20 tools each carrying a 96-value enum in one request, a 96-value enum next to `strict: true` / `minItems` / `propertyOrdering` / a sibling `anyOf`, and a 2048-value enum inside a `response_format` schema. Reliability was compared too, since a cap's whole cost is losing the enum's can't-hallucinate guarantee: 10 trials each of `enum` vs plain-string-with-full-list, over confusable near-duplicate names, at 96 and again at 512 values — both picked the correct member 10/10 with zero off-list answers.
79
+ **`GOOGLE_STRICT`/`GOOGLE_NON_STRICT` declare `maxEnumValues: 40`. Every other built-in declares no cap.** The field exists because a large `enum` was believed to make Gemini answer `400 INVALID_ARGUMENT`. An initial round of measurement against `google/gemini-3-flash-preview` through OpenRouter (2026-08-23) looked like it disproved that: single-parameter enums at 50/64/72/80/96/128/256/512/1024/**2048** SYNTHETIC values (`name_0`, `name_1`, …) all returned HTTP 200, as did an eleven-tool roster modelled on a real kind-authoring agent with a 96-value enum on one tool.
79
80
 
80
- A second pass answered the obvious objection — that an isolated probe is not what a real session sends. Same date and model, upstream **pinned to Google AI Studio** with fallbacks off (the upstream that served the production failures), an eleven-tool roster emitted through this library for a Google model and modelled on a real kind-authoring agent (`fn_load` / `fn_search` / `type_load` / `api_signature` / `api_set` / …, real descriptions, real system prompt): a 96-value enum on one of those tools returned 200 with the expected tool call under `tool_choice` `required` and `auto` alike, as did the same roster at 8 values and a tool carrying a bare typeless `{}` property.
81
+ **That conclusion was wrong, because none of it used the CONTENT of a real session.** A third round replayed the exact wire bytes of an actual product request — `fn_load`'s `names` array, enum-constrained to a real session's 98 known component/fn names (`default_renderer`, `QueryWidget`, `ConfirmPrompt`, `list_widget_types`, …) — and it 400'd, reproducibly, twice, byte-for-byte identical to what was actually sent. Bisecting that real value list: 90 values passes, 93 fails. Bisecting what differs from the earlier synthetic probes: 98 SHORT synthetic values (284 total characters) pass; 206 short values matching the real list's total character count (714 characters) also pass; only the real, longer, identifier-shaped values fail, and only past ~92 of them. So the limit tracks neither raw member count nor total character/byte budget — most likely actual tokenized grammar-state count, which short repetitive synthetic strings compress far below what real identifier text does at the same count or character budget. This is why the first two rounds' synthetic and lightly-realistic probes never tripped it.
81
82
 
82
- The caveat that keeps that from being proof: the **control** — the recursive `$defs/Any` shape documented elsewhere in this file as 100% reproducible HTTP 400 — also returned 200, pinned and unpinned, forced and automatic, both hand-built and as the product's real 32 KB gin `api_set` schema. That failure class is not visible from this route today, so treat the numbers as evidence, not a verdict.
83
+ The widen-to-string fallback this field triggers was verified against the exact failing real schema: dropping `enum` and putting the same 98 values into the `description` of a plain string field returned HTTP 200 with the expected tool call — confirming the fallback mechanism actually fixes the failure it exists for, not just a synthetic stand-in.
83
84
 
84
- One trap for whoever measures this next: at `max_tokens: 64` the `$defs/Any` tool answered `finish_reason: "length"` with no tool call and empty content, which reads exactly like a grammar that produced nothing. It is not — this model spends reasoning tokens first, and at 400 the identical request emitted a correct call in 28 output tokens. Give a forced-tool probe room before calling an empty completion a failure.
85
+ `40` is not a measured edge — it is a conservative floor with roughly 2x margin under the real one (90 passes / 93 fails) measured on this one real value set. Given the failure tracks content, not just count, a different session's actual name list could plausibly fail at a different count; 40 was also the cap the product that surfaced this ran under in production for months, which is corroborating evidence it holds in practice, not just in this one measurement.
85
86
 
86
- So: **set this only against a measured provider rejection, and set it as high as the measurement allows.** An `enum` that fits is the more correct encoding, because it is the one the model cannot answer outside of.
87
+ The reliability comparison from the earlier rounds still stands and is why a cap is preferable to leaving every enum uncapped "to be safe" at a much lower number: `enum` vs plain-string-with-full-list, 10 trials each over confusable near-duplicate names at 96 and 512 values, scored 10/10 on-target with zero off-list answers for BOTH encodings. So there is no reliability cost to raising this cap later if a higher safe value is measured — only downside risk to leaving it too high.
88
+
89
+ One trap for whoever measures this next: with `max_tokens` at 64, a forced-tool probe can come back `finish_reason: "length"` with no tool call and empty content, which reads exactly like a grammar that produced nothing. It usually is not — this model spends reasoning tokens first. Give a forced-tool probe room before calling an empty completion a failure.
90
+
91
+ So: **set this only where a provider is MEASURED to reject a large enum using the SESSION'S OWN real content, not synthetic stand-ins, and set it conservatively below the measured edge until more value sets are tested.** An `enum` that fits is the more correct encoding, because it is the one the model cannot answer outside of.
87
92
 
88
93
  ### `anyEncoding` — and what each mode requires
89
94
 
@@ -93,22 +98,42 @@ An open "any JSON value" slot has no single portable spelling, so each dialect p
93
98
  |------|------|------|
94
99
  | `'recursive-strict'` | `$defs/Any` with array-of-pairs records (OpenAI strict has no open objects) | `allowAnyOf`, `allowDefsRef`, `supportsRecursion` |
95
100
  | `'recursive-open'` | `$defs/Any` with `additionalProperties: <self>` | `allowAnyOf`, `allowDefsRef`, `supportsRecursion` |
96
- | `'flat'` | inline non-recursive `anyOf` over every JSON type | `allowAnyOf` |
101
+ | `'flat'` | inline non-recursive `anyOf` over every JSON type (xAI strict) | `allowAnyOf` |
97
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.
98
106
 
99
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.
100
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
+
101
125
  ## Built-in descriptors
102
126
 
103
- Seven ship frozen and pre-registered:
127
+ Nine ship frozen and pre-registered:
104
128
 
105
129
  | Export | Family / Strict | Notes |
106
130
  |------|------|-------|
107
131
  | `OPENAI_STRICT` | openai / strict | records→array-of-pairs, tuples→numeric-key objects, `optional → T \| null`, closed objects, restricted format whitelist. |
108
- | `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`. |
109
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`. |
110
135
  | `LENIENT` | lenient / non-strict | No rewrites; everything passes through. Default for unannotated models. |
111
- | `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. |
112
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. |
113
138
 
114
139
  ## Functions
@@ -126,6 +151,7 @@ hasDescriptorFamily(family: string | undefined): boolean
126
151
  resolveDescriptor(options: ToJSONSchemaOptions | boolean | FormatDescriptor): FormatDescriptor
127
152
  strictestOf(a, b): FormatDescriptor
128
153
  strictPriority(requested: boolean | number | undefined): number
154
+ canExpress(schema, descriptor): boolean // can it go out as structured output?
129
155
  ```
130
156
 
131
157
  - `toJSONSchema` accepts a descriptor, a `ToJSONSchemaOptions` object, or a boolean (back-compat: `true` ⇒ `OPENAI_STRICT`, `false` ⇒ `LENIENT`).
@@ -151,18 +177,18 @@ toJSONSchema(schema, false); // === LENIENT
151
177
 
152
178
  ## `SchemaBudget`
153
179
 
154
- `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:
155
181
 
156
182
  ```typescript
157
183
  budget.allocateTool(schema, requested): FormatDescriptor // returns strict or the family's non-strict, decrements
158
184
  budget.allocateOutput(schema, requested): FormatDescriptor // output schema; consumes param/union budget, not a tool slot
159
185
  ```
160
186
 
161
- 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.
162
188
 
163
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`.
164
190
 
165
- "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).
166
192
 
167
193
  ## Registering a custom descriptor
168
194
 
@@ -186,5 +212,6 @@ Once registered, `toJSONSchema(schema, MY_PROVIDER_STRICT)` / `strictify(...)` w
186
212
  ## Gotchas
187
213
 
188
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.
189
- - 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.
190
217
  - The `JSONSchema` / `JSONSchemaType` / `ToJSONSchemaOptions` / `DescriptorFamily` types are exported for tooling.