@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.
- package/package.json +4 -2
- package/src/agent.ts +34 -5
- package/src/agent_generate_result.ts +2 -0
- package/src/agent_result.ts +7 -0
- package/src/agent_runner.ts +134 -15
- package/src/agent_stream_event.ts +100 -0
- package/src/brain_config.ts +91 -1
- package/src/brain_manager.ts +287 -6
- package/src/brain_provider.ts +25 -1
- package/src/index.ts +37 -2
- package/src/mcp/client.ts +99 -13
- package/src/mcp/index.ts +7 -0
- package/src/mcp/oauth.ts +227 -0
- package/src/mcp/pool.ts +106 -0
- package/src/mcp/resolve_mcp_tools.ts +31 -9
- package/src/mcp_server.ts +16 -0
- package/src/persistence/brain_message.ts +34 -0
- package/src/persistence/brain_message_repository.ts +106 -0
- package/src/persistence/brain_store.ts +166 -0
- package/src/persistence/brain_suspended_run.ts +30 -0
- package/src/persistence/brain_suspended_run_repository.ts +68 -0
- package/src/persistence/brain_thread.ts +30 -0
- package/src/persistence/brain_thread_repository.ts +65 -0
- package/src/persistence/database_brain_store.ts +190 -0
- package/src/persistence/index.ts +48 -0
- package/src/persistence/schema/brain_message_schema.ts +61 -0
- package/src/persistence/schema/brain_suspended_run_schema.ts +58 -0
- package/src/persistence/schema/brain_thread_schema.ts +50 -0
- package/src/persistence/schema/index.ts +3 -0
- package/src/provider.ts +145 -1
- package/src/providers/anthropic_provider.ts +723 -38
- package/src/providers/deepseek_provider.ts +117 -0
- package/src/providers/gemini_provider.ts +625 -33
- package/src/providers/ollama_provider.ts +86 -0
- package/src/providers/openai_compat_provider.ts +616 -0
- package/src/providers/openai_provider.ts +801 -43
- package/src/providers/openai_responses_provider.ts +1015 -0
- package/src/suspended_run.ts +153 -0
- package/src/thread.ts +40 -1
- package/src/tool.ts +7 -0
- package/src/tool_runner.ts +81 -0
- 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
|
-
|
|
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
|
+
}
|