@strav/brain 1.0.0-alpha.11 → 1.0.0-alpha.13
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 +6 -3
- package/src/brain_config.ts +19 -1
- package/src/brain_provider.ts +9 -1
- package/src/index.ts +2 -0
- package/src/mcp/client.ts +157 -0
- package/src/mcp/index.ts +16 -0
- package/src/mcp/resolve_mcp_tools.ts +86 -0
- package/src/providers/gemini_provider.ts +445 -0
- package/src/providers/openai_provider.ts +39 -9
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/brain",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.13",
|
|
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
|
-
".": "./src/index.ts"
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./mcp": "./src/mcp/index.ts"
|
|
10
11
|
},
|
|
11
12
|
"files": [
|
|
12
13
|
"src",
|
|
@@ -19,8 +20,10 @@
|
|
|
19
20
|
"access": "public"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
|
-
"@strav/kernel": "1.0.0-alpha.11",
|
|
23
23
|
"@anthropic-ai/sdk": "^0.100.0",
|
|
24
|
+
"@google/genai": "^2.7.0",
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
26
|
+
"@strav/kernel": "1.0.0-alpha.13",
|
|
24
27
|
"openai": "^6.0.0"
|
|
25
28
|
},
|
|
26
29
|
"peerDependencies": {
|
package/src/brain_config.ts
CHANGED
|
@@ -49,7 +49,25 @@ export interface OpenAIProviderConfig {
|
|
|
49
49
|
defaultMaxTokens?: number
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
/** Google (Gemini) driver config — backed by `@google/genai`. */
|
|
53
|
+
export interface GeminiProviderConfig {
|
|
54
|
+
driver: 'google'
|
|
55
|
+
/** API key. Required. Most apps source from `env('GOOGLE_API_KEY')` or `env('GEMINI_API_KEY')`. */
|
|
56
|
+
apiKey: string
|
|
57
|
+
/** Optional override of the SDK's base URL — useful for proxies or test doubles. */
|
|
58
|
+
baseUrl?: string
|
|
59
|
+
/** Default model when neither `options.model` nor `options.tier` is passed. Defaults to `gemini-2.5-flash`. */
|
|
60
|
+
defaultModel?: string
|
|
61
|
+
/** Default `max_tokens` for `chat()` calls that don't specify one. */
|
|
62
|
+
defaultMaxTokens?: number
|
|
63
|
+
/** Optional API version pin (`v1` / `v1beta`). */
|
|
64
|
+
apiVersion?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type ProviderConfig =
|
|
68
|
+
| AnthropicProviderConfig
|
|
69
|
+
| OpenAIProviderConfig
|
|
70
|
+
| GeminiProviderConfig // | DeepSeekProviderConfig (later slice)
|
|
53
71
|
|
|
54
72
|
/** Cache-shape defaults applied when `ChatOptions.cache` is omitted. */
|
|
55
73
|
export interface BrainCacheConfig {
|
package/src/brain_provider.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { type Application, ConfigError, ConfigRepository, ServiceProvider } from
|
|
|
28
28
|
import { BrainManager } from './brain_manager.ts'
|
|
29
29
|
import type { BrainConfigShape, ProviderConfig } from './brain_config.ts'
|
|
30
30
|
import { AnthropicProvider } from './providers/anthropic_provider.ts'
|
|
31
|
+
import { GeminiProvider } from './providers/gemini_provider.ts'
|
|
31
32
|
import { OpenAIProvider } from './providers/openai_provider.ts'
|
|
32
33
|
import type { Provider } from './provider.ts'
|
|
33
34
|
|
|
@@ -101,10 +102,17 @@ function buildProvider(name: string, config: ProviderConfig): Provider {
|
|
|
101
102
|
)
|
|
102
103
|
}
|
|
103
104
|
return new OpenAIProvider(name, config)
|
|
105
|
+
case 'google':
|
|
106
|
+
if (!config.apiKey) {
|
|
107
|
+
throw new ConfigError(
|
|
108
|
+
`BrainProvider: google provider "${name}" is missing apiKey. Source from env('GOOGLE_API_KEY').`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
return new GeminiProvider(name, config)
|
|
104
112
|
default: {
|
|
105
113
|
const exhaustiveCheck: never = config
|
|
106
114
|
throw new ConfigError(
|
|
107
|
-
`BrainProvider: unknown driver for provider "${name}". Known drivers: anthropic, openai.`,
|
|
115
|
+
`BrainProvider: unknown driver for provider "${name}". Known drivers: anthropic, openai, google.`,
|
|
108
116
|
)
|
|
109
117
|
// (unreachable — kept for the exhaustive check to fire when a new driver lands)
|
|
110
118
|
// biome-ignore lint/correctness/noUnreachable: kept for the exhaustive-check above
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export {
|
|
|
16
16
|
type BrainConfigShape,
|
|
17
17
|
DEFAULT_MODEL,
|
|
18
18
|
DEFAULT_TIERS,
|
|
19
|
+
type GeminiProviderConfig,
|
|
19
20
|
type OpenAIProviderConfig,
|
|
20
21
|
type ProviderConfig,
|
|
21
22
|
} from './brain_config.ts'
|
|
@@ -29,6 +30,7 @@ export { BrainProvider } from './brain_provider.ts'
|
|
|
29
30
|
export { defineTool, type DefineToolSpec } from './define_tool.ts'
|
|
30
31
|
export type { MCPServer, MCPServerToolConfig } from './mcp_server.ts'
|
|
31
32
|
export { AnthropicProvider } from './providers/anthropic_provider.ts'
|
|
33
|
+
export { GeminiProvider } from './providers/gemini_provider.ts'
|
|
32
34
|
export { OpenAIProvider } from './providers/openai_provider.ts'
|
|
33
35
|
export type { Provider, RunWithToolsOptions } from './provider.ts'
|
|
34
36
|
export { Thread, type ThreadOptions, type ThreadState } from './thread.ts'
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MCPClient` — local MCP client for providers that lack server-side
|
|
3
|
+
* MCP support (OpenAI, Gemini, DeepSeek, …).
|
|
4
|
+
*
|
|
5
|
+
* Wraps the official `@modelcontextprotocol/sdk` client. Connects to a
|
|
6
|
+
* single MCP server over Streamable HTTP, lists its tools, and invokes
|
|
7
|
+
* them. The agentic loop sees these as ordinary `Tool`s — translation
|
|
8
|
+
* happens in `resolveMcpTools`.
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle:
|
|
11
|
+
*
|
|
12
|
+
* const client = new MCPClient(serverConfig)
|
|
13
|
+
* await client.connect()
|
|
14
|
+
* const tools = await client.listTools()
|
|
15
|
+
* const result = await client.callTool('name', {...})
|
|
16
|
+
* await client.close()
|
|
17
|
+
*
|
|
18
|
+
* Authentication:
|
|
19
|
+
* `MCPServer.authorizationToken` is forwarded as
|
|
20
|
+
* `Authorization: Bearer <token>`. OAuth-flow servers need
|
|
21
|
+
* out-of-band token exchange — same constraint as the server-side
|
|
22
|
+
* path. Full OAuth handshake is a later slice.
|
|
23
|
+
*
|
|
24
|
+
* Transport:
|
|
25
|
+
* V1 only does Streamable HTTP — the current MCP transport. Legacy
|
|
26
|
+
* SSE-only endpoints aren't supported; if a server URL ends with
|
|
27
|
+
* `/sse` and only speaks the legacy protocol, the connection will
|
|
28
|
+
* fail and apps should run against a Streamable-HTTP endpoint
|
|
29
|
+
* instead.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
33
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
34
|
+
import { BrainError } from '../brain_error.ts'
|
|
35
|
+
import type { MCPServer } from '../mcp_server.ts'
|
|
36
|
+
|
|
37
|
+
/** Result of a single MCP tool invocation, as returned by `tools/call`. */
|
|
38
|
+
export interface MCPCallToolResult {
|
|
39
|
+
/** Stringified content — text blocks concatenated; image / resource blocks JSON-serialized. */
|
|
40
|
+
content: string
|
|
41
|
+
/** `true` when the MCP server reports the tool execution failed. */
|
|
42
|
+
isError: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Tool descriptor surfaced by `tools/list`. */
|
|
46
|
+
export interface MCPToolDescriptor {
|
|
47
|
+
name: string
|
|
48
|
+
description: string
|
|
49
|
+
inputSchema: Record<string, unknown>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface MCPClientOptions {
|
|
53
|
+
/** Override the transport used to dial the server. Tests inject a mock here. */
|
|
54
|
+
client?: Client
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class MCPClient {
|
|
58
|
+
readonly server: MCPServer
|
|
59
|
+
private readonly _client: Client
|
|
60
|
+
private _connected = false
|
|
61
|
+
|
|
62
|
+
constructor(server: MCPServer, options: MCPClientOptions = {}) {
|
|
63
|
+
this.server = server
|
|
64
|
+
this._client =
|
|
65
|
+
options.client ??
|
|
66
|
+
new Client(
|
|
67
|
+
{ name: `@strav/brain:${server.name}`, version: '1.0.0' },
|
|
68
|
+
{ capabilities: {} },
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async connect(): Promise<void> {
|
|
73
|
+
if (this._connected) return
|
|
74
|
+
const transport = this._buildTransport()
|
|
75
|
+
try {
|
|
76
|
+
await this._client.connect(transport)
|
|
77
|
+
this._connected = true
|
|
78
|
+
} catch (cause) {
|
|
79
|
+
throw new BrainError(
|
|
80
|
+
`MCPClient(${this.server.name}): failed to connect to ${this.server.url}.`,
|
|
81
|
+
{ context: { server: this.server.name, url: this.server.url }, cause },
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async listTools(): Promise<MCPToolDescriptor[]> {
|
|
87
|
+
await this.connect()
|
|
88
|
+
let response: Awaited<ReturnType<Client['listTools']>>
|
|
89
|
+
try {
|
|
90
|
+
response = await this._client.listTools()
|
|
91
|
+
} catch (cause) {
|
|
92
|
+
throw new BrainError(
|
|
93
|
+
`MCPClient(${this.server.name}): tools/list failed.`,
|
|
94
|
+
{ context: { server: this.server.name }, cause },
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
return response.tools.map((t) => ({
|
|
98
|
+
name: t.name,
|
|
99
|
+
description: t.description ?? '',
|
|
100
|
+
inputSchema: (t.inputSchema ?? { type: 'object' }) as Record<string, unknown>,
|
|
101
|
+
}))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async callTool(name: string, input: unknown): Promise<MCPCallToolResult> {
|
|
105
|
+
await this.connect()
|
|
106
|
+
let response: Awaited<ReturnType<Client['callTool']>>
|
|
107
|
+
try {
|
|
108
|
+
response = await this._client.callTool({
|
|
109
|
+
name,
|
|
110
|
+
arguments: (input ?? {}) as Record<string, unknown>,
|
|
111
|
+
})
|
|
112
|
+
} catch (cause) {
|
|
113
|
+
throw new BrainError(
|
|
114
|
+
`MCPClient(${this.server.name}): tools/call ${name} failed.`,
|
|
115
|
+
{ context: { server: this.server.name, tool: name }, cause },
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
content: flattenContent(response.content),
|
|
120
|
+
isError: Boolean(response.isError),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async close(): Promise<void> {
|
|
125
|
+
if (!this._connected) return
|
|
126
|
+
try {
|
|
127
|
+
await this._client.close()
|
|
128
|
+
} finally {
|
|
129
|
+
this._connected = false
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private _buildTransport(): StreamableHTTPClientTransport {
|
|
134
|
+
const headers: Record<string, string> = {}
|
|
135
|
+
if (this.server.authorizationToken !== undefined) {
|
|
136
|
+
headers.Authorization = `Bearer ${this.server.authorizationToken}`
|
|
137
|
+
}
|
|
138
|
+
return new StreamableHTTPClientTransport(new URL(this.server.url), {
|
|
139
|
+
requestInit: { headers },
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function flattenContent(
|
|
145
|
+
content: Awaited<ReturnType<Client['callTool']>>['content'],
|
|
146
|
+
): string {
|
|
147
|
+
if (!Array.isArray(content)) return ''
|
|
148
|
+
const parts: string[] = []
|
|
149
|
+
for (const block of content) {
|
|
150
|
+
if (block.type === 'text') {
|
|
151
|
+
parts.push(block.text)
|
|
152
|
+
} else {
|
|
153
|
+
parts.push(JSON.stringify(block))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return parts.join('')
|
|
157
|
+
}
|
package/src/mcp/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Public API of `@strav/brain/mcp` — local MCP client for providers
|
|
2
|
+
// without server-side MCP support (OpenAI, Gemini, DeepSeek). The
|
|
3
|
+
// Anthropic provider continues to use server-side MCP via the
|
|
4
|
+
// top-level `MCPServer` config; nothing here is needed for that path.
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
MCPClient,
|
|
8
|
+
type MCPCallToolResult,
|
|
9
|
+
type MCPClientOptions,
|
|
10
|
+
type MCPToolDescriptor,
|
|
11
|
+
} from './client.ts'
|
|
12
|
+
export {
|
|
13
|
+
resolveMcpTools,
|
|
14
|
+
type ResolveMcpToolsOptions,
|
|
15
|
+
type ResolvedMcpTools,
|
|
16
|
+
} from './resolve_mcp_tools.ts'
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resolveMcpTools` — connects to each `MCPServer`, discovers its
|
|
3
|
+
* tools, and surfaces them as framework `Tool`s the standard agentic
|
|
4
|
+
* loop already knows how to invoke.
|
|
5
|
+
*
|
|
6
|
+
* Honors the per-server config in `MCPServer.tools`:
|
|
7
|
+
* - `enabled: false` → server is skipped entirely.
|
|
8
|
+
* - `allowedTools` → only those tool names are exposed.
|
|
9
|
+
*
|
|
10
|
+
* Naming: discovered tools are namespaced as `<server>__<tool>` to
|
|
11
|
+
* keep names unique when multiple servers expose overlapping names.
|
|
12
|
+
* The `Tool.execute` then routes back to the correct server. The
|
|
13
|
+
* underscore separator (not `.` or `/`) is chosen because OpenAI's
|
|
14
|
+
* tool-name regex rejects `.` and `/`.
|
|
15
|
+
*
|
|
16
|
+
* Lifecycle: this helper returns `{ tools, close }`. `close` runs all
|
|
17
|
+
* client `close()` calls in parallel — providers must call it in a
|
|
18
|
+
* `finally` to avoid leaking transports.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { MCPServer } from '../mcp_server.ts'
|
|
22
|
+
import type { Tool, ToolContext } from '../tool.ts'
|
|
23
|
+
import { MCPClient } from './client.ts'
|
|
24
|
+
|
|
25
|
+
export interface ResolvedMcpTools {
|
|
26
|
+
tools: Tool[]
|
|
27
|
+
close(): Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolveMcpToolsOptions {
|
|
31
|
+
/** Override the client factory — tests inject mock clients per server here. */
|
|
32
|
+
clientFactory?(server: MCPServer): MCPClient
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const NAME_SEPARATOR = '__'
|
|
36
|
+
|
|
37
|
+
export async function resolveMcpTools(
|
|
38
|
+
servers: readonly MCPServer[],
|
|
39
|
+
options: ResolveMcpToolsOptions = {},
|
|
40
|
+
): Promise<ResolvedMcpTools> {
|
|
41
|
+
const clients: MCPClient[] = []
|
|
42
|
+
const tools: Tool[] = []
|
|
43
|
+
|
|
44
|
+
for (const server of servers) {
|
|
45
|
+
if (server.tools?.enabled === false) continue
|
|
46
|
+
const client = options.clientFactory
|
|
47
|
+
? options.clientFactory(server)
|
|
48
|
+
: new MCPClient(server)
|
|
49
|
+
clients.push(client)
|
|
50
|
+
|
|
51
|
+
const allowed = server.tools?.allowedTools
|
|
52
|
+
const allowedSet = allowed ? new Set(allowed) : null
|
|
53
|
+
|
|
54
|
+
const descriptors = await client.listTools()
|
|
55
|
+
for (const descriptor of descriptors) {
|
|
56
|
+
if (allowedSet && !allowedSet.has(descriptor.name)) continue
|
|
57
|
+
tools.push(buildTool(server.name, client, descriptor))
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
tools,
|
|
63
|
+
close: async () => {
|
|
64
|
+
await Promise.all(clients.map((c) => c.close()))
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildTool(
|
|
70
|
+
serverName: string,
|
|
71
|
+
client: MCPClient,
|
|
72
|
+
descriptor: { name: string; description: string; inputSchema: Record<string, unknown> },
|
|
73
|
+
): Tool {
|
|
74
|
+
return {
|
|
75
|
+
name: `${serverName}${NAME_SEPARATOR}${descriptor.name}`,
|
|
76
|
+
description: descriptor.description,
|
|
77
|
+
inputSchema: descriptor.inputSchema,
|
|
78
|
+
async execute(input: unknown, _ctx: ToolContext): Promise<string> {
|
|
79
|
+
const result = await client.callTool(descriptor.name, input)
|
|
80
|
+
if (result.isError) {
|
|
81
|
+
return `MCP tool error: ${result.content}`
|
|
82
|
+
}
|
|
83
|
+
return result.content
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GeminiProvider` — implementation of `Provider` backed by the
|
|
3
|
+
* official `@google/genai` SDK (Gemini Developer API / Vertex AI).
|
|
4
|
+
*
|
|
5
|
+
* Maps framework shapes to Gemini's wire format:
|
|
6
|
+
*
|
|
7
|
+
* - `system` → `config.systemInstruction` (string-joined when
|
|
8
|
+
* multi-block). Cache flags on the system prompt are ignored —
|
|
9
|
+
* Gemini's prompt caching uses an explicit Caches API rather
|
|
10
|
+
* than per-block flags, so `cache: true` becomes a no-op
|
|
11
|
+
* consistent with the OpenAI provider.
|
|
12
|
+
*
|
|
13
|
+
* - `Message[]` → `Content[]`. Framework `role: 'user' | 'assistant'`
|
|
14
|
+
* maps to Gemini's `role: 'user' | 'model'`. String content
|
|
15
|
+
* becomes a single `{text}` part; `ContentBlock[]` content fans
|
|
16
|
+
* out:
|
|
17
|
+
* - `TextBlock` → `{text}`
|
|
18
|
+
* - `ToolUseBlock` → `{functionCall: {id, name, args}}`
|
|
19
|
+
* - `ToolResultBlock` → `{functionResponse: {id, name,
|
|
20
|
+
* response: {result | error}}}`
|
|
21
|
+
* - `MCP*` blocks → silently dropped (Anthropic-only).
|
|
22
|
+
*
|
|
23
|
+
* - `Tool[]` → `[{functionDeclarations: [{name, description,
|
|
24
|
+
* parametersJsonSchema: inputSchema}]}]`. We use
|
|
25
|
+
* `parametersJsonSchema` (not `parameters`) so JSON-Schema-shaped
|
|
26
|
+
* tool inputs pass through verbatim without translation to
|
|
27
|
+
* Gemini's `Schema` form.
|
|
28
|
+
*
|
|
29
|
+
* - `MCPServer[]` → resolved via the local MCP client
|
|
30
|
+
* (`@strav/brain/mcp`). Discovered tools are namespaced
|
|
31
|
+
* `<server>__<tool>` and merged with caller-supplied tools.
|
|
32
|
+
* Transports are closed in a `finally` once the loop exits.
|
|
33
|
+
* Gemini has no first-party server-side MCP equivalent to
|
|
34
|
+
* Anthropic's connector.
|
|
35
|
+
*
|
|
36
|
+
* - `thinking: 'adaptive'` → `thinkingConfig: { thinkingBudget: -1 }`
|
|
37
|
+
* (auto). `'disabled'` → `thinkingConfig: { thinkingBudget: 0 }`.
|
|
38
|
+
* Explicit `effort` (`low`/`medium`/`high`/`xhigh`/`max`) maps to
|
|
39
|
+
* `thinkingConfig.thinkingLevel`. Non-thinking models ignore the
|
|
40
|
+
* field upstream — we always emit, the SDK rejects only for
|
|
41
|
+
* models that don't support it.
|
|
42
|
+
*
|
|
43
|
+
* - `cache: true` → no-op. Gemini's prompt cache lives behind the
|
|
44
|
+
* `Caches` API; same accepted-silently behavior as OpenAI.
|
|
45
|
+
*
|
|
46
|
+
* - `countTokens` IS implemented — `ai.models.countTokens` exists
|
|
47
|
+
* and is cheap. Returns `totalTokens`.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { GoogleGenAI, ThinkingLevel } from '@google/genai'
|
|
51
|
+
import type {
|
|
52
|
+
Content,
|
|
53
|
+
FunctionDeclaration,
|
|
54
|
+
GenerateContentConfig,
|
|
55
|
+
GenerateContentParameters,
|
|
56
|
+
GenerateContentResponse,
|
|
57
|
+
Part,
|
|
58
|
+
} from '@google/genai'
|
|
59
|
+
import type { AgentResult } from '../agent_result.ts'
|
|
60
|
+
import { BrainError } from '../brain_error.ts'
|
|
61
|
+
import type { GeminiProviderConfig } from '../brain_config.ts'
|
|
62
|
+
import type { MCPServer } from '../mcp_server.ts'
|
|
63
|
+
import { resolveMcpTools, type ResolveMcpToolsOptions } from '../mcp/resolve_mcp_tools.ts'
|
|
64
|
+
import type { Provider, RunWithToolsOptions } from '../provider.ts'
|
|
65
|
+
import type { Tool } from '../tool.ts'
|
|
66
|
+
import { ToolExecutionError } from '../tool_execution_error.ts'
|
|
67
|
+
import type {
|
|
68
|
+
ChatOptions,
|
|
69
|
+
ChatResult,
|
|
70
|
+
ChatUsage,
|
|
71
|
+
ContentBlock,
|
|
72
|
+
Message,
|
|
73
|
+
StreamEvent,
|
|
74
|
+
SystemPrompt,
|
|
75
|
+
TextBlock,
|
|
76
|
+
ToolResultBlock,
|
|
77
|
+
ToolUseBlock,
|
|
78
|
+
} from '../types.ts'
|
|
79
|
+
|
|
80
|
+
const DEFAULT_GEMINI_MODEL = 'gemini-2.5-flash'
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The slice of `GoogleGenAI` the provider exercises. Narrowed so
|
|
84
|
+
* tests can inject a stub without satisfying the full SDK surface.
|
|
85
|
+
*/
|
|
86
|
+
export interface GeminiModelsClient {
|
|
87
|
+
generateContent(params: GenerateContentParameters): Promise<GenerateContentResponse>
|
|
88
|
+
generateContentStream(
|
|
89
|
+
params: GenerateContentParameters,
|
|
90
|
+
): Promise<AsyncIterable<GenerateContentResponse>>
|
|
91
|
+
countTokens(params: { model: string; contents: Content[] }): Promise<{ totalTokens?: number }>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface GeminiProviderOptions {
|
|
95
|
+
client?: { models: GeminiModelsClient }
|
|
96
|
+
/** Internal seam — tests inject a stub MCP client factory. */
|
|
97
|
+
mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class GeminiProvider implements Provider {
|
|
101
|
+
readonly name: string
|
|
102
|
+
private readonly models: GeminiModelsClient
|
|
103
|
+
private readonly defaultModel: string
|
|
104
|
+
private readonly defaultMaxTokens: number
|
|
105
|
+
private readonly mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
|
|
106
|
+
|
|
107
|
+
constructor(name: string, config: GeminiProviderConfig, options: GeminiProviderOptions = {}) {
|
|
108
|
+
this.name = name
|
|
109
|
+
this.defaultModel = config.defaultModel ?? DEFAULT_GEMINI_MODEL
|
|
110
|
+
this.defaultMaxTokens = config.defaultMaxTokens ?? 4096
|
|
111
|
+
this.mcpClientFactory = options.mcpClientFactory
|
|
112
|
+
if (options.client) {
|
|
113
|
+
this.models = options.client.models
|
|
114
|
+
} else {
|
|
115
|
+
const httpOpts =
|
|
116
|
+
config.baseUrl !== undefined || config.apiVersion !== undefined
|
|
117
|
+
? {
|
|
118
|
+
...(config.baseUrl !== undefined ? { baseUrl: config.baseUrl } : {}),
|
|
119
|
+
...(config.apiVersion !== undefined ? { apiVersion: config.apiVersion } : {}),
|
|
120
|
+
}
|
|
121
|
+
: undefined
|
|
122
|
+
const sdk = new GoogleGenAI({
|
|
123
|
+
apiKey: config.apiKey,
|
|
124
|
+
...(httpOpts ? { httpOptions: httpOpts } : {}),
|
|
125
|
+
})
|
|
126
|
+
this.models = sdk.models as unknown as GeminiModelsClient
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async chat(messages: readonly Message[], options: ChatOptions = {}): Promise<ChatResult> {
|
|
131
|
+
const params = this.buildParams(messages, options, [])
|
|
132
|
+
const response = await this.models.generateContent(params)
|
|
133
|
+
return this.toChatResult(response, params.model)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async *stream(
|
|
137
|
+
messages: readonly Message[],
|
|
138
|
+
options: ChatOptions = {},
|
|
139
|
+
): AsyncIterable<StreamEvent> {
|
|
140
|
+
const params = this.buildParams(messages, options, [])
|
|
141
|
+
const stream = await this.models.generateContentStream(params)
|
|
142
|
+
let finishReason: string | null = null
|
|
143
|
+
let lastUsage: ChatUsage | undefined
|
|
144
|
+
for await (const chunk of stream) {
|
|
145
|
+
const candidate = chunk.candidates?.[0]
|
|
146
|
+
const text = candidateText(candidate)
|
|
147
|
+
if (text.length > 0) yield { type: 'text', delta: text }
|
|
148
|
+
if (candidate?.finishReason) finishReason = String(candidate.finishReason)
|
|
149
|
+
if (chunk.usageMetadata) lastUsage = toUsage(chunk.usageMetadata)
|
|
150
|
+
}
|
|
151
|
+
yield {
|
|
152
|
+
type: 'stop',
|
|
153
|
+
stopReason: finishReason,
|
|
154
|
+
usage: lastUsage ?? {
|
|
155
|
+
inputTokens: 0,
|
|
156
|
+
outputTokens: 0,
|
|
157
|
+
cacheReadTokens: 0,
|
|
158
|
+
cacheCreationTokens: 0,
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async countTokens(messages: readonly Message[], options: ChatOptions = {}): Promise<number> {
|
|
164
|
+
const contents = this.toContents(messages)
|
|
165
|
+
const model = options.model ?? this.defaultModel
|
|
166
|
+
const response = await this.models.countTokens({ model, contents })
|
|
167
|
+
return response.totalTokens ?? 0
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async runWithTools(
|
|
171
|
+
messages: readonly Message[],
|
|
172
|
+
tools: readonly Tool[],
|
|
173
|
+
options: RunWithToolsOptions = {},
|
|
174
|
+
): Promise<AgentResult> {
|
|
175
|
+
const mcpServers: readonly MCPServer[] = options.mcpServers ?? []
|
|
176
|
+
const resolved =
|
|
177
|
+
mcpServers.length > 0
|
|
178
|
+
? await resolveMcpTools(mcpServers, {
|
|
179
|
+
...(this.mcpClientFactory ? { clientFactory: this.mcpClientFactory } : {}),
|
|
180
|
+
})
|
|
181
|
+
: { tools: [] as Tool[], close: async () => {} }
|
|
182
|
+
try {
|
|
183
|
+
return await this._runLoop(messages, [...tools, ...resolved.tools], options)
|
|
184
|
+
} finally {
|
|
185
|
+
await resolved.close()
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private async _runLoop(
|
|
190
|
+
messages: readonly Message[],
|
|
191
|
+
tools: readonly Tool[],
|
|
192
|
+
options: RunWithToolsOptions,
|
|
193
|
+
): Promise<AgentResult> {
|
|
194
|
+
const maxIterations = options.maxIterations ?? 10
|
|
195
|
+
const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
|
|
196
|
+
const workingMessages: Message[] = [...messages]
|
|
197
|
+
const aggregated: ChatUsage = {
|
|
198
|
+
inputTokens: 0,
|
|
199
|
+
outputTokens: 0,
|
|
200
|
+
cacheReadTokens: 0,
|
|
201
|
+
cacheCreationTokens: 0,
|
|
202
|
+
}
|
|
203
|
+
let iterations = 0
|
|
204
|
+
|
|
205
|
+
while (true) {
|
|
206
|
+
const params = this.buildParams(workingMessages, options, tools)
|
|
207
|
+
const response = await this.models.generateContent(params)
|
|
208
|
+
addUsage(aggregated, response.usageMetadata)
|
|
209
|
+
|
|
210
|
+
const candidate = response.candidates?.[0]
|
|
211
|
+
if (!candidate) {
|
|
212
|
+
throw new BrainError('GeminiProvider: response had no candidates.')
|
|
213
|
+
}
|
|
214
|
+
const parts = candidate.content?.parts ?? []
|
|
215
|
+
const assistantContent = fromGeminiParts(parts)
|
|
216
|
+
workingMessages.push({ role: 'assistant', content: assistantContent })
|
|
217
|
+
|
|
218
|
+
const toolUses = (Array.isArray(assistantContent) ? assistantContent : []).filter(
|
|
219
|
+
(b): b is ToolUseBlock => b.type === 'tool_use',
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if (toolUses.length === 0) {
|
|
223
|
+
return {
|
|
224
|
+
text: typeof assistantContent === 'string'
|
|
225
|
+
? assistantContent
|
|
226
|
+
: candidateText(candidate),
|
|
227
|
+
messages: workingMessages,
|
|
228
|
+
iterations,
|
|
229
|
+
stopReason: candidate.finishReason ? String(candidate.finishReason) : 'stop',
|
|
230
|
+
usage: aggregated,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const resultBlocks: ContentBlock[] = []
|
|
235
|
+
for (const call of toolUses) {
|
|
236
|
+
const tool = toolMap.get(call.name)
|
|
237
|
+
if (!tool) {
|
|
238
|
+
throw new ToolExecutionError(
|
|
239
|
+
call.name,
|
|
240
|
+
call.id,
|
|
241
|
+
new Error(`Tool "${call.name}" is not registered.`),
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
let output: unknown
|
|
245
|
+
try {
|
|
246
|
+
output = await tool.execute(call.input, {
|
|
247
|
+
callId: call.id,
|
|
248
|
+
context: options.context ?? {},
|
|
249
|
+
})
|
|
250
|
+
} catch (cause) {
|
|
251
|
+
throw new ToolExecutionError(call.name, call.id, cause)
|
|
252
|
+
}
|
|
253
|
+
const resultBlock: ToolResultBlock = {
|
|
254
|
+
type: 'tool_result',
|
|
255
|
+
toolUseId: call.id,
|
|
256
|
+
content: typeof output === 'string' ? output : JSON.stringify(output),
|
|
257
|
+
}
|
|
258
|
+
resultBlocks.push(resultBlock)
|
|
259
|
+
}
|
|
260
|
+
workingMessages.push({ role: 'user', content: resultBlocks })
|
|
261
|
+
|
|
262
|
+
iterations++
|
|
263
|
+
if (iterations >= maxIterations) {
|
|
264
|
+
return {
|
|
265
|
+
text: candidateText(candidate),
|
|
266
|
+
messages: workingMessages,
|
|
267
|
+
iterations,
|
|
268
|
+
stopReason: 'max_iterations',
|
|
269
|
+
usage: aggregated,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ─── Param translation ──────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
private buildParams(
|
|
278
|
+
messages: readonly Message[],
|
|
279
|
+
options: ChatOptions,
|
|
280
|
+
tools: readonly Tool[],
|
|
281
|
+
): GenerateContentParameters {
|
|
282
|
+
const model = options.model ?? this.defaultModel
|
|
283
|
+
const contents = this.toContents(messages)
|
|
284
|
+
const config: GenerateContentConfig = {
|
|
285
|
+
maxOutputTokens: options.maxTokens ?? this.defaultMaxTokens,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const systemText = systemPromptText(options.system)
|
|
289
|
+
if (systemText.length > 0) {
|
|
290
|
+
config.systemInstruction = systemText
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (tools.length > 0) {
|
|
294
|
+
const functionDeclarations: FunctionDeclaration[] = tools.map((t) => ({
|
|
295
|
+
name: t.name,
|
|
296
|
+
description: t.description,
|
|
297
|
+
parametersJsonSchema: t.inputSchema,
|
|
298
|
+
}))
|
|
299
|
+
config.tools = [{ functionDeclarations }]
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const thinking = buildThinkingConfig(options)
|
|
303
|
+
if (thinking !== undefined) config.thinkingConfig = thinking
|
|
304
|
+
|
|
305
|
+
return { model, contents, config }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private toContents(messages: readonly Message[]): Content[] {
|
|
309
|
+
return messages.map((m) => ({
|
|
310
|
+
role: m.role === 'assistant' ? 'model' : 'user',
|
|
311
|
+
parts: toGeminiParts(m.content),
|
|
312
|
+
}))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private toChatResult(
|
|
316
|
+
response: GenerateContentResponse,
|
|
317
|
+
requestedModel: string,
|
|
318
|
+
): ChatResult<GenerateContentResponse> {
|
|
319
|
+
const candidate = response.candidates?.[0]
|
|
320
|
+
return {
|
|
321
|
+
text: candidateText(candidate),
|
|
322
|
+
model: response.modelVersion ?? requestedModel,
|
|
323
|
+
stopReason: candidate?.finishReason ? String(candidate.finishReason) : null,
|
|
324
|
+
usage: toUsage(response.usageMetadata),
|
|
325
|
+
raw: response,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ─── Shape converters ─────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
function systemPromptText(system: SystemPrompt | undefined): string {
|
|
333
|
+
if (system === undefined) return ''
|
|
334
|
+
if (typeof system === 'string') return system
|
|
335
|
+
if (Array.isArray(system)) return system.map((b) => b.text).join('\n')
|
|
336
|
+
return system.text
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function toGeminiParts(content: string | ContentBlock[]): Part[] {
|
|
340
|
+
if (typeof content === 'string') return [{ text: content }]
|
|
341
|
+
const parts: Part[] = []
|
|
342
|
+
for (const block of content) {
|
|
343
|
+
if (block.type === 'text') {
|
|
344
|
+
parts.push({ text: block.text })
|
|
345
|
+
} else if (block.type === 'tool_use') {
|
|
346
|
+
parts.push({
|
|
347
|
+
functionCall: {
|
|
348
|
+
id: block.id,
|
|
349
|
+
name: block.name,
|
|
350
|
+
args: (block.input ?? {}) as Record<string, unknown>,
|
|
351
|
+
},
|
|
352
|
+
})
|
|
353
|
+
} else if (block.type === 'tool_result') {
|
|
354
|
+
const text = typeof block.content === 'string'
|
|
355
|
+
? block.content
|
|
356
|
+
: block.content.map((t) => t.text).join('')
|
|
357
|
+
parts.push({
|
|
358
|
+
functionResponse: {
|
|
359
|
+
id: block.toolUseId,
|
|
360
|
+
name: '',
|
|
361
|
+
response: block.isError ? { error: text } : { result: text },
|
|
362
|
+
},
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
// MCP blocks (Anthropic-only) silently dropped.
|
|
366
|
+
}
|
|
367
|
+
return parts
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function fromGeminiParts(parts: readonly Part[]): string | ContentBlock[] {
|
|
371
|
+
const blocks: ContentBlock[] = []
|
|
372
|
+
for (const part of parts) {
|
|
373
|
+
if (typeof part.text === 'string' && part.text.length > 0) {
|
|
374
|
+
blocks.push({ type: 'text', text: part.text })
|
|
375
|
+
} else if (part.functionCall) {
|
|
376
|
+
const fc = part.functionCall
|
|
377
|
+
blocks.push({
|
|
378
|
+
type: 'tool_use',
|
|
379
|
+
id: fc.id ?? `gemini_${cryptoRandomId()}`,
|
|
380
|
+
name: fc.name ?? '',
|
|
381
|
+
input: fc.args ?? {},
|
|
382
|
+
} satisfies ToolUseBlock)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (blocks.length === 1 && blocks[0]?.type === 'text') return blocks[0].text
|
|
386
|
+
return blocks
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function candidateText(candidate: { content?: { parts?: Part[] } } | undefined): string {
|
|
390
|
+
const parts = candidate?.content?.parts ?? []
|
|
391
|
+
return parts
|
|
392
|
+
.filter((p) => typeof p.text === 'string' && p.text.length > 0)
|
|
393
|
+
.map((p) => p.text as string)
|
|
394
|
+
.join('')
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function buildThinkingConfig(options: ChatOptions): GenerateContentConfig['thinkingConfig'] {
|
|
398
|
+
if (options.effort !== undefined) {
|
|
399
|
+
const level = effortToThinkingLevel(options.effort)
|
|
400
|
+
return level !== undefined ? { thinkingLevel: level } : { thinkingBudget: -1 }
|
|
401
|
+
}
|
|
402
|
+
if (options.thinking === 'adaptive') return { thinkingBudget: -1 }
|
|
403
|
+
if (options.thinking === 'disabled') return { thinkingBudget: 0 }
|
|
404
|
+
return undefined
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function effortToThinkingLevel(
|
|
408
|
+
effort: NonNullable<ChatOptions['effort']>,
|
|
409
|
+
): ThinkingLevel | undefined {
|
|
410
|
+
switch (effort) {
|
|
411
|
+
case 'low': return ThinkingLevel.LOW
|
|
412
|
+
case 'medium': return ThinkingLevel.MEDIUM
|
|
413
|
+
case 'high':
|
|
414
|
+
case 'xhigh':
|
|
415
|
+
case 'max':
|
|
416
|
+
return ThinkingLevel.HIGH
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function toUsage(u: { promptTokenCount?: number; candidatesTokenCount?: number; cachedContentTokenCount?: number } | undefined): ChatUsage {
|
|
421
|
+
return {
|
|
422
|
+
inputTokens: u?.promptTokenCount ?? 0,
|
|
423
|
+
outputTokens: u?.candidatesTokenCount ?? 0,
|
|
424
|
+
cacheReadTokens: u?.cachedContentTokenCount ?? 0,
|
|
425
|
+
cacheCreationTokens: 0,
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function addUsage(
|
|
430
|
+
acc: ChatUsage,
|
|
431
|
+
u: { promptTokenCount?: number; candidatesTokenCount?: number; cachedContentTokenCount?: number } | undefined,
|
|
432
|
+
): void {
|
|
433
|
+
if (!u) return
|
|
434
|
+
acc.inputTokens += u.promptTokenCount ?? 0
|
|
435
|
+
acc.outputTokens += u.candidatesTokenCount ?? 0
|
|
436
|
+
acc.cacheReadTokens += u.cachedContentTokenCount ?? 0
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function cryptoRandomId(): string {
|
|
440
|
+
// Stable, low-entropy fallback for synthesizing tool-use ids when
|
|
441
|
+
// Gemini omits them. Uniqueness within a single response is all the
|
|
442
|
+
// loop requires — the id only travels back paired with its result
|
|
443
|
+
// and never escapes to the caller.
|
|
444
|
+
return Math.random().toString(36).slice(2, 12)
|
|
445
|
+
}
|
|
@@ -22,9 +22,12 @@
|
|
|
22
22
|
* a `function` namespace where Anthropic uses flat tool
|
|
23
23
|
* definitions.
|
|
24
24
|
*
|
|
25
|
-
* - `MCPServer[]` →
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* - `MCPServer[]` → resolved via the local MCP client
|
|
26
|
+
* (`@strav/brain/mcp`). Each server is dialed, its tools are
|
|
27
|
+
* discovered, and they're merged with locally-defined tools.
|
|
28
|
+
* The agentic loop then treats them uniformly. Tool names are
|
|
29
|
+
* namespaced `<server>__<tool>` to avoid collisions. Transports
|
|
30
|
+
* are closed in a `finally` once the loop exits.
|
|
28
31
|
*
|
|
29
32
|
* - `cache: true` is a no-op. OpenAI auto-caches; there's no
|
|
30
33
|
* per-block cache_control to set. The framework flag is
|
|
@@ -48,6 +51,8 @@ import OpenAI from 'openai'
|
|
|
48
51
|
import type { AgentResult } from '../agent_result.ts'
|
|
49
52
|
import { BrainError } from '../brain_error.ts'
|
|
50
53
|
import type { OpenAIProviderConfig } from '../brain_config.ts'
|
|
54
|
+
import type { MCPServer } from '../mcp_server.ts'
|
|
55
|
+
import { resolveMcpTools, type ResolveMcpToolsOptions } from '../mcp/resolve_mcp_tools.ts'
|
|
51
56
|
import type { Provider, RunWithToolsOptions } from '../provider.ts'
|
|
52
57
|
import type { Tool } from '../tool.ts'
|
|
53
58
|
import { ToolExecutionError } from '../tool_execution_error.ts'
|
|
@@ -66,20 +71,32 @@ import type {
|
|
|
66
71
|
|
|
67
72
|
const DEFAULT_OPENAI_MODEL = 'gpt-5'
|
|
68
73
|
|
|
74
|
+
export interface OpenAIProviderOptions {
|
|
75
|
+
client?: OpenAI
|
|
76
|
+
/**
|
|
77
|
+
* Internal seam — tests inject a stub MCP client factory so MCP
|
|
78
|
+
* tool resolution doesn't dial the network. Real apps leave it
|
|
79
|
+
* unset; the provider uses the default `MCPClient`.
|
|
80
|
+
*/
|
|
81
|
+
mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
|
|
82
|
+
}
|
|
83
|
+
|
|
69
84
|
export class OpenAIProvider implements Provider {
|
|
70
85
|
readonly name: string
|
|
71
86
|
private readonly client: OpenAI
|
|
72
87
|
private readonly defaultModel: string
|
|
73
88
|
private readonly defaultMaxTokens: number
|
|
89
|
+
private readonly mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
|
|
74
90
|
|
|
75
91
|
constructor(
|
|
76
92
|
name: string,
|
|
77
93
|
config: OpenAIProviderConfig,
|
|
78
|
-
options:
|
|
94
|
+
options: OpenAIProviderOptions = {},
|
|
79
95
|
) {
|
|
80
96
|
this.name = name
|
|
81
97
|
this.defaultModel = config.defaultModel ?? DEFAULT_OPENAI_MODEL
|
|
82
98
|
this.defaultMaxTokens = config.defaultMaxTokens ?? 4096
|
|
99
|
+
this.mcpClientFactory = options.mcpClientFactory
|
|
83
100
|
this.client =
|
|
84
101
|
options.client ??
|
|
85
102
|
new OpenAI({
|
|
@@ -129,12 +146,25 @@ export class OpenAIProvider implements Provider {
|
|
|
129
146
|
tools: readonly Tool[],
|
|
130
147
|
options: RunWithToolsOptions = {},
|
|
131
148
|
): Promise<AgentResult> {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
149
|
+
const mcpServers: readonly MCPServer[] = options.mcpServers ?? []
|
|
150
|
+
const resolved =
|
|
151
|
+
mcpServers.length > 0
|
|
152
|
+
? await resolveMcpTools(mcpServers, {
|
|
153
|
+
...(this.mcpClientFactory ? { clientFactory: this.mcpClientFactory } : {}),
|
|
154
|
+
})
|
|
155
|
+
: { tools: [] as Tool[], close: async () => {} }
|
|
156
|
+
try {
|
|
157
|
+
return await this._runLoop(messages, [...tools, ...resolved.tools], options)
|
|
158
|
+
} finally {
|
|
159
|
+
await resolved.close()
|
|
137
160
|
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async _runLoop(
|
|
164
|
+
messages: readonly Message[],
|
|
165
|
+
tools: readonly Tool[],
|
|
166
|
+
options: RunWithToolsOptions,
|
|
167
|
+
): Promise<AgentResult> {
|
|
138
168
|
const maxIterations = options.maxIterations ?? 10
|
|
139
169
|
const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
|
|
140
170
|
const workingMessages: Message[] = [...messages]
|