@strav/brain 1.0.0-alpha.8 → 1.0.0-alpha.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/brain",
3
- "version": "1.0.0-alpha.8",
3
+ "version": "1.0.0-alpha.9",
4
4
  "description": "Strav AI module — unified Provider interface, BrainManager, threads, prompt caching. Anthropic provider in V1; OpenAI / Gemini / DeepSeek follow.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -19,7 +19,7 @@
19
19
  "access": "public"
20
20
  },
21
21
  "dependencies": {
22
- "@strav/kernel": "1.0.0-alpha.8",
22
+ "@strav/kernel": "1.0.0-alpha.9",
23
23
  "@anthropic-ai/sdk": "^0.100.0"
24
24
  },
25
25
  "peerDependencies": {
package/src/agent.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `Agent` — declarative base class for AI agents.
3
+ *
4
+ * Apps subclass and set the static-ish properties: which model to
5
+ * use, what the agent's persona is, which tools it has access to,
6
+ * and an optional iteration ceiling. The `BrainManager.agent(Class)`
7
+ * call resolves an instance via the container, builds an
8
+ * `AgentRunner`, and lets the app stream input + context into it.
9
+ *
10
+ * ```ts
11
+ * @inject()
12
+ * class ResearchAgent extends Agent {
13
+ * override readonly instructions = 'You are a meticulous research assistant.'
14
+ * override readonly tools = [searchTool, summarizeTool]
15
+ * override readonly tier: ModelTier = 'powerful'
16
+ * }
17
+ *
18
+ * const result = await brain.agent(ResearchAgent)
19
+ * .input('What is the current state of bun.sql?')
20
+ * .context({ userId: '01ABC...' })
21
+ * .run()
22
+ * ```
23
+ *
24
+ * V1 makes the configuration declarative-only — apps that need
25
+ * runtime knobs (per-request model overrides, dynamic tool sets)
26
+ * use `BrainManager.runTools(...)` directly. Adding per-instance
27
+ * overrides on the Agent class is a future ergonomic slice.
28
+ */
29
+
30
+ import type { ModelTier } from './types.ts'
31
+ import type { Tool } from './tool.ts'
32
+
33
+ export abstract class Agent {
34
+ /** System prompt — the persona / instructions Claude sees on every turn. */
35
+ abstract readonly instructions: string
36
+
37
+ /** Tools the agent can call. Empty array → the model answers without tools. */
38
+ readonly tools: readonly Tool[] = []
39
+
40
+ /** Override the configured default provider. Default = brain's default provider. */
41
+ readonly provider?: string
42
+
43
+ /** Explicit model ID. Wins over `tier`. */
44
+ readonly model?: string
45
+
46
+ /** Tier sugar. Default `'powerful'` for agentic work. */
47
+ readonly tier: ModelTier = 'powerful'
48
+
49
+ /**
50
+ * Safety ceiling on the agentic loop. Default `10`. Hitting it
51
+ * returns a result with `stopReason: 'max_iterations'`; the loop
52
+ * doesn't throw because partial progress (assistant messages, tool
53
+ * results) is usually still useful to surface.
54
+ */
55
+ readonly maxIterations: number = 10
56
+
57
+ /** Hard cap on per-call response tokens. Default `4096`. */
58
+ readonly maxTokens: number = 4096
59
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `AgentResult` — what an agentic loop returns when it ends. Combines
3
+ * the final assistant `text`, the full message history (including
4
+ * tool calls + results so apps can render the trace), the total
5
+ * iteration count (how many tool-use round-trips the loop made),
6
+ * and aggregated token usage across every model call inside the
7
+ * loop.
8
+ *
9
+ * `stopReason` is the provider's terminal stop reason (typically
10
+ * `'end_turn'`). When the loop exits because it hit `maxIterations`,
11
+ * `stopReason` is `'max_iterations'` — distinct from the provider
12
+ * value so apps can detect "the model would have kept going."
13
+ */
14
+
15
+ import type { ChatUsage, Message } from './types.ts'
16
+
17
+ export interface AgentResult {
18
+ /** Concatenated text from the final assistant turn. */
19
+ text: string
20
+ /** Full message history of the loop, including tool_use / tool_result blocks. */
21
+ messages: Message[]
22
+ /** Number of tool-use rounds. `0` when the model answered without tools. */
23
+ iterations: number
24
+ /**
25
+ * Terminal stop reason. Either the provider's stop_reason (typically
26
+ * `'end_turn'`) or the framework-specific `'max_iterations'` when
27
+ * the loop hit its iteration ceiling.
28
+ */
29
+ stopReason: string
30
+ /** Token usage summed across every model call in the loop. */
31
+ usage: ChatUsage
32
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `AgentRunner` — fluent builder returned by `BrainManager.agent(Class)`.
3
+ *
4
+ * Carries the agent instance + an input message + an optional
5
+ * per-run context bag. `run()` translates the agent's declarative
6
+ * configuration into a `runWithTools` call and returns the
7
+ * `AgentResult`.
8
+ *
9
+ * Designed to chain: `brain.agent(R).input(text).context({...}).run()`.
10
+ * Apps that need the full Message-array surface bypass the runner
11
+ * and call `BrainManager.runTools(messages, tools, options)` directly.
12
+ */
13
+
14
+ import type { Agent } from './agent.ts'
15
+ import type { AgentResult } from './agent_result.ts'
16
+ import type { BrainManager } from './brain_manager.ts'
17
+ import type { ChatOptions, Message } from './types.ts'
18
+
19
+ export class AgentRunner {
20
+ private prompt: string | undefined
21
+ private contextBag: Record<string, unknown> = {}
22
+
23
+ constructor(
24
+ private readonly brain: BrainManager,
25
+ private readonly agent: Agent,
26
+ ) {}
27
+
28
+ /** Set the user input. Required before `run()`. */
29
+ input(text: string): this {
30
+ this.prompt = text
31
+ return this
32
+ }
33
+
34
+ /**
35
+ * Attach context that every tool's `execute(input, ctx)` will see
36
+ * on `ctx.context`. Useful for per-request data the agent's tools
37
+ * need but the model shouldn't see directly (auth identity,
38
+ * tenant id, request-id for tracing).
39
+ */
40
+ context(data: Record<string, unknown>): this {
41
+ this.contextBag = { ...this.contextBag, ...data }
42
+ return this
43
+ }
44
+
45
+ async run(): Promise<AgentResult> {
46
+ if (this.prompt === undefined) {
47
+ throw new Error('AgentRunner.run: input() must be called before run().')
48
+ }
49
+ const messages: Message[] = [{ role: 'user', content: this.prompt }]
50
+ const options: ChatOptions & { maxIterations?: number; context?: Record<string, unknown> } = {
51
+ tier: this.agent.tier,
52
+ maxTokens: this.agent.maxTokens,
53
+ system: this.agent.instructions,
54
+ maxIterations: this.agent.maxIterations,
55
+ context: this.contextBag,
56
+ }
57
+ if (this.agent.model !== undefined) options.model = this.agent.model
58
+ if (this.agent.provider !== undefined) options.provider = this.agent.provider
59
+ return this.brain.runTools(messages, this.agent.tools, options)
60
+ }
61
+ }
@@ -19,6 +19,9 @@
19
19
  * ```
20
20
  */
21
21
 
22
+ import type { Agent } from './agent.ts'
23
+ import type { AgentResult } from './agent_result.ts'
24
+ import { AgentRunner } from './agent_runner.ts'
22
25
  import { BrainError } from './brain_error.ts'
23
26
  import type { ModelTier } from './types.ts'
24
27
  import type {
@@ -27,9 +30,13 @@ import type {
27
30
  Message,
28
31
  StreamEvent,
29
32
  } from './types.ts'
30
- import type { Provider } from './provider.ts'
33
+ import type { Provider, RunWithToolsOptions } from './provider.ts'
34
+ import type { Tool } from './tool.ts'
31
35
  import { DEFAULT_TIERS } from './brain_config.ts'
32
36
 
37
+ /** Container-aware Agent constructor resolver — `BrainProvider` installs one wired to `app.resolve(...)`. */
38
+ export type AgentResolver = <A extends Agent>(cls: new (...args: never[]) => A) => A
39
+
33
40
  export interface BrainManagerOptions {
34
41
  /** Name of the default provider — must exist in `providers`. */
35
42
  default: string
@@ -117,8 +124,67 @@ export class BrainManager {
117
124
  return provider.countTokens(messages, resolved)
118
125
  }
119
126
 
127
+ /**
128
+ * Run an agentic loop: send `messages` + `tools` to the model;
129
+ * execute any tool the model calls; loop until the model returns
130
+ * a terminal `stop_reason` (`'end_turn'`) or `maxIterations` is hit.
131
+ *
132
+ * Throws `BrainError` when the configured provider doesn't
133
+ * implement `runWithTools` (V1: OpenAI / Gemini / DeepSeek providers
134
+ * don't yet — only `AnthropicProvider`).
135
+ */
136
+ async runTools(
137
+ input: string | readonly Message[],
138
+ tools: readonly Tool[],
139
+ options: RunWithToolsOptions = {},
140
+ ): Promise<AgentResult> {
141
+ const provider = this.provider(options.provider)
142
+ if (!provider.runWithTools) {
143
+ throw new BrainError(
144
+ `BrainManager.runTools: provider "${provider.name}" does not implement runWithTools. Use a provider that supports tool use (V1: Anthropic).`,
145
+ { context: { provider: provider.name } },
146
+ )
147
+ }
148
+ const messages = normalizeInput(input)
149
+ const resolved = this.applyDefaults(options) as RunWithToolsOptions
150
+ return provider.runWithTools(messages, tools, resolved)
151
+ }
152
+
153
+ /**
154
+ * Resolve an `Agent` subclass from the container and return an
155
+ * `AgentRunner` ready to receive `input(...)` and `run()`. Apps
156
+ * `@inject()`-decorate their Agent subclass so constructor
157
+ * injection of dependencies (Repositories, services, etc.) flows
158
+ * through normally.
159
+ */
160
+ agent<A extends Agent>(AgentClass: new (...args: never[]) => A, instance?: A): AgentRunner {
161
+ const agent = instance ?? this.resolveAgent(AgentClass)
162
+ return new AgentRunner(this, agent)
163
+ }
164
+
120
165
  // ─── Internal ────────────────────────────────────────────────────────────
121
166
 
167
+ private resolveAgent<A extends Agent>(AgentClass: new (...args: never[]) => A): A {
168
+ if (this.agentResolver) return this.agentResolver(AgentClass)
169
+ // Fallback: assume the Agent class is constructible without args.
170
+ // Apps that need DI on the agent register a resolver via
171
+ // `setAgentResolver` (BrainProvider wires this to the container).
172
+ return new (AgentClass as unknown as new () => A)()
173
+ }
174
+
175
+ /**
176
+ * Internal — `BrainProvider` calls this at boot to plug in the
177
+ * container's resolution function so `brain.agent(MyAgent)` runs
178
+ * `app.resolve(MyAgent)` under the hood. Apps that build a
179
+ * `BrainManager` by hand for tests can leave this unset and pass
180
+ * a pre-constructed agent to `brain.agent(_, instance)`.
181
+ */
182
+ setAgentResolver(resolver: AgentResolver): void {
183
+ this.agentResolver = resolver
184
+ }
185
+
186
+ private agentResolver: AgentResolver | undefined
187
+
122
188
  private applyDefaults(options: ChatOptions): ChatOptions {
123
189
  const resolved: ChatOptions = { ...options }
124
190
  if (resolved.model === undefined && resolved.tier !== undefined) {
@@ -64,7 +64,16 @@ export class BrainProvider extends ServiceProvider {
64
64
  }
65
65
  if (config.tiers !== undefined) options.tiers = config.tiers
66
66
  if (config.cache?.auto !== undefined) options.defaultCache = config.cache.auto
67
- return new BrainManager(options)
67
+ const manager = new BrainManager(options)
68
+ // Plug in the container so `brain.agent(MyAgent)` resolves
69
+ // its constructor deps through `@inject()` like every other
70
+ // injected class. The variance widening at the boundary
71
+ // (`never[]` ↔ `any[]`) is purely a TS typing artifact — the
72
+ // container call is identical to a direct `c.resolve(MyAgent)`.
73
+ manager.setAgentResolver(<A>(cls: new (...args: never[]) => A) =>
74
+ c.resolve(cls as unknown as new (...args: unknown[]) => A),
75
+ )
76
+ return manager
68
77
  })
69
78
  }
70
79
 
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `defineTool({ name, description, inputSchema, execute })` — typed
3
+ * factory mirroring `defineWorkflow` / `defineMachine` / `defineDurable`.
4
+ *
5
+ * ```ts
6
+ * const getWeather = defineTool({
7
+ * name: 'get_weather',
8
+ * description: 'Get current weather for a location.',
9
+ * inputSchema: {
10
+ * type: 'object',
11
+ * properties: { city: { type: 'string' } },
12
+ * required: ['city'],
13
+ * },
14
+ * execute: async ({ city }: { city: string }, ctx) => {
15
+ * return weatherService.lookup(city, ctx.context.userId as string)
16
+ * },
17
+ * })
18
+ * ```
19
+ *
20
+ * The generic parameters are usually inferred from `execute`'s first
21
+ * arg + return type; apps that want explicit typing pass them.
22
+ */
23
+
24
+ import type { Tool, ToolContext } from './tool.ts'
25
+
26
+ export interface DefineToolSpec<TInput, TOutput> {
27
+ name: string
28
+ description: string
29
+ inputSchema: Record<string, unknown>
30
+ execute(input: TInput, ctx: ToolContext): Promise<TOutput>
31
+ }
32
+
33
+ export function defineTool<TInput = unknown, TOutput = unknown>(
34
+ spec: DefineToolSpec<TInput, TOutput>,
35
+ ): Tool<TInput, TOutput> {
36
+ return {
37
+ name: spec.name,
38
+ description: spec.description,
39
+ inputSchema: spec.inputSchema,
40
+ execute: spec.execute,
41
+ }
42
+ }
package/src/index.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  // Public API of @strav/brain.
2
2
  //
3
- // Foundation slice: Provider interface + AnthropicProvider, BrainManager,
4
- // Thread, BrainProvider service-wiring, prompt caching. Tools / agents /
5
- // MCP / embeddings / other providers (OpenAI/Google/DeepSeek) land in
6
- // follow-up slices.
3
+ // V1: Provider interface + AnthropicProvider, BrainManager, Thread,
4
+ // BrainProvider service-wiring, prompt caching.
5
+ // V2 (this slice): tools + agents defineTool, Agent base + AgentRunner,
6
+ // BrainManager.runTools / .agent(Class), Provider.runWithTools.
7
+ // Still deferred: MCP, embeddings, streaming agent loops, server-side
8
+ // tools, structured outputs, other providers.
7
9
 
10
+ export { Agent } from './agent.ts'
11
+ export type { AgentResult } from './agent_result.ts'
12
+ export { AgentRunner } from './agent_runner.ts'
8
13
  export {
9
14
  type AnthropicProviderConfig,
10
15
  type BrainCacheConfig,
@@ -14,11 +19,18 @@ export {
14
19
  type ProviderConfig,
15
20
  } from './brain_config.ts'
16
21
  export { BrainError } from './brain_error.ts'
17
- export { BrainManager, type BrainManagerOptions } from './brain_manager.ts'
22
+ export {
23
+ type AgentResolver,
24
+ BrainManager,
25
+ type BrainManagerOptions,
26
+ } from './brain_manager.ts'
18
27
  export { BrainProvider } from './brain_provider.ts'
28
+ export { defineTool, type DefineToolSpec } from './define_tool.ts'
19
29
  export { AnthropicProvider } from './providers/anthropic_provider.ts'
20
- export type { Provider } from './provider.ts'
30
+ export type { Provider, RunWithToolsOptions } from './provider.ts'
21
31
  export { Thread, type ThreadOptions, type ThreadState } from './thread.ts'
32
+ export type { Tool, ToolContext } from './tool.ts'
33
+ export { ToolExecutionError } from './tool_execution_error.ts'
22
34
  export type {
23
35
  ChatOptions,
24
36
  ChatResult,
@@ -29,4 +41,6 @@ export type {
29
41
  StreamEvent,
30
42
  SystemPrompt,
31
43
  TextBlock,
44
+ ToolResultBlock,
45
+ ToolUseBlock,
32
46
  } from './types.ts'
package/src/provider.ts CHANGED
@@ -12,6 +12,8 @@
12
12
  * subclassing.
13
13
  */
14
14
 
15
+ import type { AgentResult } from './agent_result.ts'
16
+ import type { Tool } from './tool.ts'
15
17
  import type {
16
18
  ChatOptions,
17
19
  ChatResult,
@@ -19,6 +21,13 @@ import type {
19
21
  StreamEvent,
20
22
  } from './types.ts'
21
23
 
24
+ export interface RunWithToolsOptions extends ChatOptions {
25
+ /** Safety ceiling on tool-use round-trips. Default `10`. */
26
+ maxIterations?: number
27
+ /** Free-form context bag passed to every tool's `execute(input, ctx)`. */
28
+ context?: Record<string, unknown>
29
+ }
30
+
22
31
  export interface Provider {
23
32
  /** Identifier — matches the `config.brain.providers` key. */
24
33
  readonly name: string
@@ -45,4 +54,21 @@ export interface Provider {
45
54
  * implementation may approximate.
46
55
  */
47
56
  countTokens?(messages: readonly Message[], options?: ChatOptions): Promise<number>
57
+
58
+ /**
59
+ * Agentic loop. Sends the `messages` + `tools` to the model;
60
+ * detects tool-use blocks in the response; runs the matching
61
+ * tool's `execute`; appends the result and re-asks. Loops until
62
+ * the model returns `stop_reason: 'end_turn'` (or its
63
+ * provider-specific equivalent) or `maxIterations` is hit.
64
+ *
65
+ * Optional on the interface so providers that don't (yet) support
66
+ * tool use can omit it; `BrainManager.runTools` throws a
67
+ * `BrainError` when the configured provider lacks the method.
68
+ */
69
+ runWithTools?(
70
+ messages: readonly Message[],
71
+ tools: readonly Tool[],
72
+ options?: RunWithToolsOptions,
73
+ ): Promise<AgentResult>
48
74
  }
@@ -24,16 +24,23 @@
24
24
  */
25
25
 
26
26
  import Anthropic from '@anthropic-ai/sdk'
27
+ import type { AgentResult } from '../agent_result.ts'
27
28
  import type { AnthropicProviderConfig } from '../brain_config.ts'
28
29
  import { DEFAULT_MODEL } from '../brain_config.ts'
29
- import type { Provider } from '../provider.ts'
30
+ import type { Provider, RunWithToolsOptions } from '../provider.ts'
31
+ import type { Tool } from '../tool.ts'
32
+ import { ToolExecutionError } from '../tool_execution_error.ts'
30
33
  import type {
31
34
  ChatOptions,
32
35
  ChatResult,
33
36
  ChatUsage,
37
+ ContentBlock,
34
38
  Message,
35
39
  StreamEvent,
36
40
  SystemPrompt,
41
+ TextBlock,
42
+ ToolResultBlock,
43
+ ToolUseBlock,
37
44
  } from '../types.ts'
38
45
 
39
46
  const EPHEMERAL_CACHE = { type: 'ephemeral' } as const
@@ -109,6 +116,110 @@ export class AnthropicProvider implements Provider {
109
116
  return result.input_tokens
110
117
  }
111
118
 
119
+ /**
120
+ * Agentic loop. Send → detect tool_use blocks → execute → append
121
+ * tool_result → re-send, until the model returns `end_turn` or
122
+ * the iteration ceiling is hit.
123
+ *
124
+ * Tools are passed once on every call — Anthropic doesn't carry
125
+ * tool state across requests; the model rediscovers them from the
126
+ * `tools` array each turn. Apps that care about cache hits keep
127
+ * the tool list stable across runs.
128
+ */
129
+ async runWithTools(
130
+ messages: readonly Message[],
131
+ tools: readonly Tool[],
132
+ options: RunWithToolsOptions = {},
133
+ ): Promise<AgentResult> {
134
+ const maxIterations = options.maxIterations ?? 10
135
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
136
+ const workingMessages: Message[] = [...messages]
137
+ const aggregated: ChatUsage = {
138
+ inputTokens: 0,
139
+ outputTokens: 0,
140
+ cacheReadTokens: 0,
141
+ cacheCreationTokens: 0,
142
+ }
143
+ let iterations = 0
144
+ let lastStopReason: string | null = null
145
+
146
+ while (true) {
147
+ const params = this.buildParams(workingMessages, options)
148
+ params.tools = tools.map((t) => ({
149
+ name: t.name,
150
+ description: t.description,
151
+ input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
152
+ }))
153
+
154
+ const response = await this.client.messages.create(params)
155
+ addUsage(aggregated, response.usage)
156
+ lastStopReason = response.stop_reason ?? null
157
+
158
+ // Append the assistant turn verbatim from the SDK shape so
159
+ // tool_use blocks survive to the next request unchanged.
160
+ workingMessages.push({
161
+ role: 'assistant',
162
+ content: fromAnthropicContent(response.content),
163
+ })
164
+
165
+ if (response.stop_reason !== 'tool_use') {
166
+ return {
167
+ text: collectText(response.content),
168
+ messages: workingMessages,
169
+ iterations,
170
+ stopReason: lastStopReason ?? 'end_turn',
171
+ usage: aggregated,
172
+ }
173
+ }
174
+
175
+ // Execute every tool_use block in the response and append the
176
+ // results in a single user-role turn. The SDK's API expects all
177
+ // tool_result blocks for a given assistant turn to land in the
178
+ // same user message.
179
+ const toolUseBlocks = response.content.filter(
180
+ (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
181
+ )
182
+ const resultBlocks: ContentBlock[] = []
183
+ for (const block of toolUseBlocks) {
184
+ const tool = toolMap.get(block.name)
185
+ if (!tool) {
186
+ throw new ToolExecutionError(
187
+ block.name,
188
+ block.id,
189
+ new Error(`Tool "${block.name}" is not registered.`),
190
+ )
191
+ }
192
+ let output: unknown
193
+ try {
194
+ output = await tool.execute(block.input, {
195
+ callId: block.id,
196
+ context: options.context ?? {},
197
+ })
198
+ } catch (cause) {
199
+ throw new ToolExecutionError(block.name, block.id, cause)
200
+ }
201
+ const resultBlock: ToolResultBlock = {
202
+ type: 'tool_result',
203
+ toolUseId: block.id,
204
+ content: typeof output === 'string' ? output : JSON.stringify(output),
205
+ }
206
+ resultBlocks.push(resultBlock)
207
+ }
208
+ workingMessages.push({ role: 'user', content: resultBlocks })
209
+
210
+ iterations++
211
+ if (iterations >= maxIterations) {
212
+ return {
213
+ text: collectText(response.content),
214
+ messages: workingMessages,
215
+ iterations,
216
+ stopReason: 'max_iterations',
217
+ usage: aggregated,
218
+ }
219
+ }
220
+ }
221
+ }
222
+
112
223
  // ─── Param translation ──────────────────────────────────────────────────
113
224
 
114
225
  private buildParams(
@@ -181,10 +292,30 @@ function toMessageParam(message: Message): Anthropic.MessageParam {
181
292
  }
182
293
  return {
183
294
  role: message.role,
184
- content: message.content.map((block) => {
185
- const param: Anthropic.TextBlockParam = { type: 'text', text: block.text }
186
- if (block.cache) param.cache_control = EPHEMERAL_CACHE
187
- return param
295
+ content: message.content.map((block): Anthropic.ContentBlockParam => {
296
+ if (block.type === 'tool_use') {
297
+ return {
298
+ type: 'tool_use',
299
+ id: block.id,
300
+ name: block.name,
301
+ input: block.input as Record<string, unknown>,
302
+ }
303
+ }
304
+ if (block.type === 'tool_result') {
305
+ const param: Anthropic.ToolResultBlockParam = {
306
+ type: 'tool_result',
307
+ tool_use_id: block.toolUseId,
308
+ content:
309
+ typeof block.content === 'string'
310
+ ? block.content
311
+ : block.content.map((b) => ({ type: 'text', text: b.text }) as Anthropic.TextBlockParam),
312
+ }
313
+ if (block.isError) param.is_error = true
314
+ return param
315
+ }
316
+ const text: Anthropic.TextBlockParam = { type: 'text', text: block.text }
317
+ if (block.cache) text.cache_control = EPHEMERAL_CACHE
318
+ return text
188
319
  }),
189
320
  }
190
321
  }
@@ -225,3 +356,42 @@ function mergeBetas(
225
356
  }
226
357
  return out
227
358
  }
359
+
360
+ function addUsage(acc: ChatUsage, u: Anthropic.Usage): void {
361
+ acc.inputTokens += u.input_tokens
362
+ acc.outputTokens += u.output_tokens
363
+ acc.cacheReadTokens += u.cache_read_input_tokens ?? 0
364
+ acc.cacheCreationTokens += u.cache_creation_input_tokens ?? 0
365
+ }
366
+
367
+ function collectText(content: Anthropic.ContentBlock[]): string {
368
+ return content
369
+ .filter((b): b is Anthropic.TextBlock => b.type === 'text')
370
+ .map((b) => b.text)
371
+ .join('')
372
+ }
373
+
374
+ /**
375
+ * Translate the SDK's response content blocks back into framework
376
+ * `ContentBlock`s for storage in `workingMessages`. We preserve
377
+ * `text` and `tool_use` blocks verbatim; other server-side block
378
+ * types (thinking, server tool blocks) are dropped — V1 doesn't
379
+ * surface them, and re-sending them as part of the assistant turn
380
+ * could confuse the model.
381
+ */
382
+ function fromAnthropicContent(content: Anthropic.ContentBlock[]): ContentBlock[] {
383
+ const out: ContentBlock[] = []
384
+ for (const block of content) {
385
+ if (block.type === 'text') {
386
+ out.push({ type: 'text', text: block.text } satisfies TextBlock)
387
+ } else if (block.type === 'tool_use') {
388
+ out.push({
389
+ type: 'tool_use',
390
+ id: block.id,
391
+ name: block.name,
392
+ input: block.input,
393
+ } satisfies ToolUseBlock)
394
+ }
395
+ }
396
+ return out
397
+ }
package/src/tool.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `Tool` — the framework-native shape every tool implementation
3
+ * conforms to. Providers translate the `name`, `description`, and
4
+ * `inputSchema` into their vendor's tool-definition wire format;
5
+ * `execute` runs in-process on the framework side when the model
6
+ * calls the tool.
7
+ *
8
+ * `inputSchema` is plain JSON Schema (draft 2020-12 compatible).
9
+ * Apps that prefer Zod use the SDK's helpers and feed the resulting
10
+ * JSON Schema into `defineTool`; the framework deliberately doesn't
11
+ * couple to Zod so apps stay free to bring whatever schema library
12
+ * they want.
13
+ *
14
+ * Generics: `TInput` is what `execute` receives (after the model's
15
+ * raw input has been narrowed by validation at the call site, when
16
+ * apps choose to validate). `TOutput` is what the agentic loop
17
+ * appends as the `tool_result.content`. Both default to `unknown`
18
+ * for apps that don't want the cognitive overhead of typing tools.
19
+ */
20
+
21
+ export interface ToolContext {
22
+ /** Provider-assigned call id — matches `ToolUseBlock.id`. */
23
+ readonly callId: string
24
+ /** Per-run free-form context bag passed by the caller. Optional. */
25
+ readonly context: Readonly<Record<string, unknown>>
26
+ }
27
+
28
+ export interface Tool<TInput = unknown, TOutput = unknown> {
29
+ name: string
30
+ description: string
31
+ /** JSON Schema for the tool's input. Providers translate this into their wire format. */
32
+ inputSchema: Record<string, unknown>
33
+ /** In-process executor. Throws propagate as `ToolExecutionError` through the runner. */
34
+ execute(input: TInput, ctx: ToolContext): Promise<TOutput>
35
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `ToolExecutionError` — wrapper thrown by the agentic loop when a
3
+ * tool's `execute` function throws. Carries the tool name + the
4
+ * provider's call id on `context` so apps building error reporters /
5
+ * traces can correlate failures with model output without parsing
6
+ * stack frames.
7
+ *
8
+ * V1 propagates these out of `runWithTools` — the loop aborts on the
9
+ * first tool failure. A later slice may add a graceful path
10
+ * (`{ type: 'tool_result', isError: true }` is appended and the
11
+ * loop continues) but apps that need that today can catch the
12
+ * error, append the result themselves, and re-call the runner.
13
+ */
14
+
15
+ import { StravError } from '@strav/kernel'
16
+
17
+ export class ToolExecutionError extends StravError {
18
+ constructor(toolName: string, callId: string, cause: unknown) {
19
+ const message = cause instanceof Error ? cause.message : String(cause)
20
+ super(
21
+ `Tool "${toolName}" execution failed: ${message}`,
22
+ { code: 'brain.tool-execution-failed', status: 500 },
23
+ { context: { tool: toolName, callId }, cause },
24
+ )
25
+ }
26
+ }
package/src/types.ts CHANGED
@@ -38,7 +38,40 @@ export interface TextBlock {
38
38
  cache?: boolean
39
39
  }
40
40
 
41
- export type ContentBlock = TextBlock
41
+ /**
42
+ * Provider-emitted tool-use block. Appears in `assistant`-role
43
+ * messages when the model decides to call a tool. `input` is the
44
+ * parsed JSON the model produced for the tool's `inputSchema`; apps
45
+ * that need to validate it (Zod, ajv, etc.) do so at the call site.
46
+ *
47
+ * The agentic loop creates a matching `ToolResultBlock` and appends
48
+ * it to the next `user`-role message before re-asking the model.
49
+ */
50
+ export interface ToolUseBlock {
51
+ type: 'tool_use'
52
+ /** Provider-assigned call id. The matching tool_result references this verbatim. */
53
+ id: string
54
+ /** Tool name — matches a registered `Tool.name`. */
55
+ name: string
56
+ /** Parsed input the model produced. Apps validate against the tool's schema. */
57
+ input: unknown
58
+ }
59
+
60
+ /**
61
+ * Result of executing a tool. Appended to a `user`-role message and
62
+ * fed back to the model. `content` is either a plain string (the
63
+ * common case) or a list of text blocks for richer payloads. Mark
64
+ * `isError: true` so the model knows the tool call failed and can
65
+ * adjust its approach.
66
+ */
67
+ export interface ToolResultBlock {
68
+ type: 'tool_result'
69
+ toolUseId: string
70
+ content: string | TextBlock[]
71
+ isError?: boolean
72
+ }
73
+
74
+ export type ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock
42
75
 
43
76
  /** A single conversation turn. `content` can be a bare string or a typed block list. */
44
77
  export interface Message {