@aeye/core 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -424,17 +424,116 @@ const adaptivePrompt = new Prompt({
424
424
  | `tools` | Function/tool calling |
425
425
  | `json` | JSON output mode |
426
426
  | `structured` | Structured outputs with schemas |
427
+ | `toolsStrict` | Strict-mode tool input schemas (auto-derived; see [Strict Mode](#strict-mode)) |
427
428
  | `reasoning` | Extended chain-of-thought reasoning |
428
429
  | `zdr` | Zero data retention |
429
430
 
431
+ ## Strict Mode
432
+
433
+ Each LLM provider that supports strict mode does so with its own JSON Schema dialect. `@aeye/core` reconciles them under a single `FormatDescriptor` so a Zod schema authored once works against any of them.
434
+
435
+ ### `strict` on Tool / Prompt
436
+
437
+ `Tool.strict` and `Prompt.strict` are `boolean | number`:
438
+
439
+ | Value | Meaning |
440
+ |---|---|
441
+ | `true` | **Require** strict. `@aeye/ai` filters selection down to models that declare strict support; the request fails if none qualify. |
442
+ | `false` | **Force** lenient. Standard JSON Schema; no `strict: true` on the wire. |
443
+ | `number > 0` (default `1`) | **Prefer** strict, accept fallback. Higher numbers win when there are more strict-requesting items than the chosen model's per-request budget allows. |
444
+ | omitted | Treated as `1`. |
445
+
446
+ ```typescript
447
+ new Tool({ name: 'must', /* default 1 */ schema: z.object({...}) }); // best-effort
448
+ new Tool({ name: 'critical', strict: true, schema: z.object({...}) }); // hard require
449
+ new Tool({ name: 'meh', strict: false, schema: z.object({...}) }); // never strict
450
+ new Tool({ name: 'top', strict: 100, schema: z.object({...}) }); // high priority
451
+ ```
452
+
453
+ Default `1` keeps "it just works" against unannotated models — strict where the chosen model supports it, lenient elsewhere. Set `true` only when strict is non-negotiable.
454
+
455
+ ### Native format descriptors
456
+
457
+ Seven descriptors ship out of the box:
458
+
459
+ | Descriptor | Family / Strict | Notes |
460
+ |---|---|---|
461
+ | `OPENAI_STRICT` | openai / strict | Records → array-of-pairs, tuples → numeric-key objects, `optional → T \| null`, closed objects, restricted format whitelist. |
462
+ | `ANTHROPIC_STRICT` | anthropic / strict | Closed objects, no recursion, no length / numeric range constraints, per-request slot budgets (20 strict tools / 24 optional params / 16 union types). |
463
+ | `GOOGLE_STRICT` | google / strict | `prefixItems`, `$ref: '#'` recursion, `propertyOrdering` emitted, restricted format whitelist. |
464
+ | `LENIENT` | lenient / non-strict | No rewrites, all features pass through. Default for unannotated models. |
465
+ | `OPENAI_NON_STRICT`, `ANTHROPIC_NON_STRICT`, `GOOGLE_NON_STRICT` | family / non-strict | Aliased to `LENIENT` but tagged with the family for diagnostics. |
466
+
467
+ ```typescript
468
+ import { OPENAI_STRICT, ANTHROPIC_STRICT, getDescriptor, toJSONSchema } from '@aeye/core';
469
+
470
+ const schema = z.object({
471
+ name: z.string(),
472
+ tags: z.record(z.string(), z.string()),
473
+ });
474
+
475
+ // Same Zod, different wire shapes:
476
+ toJSONSchema(schema, OPENAI_STRICT); // tags → array of {key, value}
477
+ toJSONSchema(schema, ANTHROPIC_STRICT); // tags → array of {key, value} (open records unsupported)
478
+ toJSONSchema(schema, getDescriptor('google', true)); // tags → standard additionalProperties
479
+
480
+ // Backward-compat overloads still work:
481
+ toJSONSchema(schema, true); // === OPENAI_STRICT
482
+ toJSONSchema(schema, false); // === LENIENT
483
+ ```
484
+
485
+ ### Registering a custom descriptor
486
+
487
+ You can ship your own descriptor for a provider or dialect that isn't supported natively. Define it as a `FormatDescriptor` value and register it with `registerDescriptor(descriptor)`:
488
+
489
+ ```typescript
490
+ import { registerDescriptor, type FormatDescriptor, OPENAI_STRICT } from '@aeye/core';
491
+
492
+ const MY_PROVIDER_STRICT: FormatDescriptor = {
493
+ ...OPENAI_STRICT, // start from a known good baseline
494
+ id: 'my-provider-strict',
495
+ family: 'openai', // or 'anthropic' | 'google' | 'lenient'
496
+ // Override only the fields that differ from the baseline:
497
+ recordEncoding: 'open-record', // your provider supports additionalProperties: <schema>
498
+ tupleEncoding: 'prefix-items',
499
+ supportsRecursion: true,
500
+ };
501
+
502
+ registerDescriptor(MY_PROVIDER_STRICT);
503
+ ```
504
+
505
+ Once registered:
506
+
507
+ - Provider code can pass the descriptor to `toJSONSchema(schema, MY_PROVIDER_STRICT)` and `strictify(schema, MY_PROVIDER_STRICT)`.
508
+ - The Prompt's validation roundtrip (which looks up descriptors by id from the request) will resolve `'my-provider-strict'` correctly.
509
+ - A custom `Provider` implementation can return this descriptor from its `getStrictFormat`-equivalent helper.
510
+
511
+ The descriptor's full field list is exported as the `FormatDescriptor` interface — every flag is documented inline. Common adjustments:
512
+
513
+ | Field | Controls |
514
+ |---|---|
515
+ | `objectAllFieldsRequired` | Whether every property must appear in `required[]`. |
516
+ | `objectClosedByDefault` | Whether every object emits `additionalProperties: false`. |
517
+ | `recordEncoding` | `'array-of-pairs'` vs `'open-record'`. |
518
+ | `tupleEncoding` | `'object-numeric-keys'` vs `'prefix-items'` vs `'items-union'`. |
519
+ | `allowAllOf` / `allowAnyOf` / `allowOneOf` | JSON-Schema combinator support. |
520
+ | `supportedStringFormats` | `'all'` or a `Set<string>` whitelist (`'date-time'`, `'email'`, etc.). |
521
+ | `optionalAsNullable` | Whether `optional` fields are emitted as `T \| null` (OpenAI strict) vs omitted from `required[]`. |
522
+ | `supportsRecursion` | Whether `$ref` self-reference works under this dialect. |
523
+ | `maxStrictTools` / `maxStrictOptionalParams` / `maxStrictUnionTypes` | Per-request budget caps; `undefined` means no documented limit. |
524
+
525
+ You don't need to set every field — spread an existing descriptor and override deltas. Tested via `analyzeSchema` + the schema test suite; see the strict-mode guide in `@aeye/docs` for end-to-end examples.
526
+
430
527
  ## API Reference
431
528
 
432
529
  ### Prompt
433
530
 
434
531
  ```typescript
435
- class Prompt<TContext, TMetadata, TName extends string, TInput extends object, TOutput extends object | string, TTools extends Tuple<ToolCompatible<TContext, TMetadata>>>
532
+ class Prompt<TContext, TMetadata, TName extends string, TInput extends object, TOutput extends object | string, TTools extends Tuple<ToolCompatible<TContext, TMetadata>>, TDecoded extends unknown = TOutput>
436
533
  ```
437
534
 
535
+ `TDecoded` is the type a custom `parse` produces from the model's structured output (e.g. a built AST/class instance); it types `validate` and the decoded output. It defaults to `TOutput` (the wire type) when no custom `parse` is supplied — `schema` always stays the wire type.
536
+
438
537
  **Constructor Options (`PromptInput`):**
439
538
  - `name: string` - Unique identifier
440
539
  - `description: string` - Purpose description
@@ -461,9 +560,11 @@ class Prompt<TContext, TMetadata, TName extends string, TInput extends object, T
461
560
  ### Tool
462
561
 
463
562
  ```typescript
464
- class Tool<TContext, TMetadata, TName extends string, TParams extends object, TOutput, TRefs extends Tuple<ComponentCompatible<TContext, TMetadata>>>
563
+ class Tool<TContext, TMetadata, TName extends string, TParams extends object, TOutput, TRefs extends Tuple<ComponentCompatible<TContext, TMetadata>>, TDecoded extends unknown = TParams>
465
564
  ```
466
565
 
566
+ `TDecoded` is the type a custom `parse` produces from the raw wire args (e.g. a built class instance); it types `call`, `validate`, and `metadataFn`. It defaults to `TParams` (the wire type) when no custom `parse` is supplied — `schema` always stays the wire type.
567
+
467
568
  **Constructor Options (`ToolInput`):**
468
569
  - `name: string` - Unique identifier (shown to the AI model)
469
570
  - `description: string` - What the tool does (shown to the AI model)
@@ -0,0 +1,171 @@
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
+ ## Methods
51
+
52
+ ```typescript
53
+ get(mode?: PromptGetType, input?: TInput, ctx?: Context): PromptGet<mode, TOutput, TTools>
54
+ run(input?: TInput, ctx?: Context): AsyncGenerator<PromptEvent<TOutput, TTools>, TOutput | undefined>
55
+ applicable(ctx?: Context): Promise<boolean>
56
+ metadata(input?, ctx?): TMetadata | Promise<TMetadata>
57
+ ```
58
+
59
+ ### `get()` modes
60
+
61
+ | Mode (default `'result'`) | Returns |
62
+ |------|---------|
63
+ | `'result'` | `Promise<TOutput \| undefined>` — final structured output |
64
+ | `'tools'` | `Promise<PromptToolOutput<TTools>[] \| undefined>` — `{ tool, result }[]` |
65
+ | `'stream'` | `AsyncGenerator<PromptEvent, TOutput \| undefined>` — all events |
66
+ | `'streamTools'` | `AsyncGenerator<PromptToolOutput, TOutput \| undefined>` |
67
+ | `'streamContent'` | `AsyncGenerator<string, TOutput \| undefined>` — text deltas only |
68
+
69
+ `run()` is equivalent to `get('stream', …)`.
70
+
71
+ ```typescript
72
+ // Structured result
73
+ const r = await prompt.get('result', { text: 'hi' }, { execute, messages: [] });
74
+
75
+ // Stream text deltas
76
+ for await (const chunk of prompt.get('streamContent', { text: 'hi' }, { stream, messages: [] })) {
77
+ process.stdout.write(chunk);
78
+ }
79
+
80
+ // Stream all events
81
+ for await (const e of prompt.get('stream', { text: 'hi' }, { stream, messages: [] })) {
82
+ if (e.type === 'textPartial') process.stdout.write(e.content);
83
+ else if (e.type === 'toolStart') console.log('tool:', e.tool.name);
84
+ else if (e.type === 'toolOutput') console.log('result:', e.result);
85
+ else if (e.type === 'complete') console.log('output:', e.output);
86
+ }
87
+ ```
88
+
89
+ ## `PromptEvent` union
90
+
91
+ Every event carries `request: Request`. Types:
92
+
93
+ - `request` (`+ iterations`), `textPartial`, `text`, `textComplete`, `textReset` (`+ reason?`)
94
+ - `refusal`, `reason` / `reasonPartial` (reasoning trace)
95
+ - `toolParseName`, `toolParseArguments` (`+ args`), `toolArgRepairAttempt` (`+ fields, success`)
96
+ - tool lifecycle: `toolStart`, `toolOutput` (`+ result` raw, `+ toModel` presented value), `toolInterrupt`, `toolSuspend`, `toolError` (`+ error, rawArgs?`) — each carries `tool` + `args`
97
+ - `message` (`+ message`), `suspend`, `complete` (`+ output`)
98
+ - `requestUsage`, `responseTokens`, `usage` (`+ usage`)
99
+
100
+ ## Prompt with tools
101
+
102
+ ```typescript
103
+ const advisor = new Prompt({
104
+ name: 'travelAdvisor',
105
+ description: 'Plans a trip',
106
+ content: 'Plan a trip to {{destination}}. Use getWeather to check conditions.',
107
+ input: (i: { destination: string }) => ({ destination: i.destination }),
108
+ tools: [getWeather], // see aeye-core-tools.md
109
+ toolExecution: 'parallel',
110
+ toolIterations: 3,
111
+ schema: z.object({
112
+ recommendations: z.array(z.string()),
113
+ packingList: z.array(z.string()),
114
+ }),
115
+ });
116
+
117
+ const advice = await advisor.get('result', { destination: 'Paris' }, { execute, messages: [] });
118
+ ```
119
+
120
+ ## Tool result transformer
121
+
122
+ `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.
123
+
124
+ ```typescript
125
+ const prompt = new Prompt({
126
+ name: 'searcher',
127
+ description: 'Searches and answers',
128
+ content: 'Answer using search.',
129
+ tools: [searchTool, mathTool], // searchTool → { hits, ids }, mathTool → number
130
+ onToolResult: (event) => {
131
+ if (event.tool === 'search') {
132
+ // event.result is the search result; event.args is { query }
133
+ return `Found ${event.result.hits} hits for "${event.args.query}"`;
134
+ }
135
+ // catch-all: here event is the `math` member (event.result: number)
136
+ return event.result; // pass-through
137
+ },
138
+ });
139
+ ```
140
+
141
+ - **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`.
142
+ - **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).
143
+ - **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.)
144
+ - **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`.
145
+ - `async` handlers are awaited.
146
+
147
+ Compile-time safety: referencing a non-existent tool name, or accessing a field not on the narrowed tool's `result`/`args`, fails to compile.
148
+
149
+ ## Token / context-window management
150
+
151
+ When the rendered messages plus reserved output tokens exceed the window, the prompt trims older messages and retries (`forgetRetries`). Supply `estimateUsage` for accuracy:
152
+
153
+ ```typescript
154
+ await prompt.get('result', { text }, {
155
+ execute, messages: history,
156
+ maxOutputTokens: 2048,
157
+ estimateUsage: (m) => ({ text: { input: Math.ceil(String(m.content).length / 4) } }),
158
+ });
159
+ ```
160
+
161
+ ## Suspend / resume (human-in-the-loop)
162
+
163
+ 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`.
164
+
165
+ ## Gotchas
166
+
167
+ - Default `get` mode is `'result'`; pass the mode explicitly when streaming.
168
+ - `schema` returning `false` disables structured output — output is the raw text string.
169
+ - `config`/`retool` returning `false` marks the prompt incompatible with the context.
170
+ - `toolsMax` is a soft cap unless `toolsOneAtATime` (request flag) is set — it stops *offering* tools after the threshold.
171
+ - Validation errors (Zod / JSON / `validate`) are truncated to `validationErrorMaxLength` before being shown to the model.
@@ -0,0 +1,117 @@
1
+ # @aeye/core — Schema & strict mode
2
+
3
+ `src/schema.ts` converts Zod schemas to provider-correct JSON Schema. Each LLM provider implements strict structured output / strict tool args with its *own* JSON-Schema dialect; a `FormatDescriptor` captures one dialect, so a single Zod schema can target all of them. Most apps never call these directly — `@aeye/ai` selects and applies a descriptor per request — but provider authors and advanced users do.
4
+
5
+ ## The `strict` policy (tri-state)
6
+
7
+ `Tool.strict` and `Prompt.strict` (and `ToolDefinition.strict` / `ResponseFormat.strict`) are `boolean | number`:
8
+
9
+ | Value | Meaning |
10
+ |------|---------|
11
+ | `true` | **Require** strict. `@aeye/ai` filters model selection to strict-capable models; fails if none qualify. |
12
+ | `false` | **Force** lenient. Standard JSON Schema, no `strict: true` on the wire. |
13
+ | `number > 0` (default `1`) | **Prefer** strict, accept fallback. Higher number = higher priority when more strict items are requested than the model's per-request budget allows. |
14
+ | omitted | Treated as `1`. |
15
+
16
+ Default `1` keeps things working against unannotated models. Use `true` only when strict is non-negotiable.
17
+
18
+ ## `FormatDescriptor`
19
+
20
+ A descriptor (see `src/schema.ts` line ~26 for the fully documented interface) describes how to emit JSON Schema for one dialect. Key fields:
21
+
22
+ | Field | Controls |
23
+ |------|----------|
24
+ | `id` | Unique id; pinned onto `request.responseFormat.descriptor` / `ToolDefinition.descriptor` so the validator can re-resolve it. |
25
+ | `family` | `'openai' \| 'anthropic' \| 'google' \| (string & {})`. |
26
+ | `objectAllFieldsRequired` | Whether every property must appear in `required[]`. |
27
+ | `objectClosedByDefault` | Whether objects emit `additionalProperties: false`. |
28
+ | `recordEncoding` | `'array-of-pairs'` vs `'open-record'`. |
29
+ | `tupleEncoding` | `'object-numeric-keys'` vs `'prefix-items'` vs `'items-union'`. |
30
+ | `optionalAsNullable` | Emit optionals as `T \| null` (OpenAI strict) vs drop from `required[]`. |
31
+ | `allowAllOf` / `allowAnyOf` / `allowOneOf` | Combinator support. |
32
+ | `supportedStringFormats` | `'all'` or a `Set<string>` whitelist. |
33
+ | `supportsRecursion` | Whether `$ref` self-reference works. |
34
+ | `maxStrictTools` / `maxStrictOptionalParams` / `maxStrictUnionTypes` | Per-request slot budgets (`undefined` = no documented limit). |
35
+
36
+ ## Built-in descriptors
37
+
38
+ Seven ship frozen and pre-registered:
39
+
40
+ | Export | Family / Strict | Notes |
41
+ |------|------|-------|
42
+ | `OPENAI_STRICT` | openai / strict | records→array-of-pairs, tuples→numeric-key objects, `optional → T \| null`, closed objects, restricted format whitelist. |
43
+ | `ANTHROPIC_STRICT` | anthropic / strict | closed objects, no recursion, no length/range constraints; budgets 20 tools / 24 optional params / 16 unions. |
44
+ | `GOOGLE_STRICT` | google / strict | `prefixItems`, `$ref: '#'` recursion, `propertyOrdering`, restricted format whitelist. |
45
+ | `LENIENT` | lenient / non-strict | No rewrites; everything passes through. Default for unannotated models. |
46
+ | `OPENAI_NON_STRICT`, `ANTHROPIC_NON_STRICT`, `GOOGLE_NON_STRICT` | family / non-strict | Aliased to `LENIENT` but tagged with the family for diagnostics. |
47
+
48
+ ## Functions
49
+
50
+ ```typescript
51
+ toJSONSchema(schema: z.ZodType,
52
+ options: ToJSONSchemaOptions | boolean | FormatDescriptor): JSONSchema
53
+ strictify<S extends z.ZodType>(schema: S, descriptor?: FormatDescriptor): S // default OPENAI_STRICT
54
+ analyzeSchema(schema): SchemaFeatures
55
+ registerDescriptor(descriptor: FormatDescriptor): void
56
+ getDescriptor(family: DescriptorFamily, strict: boolean): FormatDescriptor
57
+ getDescriptorById(id: string | undefined): FormatDescriptor
58
+ hasDescriptorFamily(family: string | undefined): boolean
59
+ resolveDescriptor(options: ToJSONSchemaOptions | boolean | FormatDescriptor): FormatDescriptor
60
+ strictestOf(a, b): FormatDescriptor
61
+ strictPriority(requested: boolean | number | undefined): number
62
+ ```
63
+
64
+ - `toJSONSchema` accepts a descriptor, a `ToJSONSchemaOptions` object, or a boolean (back-compat: `true` ⇒ `OPENAI_STRICT`, `false` ⇒ `LENIENT`).
65
+ - `strictify` rewrites a Zod schema in place per the descriptor (results cached in a WeakMap; same OOM-safe pattern as `analyzeSchema`).
66
+ - `analyzeSchema` returns `SchemaFeatures`: `{ hasRecursion, optionalParameterCount, unionTypeCount, recordCount, tupleCount }` — cached per schema.
67
+
68
+ ```typescript
69
+ import { OPENAI_STRICT, ANTHROPIC_STRICT, getDescriptor, toJSONSchema } from '@aeye/core';
70
+
71
+ const schema = z.object({
72
+ name: z.string(),
73
+ tags: z.record(z.string(), z.string()),
74
+ });
75
+
76
+ toJSONSchema(schema, OPENAI_STRICT); // tags → array of {key, value}
77
+ toJSONSchema(schema, ANTHROPIC_STRICT); // tags → array of {key, value}
78
+ toJSONSchema(schema, getDescriptor('google', true)); // tags → standard additionalProperties
79
+ toJSONSchema(schema, true); // === OPENAI_STRICT
80
+ toJSONSchema(schema, false); // === LENIENT
81
+ ```
82
+
83
+ ## `SchemaBudget`
84
+
85
+ `new SchemaBudget(descriptor)` allocates strict "slots" across a single request when a descriptor has per-request limits (e.g. Anthropic). Providers call:
86
+
87
+ ```typescript
88
+ budget.allocateTool(schema, requested): FormatDescriptor // returns strict or LENIENT, decrements
89
+ budget.allocateOutput(schema, requested): FormatDescriptor // output schema; consumes param/union budget, not a tool slot
90
+ ```
91
+
92
+ It returns `LENIENT` when `requested === false`, when the schema uses an unrepresentable feature, or when a positive-number request can't fit the remaining budget (priority order; `true` always wins subject to feasibility).
93
+
94
+ ## Registering a custom descriptor
95
+
96
+ ```typescript
97
+ import { registerDescriptor, type FormatDescriptor, OPENAI_STRICT } from '@aeye/core';
98
+
99
+ const MY_PROVIDER_STRICT: FormatDescriptor = {
100
+ ...OPENAI_STRICT, // start from a known-good baseline
101
+ id: 'my-provider-strict',
102
+ family: 'openai',
103
+ recordEncoding: 'open-record', // override only the deltas
104
+ tupleEncoding: 'prefix-items',
105
+ supportsRecursion: true,
106
+ };
107
+
108
+ registerDescriptor(MY_PROVIDER_STRICT);
109
+ ```
110
+
111
+ Once registered, `toJSONSchema(schema, MY_PROVIDER_STRICT)` / `strictify(...)` work, and the Prompt/Tool validation roundtrip resolves `'my-provider-strict'` by id from the request.
112
+
113
+ ## Gotchas
114
+
115
+ - `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.
116
+ - 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`.
117
+ - The `JSONSchema` / `JSONSchemaType` / `ToJSONSchemaOptions` / `DescriptorFamily` types are exported for tooling.
@@ -0,0 +1,177 @@
1
+ # @aeye/core — Tool & Agent
2
+
3
+ Both are `Component`s. A **Tool** is a single callable function exposed to the model (or invoked directly). An **Agent** is plain orchestration code that composes other components — no LLM loop of its own.
4
+
5
+ ## Tool
6
+
7
+ ```typescript
8
+ class Tool<TContext = {}, TMetadata = {}, TName extends string = string,
9
+ TParams extends object = {}, TOutput = string,
10
+ TRefs extends Tuple<ComponentCompatible<TContext, TMetadata>> = [],
11
+ TDecoded extends unknown = TParams>
12
+ implements Component<...>
13
+ ```
14
+
15
+ `TDecoded` is the decoded type a custom `parse` produces from the raw wire args — **any** value (a built class instance, a number, a `Date`, …), not just an object; it types `call`, `validate`, and `metadataFn`. It defaults to the wire `TParams` when no custom `parse` is supplied — `schema` always stays the wire type.
16
+
17
+ ### `ToolInput` (constructor config)
18
+
19
+ | Field | Type | Notes |
20
+ |------|------|-------|
21
+ | `name` | `TName` | Shown to the model; unique per request. |
22
+ | `description` | `string` | Shown to the model. |
23
+ | `descriptionFn?` | `Fn<string, [ctx]>` | Refine description from context. |
24
+ | `instructions?` | `string` | Handlebars usage instructions. |
25
+ | `instructionsFn?` | `Fn<string, [ctx]>` | Context-derived instructions. |
26
+ | `input?` | `Fn<Record<string,any>, [ctx]>` | Variables for the instructions template. |
27
+ | `schema` | `Fn<ZodType<TParams> \| undefined, [ctx]>` | **Required.** Param schema; may depend on ctx. `undefined` ⇒ unavailable. |
28
+ | `strict?` | `boolean \| number` | Strict-mode policy, default `1`. See [schema doc](./aeye-core-schema.md). |
29
+ | `refs?` | `TRefs` | Components passed positionally to `call`. |
30
+ | `call` | `(input: TParams, refs: TRefs, ctx) => TOutput` | **Required.** Implementation (sync or async). |
31
+ | `validate?` | `(input, ctx) => void \| Promise<void>` | Runs after Zod parse; throw to re-prompt. |
32
+ | `maxArgsLength?` | `number` | Reject raw args string over N chars before `JSON.parse` (guards against providers that corrupt large tool args). |
33
+ | `applicable?` | `(ctx) => boolean \| Promise<boolean>` | Availability check. |
34
+ | `metadata?` / `metadataFn?` | `TMetadata` / fn | Execution metadata. |
35
+
36
+ ### Methods
37
+
38
+ ```typescript
39
+ run(input?: TParams, ctx?: Context): TOutput // call directly
40
+ compile(ctx): Promise<readonly [string, ToolDefinition] | undefined> // build AI tool def
41
+ parse(ctx, args, schema?, descriptor?, onRepairAttempt?): Promise<...> // validate raw JSON args
42
+ applicable(ctx?): Promise<boolean>
43
+ metadata(input?, ctx?): TMetadata | Promise<TMetadata>
44
+ ```
45
+
46
+ `compile` returns `undefined` when the tool isn't applicable / has no schema for the context. `parse` validates the model's JSON `arguments` string against the (descriptor-strictified) schema and runs `validate`; it tolerates one provider misbehavior — top-level JSON-stringified fields are re-parsed and `onRepairAttempt` fires for telemetry. When a custom `parse` is supplied (it REPLACES Zod validation), core first wire-decodes the args to the conceptual shape with the request's `FormatDescriptor` (`decodeWire` — array-of-pairs→record, null→undefined, numeric-key→tuple, …), so a custom parser is provider-agnostic and never sees a model family's wire quirks.
47
+
48
+ ### Basic tool
49
+
50
+ ```typescript
51
+ import { Tool } from '@aeye/core';
52
+ import z from 'zod';
53
+
54
+ const getWeather = new Tool({
55
+ name: 'getWeather',
56
+ description: 'Get current weather for a location',
57
+ instructions: 'Use this to look up weather for {{location}}.',
58
+ schema: z.object({
59
+ location: z.string().describe('City name'),
60
+ units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
61
+ }),
62
+ call: async (input, _refs, _ctx) => {
63
+ const data = await fetchWeather(input.location, input.units);
64
+ return { temperature: data.temp, condition: data.condition };
65
+ },
66
+ });
67
+
68
+ // Direct invocation (no model needed):
69
+ const w = await getWeather.run({ location: 'Paris' }, {});
70
+ ```
71
+
72
+ ### Context-aware schema & applicability
73
+
74
+ ```typescript
75
+ const manageData = new Tool({
76
+ name: 'manageData',
77
+ description: 'Manage data records',
78
+ schema: (ctx: Context<MyCtx, {}>) =>
79
+ ctx.userRole === 'admin'
80
+ ? z.object({ action: z.enum(['read', 'write', 'delete']) })
81
+ : z.object({ action: z.enum(['read']) }),
82
+ applicable: (ctx) => ctx.isAuthenticated,
83
+ call: async (input, _refs, ctx) => ({ action: input.action, by: ctx.userRole }),
84
+ });
85
+ ```
86
+
87
+ ### Business-logic validation
88
+
89
+ ```typescript
90
+ const placeOrder = new Tool({
91
+ name: 'placeOrder',
92
+ description: 'Place a product order',
93
+ schema: z.object({ itemId: z.string(), quantity: z.number().int().min(1) }),
94
+ validate: async (input) => {
95
+ const inStock = await checkInventory(input.itemId);
96
+ if (inStock < input.quantity) throw new Error(`Only ${inStock} available.`);
97
+ },
98
+ call: async (input) => ({ orderId: crypto.randomUUID(), ...input }),
99
+ });
100
+ ```
101
+
102
+ ### Control-flow errors
103
+
104
+ - `ToolInterrupt(message?)` — stop the prompt loop and return control to the caller. With `toolsComplete` a synthetic `[interrupted]` result is paired so the next round-trip is valid.
105
+ - `PromptSuspend(message?)` — suspend without writing a tool result, for human-in-the-loop. See [prompts doc](./aeye-core-prompts.md#suspend--resume-human-in-the-loop). Never auto-paired.
106
+
107
+ ```typescript
108
+ import { PromptSuspend } from '@aeye/core';
109
+
110
+ call: async (input, _refs, ctx) => {
111
+ if (await needsApproval(input)) throw new PromptSuspend('awaiting approval');
112
+ return performAction(input);
113
+ }
114
+ ```
115
+
116
+ Also exported: `AnyTool`, `ToolCompatible<TContext, TMetadata>`.
117
+
118
+ ## Agent
119
+
120
+ ```typescript
121
+ class Agent<TContext = {}, TMetadata = {}, TName extends string = string,
122
+ TInput extends object = {}, TOutput = string,
123
+ TRefs extends Tuple<ComponentCompatible<TContext, TMetadata>> = []>
124
+ implements Component<...>
125
+ ```
126
+
127
+ An Agent is just typed orchestration — its `call` receives the resolved `refs` tuple positionally and wires components together however you like.
128
+
129
+ ### `AgentInput`
130
+
131
+ | Field | Type | Notes |
132
+ |------|------|-------|
133
+ | `name` | `TName` | Identifier. |
134
+ | `description` | `string` | Informational. |
135
+ | `refs` | `TRefs` | **Required.** Components this agent uses. |
136
+ | `call` | `(input: TInput, refs: TRefs, ctx) => TOutput` | **Required.** Orchestration logic. |
137
+ | `applicable?` | `(ctx) => boolean \| Promise<boolean>` | Defaults to "any ref is applicable" (or `true` when no refs). |
138
+ | `metadata?` / `metadataFn?` | `TMetadata` / fn | Execution metadata. |
139
+
140
+ ### Methods
141
+
142
+ ```typescript
143
+ run(input?: TInput, ctx?: Context): TOutput
144
+ applicable(ctx?): Promise<boolean>
145
+ metadata(input?, ctx?): TMetadata | Promise<TMetadata>
146
+ ```
147
+
148
+ ### Example
149
+
150
+ ```typescript
151
+ import { Agent } from '@aeye/core';
152
+
153
+ const researchAgent = new Agent({
154
+ name: 'researcher',
155
+ description: 'Researches a topic',
156
+ refs: [searchTool, summarizePrompt, analyzePrompt] as const,
157
+ call: async (input: { topic: string }, [search, summarize, analyze], ctx) => {
158
+ const results = await search.run({ query: input.topic, limit: 5 }, ctx);
159
+ const summaries: string[] = [];
160
+ for (const r of results.items) {
161
+ const s = await summarize.get('result', { text: r.content }, ctx);
162
+ summaries.push(s?.summary ?? '');
163
+ }
164
+ return await analyze.get('result', { topic: input.topic, sources: summaries }, ctx);
165
+ },
166
+ });
167
+
168
+ const out = await researchAgent.run({ topic: 'Quantum Computing' }, { execute, messages: [] });
169
+ ```
170
+
171
+ ## Gotchas
172
+
173
+ - Pass `refs` `as const` to keep the tuple's element types (so destructuring is correctly typed).
174
+ - An Agent does **no** AI on its own — any model calls come from the prompts/tools it invokes, which need `execute`/`stream` on the forwarded `ctx`.
175
+ - Tool `schema` returning `undefined` (or `applicable` false) excludes the tool from `compile()` output, so the model never sees it for that context.
176
+ - `call` can be sync or async; `TOutput` flows through `Resolved<>` where the framework awaits it (e.g. tool results in prompt events).
177
+ - A tool's return value is what the model sees by default (strings verbatim, otherwise `JSON.stringify`-d). To transform per-prompt what a tool result looks like to the model without changing the tool, use the prompt's [`onToolResult`](./aeye-core-prompts.md#tool-result-transformer) transformer (model-facing only; the raw result stays on `get('tools')`/`streamTools`).