@strav/brain 1.0.0-alpha.16 → 1.0.0-alpha.18

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.
Files changed (42) hide show
  1. package/package.json +4 -2
  2. package/src/agent.ts +34 -5
  3. package/src/agent_generate_result.ts +2 -0
  4. package/src/agent_result.ts +7 -0
  5. package/src/agent_runner.ts +134 -15
  6. package/src/agent_stream_event.ts +100 -0
  7. package/src/brain_config.ts +91 -1
  8. package/src/brain_manager.ts +287 -6
  9. package/src/brain_provider.ts +25 -1
  10. package/src/index.ts +37 -2
  11. package/src/mcp/client.ts +99 -13
  12. package/src/mcp/index.ts +7 -0
  13. package/src/mcp/oauth.ts +227 -0
  14. package/src/mcp/pool.ts +106 -0
  15. package/src/mcp/resolve_mcp_tools.ts +31 -9
  16. package/src/mcp_server.ts +16 -0
  17. package/src/persistence/brain_message.ts +34 -0
  18. package/src/persistence/brain_message_repository.ts +106 -0
  19. package/src/persistence/brain_store.ts +166 -0
  20. package/src/persistence/brain_suspended_run.ts +30 -0
  21. package/src/persistence/brain_suspended_run_repository.ts +68 -0
  22. package/src/persistence/brain_thread.ts +30 -0
  23. package/src/persistence/brain_thread_repository.ts +65 -0
  24. package/src/persistence/database_brain_store.ts +190 -0
  25. package/src/persistence/index.ts +48 -0
  26. package/src/persistence/schema/brain_message_schema.ts +61 -0
  27. package/src/persistence/schema/brain_suspended_run_schema.ts +58 -0
  28. package/src/persistence/schema/brain_thread_schema.ts +50 -0
  29. package/src/persistence/schema/index.ts +3 -0
  30. package/src/provider.ts +145 -1
  31. package/src/providers/anthropic_provider.ts +723 -38
  32. package/src/providers/deepseek_provider.ts +117 -0
  33. package/src/providers/gemini_provider.ts +625 -33
  34. package/src/providers/ollama_provider.ts +86 -0
  35. package/src/providers/openai_compat_provider.ts +616 -0
  36. package/src/providers/openai_provider.ts +801 -43
  37. package/src/providers/openai_responses_provider.ts +1015 -0
  38. package/src/suspended_run.ts +153 -0
  39. package/src/thread.ts +40 -1
  40. package/src/tool.ts +7 -0
  41. package/src/tool_runner.ts +81 -0
  42. package/src/types.ts +343 -0
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `brainSuspendedRunSchema` — a paused agentic loop awaiting
3
+ * human-in-the-loop tool approval.
4
+ *
5
+ * Two real use cases drive the shape:
6
+ *
7
+ * 1. **Linked to a thread** — the suspending run was part of a
8
+ * conversational thread; the app wants the suspended state to
9
+ * reference its thread so the UI can show "thread X is paused
10
+ * waiting on Y." `thread_id` is the FK, nullable so detached
11
+ * runs are fine.
12
+ * 2. **Standalone** — the run came from a one-shot `runTools(...)`
13
+ * call (cron job, queued worker, ...). No thread context;
14
+ * `thread_id` stays NULL.
15
+ *
16
+ * Columns:
17
+ *
18
+ * - `id` ULID primary key. The id apps reference
19
+ * when resuming.
20
+ * - `thread_id` FK → `brain_thread`, NULLABLE,
21
+ * `onDelete: set null` — if the thread
22
+ * gets deleted, the suspended run keeps
23
+ * its data so the human approver can
24
+ * still inspect it.
25
+ * - `user_id` App-defined approver / owner.
26
+ * - `pending_tool_calls` JSONB — `ToolUseBlock[]` the model
27
+ * wants executed. Multi-call batches are
28
+ * captured together (mid-batch invariant).
29
+ * - `state` JSONB — `SuspendedState` snapshot. The
30
+ * framework's `brain.resumeTools(state,
31
+ * ...)` takes this as its first arg.
32
+ * - `status` `pending | resumed | cancelled`. Apps
33
+ * bulk-list pending runs and walk through
34
+ * an approval queue.
35
+ * - `timestamps` `created_at` for "how long pending?"
36
+ * sorts, `updated_at` for transition
37
+ * tracking.
38
+ *
39
+ * Tenanted: standard `tenant_id` + RLS.
40
+ */
41
+
42
+ import { Archetype, defineSchema } from '@strav/database'
43
+ import { brainThreadSchema } from './brain_thread_schema.ts'
44
+
45
+ export const brainSuspendedRunSchema = defineSchema(
46
+ 'brain_suspended_run',
47
+ Archetype.Entity,
48
+ (t) => {
49
+ t.id()
50
+ t.reference('thread_id').to(brainThreadSchema).onDelete('set null').nullable()
51
+ t.string('user_id').max(64).nullable()
52
+ t.json('pending_tool_calls').notNull()
53
+ t.json('state').notNull()
54
+ t.enum('status', ['pending', 'resumed', 'cancelled']).notNull().default('pending')
55
+ t.timestamps()
56
+ },
57
+ { tenanted: true },
58
+ )
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `brainThreadSchema` — one row per conversation.
3
+ *
4
+ * Carries the per-thread defaults that `Thread` already serializes
5
+ * (`system`, `options`, `lastResponseId`) plus a few framework-side
6
+ * fields apps want to filter / sort on:
7
+ *
8
+ * - `id` ULID primary key. Hand the same value back to
9
+ * `BrainStore.loadThread(id)` to rehydrate.
10
+ * - `user_id` App-defined owner. Stored as `text` (no FK) —
11
+ * user table shape varies per app. Indexed in
12
+ * the recommended migration so "list threads
13
+ * for user X" stays fast.
14
+ * - `title` Human label. Apps set it from the first user
15
+ * turn or via an explicit "rename" UI.
16
+ * - `system` Thread-owned system prompt. Mirrors
17
+ * `ThreadState.system`. JSONB so the structured
18
+ * form (text + cache flag) round-trips.
19
+ * - `options` Thread defaults applied to every `send()`.
20
+ * Mirrors `ThreadState.options`.
21
+ * - `last_response_id` OpenAI Responses API stateful pointer.
22
+ * Mirrors `ThreadState.lastResponseId`. NULL for
23
+ * non-Responses providers.
24
+ * - `timestamps` `created_at` + `updated_at` for sort / audit.
25
+ *
26
+ * Tenanted: `tenant_id` FK + RLS policies auto-injected by
27
+ * `@strav/database`. Apps wrap calls in `tenants.withTenant(...)`
28
+ * and the database enforces isolation — no app-level filter needed.
29
+ *
30
+ * The per-turn message history lives in `brain_message`, joined by
31
+ * `thread_id`. This keeps every send to an O(1) INSERT and makes
32
+ * pagination / per-turn analytics cheap.
33
+ */
34
+
35
+ import { Archetype, defineSchema } from '@strav/database'
36
+
37
+ export const brainThreadSchema = defineSchema(
38
+ 'brain_thread',
39
+ Archetype.Entity,
40
+ (t) => {
41
+ t.id()
42
+ t.string('user_id').max(64).nullable()
43
+ t.string('title').max(255).nullable()
44
+ t.json('system').nullable()
45
+ t.json('options').nullable()
46
+ t.string('last_response_id').max(128).nullable()
47
+ t.timestamps()
48
+ },
49
+ { tenanted: true },
50
+ )
@@ -0,0 +1,3 @@
1
+ export { brainMessageSchema } from './brain_message_schema.ts'
2
+ export { brainSuspendedRunSchema } from './brain_suspended_run_schema.ts'
3
+ export { brainThreadSchema } from './brain_thread_schema.ts'
package/src/provider.ts CHANGED
@@ -12,16 +12,26 @@
12
12
  * subclassing.
13
13
  */
14
14
 
15
+ import type { AgentGenerateResult } from './agent_generate_result.ts'
15
16
  import type { AgentResult } from './agent_result.ts'
17
+ import type { AgentStreamEvent } from './agent_stream_event.ts'
16
18
  import type { MCPServer } from './mcp_server.ts'
17
19
  import type { OutputSchema } from './output_schema.ts'
20
+ import type { SuspendedRun } from './suspended_run.ts'
18
21
  import type { Tool } from './tool.ts'
22
+ import type { ToolExecutionError } from './tool_execution_error.ts'
19
23
  import type {
24
+ AudioSource,
20
25
  ChatOptions,
21
26
  ChatResult,
27
+ EmbedOptions,
28
+ EmbedResult,
22
29
  GenerateResult,
23
30
  Message,
24
31
  StreamEvent,
32
+ ToolUseBlock,
33
+ TranscribeOptions,
34
+ TranscribeResult,
25
35
  } from './types.ts'
26
36
 
27
37
  export interface RunWithToolsOptions extends ChatOptions {
@@ -37,6 +47,63 @@ export interface RunWithToolsOptions extends ChatOptions {
37
47
  * resulting `mcp_tool_use` / `mcp_tool_result` blocks.
38
48
  */
39
49
  mcpServers?: readonly MCPServer[]
50
+ /**
51
+ * Tool-error recovery hook. Called when a tool's `execute` throws
52
+ * — OR when the model called a tool that isn't registered. Two
53
+ * outcomes:
54
+ *
55
+ * - Return a string → the loop continues. The string lands as
56
+ * `tool_result.content` with `isError: true`, the model sees
57
+ * the error and can adapt (try a different approach, ask the
58
+ * user, give up). Recommended for production agents that
59
+ * should survive transient failures.
60
+ *
61
+ * - Return `undefined` (the default when this option is unset)
62
+ * → the framework throws `ToolExecutionError` and the loop
63
+ * aborts. Same behavior as before this option existed.
64
+ *
65
+ * The hook may inspect `error.cause` to filter — e.g., feed back
66
+ * transient HTTP errors but rethrow programmer errors:
67
+ *
68
+ * ```ts
69
+ * onToolError: (err) =>
70
+ * err.cause instanceof TransientError ? err.cause.message : undefined
71
+ * ```
72
+ */
73
+ onToolError?(error: ToolExecutionError): string | undefined
74
+ /**
75
+ * Human-in-the-loop gate. Called before each tool execution; when
76
+ * it returns `true`, the loop suspends and `runWithTools` returns
77
+ * a `SuspendedRun` carrying the pending tool calls + a JSON-
78
+ * serializable snapshot of the loop state. Apps obtain results
79
+ * out-of-band (human approval, queued worker, external system,
80
+ * ...) and call `brain.resumeTools(state, results, tools, options)`
81
+ * to continue.
82
+ *
83
+ * Mid-batch invariant: if a tool call inside a multi-call batch
84
+ * triggers suspension, the framework also captures all unexecuted
85
+ * siblings from the same assistant turn — the provider's
86
+ * `tool_use` / `tool_result` pairing must stay balanced on resume.
87
+ *
88
+ * V1 scope: only honored on non-streaming `runWithTools`. Pass it
89
+ * to `streamWithTools`, `runWithToolsAndSchema`, or
90
+ * `streamWithToolsAndSchema` and the framework throws `BrainError`
91
+ * — those entrypoints don't yet model the pause/resume protocol.
92
+ */
93
+ shouldSuspend?(
94
+ call: ToolUseBlock,
95
+ context?: Record<string, unknown>,
96
+ ): boolean | Promise<boolean>
97
+ }
98
+
99
+ /**
100
+ * Same as `RunWithToolsOptions` but with `shouldSuspend` required.
101
+ * Used to narrow the return type of `runWithTools` overloads — when
102
+ * apps opt in to the human-in-the-loop gate, the result widens to
103
+ * `AgentResult | SuspendedRun`; otherwise it's just `AgentResult`.
104
+ */
105
+ export type RunWithToolsOptionsWithSuspend = RunWithToolsOptions & {
106
+ shouldSuspend: NonNullable<RunWithToolsOptions['shouldSuspend']>
40
107
  }
41
108
 
42
109
  export interface Provider {
@@ -81,7 +148,7 @@ export interface Provider {
81
148
  messages: readonly Message[],
82
149
  tools: readonly Tool[],
83
150
  options?: RunWithToolsOptions,
84
- ): Promise<AgentResult>
151
+ ): Promise<AgentResult | SuspendedRun>
85
152
 
86
153
  /**
87
154
  * Structured output. Sends `messages` to the model with a
@@ -99,4 +166,81 @@ export interface Provider {
99
166
  schema: OutputSchema<T>,
100
167
  options?: ChatOptions,
101
168
  ): Promise<GenerateResult<T>>
169
+
170
+ /**
171
+ * Tool-loop + structured output combined. Runs the agentic loop
172
+ * with the same tool-handling as `runWithTools`, but pins a
173
+ * JSON-Schema constraint on every turn — so when the model
174
+ * finally answers without calling a tool, its text is JSON
175
+ * matching the schema. Returns the parsed value alongside the
176
+ * loop bookkeeping.
177
+ *
178
+ * Optional on the interface; `BrainManager.generateWithTools`
179
+ * throws `BrainError` when the configured provider lacks it.
180
+ */
181
+ runWithToolsAndSchema?<T>(
182
+ messages: readonly Message[],
183
+ tools: readonly Tool[],
184
+ schema: OutputSchema<T>,
185
+ options?: RunWithToolsOptions,
186
+ ): Promise<AgentGenerateResult<T>>
187
+
188
+ /**
189
+ * Streaming variant of `runWithToolsAndSchema`. Same agentic loop,
190
+ * same schema constraint on every turn — yielded as
191
+ * `AgentStreamEvent<T>`s. The terminal `stop` event carries the
192
+ * parsed `value` + raw `text` alongside the loop bookkeeping.
193
+ *
194
+ * Optional; `BrainManager.streamGenerateWithTools` throws
195
+ * `BrainError` when the chosen provider doesn't implement it.
196
+ */
197
+ streamWithToolsAndSchema?<T>(
198
+ messages: readonly Message[],
199
+ tools: readonly Tool[],
200
+ schema: OutputSchema<T>,
201
+ options?: RunWithToolsOptions,
202
+ ): AsyncIterable<AgentStreamEvent<T>>
203
+
204
+ /**
205
+ * Streaming variant of `runWithTools`. Yields `AgentStreamEvent`s
206
+ * as the loop progresses — text deltas during model turns,
207
+ * `tool_use` / `tool_result` boundaries around tool execution,
208
+ * `iteration_start` / `iteration_end` per round, a terminal
209
+ * `stop` with the full trace + usage.
210
+ *
211
+ * Optional — providers without a streaming tool-loop implementation
212
+ * can omit it; `BrainManager.streamTools` throws `BrainError` in
213
+ * that case.
214
+ */
215
+ streamWithTools?(
216
+ messages: readonly Message[],
217
+ tools: readonly Tool[],
218
+ options?: RunWithToolsOptions,
219
+ ): AsyncIterable<AgentStreamEvent>
220
+
221
+ /**
222
+ * Embeddings — turn one or more text inputs into vectors for
223
+ * similarity search / RAG / clustering. Optional because not
224
+ * every provider exposes an embeddings endpoint (V1: Anthropic
225
+ * and DeepSeek don't; OpenAI, Gemini, Ollama do).
226
+ */
227
+ embed?(
228
+ texts: readonly string[],
229
+ options?: EmbedOptions,
230
+ ): Promise<EmbedResult>
231
+
232
+ /**
233
+ * Audio transcription — convert an audio clip to text.
234
+ * Complements `AudioBlock` (which sends audio + text together
235
+ * to a multimodal chat model) by exposing the dedicated
236
+ * transcription endpoint where the provider has one. V1:
237
+ * OpenAI (Whisper / gpt-4o-transcribe), Ollama (inherits via
238
+ * OpenAI-compat), Gemini (chat-wrap fallback — internally
239
+ * sends an AudioBlock with a "transcribe verbatim" prompt).
240
+ * Anthropic + DeepSeek throw — no transcription API.
241
+ */
242
+ transcribe?(
243
+ audio: AudioSource,
244
+ options?: TranscribeOptions,
245
+ ): Promise<TranscribeResult>
102
246
  }