@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 +2 -2
- package/src/agent.ts +59 -0
- package/src/agent_result.ts +32 -0
- package/src/agent_runner.ts +61 -0
- package/src/brain_manager.ts +67 -1
- package/src/brain_provider.ts +10 -1
- package/src/define_tool.ts +42 -0
- package/src/index.ts +20 -6
- package/src/provider.ts +26 -0
- package/src/providers/anthropic_provider.ts +175 -5
- package/src/tool.ts +35 -0
- package/src/tool_execution_error.ts +26 -0
- package/src/types.ts +34 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/brain",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
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.
|
|
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
|
+
}
|
package/src/brain_manager.ts
CHANGED
|
@@ -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) {
|
package/src/brain_provider.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 {
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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 {
|