@strav/brain 1.0.0-alpha.13 → 1.0.0-alpha.15

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/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@strav/brain",
3
- "version": "1.0.0-alpha.13",
3
+ "version": "1.0.0-alpha.15",
4
4
  "description": "Strav AI module — unified Provider interface, BrainManager, threads, prompt caching, tools / agents / MCP. Anthropic + OpenAI providers; Gemini / DeepSeek follow.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
- "./mcp": "./src/mcp/index.ts"
10
+ "./mcp": "./src/mcp/index.ts",
11
+ "./zod": "./src/zod/index.ts"
11
12
  },
12
13
  "files": [
13
14
  "src",
@@ -23,11 +24,19 @@
23
24
  "@anthropic-ai/sdk": "^0.100.0",
24
25
  "@google/genai": "^2.7.0",
25
26
  "@modelcontextprotocol/sdk": "^1.29.0",
26
- "@strav/kernel": "1.0.0-alpha.13",
27
+ "@strav/kernel": "1.0.0-alpha.15",
27
28
  "openai": "^6.0.0"
28
29
  },
29
30
  "peerDependencies": {
30
- "@types/bun": ">=1.3.14"
31
+ "@types/bun": ">=1.3.14",
32
+ "zod": "^4.0.0"
31
33
  },
32
- "devDependencies": null
34
+ "peerDependenciesMeta": {
35
+ "zod": {
36
+ "optional": true
37
+ }
38
+ },
39
+ "devDependencies": {
40
+ "zod": "^4.4.3"
41
+ }
33
42
  }
@@ -22,12 +22,14 @@
22
22
  import type { Agent } from './agent.ts'
23
23
  import type { AgentResult } from './agent_result.ts'
24
24
  import type { MCPServer } from './mcp_server.ts'
25
+ import type { OutputSchema } from './output_schema.ts'
25
26
  import { AgentRunner } from './agent_runner.ts'
26
27
  import { BrainError } from './brain_error.ts'
27
28
  import type { ModelTier } from './types.ts'
28
29
  import type {
29
30
  ChatOptions,
30
31
  ChatResult,
32
+ GenerateResult,
31
33
  Message,
32
34
  StreamEvent,
33
35
  } from './types.ts'
@@ -166,6 +168,32 @@ export class BrainManager {
166
168
  return provider.runWithTools(messages, tools, resolved)
167
169
  }
168
170
 
171
+ /**
172
+ * Structured output. Sends `input` to the configured (or
173
+ * `options.provider`-overridden) provider with the JSON-Schema
174
+ * constraint described by `schema`; returns the parsed object.
175
+ *
176
+ * Throws `BrainError` when the chosen provider doesn't implement
177
+ * `generate`. All three V1 providers (Anthropic, OpenAI, Gemini)
178
+ * do.
179
+ */
180
+ async generate<T>(
181
+ input: string | readonly Message[],
182
+ schema: OutputSchema<T>,
183
+ options: ChatOptions = {},
184
+ ): Promise<GenerateResult<T>> {
185
+ const provider = this.provider(options.provider)
186
+ if (!provider.generate) {
187
+ throw new BrainError(
188
+ `BrainManager.generate: provider "${provider.name}" does not implement generate.`,
189
+ { context: { provider: provider.name } },
190
+ )
191
+ }
192
+ const messages = normalizeInput(input)
193
+ const resolved = this.applyDefaults(options)
194
+ return provider.generate<T>(messages, schema, resolved)
195
+ }
196
+
169
197
  /**
170
198
  * Resolve an `Agent` subclass from the container and return an
171
199
  * `AgentRunner` ready to receive `input(...)` and `run()`. Apps
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export {
29
29
  export { BrainProvider } from './brain_provider.ts'
30
30
  export { defineTool, type DefineToolSpec } from './define_tool.ts'
31
31
  export type { MCPServer, MCPServerToolConfig } from './mcp_server.ts'
32
+ export type { OutputSchema } from './output_schema.ts'
32
33
  export { AnthropicProvider } from './providers/anthropic_provider.ts'
33
34
  export { GeminiProvider } from './providers/gemini_provider.ts'
34
35
  export { OpenAIProvider } from './providers/openai_provider.ts'
@@ -41,6 +42,7 @@ export type {
41
42
  ChatResult,
42
43
  ChatUsage,
43
44
  ContentBlock,
45
+ GenerateResult,
44
46
  MCPToolResultBlock,
45
47
  MCPToolUseBlock,
46
48
  Message,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `OutputSchema<T>` — declarative description of a structured-output
3
+ * target. Apps pass one of these to `BrainManager.generate(...)` and
4
+ * get back a `GenerateResult<T>` whose `value` is the parsed JSON
5
+ * the model produced.
6
+ *
7
+ * Schema shape:
8
+ * - `name` — short identifier; OpenAI requires it on
9
+ * `response_format.json_schema.name`. Other providers ignore it
10
+ * but apps should still set something stable and meaningful for
11
+ * logs / telemetry.
12
+ * - `description` — optional hint the model sees (some providers
13
+ * surface it next to the schema; others embed it in the prompt
14
+ * translation).
15
+ * - `jsonSchema` — plain JSON Schema (draft 2020-12 compatible).
16
+ * The framework deliberately doesn't depend on Zod; apps that
17
+ * want Zod use `zod-to-json-schema` (or similar) at the call
18
+ * site and feed the result here.
19
+ * - `parse` — optional runtime validator/parser. When set, the
20
+ * framework runs every model response through it before
21
+ * returning. Apps that just want type-level inference (and
22
+ * trust the model + provider validation) can omit it; the
23
+ * return type is then `T` by type assertion only.
24
+ *
25
+ * Why no built-in Zod dep:
26
+ * Same reason `Tool.inputSchema` is plain JSON Schema — Strav
27
+ * doesn't pin apps to a schema library. A thin `@strav/brain-zod`
28
+ * helper that produces `OutputSchema<T>` from a Zod schema is
29
+ * straightforward to ship later without touching this file.
30
+ */
31
+
32
+ export interface OutputSchema<T = unknown> {
33
+ /** Short identifier — provider-specific; some surface it, others ignore. */
34
+ name: string
35
+ /** Optional hint shown to the model alongside the schema. */
36
+ description?: string
37
+ /** JSON Schema describing the expected shape. */
38
+ jsonSchema: Record<string, unknown>
39
+ /** Optional runtime parser/validator. Apps that use Zod plug it in here. */
40
+ parse?(value: unknown): T
41
+ }
42
+
43
+ /**
44
+ * Shared helper used by every provider's `generate` implementation:
45
+ * parse the raw JSON response, run the optional `parse` hook, and
46
+ * wrap parse failures in `BrainError` with the raw text on `.context`
47
+ * so apps can inspect what came back when validation rejects.
48
+ */
49
+ import { BrainError } from './brain_error.ts'
50
+
51
+ export function parseGenerated<T>(text: string, schema: OutputSchema<T>): T {
52
+ let parsed: unknown
53
+ try {
54
+ parsed = JSON.parse(text)
55
+ } catch (cause) {
56
+ throw new BrainError(
57
+ `BrainProvider.generate: response was not valid JSON for schema "${schema.name}".`,
58
+ { context: { schema: schema.name, text }, cause },
59
+ )
60
+ }
61
+ if (schema.parse) {
62
+ try {
63
+ return schema.parse(parsed)
64
+ } catch (cause) {
65
+ throw new BrainError(
66
+ `BrainProvider.generate: response failed schema.parse for "${schema.name}".`,
67
+ { context: { schema: schema.name, text }, cause },
68
+ )
69
+ }
70
+ }
71
+ return parsed as T
72
+ }
package/src/provider.ts CHANGED
@@ -14,10 +14,12 @@
14
14
 
15
15
  import type { AgentResult } from './agent_result.ts'
16
16
  import type { MCPServer } from './mcp_server.ts'
17
+ import type { OutputSchema } from './output_schema.ts'
17
18
  import type { Tool } from './tool.ts'
18
19
  import type {
19
20
  ChatOptions,
20
21
  ChatResult,
22
+ GenerateResult,
21
23
  Message,
22
24
  StreamEvent,
23
25
  } from './types.ts'
@@ -80,4 +82,21 @@ export interface Provider {
80
82
  tools: readonly Tool[],
81
83
  options?: RunWithToolsOptions,
82
84
  ): Promise<AgentResult>
85
+
86
+ /**
87
+ * Structured output. Sends `messages` to the model with a
88
+ * JSON-Schema constraint and returns the parsed object. Apps that
89
+ * supplied `schema.parse` get a runtime-validated value; otherwise
90
+ * the value is `T` by type assertion (the provider does its own
91
+ * upstream schema enforcement, but the framework doesn't validate).
92
+ *
93
+ * Optional on the interface so providers that lack a structured-
94
+ * output endpoint can omit it; `BrainManager.generate` throws a
95
+ * `BrainError` when the configured provider doesn't expose this.
96
+ */
97
+ generate?<T>(
98
+ messages: readonly Message[],
99
+ schema: OutputSchema<T>,
100
+ options?: ChatOptions,
101
+ ): Promise<GenerateResult<T>>
83
102
  }
@@ -35,6 +35,7 @@ import type {
35
35
  ChatResult,
36
36
  ChatUsage,
37
37
  ContentBlock,
38
+ GenerateResult,
38
39
  MCPToolResultBlock,
39
40
  MCPToolUseBlock,
40
41
  Message,
@@ -44,6 +45,7 @@ import type {
44
45
  ToolResultBlock,
45
46
  ToolUseBlock,
46
47
  } from '../types.ts'
48
+ import { parseGenerated, type OutputSchema } from '../output_schema.ts'
47
49
 
48
50
  const EPHEMERAL_CACHE = { type: 'ephemeral' } as const
49
51
 
@@ -263,6 +265,29 @@ export class AnthropicProvider implements Provider {
263
265
  }
264
266
  }
265
267
 
268
+ async generate<T>(
269
+ messages: readonly Message[],
270
+ schema: OutputSchema<T>,
271
+ options: ChatOptions = {},
272
+ ): Promise<GenerateResult<T>> {
273
+ const params = this.buildParams(messages, options) as Anthropic.MessageCreateParamsNonStreaming
274
+ params.output_config = {
275
+ ...(params.output_config ?? {}),
276
+ format: { type: 'json_schema', schema: schema.jsonSchema },
277
+ }
278
+ const response = await this.client.messages.create(params)
279
+ const text = collectText(response.content)
280
+ const value = parseGenerated(text, schema)
281
+ return {
282
+ value,
283
+ text,
284
+ model: response.model,
285
+ stopReason: response.stop_reason,
286
+ usage: toUsage(response.usage),
287
+ raw: response,
288
+ }
289
+ }
290
+
266
291
  // ─── Param translation ──────────────────────────────────────────────────
267
292
 
268
293
  private buildParams(
@@ -61,6 +61,7 @@ import { BrainError } from '../brain_error.ts'
61
61
  import type { GeminiProviderConfig } from '../brain_config.ts'
62
62
  import type { MCPServer } from '../mcp_server.ts'
63
63
  import { resolveMcpTools, type ResolveMcpToolsOptions } from '../mcp/resolve_mcp_tools.ts'
64
+ import { parseGenerated, type OutputSchema } from '../output_schema.ts'
64
65
  import type { Provider, RunWithToolsOptions } from '../provider.ts'
65
66
  import type { Tool } from '../tool.ts'
66
67
  import { ToolExecutionError } from '../tool_execution_error.ts'
@@ -69,6 +70,7 @@ import type {
69
70
  ChatResult,
70
71
  ChatUsage,
71
72
  ContentBlock,
73
+ GenerateResult,
72
74
  Message,
73
75
  StreamEvent,
74
76
  SystemPrompt,
@@ -272,6 +274,31 @@ export class GeminiProvider implements Provider {
272
274
  }
273
275
  }
274
276
 
277
+ async generate<T>(
278
+ messages: readonly Message[],
279
+ schema: OutputSchema<T>,
280
+ options: ChatOptions = {},
281
+ ): Promise<GenerateResult<T>> {
282
+ const params = this.buildParams(messages, options, [])
283
+ params.config = {
284
+ ...(params.config ?? {}),
285
+ responseMimeType: 'application/json',
286
+ responseJsonSchema: schema.jsonSchema,
287
+ }
288
+ const response = await this.models.generateContent(params)
289
+ const candidate = response.candidates?.[0]
290
+ const text = candidateText(candidate)
291
+ const value = parseGenerated(text, schema)
292
+ return {
293
+ value,
294
+ text,
295
+ model: response.modelVersion ?? params.model,
296
+ stopReason: candidate?.finishReason ? String(candidate.finishReason) : null,
297
+ usage: toUsage(response.usageMetadata),
298
+ raw: response,
299
+ }
300
+ }
301
+
275
302
  // ─── Param translation ──────────────────────────────────────────────────
276
303
 
277
304
  private buildParams(
@@ -53,6 +53,7 @@ import { BrainError } from '../brain_error.ts'
53
53
  import type { OpenAIProviderConfig } from '../brain_config.ts'
54
54
  import type { MCPServer } from '../mcp_server.ts'
55
55
  import { resolveMcpTools, type ResolveMcpToolsOptions } from '../mcp/resolve_mcp_tools.ts'
56
+ import { parseGenerated, type OutputSchema } from '../output_schema.ts'
56
57
  import type { Provider, RunWithToolsOptions } from '../provider.ts'
57
58
  import type { Tool } from '../tool.ts'
58
59
  import { ToolExecutionError } from '../tool_execution_error.ts'
@@ -61,6 +62,7 @@ import type {
61
62
  ChatResult,
62
63
  ChatUsage,
63
64
  ContentBlock,
65
+ GenerateResult,
64
66
  Message,
65
67
  StreamEvent,
66
68
  SystemPrompt,
@@ -257,6 +259,35 @@ export class OpenAIProvider implements Provider {
257
259
  }
258
260
  }
259
261
 
262
+ async generate<T>(
263
+ messages: readonly Message[],
264
+ schema: OutputSchema<T>,
265
+ options: ChatOptions = {},
266
+ ): Promise<GenerateResult<T>> {
267
+ const params = this.buildParams(messages, options, [])
268
+ params.response_format = {
269
+ type: 'json_schema',
270
+ json_schema: {
271
+ name: schema.name,
272
+ ...(schema.description !== undefined ? { description: schema.description } : {}),
273
+ schema: schema.jsonSchema,
274
+ strict: true,
275
+ },
276
+ }
277
+ const response = await this.client.chat.completions.create(params)
278
+ const choice = response.choices[0]
279
+ const text = choice?.message?.content ?? ''
280
+ const value = parseGenerated(text, schema)
281
+ return {
282
+ value,
283
+ text,
284
+ model: response.model,
285
+ stopReason: choice?.finish_reason ?? null,
286
+ usage: toUsage(response.usage),
287
+ raw: response,
288
+ }
289
+ }
290
+
260
291
  // ─── Param translation ──────────────────────────────────────────────────
261
292
 
262
293
  private buildParams(
package/src/types.ts CHANGED
@@ -200,3 +200,18 @@ export interface ChatResult<Raw = unknown> {
200
200
  export type StreamEvent =
201
201
  | { type: 'text'; delta: string }
202
202
  | { type: 'stop'; stopReason: string | null; usage: ChatUsage }
203
+
204
+ /**
205
+ * Result of a structured-output call. `value` is the parsed JSON
206
+ * shaped to the `OutputSchema<T>` passed in. `text` is the raw JSON
207
+ * string the model produced (useful for logging / debugging when
208
+ * `parse` rejects). `raw` is the provider's full native response.
209
+ */
210
+ export interface GenerateResult<T = unknown, Raw = unknown> {
211
+ value: T
212
+ text: string
213
+ model: string
214
+ stopReason: string | null
215
+ usage: ChatUsage
216
+ raw: Raw
217
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `@strav/brain/zod` — Zod-flavored helpers on top of the
3
+ * schema-library-agnostic core.
4
+ *
5
+ * The default `@strav/brain` import deliberately doesn't depend on
6
+ * Zod — `Tool.inputSchema` and `OutputSchema.jsonSchema` are plain
7
+ * JSON Schema so apps stay free to pick Ajv, Valibot, ArkType, or
8
+ * nothing at all. This sub-path opt-in adds two thin wrappers for
9
+ * apps that already use Zod:
10
+ *
11
+ * - `outputSchema(z, opts?)` turns a Zod schema into an
12
+ * `OutputSchema<z.infer<typeof z>>` — `jsonSchema` is derived
13
+ * via Zod's built-in `z.toJSONSchema`, and `parse` is wired to
14
+ * `z.parse`. Apps then pass the result straight to
15
+ * `BrainManager.generate(input, schema)`.
16
+ *
17
+ * - `tool({ name, description, input, execute })` turns a Zod
18
+ * schema for the tool's input into a framework `Tool` — the
19
+ * wrapper validates the model's raw input through the Zod
20
+ * schema before calling the app's `execute`. Apps get inferred
21
+ * types on `execute(input)` for free.
22
+ *
23
+ * `zod` is an optional peer dependency. Apps that don't use Zod
24
+ * don't install it, don't bundle it, and never import this
25
+ * sub-path — they keep using `defineTool` / hand-written
26
+ * `OutputSchema` literals with raw JSON Schema.
27
+ */
28
+
29
+ import { z } from 'zod'
30
+ import type { OutputSchema } from '../output_schema.ts'
31
+ import type { Tool, ToolContext } from '../tool.ts'
32
+
33
+ /**
34
+ * Options for `outputSchema`. `name` defaults to `'output'` —
35
+ * apps that surface multiple schemas in logs or to OpenAI's wire
36
+ * format should pass a stable, descriptive identifier.
37
+ */
38
+ export interface OutputSchemaOptions {
39
+ /** Identifier — defaults to `'output'`. */
40
+ name?: string
41
+ /** Optional model-facing hint. Defaults to the Zod schema's `.describe(…)` if set. */
42
+ description?: string
43
+ }
44
+
45
+ /**
46
+ * Build an `OutputSchema<T>` from a Zod schema. The returned shape
47
+ * is ready to pass to `BrainManager.generate(...)`.
48
+ *
49
+ * ```ts
50
+ * const CityZ = z.object({ city: z.string(), population: z.number().int() })
51
+ * const { value } = await brain.generate('Capital of France?', outputSchema(CityZ, { name: 'city_answer' }))
52
+ * // ^? { city: string; population: number }
53
+ * ```
54
+ */
55
+ export function outputSchema<T>(
56
+ schema: z.ZodType<T>,
57
+ options: OutputSchemaOptions = {},
58
+ ): OutputSchema<T> {
59
+ const description = options.description ?? zodDescription(schema)
60
+ const result: OutputSchema<T> = {
61
+ name: options.name ?? 'output',
62
+ jsonSchema: z.toJSONSchema(schema) as Record<string, unknown>,
63
+ parse: (value) => schema.parse(value),
64
+ }
65
+ if (description !== undefined) result.description = description
66
+ return result
67
+ }
68
+
69
+ /**
70
+ * Spec passed to `tool(...)`. `execute` receives the model's input
71
+ * already validated + typed against `input` — no need to call
72
+ * `input.parse` manually.
73
+ */
74
+ export interface ZodToolSpec<TInput, TOutput> {
75
+ name: string
76
+ description: string
77
+ input: z.ZodType<TInput>
78
+ execute(input: TInput, ctx: ToolContext): Promise<TOutput>
79
+ }
80
+
81
+ /**
82
+ * Build a framework `Tool` from a Zod-typed spec. The wrapper
83
+ * derives `inputSchema` via `z.toJSONSchema` and validates the
84
+ * model's raw input through `input.parse` before delegating to
85
+ * `execute`. Validation failures propagate as `ZodError`; the
86
+ * agentic loop wraps that into a `ToolExecutionError`.
87
+ *
88
+ * ```ts
89
+ * const search = tool({
90
+ * name: 'search_orders',
91
+ * description: 'Look up an order by id.',
92
+ * input: z.object({ orderId: z.string() }),
93
+ * async execute({ orderId }, ctx) {
94
+ * // ^? { orderId: string }
95
+ * return await orders.find(orderId, ctx.context)
96
+ * },
97
+ * })
98
+ * ```
99
+ */
100
+ export function tool<TInput, TOutput>(
101
+ spec: ZodToolSpec<TInput, TOutput>,
102
+ ): Tool<TInput, TOutput> {
103
+ const jsonSchema = z.toJSONSchema(spec.input) as Record<string, unknown>
104
+ return {
105
+ name: spec.name,
106
+ description: spec.description,
107
+ inputSchema: jsonSchema,
108
+ async execute(raw: TInput, ctx: ToolContext): Promise<TOutput> {
109
+ const parsed = spec.input.parse(raw)
110
+ return spec.execute(parsed, ctx)
111
+ },
112
+ }
113
+ }
114
+
115
+ function zodDescription(schema: z.ZodType<unknown>): string | undefined {
116
+ // Zod stores `.describe(…)` on the schema's `_def`; surface it
117
+ // as the model-facing hint when callers don't pass one
118
+ // explicitly.
119
+ const def = (schema as unknown as { description?: string }).description
120
+ return typeof def === 'string' && def.length > 0 ? def : undefined
121
+ }