@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,153 @@
1
+ /**
2
+ * `SuspendedRun` — what `runWithTools` (and `runner.run()`) returns
3
+ * when the agentic loop pauses because `shouldSuspend(call)` returned
4
+ * `true` for a tool the model wants to call.
5
+ *
6
+ * Use case: human-in-the-loop gating. The integrator inspects
7
+ * `pendingToolCalls`, obtains results out-of-band (human approval,
8
+ * external worker, queued job, ...), and calls
9
+ * `brain.resumeTools(state, results, ...)` or
10
+ * `runner.resume(state, results)` to continue the conversation.
11
+ *
12
+ * State model:
13
+ * - `state.messages` contains every message exchanged up to and
14
+ * including the assistant turn that requested the pending tool
15
+ * calls. Resume picks up by appending tool_result blocks for
16
+ * each pending call and re-entering the loop — no special
17
+ * provider-level resume hook is needed.
18
+ * - `state` is plain JSON — apps persist it across process
19
+ * boundaries (e.g., one row per pending agent run in Postgres).
20
+ *
21
+ * Mid-batch invariant: when a tool call in a multi-call batch
22
+ * triggers suspension, ALL remaining calls in that same batch are
23
+ * captured together in `pendingToolCalls`. Apps MUST supply results
24
+ * for every entry on resume; otherwise the provider's
25
+ * tool_use / tool_result pairing becomes unbalanced and the next
26
+ * model call rejects.
27
+ */
28
+
29
+ import { BrainError } from './brain_error.ts'
30
+ import type {
31
+ ChatUsage,
32
+ ContentBlock,
33
+ Message,
34
+ ToolResultBlock,
35
+ ToolUseBlock,
36
+ } from './types.ts'
37
+
38
+ export interface SuspendedRun {
39
+ status: 'suspended'
40
+ /**
41
+ * The model's pending tool calls — the one that triggered the
42
+ * suspension, plus any unexecuted siblings from the same
43
+ * assistant turn. Match by `id` when supplying results.
44
+ */
45
+ pendingToolCalls: ToolUseBlock[]
46
+ /** JSON-serializable snapshot of the loop state at the suspension point. */
47
+ state: SuspendedState
48
+ }
49
+
50
+ export interface SuspendedState {
51
+ /** Full message history up to and including the suspending assistant turn. */
52
+ messages: Message[]
53
+ /** Iteration count at the suspension point — preserved across resume. */
54
+ iterations: number
55
+ /** Aggregated token usage across the iterations completed so far. */
56
+ usage: ChatUsage
57
+ /**
58
+ * Provider response id captured at the suspension point. When the
59
+ * provider supports stateful conversations (OpenAI Responses API),
60
+ * resume threads this back through `previousResponseId` so the
61
+ * model picks up exactly where it paused.
62
+ */
63
+ responseId?: string
64
+ }
65
+
66
+ /**
67
+ * Result of one pending tool call, supplied to `resumeTools`. The
68
+ * shape mirrors `ToolResultBlock` minus the `type` discriminator —
69
+ * the framework builds the block at resume time.
70
+ *
71
+ * To signal a failure (so the model adapts rather than crashing the
72
+ * loop), pass a string describing the error as `content` and set
73
+ * `isError: true`.
74
+ */
75
+ export interface ToolResultInput {
76
+ toolUseId: string
77
+ content: string
78
+ isError?: boolean
79
+ }
80
+
81
+ /**
82
+ * Type guard. Convenient at call sites that need to discriminate
83
+ * between a completed `AgentResult` and a `SuspendedRun`.
84
+ *
85
+ * ```ts
86
+ * const out = await brain.runTools(prompt, tools, { shouldSuspend })
87
+ * if (isSuspended(out)) {
88
+ * await persistForLater(out.pendingToolCalls, out.state)
89
+ * return
90
+ * }
91
+ * render(out.text)
92
+ * ```
93
+ */
94
+ export function isSuspended(value: unknown): value is SuspendedRun {
95
+ return (
96
+ typeof value === 'object' &&
97
+ value !== null &&
98
+ (value as { status?: unknown }).status === 'suspended'
99
+ )
100
+ }
101
+
102
+ /**
103
+ * Append a `tool_result` user-role message to `state.messages` that
104
+ * carries one block per supplied result. Validates that the pending
105
+ * tool_use ids referenced in the latest assistant turn are all
106
+ * covered — missing results throw `BrainError` so the next provider
107
+ * call doesn't fail with an opaque "tool_use without tool_result"
108
+ * upstream error.
109
+ *
110
+ * Exported for `BrainManager.resumeTools` / `AgentRunner.resume`;
111
+ * tests can use it directly to verify resume mechanics without
112
+ * round-tripping through a provider.
113
+ */
114
+ export function appendResumeResults(
115
+ state: SuspendedState,
116
+ results: readonly ToolResultInput[],
117
+ ): Message[] {
118
+ const pending = collectPendingIds(state.messages)
119
+ for (const id of pending) {
120
+ if (!results.some((r) => r.toolUseId === id)) {
121
+ throw new BrainError(
122
+ `resumeTools: missing result for pending tool call id "${id}". Every pending tool_use in the suspending assistant turn must be answered on resume.`,
123
+ { context: { pendingIds: [...pending], suppliedIds: results.map((r) => r.toolUseId) } },
124
+ )
125
+ }
126
+ }
127
+ const resultBlocks: ContentBlock[] = results.map((r) => {
128
+ const block: ToolResultBlock = {
129
+ type: 'tool_result',
130
+ toolUseId: r.toolUseId,
131
+ content: r.content,
132
+ ...(r.isError ? { isError: true } : {}),
133
+ }
134
+ return block
135
+ })
136
+ return [...state.messages, { role: 'user', content: resultBlocks }]
137
+ }
138
+
139
+ /**
140
+ * Look at the latest assistant turn in `messages` and pull every
141
+ * tool_use block's id. Used to validate resume coverage.
142
+ */
143
+ function collectPendingIds(messages: readonly Message[]): string[] {
144
+ for (let i = messages.length - 1; i >= 0; i--) {
145
+ const m = messages[i]!
146
+ if (m.role !== 'assistant') continue
147
+ if (typeof m.content === 'string') return []
148
+ return m.content
149
+ .filter((b): b is ToolUseBlock => b.type === 'tool_use')
150
+ .map((b) => b.id)
151
+ }
152
+ return []
153
+ }
package/src/thread.ts CHANGED
@@ -35,6 +35,14 @@ export interface ThreadState {
35
35
  messages: Message[]
36
36
  system?: SystemPrompt
37
37
  options?: ChatOptions
38
+ /**
39
+ * Last provider response id captured by `send(...)` — restored on
40
+ * `fromJSON` so subsequent sends thread it via
41
+ * `ChatOptions.previousResponseId` automatically. Only ever set
42
+ * when the underlying provider surfaces `responseId` (OpenAI
43
+ * Responses API today).
44
+ */
45
+ lastResponseId?: string
38
46
  }
39
47
 
40
48
  export class Thread {
@@ -42,6 +50,13 @@ export class Thread {
42
50
  readonly messages: Message[] = []
43
51
  readonly system?: SystemPrompt
44
52
  readonly options?: ChatOptions
53
+ /**
54
+ * Last response id returned by the provider on this thread. Used to
55
+ * thread stateful-conversation hints (OpenAI Responses API) into
56
+ * the next `send(...)` so apps don't have to manage it manually.
57
+ * `undefined` for providers that don't surface a response id.
58
+ */
59
+ lastResponseId?: string
45
60
  private readonly brain: BrainManager
46
61
 
47
62
  constructor(brain: BrainManager, opts: ThreadOptions = {}) {
@@ -54,6 +69,11 @@ export class Thread {
54
69
  * Append a user turn, call the model, append the assistant reply,
55
70
  * and return the reply text. Per-call options override the
56
71
  * thread's defaults; `system` always comes from the thread.
72
+ *
73
+ * When the underlying provider supports stateful conversations
74
+ * (OpenAI Responses API), `previousResponseId` is auto-threaded
75
+ * from the prior turn — apps don't need to manage it. Per-call
76
+ * `options.previousResponseId` wins if supplied explicitly.
57
77
  */
58
78
  async send(text: string, options: ChatOptions = {}): Promise<string> {
59
79
  this.messages.push({ role: 'user', content: text })
@@ -65,8 +85,25 @@ export class Thread {
65
85
  // mid-thread by changing the system prompt every turn.
66
86
  ...(this.system !== undefined ? { system: this.system } : {}),
67
87
  }
88
+ if (
89
+ merged.previousResponseId === undefined &&
90
+ this.lastResponseId !== undefined
91
+ ) {
92
+ merged.previousResponseId = this.lastResponseId
93
+ }
68
94
  const result = await this.brain.chat(this.messages, merged)
69
- this.messages.push({ role: 'assistant', content: result.text })
95
+ // Preserve structured assistant content when present (compaction
96
+ // blocks today; reasoning blocks later). Round-tripping these
97
+ // back to the provider on subsequent sends is what makes
98
+ // server-side compaction actually save tokens — once a turn
99
+ // carries a `compaction` block, the older raw turns drop out
100
+ // and the model only re-reads the summary.
101
+ if (result.content !== undefined && result.content.length > 0) {
102
+ this.messages.push({ role: 'assistant', content: result.content })
103
+ } else {
104
+ this.messages.push({ role: 'assistant', content: result.text })
105
+ }
106
+ if (result.responseId !== undefined) this.lastResponseId = result.responseId
70
107
  return result.text
71
108
  }
72
109
 
@@ -80,6 +117,7 @@ export class Thread {
80
117
  const state: ThreadState = { messages: [...this.messages] }
81
118
  if (this.system !== undefined) state.system = this.system
82
119
  if (this.options !== undefined) state.options = this.options
120
+ if (this.lastResponseId !== undefined) state.lastResponseId = this.lastResponseId
83
121
  return state
84
122
  }
85
123
 
@@ -94,6 +132,7 @@ export class Thread {
94
132
  if (state.options !== undefined) options.options = state.options
95
133
  const thread = new Thread(brain, options)
96
134
  for (const m of state.messages) thread.messages.push(m)
135
+ if (state.lastResponseId !== undefined) thread.lastResponseId = state.lastResponseId
97
136
  return thread
98
137
  }
99
138
  }
package/src/tool.ts CHANGED
@@ -23,6 +23,13 @@ export interface ToolContext {
23
23
  readonly callId: string
24
24
  /** Per-run free-form context bag passed by the caller. Optional. */
25
25
  readonly context: Readonly<Record<string, unknown>>
26
+ /**
27
+ * Cancellation signal forwarded from the run's `options.signal`.
28
+ * Tools that wrap network calls (HTTP fetches, MCP servers, child
29
+ * processes) should pass this through so cancellation actually
30
+ * unwinds in-flight work.
31
+ */
32
+ readonly signal?: AbortSignal
26
33
  }
27
34
 
28
35
  export interface Tool<TInput = unknown, TOutput = unknown> {
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `runToolWithRecovery` — shared helper used by every provider's
3
+ * agentic loop to execute one tool call.
4
+ *
5
+ * Encapsulates two error paths and the optional `onToolError`
6
+ * recovery callback:
7
+ *
8
+ * 1. **Tool not registered** — the model called a name that
9
+ * isn't in `toolMap`. Without recovery, throw
10
+ * `ToolExecutionError`. With recovery, the callback's return
11
+ * string becomes the `tool_result.content` (with `isError:
12
+ * true`) and the loop continues — the model sees "unknown
13
+ * tool" and adapts.
14
+ *
15
+ * 2. **`execute()` throws** — the tool's body raised. Same
16
+ * pattern: either rethrow as `ToolExecutionError` or feed
17
+ * back as an error result.
18
+ *
19
+ * The returned shape is the framework-agnostic `{ content, isError }`
20
+ * pair each provider then wraps into its own `tool_result` block
21
+ * shape (Anthropic `tool_result` with `is_error`; OpenAI tool-role
22
+ * message content; Gemini `functionResponse` with `{ error }`).
23
+ */
24
+
25
+ import type { RunWithToolsOptions } from './provider.ts'
26
+ import type { Tool, ToolContext } from './tool.ts'
27
+ import { ToolExecutionError } from './tool_execution_error.ts'
28
+
29
+ export interface ToolRunResult {
30
+ content: string
31
+ isError: boolean
32
+ }
33
+
34
+ export async function runToolWithRecovery(
35
+ tool: Tool | undefined,
36
+ toolName: string,
37
+ callId: string,
38
+ input: unknown,
39
+ options: RunWithToolsOptions,
40
+ ): Promise<ToolRunResult> {
41
+ if (!tool) {
42
+ return recoverOrThrow(
43
+ new ToolExecutionError(
44
+ toolName,
45
+ callId,
46
+ new Error(`Tool "${toolName}" is not registered.`),
47
+ ),
48
+ options,
49
+ )
50
+ }
51
+
52
+ const ctx: ToolContext = {
53
+ callId,
54
+ context: options.context ?? {},
55
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
56
+ }
57
+ let output: unknown
58
+ try {
59
+ output = await tool.execute(input, ctx)
60
+ } catch (cause) {
61
+ return recoverOrThrow(new ToolExecutionError(toolName, callId, cause), options)
62
+ }
63
+ return {
64
+ content: typeof output === 'string' ? output : JSON.stringify(output),
65
+ isError: false,
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Resolve a `ToolExecutionError` through the `onToolError` callback
71
+ * (when set) or rethrow. Used by providers for failures that happen
72
+ * outside `tool.execute` — e.g., OpenAI's JSON-parse-arguments path.
73
+ */
74
+ export function recoverOrThrow(
75
+ error: ToolExecutionError,
76
+ options: RunWithToolsOptions,
77
+ ): ToolRunResult {
78
+ const recovered = options.onToolError?.(error)
79
+ if (typeof recovered !== 'string') throw error
80
+ return { content: recovered, isError: true }
81
+ }