botholomew 0.18.6 → 0.19.3
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/README.md +56 -2
- package/package.json +12 -9
- package/src/chat/agent.ts +175 -181
- package/src/chat/session.ts +30 -31
- package/src/chat/usage.ts +19 -20
- package/src/commands/init.ts +20 -0
- package/src/config/loader.ts +50 -10
- package/src/config/schemas.ts +48 -22
- package/src/init/index.ts +12 -5
- package/src/init/templates.ts +45 -4
- package/src/llm/abort.ts +9 -0
- package/src/llm/cache-control.ts +65 -0
- package/src/llm/capabilities.ts +155 -0
- package/src/llm/error-format.ts +95 -0
- package/src/llm/fake.ts +226 -0
- package/src/llm/index.ts +19 -0
- package/src/llm/provider-options.ts +29 -0
- package/src/llm/provider.ts +65 -0
- package/src/llm/tools.ts +24 -0
- package/src/llm/types.ts +20 -0
- package/src/llm/usage.ts +33 -0
- package/src/prompts/capabilities.ts +72 -108
- package/src/tools/membot/adapter.ts +8 -6
- package/src/tools/membot/edit.ts +1 -1
- package/src/tools/tool.ts +2 -22
- package/src/tui/components/ContextPanel.tsx +1 -1
- package/src/tui/hooks/useMessageQueue.ts +2 -1
- package/src/tui/markdown.ts +45 -2
- package/src/tui/markdownTables.ts +288 -0
- package/src/utils/title.ts +21 -22
- package/src/worker/context.ts +45 -77
- package/src/worker/llm.ts +147 -112
- package/src/worker/prompt.ts +1 -1
- package/src/worker/schedules.ts +43 -54
- package/src/worker/tick.ts +3 -3
- package/src/worker/fake-llm.ts +0 -277
- package/src/worker/llm-client.ts +0 -12
package/README.md
CHANGED
|
@@ -101,9 +101,14 @@ bun run dev -- --help
|
|
|
101
101
|
# 1. Initialize a project in the current directory
|
|
102
102
|
botholomew init
|
|
103
103
|
|
|
104
|
-
#
|
|
104
|
+
# 2a. Add your Anthropic key (Claude is the default) to config/config.json, or export it
|
|
105
105
|
export ANTHROPIC_API_KEY=sk-ant-...
|
|
106
|
-
# Embeddings run locally
|
|
106
|
+
# Embeddings always run locally.
|
|
107
|
+
#
|
|
108
|
+
# 2b. ...or initialize for a local Ollama model — no API key required:
|
|
109
|
+
# ollama serve & ollama pull llama3.1:8b
|
|
110
|
+
# botholomew init --force --provider ollama
|
|
111
|
+
# See docs/configuration.md for OpenAI-compatible endpoints (LM Studio, OpenRouter, etc.).
|
|
107
112
|
|
|
108
113
|
# 3. Queue some work
|
|
109
114
|
botholomew task add "Summarize every markdown file in ~/notes"
|
|
@@ -121,6 +126,55 @@ want Botholomew to advance on its own.
|
|
|
121
126
|
|
|
122
127
|
---
|
|
123
128
|
|
|
129
|
+
## Example configs
|
|
130
|
+
|
|
131
|
+
Two `config/config.json` shapes covering the common cases. Full schema in
|
|
132
|
+
[docs/configuration.md](docs/configuration.md).
|
|
133
|
+
|
|
134
|
+
### Anthropic (Claude — default)
|
|
135
|
+
|
|
136
|
+
```jsonc
|
|
137
|
+
{
|
|
138
|
+
"llm": {
|
|
139
|
+
"provider": "anthropic",
|
|
140
|
+
"model": "claude-opus-4-6",
|
|
141
|
+
"api_key": "sk-ant-..."
|
|
142
|
+
},
|
|
143
|
+
"chunker_llm": {
|
|
144
|
+
"provider": "anthropic",
|
|
145
|
+
"model": "claude-haiku-4-5-20251001",
|
|
146
|
+
"api_key": "sk-ant-..."
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Or leave `api_key` blank and export `ANTHROPIC_API_KEY` in your shell.
|
|
152
|
+
|
|
153
|
+
### Ollama (fully local)
|
|
154
|
+
|
|
155
|
+
```jsonc
|
|
156
|
+
{
|
|
157
|
+
"llm": {
|
|
158
|
+
"provider": "ollama",
|
|
159
|
+
"model": "qwen2.5:7b",
|
|
160
|
+
"base_url": "http://localhost:11434"
|
|
161
|
+
},
|
|
162
|
+
"chunker_llm": {
|
|
163
|
+
"provider": "ollama",
|
|
164
|
+
"model": "qwen2.5:7b",
|
|
165
|
+
"base_url": "http://localhost:11434"
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Start Ollama first: `ollama serve &` then `ollama pull qwen2.5:7b`. No
|
|
171
|
+
API key required. Tool calling is a hard requirement — known-good local
|
|
172
|
+
models include `qwen2.5:7b`, `llama3.1:8b`, `mistral-nemo`, and
|
|
173
|
+
`command-r`. For OpenAI-compatible endpoints (LM Studio, OpenRouter,
|
|
174
|
+
vLLM, …) see [docs/configuration.md](docs/configuration.md).
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
124
178
|
## What a project looks like
|
|
125
179
|
|
|
126
180
|
A project is the directory you ran `botholomew init` in. Every entity
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botholomew",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.3",
|
|
4
4
|
"description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,17 +27,20 @@
|
|
|
27
27
|
"docs:preview": "vitepress preview docs"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@
|
|
31
|
-
"@
|
|
30
|
+
"@ai-sdk/anthropic": "^3.0.78",
|
|
31
|
+
"@ai-sdk/openai-compatible": "^2.0.47",
|
|
32
|
+
"@evantahler/mcpx": "0.21.9",
|
|
33
|
+
"ai": "^6.0.188",
|
|
32
34
|
"ansis": "^4.3.0",
|
|
33
|
-
"commander": "^14.0.
|
|
35
|
+
"commander": "^14.0.3",
|
|
34
36
|
"gray-matter": "^4.0.3",
|
|
35
|
-
"ink": "^7.0.
|
|
37
|
+
"ink": "^7.0.3",
|
|
36
38
|
"ink-spinner": "^5.0.0",
|
|
37
39
|
"ink-text-input": "^6.0.0",
|
|
38
40
|
"istextorbinary": "^9.5.0",
|
|
39
|
-
"membot": "^0.
|
|
41
|
+
"membot": "^0.17.0",
|
|
40
42
|
"nanospinner": "^1.2.2",
|
|
43
|
+
"ollama-ai-provider-v2": "^3.5.1",
|
|
41
44
|
"react": "^19.2.6",
|
|
42
45
|
"uuid": "^14.0.0",
|
|
43
46
|
"wrap-ansi": "^10.0.0",
|
|
@@ -45,11 +48,11 @@
|
|
|
45
48
|
},
|
|
46
49
|
"devDependencies": {
|
|
47
50
|
"@biomejs/biome": "^2.4.15",
|
|
48
|
-
"@types/bun": "
|
|
49
|
-
"@types/react": "^19.2.
|
|
51
|
+
"@types/bun": "^1.3.14",
|
|
52
|
+
"@types/react": "^19.2.14",
|
|
50
53
|
"@types/uuid": "^11.0.0",
|
|
51
54
|
"typescript": "^6.0.3",
|
|
52
|
-
"vitepress": "^1.
|
|
55
|
+
"vitepress": "^1.6.4",
|
|
53
56
|
"vitepress-plugin-llms": "^1.12.2",
|
|
54
57
|
"vue": "^3.5.34"
|
|
55
58
|
},
|
package/src/chat/agent.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { APIUserAbortError } from "@anthropic-ai/sdk";
|
|
3
|
-
import type {
|
|
4
|
-
Message,
|
|
5
|
-
MessageParam,
|
|
6
|
-
ToolResultBlockParam,
|
|
7
|
-
ToolUseBlock,
|
|
8
|
-
} from "@anthropic-ai/sdk/resources/messages";
|
|
1
|
+
import { isAbortError } from "@ai-sdk/provider-utils";
|
|
9
2
|
import type { McpxClient } from "@evantahler/mcpx";
|
|
3
|
+
import type { LanguageModel, ModelMessage, ToolCallPart } from "ai";
|
|
4
|
+
import { streamText } from "ai";
|
|
10
5
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
6
|
+
import {
|
|
7
|
+
type AbortHandle,
|
|
8
|
+
buildProviderOptions,
|
|
9
|
+
createAbortHandle,
|
|
10
|
+
drainStreamPromises,
|
|
11
|
+
extractCacheTokens,
|
|
12
|
+
formatLlmError,
|
|
13
|
+
getLanguageModel,
|
|
14
|
+
toAiSdkTools,
|
|
15
|
+
withAnthropicCacheBreakpoints,
|
|
16
|
+
} from "../llm/index.ts";
|
|
11
17
|
import {
|
|
12
18
|
openMembot,
|
|
13
19
|
resolveMembotDir,
|
|
@@ -16,15 +22,9 @@ import {
|
|
|
16
22
|
} from "../mem/client.ts";
|
|
17
23
|
import { logInteraction } from "../threads/store.ts";
|
|
18
24
|
import { registerAllTools } from "../tools/registry.ts";
|
|
19
|
-
import {
|
|
20
|
-
getAllTools,
|
|
21
|
-
getTool,
|
|
22
|
-
type ToolContext,
|
|
23
|
-
toAnthropicTool,
|
|
24
|
-
} from "../tools/tool.ts";
|
|
25
|
+
import { getAllTools, getTool, type ToolContext } from "../tools/tool.ts";
|
|
25
26
|
import { fitToContextWindow, getMaxInputTokens } from "../worker/context.ts";
|
|
26
27
|
import { maybeStoreResult } from "../worker/large-results.ts";
|
|
27
|
-
import { createLlmClient } from "../worker/llm-client.ts";
|
|
28
28
|
import {
|
|
29
29
|
buildMetaHeader,
|
|
30
30
|
extractKeywords,
|
|
@@ -86,19 +86,18 @@ const CHAT_TOOL_NAMES = new Set([
|
|
|
86
86
|
"skill_search",
|
|
87
87
|
"skill_delete",
|
|
88
88
|
"sleep",
|
|
89
|
+
"read_large_result",
|
|
89
90
|
]);
|
|
90
91
|
|
|
91
92
|
export function getChatTools() {
|
|
92
|
-
return getAllTools()
|
|
93
|
-
.filter((t) => CHAT_TOOL_NAMES.has(t.name))
|
|
94
|
-
.map(toAnthropicTool);
|
|
93
|
+
return toAiSdkTools(getAllTools().filter((t) => CHAT_TOOL_NAMES.has(t.name)));
|
|
95
94
|
}
|
|
96
95
|
|
|
97
96
|
export async function buildChatSystemPrompt(
|
|
98
97
|
projectDir: string,
|
|
99
98
|
options?: {
|
|
100
99
|
keywordSource?: string;
|
|
101
|
-
config?:
|
|
100
|
+
config?: BotholomewConfig;
|
|
102
101
|
hasMcpTools?: boolean;
|
|
103
102
|
},
|
|
104
103
|
): Promise<string> {
|
|
@@ -175,65 +174,51 @@ export interface ChatTurnCallbacks {
|
|
|
175
174
|
isError: boolean,
|
|
176
175
|
meta?: ToolEndMeta,
|
|
177
176
|
) => void;
|
|
178
|
-
/** Side-effect notification from inside a tool ("Created subtask: …"). The
|
|
179
|
-
* TUI renders these inside the tool-call card so they stay anchored to the
|
|
180
|
-
* tool that produced them. Workers don't supply this; tools fall back to
|
|
181
|
-
* `logger.info`. */
|
|
182
177
|
onToolNotify?: (toolUseId: string, message: string) => void;
|
|
183
|
-
/** Called between LLM turns. The TUI returns any queued user messages so
|
|
184
|
-
* the agent can inject them into the running turn instead of waiting for
|
|
185
|
-
* the entire tool loop to finish. Each returned message is logged + pushed
|
|
186
|
-
* to `messages` before the next `messages.stream(...)` call. */
|
|
187
178
|
takeInjections?: () => string[];
|
|
188
|
-
/** Fired after each finalized assistant turn with the prompt size the
|
|
189
|
-
* server billed for (sum of fresh, cache-read, and cache-creation input
|
|
190
|
-
* tokens), the model's max input tokens, and a local estimate of where
|
|
191
|
-
* the bytes went. The TUI uses this to render the tab-bar indicator and
|
|
192
|
-
* the breakdown shown on the Help tab. */
|
|
193
179
|
onUsage?: (info: ContextUsage) => void;
|
|
194
180
|
}
|
|
195
181
|
|
|
196
|
-
|
|
197
|
-
* Walk messages backward to find the most recent human-authored user message.
|
|
198
|
-
* After tool turns, `messages[messages.length - 1]` is a user entry whose
|
|
199
|
-
* content is a `ToolResultBlockParam[]` — we want the string content from the
|
|
200
|
-
* actual user, not tool output, as the keyword source.
|
|
201
|
-
*/
|
|
202
|
-
function findLastUserText(messages: MessageParam[]): string {
|
|
182
|
+
function findLastUserText(messages: ModelMessage[]): string {
|
|
203
183
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
204
184
|
const m = messages[i];
|
|
205
|
-
if (m
|
|
185
|
+
if (!m) continue;
|
|
186
|
+
if (m.role !== "user") continue;
|
|
187
|
+
if (typeof m.content === "string") return m.content;
|
|
188
|
+
if (Array.isArray(m.content)) {
|
|
189
|
+
for (const part of m.content) {
|
|
190
|
+
const p = part as { type?: string; text?: unknown };
|
|
191
|
+
if (p.type === "text" && typeof p.text === "string") return p.text;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
206
194
|
}
|
|
207
195
|
return "";
|
|
208
196
|
}
|
|
209
197
|
|
|
198
|
+
interface CollectedToolCall {
|
|
199
|
+
id: string;
|
|
200
|
+
name: string;
|
|
201
|
+
input: unknown;
|
|
202
|
+
}
|
|
203
|
+
|
|
210
204
|
/**
|
|
211
205
|
* Run a single chat turn: stream the assistant response, execute any tool calls,
|
|
212
|
-
* and loop until the model produces
|
|
206
|
+
* and loop until the model produces no more tool calls.
|
|
213
207
|
* Mutates `messages` in-place by appending assistant/tool messages.
|
|
214
208
|
*/
|
|
215
209
|
export async function runChatTurn(input: {
|
|
216
|
-
messages:
|
|
210
|
+
messages: ModelMessage[];
|
|
217
211
|
projectDir: string;
|
|
218
|
-
config:
|
|
212
|
+
config: BotholomewConfig;
|
|
219
213
|
threadId: string;
|
|
220
214
|
mcpxClient: McpxClient | null;
|
|
221
215
|
callbacks: ChatTurnCallbacks;
|
|
222
|
-
/** When supplied, the loop honors `session.aborted` (set by Esc in the TUI)
|
|
223
|
-
* and writes the live `MessageStream` to `session.activeStream` so it can
|
|
224
|
-
* be aborted from outside. */
|
|
225
216
|
session?: ChatSession;
|
|
226
|
-
/** Test seam: inject a pre-built
|
|
227
|
-
|
|
228
|
-
_testClient?: Anthropic;
|
|
217
|
+
/** Test seam: inject a pre-built language model. */
|
|
218
|
+
_testModel?: LanguageModel;
|
|
229
219
|
_testMaxInputTokens?: number;
|
|
230
|
-
/** Test seam: when set, the turn uses this `withMem` instead of opening its
|
|
231
|
-
* own membot client. Production callers leave this unset. */
|
|
232
220
|
_testWithMem?: WithMem;
|
|
233
221
|
}): Promise<void> {
|
|
234
|
-
// Open membot for the duration of this turn so the DuckDB file lock is held
|
|
235
|
-
// only while the turn is actively executing — idle chat sessions leave the
|
|
236
|
-
// shared `~/.membot` store available to other Botholomew processes.
|
|
237
222
|
if (input._testWithMem) {
|
|
238
223
|
await runChatTurnBody({ ...input, withMem: input._testWithMem });
|
|
239
224
|
return;
|
|
@@ -248,15 +233,15 @@ export async function runChatTurn(input: {
|
|
|
248
233
|
}
|
|
249
234
|
|
|
250
235
|
async function runChatTurnBody(input: {
|
|
251
|
-
messages:
|
|
236
|
+
messages: ModelMessage[];
|
|
252
237
|
projectDir: string;
|
|
253
|
-
config:
|
|
238
|
+
config: BotholomewConfig;
|
|
254
239
|
withMem: WithMem;
|
|
255
240
|
threadId: string;
|
|
256
241
|
mcpxClient: McpxClient | null;
|
|
257
242
|
callbacks: ChatTurnCallbacks;
|
|
258
243
|
session?: ChatSession;
|
|
259
|
-
|
|
244
|
+
_testModel?: LanguageModel;
|
|
260
245
|
_testMaxInputTokens?: number;
|
|
261
246
|
}): Promise<void> {
|
|
262
247
|
const {
|
|
@@ -270,20 +255,16 @@ async function runChatTurnBody(input: {
|
|
|
270
255
|
session,
|
|
271
256
|
} = input;
|
|
272
257
|
|
|
273
|
-
const
|
|
258
|
+
const model = input._testModel ?? getLanguageModel(config.llm);
|
|
274
259
|
|
|
275
260
|
const chatTools = getChatTools();
|
|
276
261
|
const maxInputTokens =
|
|
277
|
-
input._testMaxInputTokens ??
|
|
278
|
-
(await getMaxInputTokens(config.anthropic_api_key, config.model));
|
|
262
|
+
input._testMaxInputTokens ?? (await getMaxInputTokens(config.llm));
|
|
279
263
|
const maxTurns = config.max_turns;
|
|
280
264
|
|
|
281
265
|
for (let turn = 0; !maxTurns || turn < maxTurns; turn++) {
|
|
282
266
|
if (session?.aborted) return;
|
|
283
267
|
|
|
284
|
-
// Steering: drain any user messages the TUI queued during the previous
|
|
285
|
-
// iteration so they land in the next LLM call rather than waiting for
|
|
286
|
-
// the whole tool loop to finish.
|
|
287
268
|
const injections = callbacks.takeInjections?.() ?? [];
|
|
288
269
|
for (const text of injections) {
|
|
289
270
|
await logInteraction(projectDir, threadId, {
|
|
@@ -296,98 +277,101 @@ async function runChatTurnBody(input: {
|
|
|
296
277
|
|
|
297
278
|
const startTime = Date.now();
|
|
298
279
|
|
|
299
|
-
// Rebuild the system prompt every iteration so that:
|
|
300
|
-
// (1) `loading: contextual` files get matched against the latest user
|
|
301
|
-
// message, and
|
|
302
|
-
// (2) any prompt_edit tool call in the previous
|
|
303
|
-
// iteration is reflected in the next LLM call.
|
|
304
280
|
const keywordSource = findLastUserText(messages);
|
|
305
281
|
const systemPrompt = await buildChatSystemPrompt(projectDir, {
|
|
306
282
|
keywordSource,
|
|
307
283
|
config,
|
|
308
284
|
hasMcpTools: mcpxClient != null,
|
|
309
285
|
});
|
|
310
|
-
// Re-derive the persistent-context portion (prompts files) so the Help
|
|
311
|
-
// tab can show how much of the system prompt is user-authored prompts vs
|
|
312
|
-
// built-in instructions. Cheap — same FS read just hit by
|
|
313
|
-
// buildChatSystemPrompt is still hot.
|
|
314
286
|
const persistentContext = await loadPersistentContext(
|
|
315
287
|
projectDir,
|
|
316
288
|
keywordSource ? extractKeywords(keywordSource) : null,
|
|
317
289
|
);
|
|
318
290
|
|
|
319
291
|
fitToContextWindow(messages, systemPrompt, maxInputTokens);
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
292
|
+
|
|
293
|
+
const wrapped = withAnthropicCacheBreakpoints({
|
|
294
|
+
provider: config.llm.provider,
|
|
323
295
|
system: systemPrompt,
|
|
324
296
|
messages,
|
|
325
297
|
tools: chatTools,
|
|
326
298
|
});
|
|
327
|
-
if (session) session.activeStream = stream;
|
|
328
299
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
300
|
+
const abortHandle: AbortHandle = createAbortHandle();
|
|
301
|
+
if (session) session.activeAbort = abortHandle;
|
|
302
|
+
|
|
303
|
+
const result = streamText({
|
|
304
|
+
model,
|
|
305
|
+
system: wrapped.system,
|
|
306
|
+
messages: wrapped.messages,
|
|
307
|
+
tools: wrapped.tools,
|
|
308
|
+
maxOutputTokens: 4096,
|
|
309
|
+
abortSignal: abortHandle.signal,
|
|
310
|
+
providerOptions: buildProviderOptions(config.llm, maxInputTokens),
|
|
336
311
|
});
|
|
337
312
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
event.content_block.type === "tool_use"
|
|
342
|
-
) {
|
|
343
|
-
callbacks.onToolPreparing?.(
|
|
344
|
-
event.content_block.id,
|
|
345
|
-
event.content_block.name,
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
stream.on("contentBlock", (block) => {
|
|
351
|
-
if (block.type === "tool_use") {
|
|
352
|
-
earlyReportedToolIds.add(block.id);
|
|
353
|
-
callbacks.onToolStart(
|
|
354
|
-
block.id,
|
|
355
|
-
block.name,
|
|
356
|
-
JSON.stringify(block.input),
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
});
|
|
313
|
+
let assistantText = "";
|
|
314
|
+
const collectedToolCalls: CollectedToolCall[] = [];
|
|
315
|
+
const earlyReportedToolIds = new Set<string>();
|
|
360
316
|
|
|
361
|
-
let
|
|
317
|
+
let streamError: unknown = null;
|
|
362
318
|
try {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
319
|
+
for await (const part of result.fullStream) {
|
|
320
|
+
switch (part.type) {
|
|
321
|
+
case "text-delta":
|
|
322
|
+
assistantText += part.text;
|
|
323
|
+
callbacks.onToken(part.text);
|
|
324
|
+
break;
|
|
325
|
+
case "tool-input-start":
|
|
326
|
+
earlyReportedToolIds.add(part.id);
|
|
327
|
+
callbacks.onToolPreparing?.(part.id, part.toolName);
|
|
328
|
+
break;
|
|
329
|
+
case "tool-call":
|
|
330
|
+
collectedToolCalls.push({
|
|
331
|
+
id: part.toolCallId,
|
|
332
|
+
name: part.toolName,
|
|
333
|
+
input: part.input,
|
|
334
|
+
});
|
|
335
|
+
break;
|
|
336
|
+
case "error":
|
|
337
|
+
streamError = part.error;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
379
340
|
}
|
|
380
|
-
|
|
341
|
+
} catch (err) {
|
|
342
|
+
streamError = err;
|
|
381
343
|
} finally {
|
|
382
|
-
if (session) session.
|
|
344
|
+
if (session) session.activeAbort = null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (streamError) {
|
|
348
|
+
// Swallow the eagerly-created usage/providerMetadata rejections so they
|
|
349
|
+
// don't escape as unhandled-promise crashes after we throw below.
|
|
350
|
+
drainStreamPromises(result);
|
|
351
|
+
if (abortHandle.signal.aborted || isAbortError(streamError)) {
|
|
352
|
+
if (assistantText) {
|
|
353
|
+
await logInteraction(projectDir, threadId, {
|
|
354
|
+
role: "assistant",
|
|
355
|
+
kind: "message",
|
|
356
|
+
content: assistantText,
|
|
357
|
+
durationMs: Date.now() - startTime,
|
|
358
|
+
tokenCount: 0,
|
|
359
|
+
});
|
|
360
|
+
messages.push({ role: "assistant", content: assistantText });
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
throw new Error(formatLlmError(streamError, config.llm));
|
|
383
365
|
}
|
|
366
|
+
|
|
384
367
|
const durationMs = Date.now() - startTime;
|
|
385
|
-
const
|
|
386
|
-
|
|
368
|
+
const usage = await result.usage;
|
|
369
|
+
const providerMeta = await result.providerMetadata;
|
|
370
|
+
const cacheTokens = extractCacheTokens(usage, providerMeta);
|
|
371
|
+
const tokenCount = cacheTokens.input + cacheTokens.output;
|
|
387
372
|
const promptTokens =
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
(response.usage.cache_creation_input_tokens ?? 0);
|
|
373
|
+
cacheTokens.input + cacheTokens.cacheRead + cacheTokens.cacheCreation;
|
|
374
|
+
|
|
391
375
|
if (callbacks.onUsage) {
|
|
392
376
|
const { textChars, toolIoChars } = partitionMessages(messages);
|
|
393
377
|
const promptsChars = persistentContext.length;
|
|
@@ -406,7 +390,6 @@ async function runChatTurnBody(input: {
|
|
|
406
390
|
});
|
|
407
391
|
}
|
|
408
392
|
|
|
409
|
-
// Log assistant text
|
|
410
393
|
if (assistantText) {
|
|
411
394
|
await logInteraction(projectDir, threadId, {
|
|
412
395
|
role: "assistant",
|
|
@@ -417,115 +400,126 @@ async function runChatTurnBody(input: {
|
|
|
417
400
|
});
|
|
418
401
|
}
|
|
419
402
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (toolUseBlocks.length === 0) {
|
|
426
|
-
// No tool calls — turn is complete
|
|
427
|
-
messages.push({ role: "assistant", content: response.content });
|
|
403
|
+
if (collectedToolCalls.length === 0) {
|
|
404
|
+
if (assistantText) {
|
|
405
|
+
messages.push({ role: "assistant", content: assistantText });
|
|
406
|
+
}
|
|
428
407
|
return;
|
|
429
408
|
}
|
|
430
409
|
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
410
|
+
// Build assistant turn (text + tool calls) for the conversation history.
|
|
411
|
+
const assistantContent: Array<
|
|
412
|
+
ToolCallPart | { type: "text"; text: string }
|
|
413
|
+
> = [];
|
|
414
|
+
if (assistantText) {
|
|
415
|
+
assistantContent.push({ type: "text", text: assistantText });
|
|
416
|
+
}
|
|
417
|
+
for (const tc of collectedToolCalls) {
|
|
418
|
+
assistantContent.push({
|
|
419
|
+
type: "tool-call",
|
|
420
|
+
toolCallId: tc.id,
|
|
421
|
+
toolName: tc.name,
|
|
422
|
+
input: tc.input,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
messages.push({ role: "assistant", content: assistantContent });
|
|
426
|
+
|
|
427
|
+
for (const tc of collectedToolCalls) {
|
|
428
|
+
const toolInput = JSON.stringify(tc.input);
|
|
429
|
+
if (!earlyReportedToolIds.has(tc.id)) {
|
|
430
|
+
callbacks.onToolStart(tc.id, tc.name, toolInput);
|
|
431
|
+
} else {
|
|
432
|
+
// Promote: emit onToolStart now that we have the final input.
|
|
433
|
+
callbacks.onToolStart(tc.id, tc.name, toolInput);
|
|
439
434
|
}
|
|
440
435
|
|
|
441
436
|
await logInteraction(projectDir, threadId, {
|
|
442
437
|
role: "assistant",
|
|
443
438
|
kind: "tool_use",
|
|
444
|
-
content: `Calling ${
|
|
445
|
-
toolName:
|
|
439
|
+
content: `Calling ${tc.name}`,
|
|
440
|
+
toolName: tc.name,
|
|
446
441
|
toolInput,
|
|
447
442
|
});
|
|
448
443
|
}
|
|
449
444
|
|
|
450
|
-
// Execute all tools in parallel. Each tool call opens its own short-lived
|
|
451
|
-
// connection; parallel calls share the process-local DuckDB instance and
|
|
452
|
-
// release the file lock as soon as the last one finishes.
|
|
453
445
|
const execResults = await Promise.all(
|
|
454
|
-
|
|
446
|
+
collectedToolCalls.map(async (tc) => {
|
|
455
447
|
const start = Date.now();
|
|
456
|
-
const
|
|
448
|
+
const exec = await executeChatToolCall(tc, {
|
|
457
449
|
withMem,
|
|
458
450
|
projectDir,
|
|
459
451
|
config,
|
|
460
452
|
mcpxClient,
|
|
461
453
|
shouldAbort: session ? () => session.aborted : undefined,
|
|
462
454
|
notify: callbacks.onToolNotify
|
|
463
|
-
? (msg) => callbacks.onToolNotify?.(
|
|
455
|
+
? (msg) => callbacks.onToolNotify?.(tc.id, msg)
|
|
464
456
|
: undefined,
|
|
465
457
|
});
|
|
466
|
-
const
|
|
467
|
-
const stored = maybeStoreResult(
|
|
458
|
+
const d = Date.now() - start;
|
|
459
|
+
const stored = maybeStoreResult(tc.name, exec.output);
|
|
468
460
|
const meta: ToolEndMeta | undefined = stored.stored
|
|
469
461
|
? { largeResult: stored.stored }
|
|
470
462
|
: undefined;
|
|
471
|
-
callbacks.onToolEnd(
|
|
472
|
-
|
|
473
|
-
toolUse.name,
|
|
474
|
-
result.output,
|
|
475
|
-
result.isError,
|
|
476
|
-
meta,
|
|
477
|
-
);
|
|
478
|
-
return { toolUse, result, durationMs, stored };
|
|
463
|
+
callbacks.onToolEnd(tc.id, tc.name, exec.output, exec.isError, meta);
|
|
464
|
+
return { tc, exec, durationMs: d, stored };
|
|
479
465
|
}),
|
|
480
466
|
);
|
|
481
467
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
468
|
+
const toolResultContent: Array<{
|
|
469
|
+
type: "tool-result";
|
|
470
|
+
toolCallId: string;
|
|
471
|
+
toolName: string;
|
|
472
|
+
output:
|
|
473
|
+
| { type: "text"; value: string }
|
|
474
|
+
| { type: "error-text"; value: string };
|
|
475
|
+
}> = [];
|
|
476
|
+
|
|
477
|
+
for (const { tc, exec, durationMs, stored } of execResults) {
|
|
485
478
|
await logInteraction(projectDir, threadId, {
|
|
486
479
|
role: "tool",
|
|
487
480
|
kind: "tool_result",
|
|
488
|
-
content:
|
|
489
|
-
toolName:
|
|
481
|
+
content: exec.output,
|
|
482
|
+
toolName: tc.name,
|
|
490
483
|
durationMs,
|
|
491
484
|
});
|
|
492
485
|
|
|
493
|
-
|
|
494
|
-
type: "
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
486
|
+
toolResultContent.push({
|
|
487
|
+
type: "tool-result",
|
|
488
|
+
toolCallId: tc.id,
|
|
489
|
+
toolName: tc.name,
|
|
490
|
+
output: exec.isError
|
|
491
|
+
? { type: "error-text", value: stored.text }
|
|
492
|
+
: { type: "text", value: stored.text },
|
|
498
493
|
});
|
|
499
494
|
}
|
|
500
495
|
|
|
501
|
-
messages.push({ role: "
|
|
496
|
+
messages.push({ role: "tool", content: toolResultContent });
|
|
502
497
|
if (session?.aborted) return;
|
|
503
|
-
// Loop to get the model's next response after tool results
|
|
504
498
|
}
|
|
505
499
|
}
|
|
506
500
|
|
|
507
501
|
interface ChatToolCallCtx {
|
|
508
502
|
withMem: WithMem;
|
|
509
503
|
projectDir: string;
|
|
510
|
-
config:
|
|
504
|
+
config: BotholomewConfig;
|
|
511
505
|
mcpxClient: McpxClient | null;
|
|
512
506
|
shouldAbort?: () => boolean;
|
|
513
507
|
notify?: (message: string) => void;
|
|
514
508
|
}
|
|
515
509
|
|
|
516
510
|
async function executeChatToolCall(
|
|
517
|
-
|
|
511
|
+
toolCall: CollectedToolCall,
|
|
518
512
|
baseCtx: ChatToolCallCtx,
|
|
519
513
|
): Promise<{ output: string; isError: boolean }> {
|
|
520
|
-
const tool = getTool(
|
|
521
|
-
if (!tool) return { output: `Unknown tool: ${
|
|
514
|
+
const tool = getTool(toolCall.name);
|
|
515
|
+
if (!tool) return { output: `Unknown tool: ${toolCall.name}`, isError: true };
|
|
522
516
|
if (!CHAT_TOOL_NAMES.has(tool.name))
|
|
523
517
|
return {
|
|
524
518
|
output: `Tool not available in chat mode: ${tool.name}`,
|
|
525
519
|
isError: true,
|
|
526
520
|
};
|
|
527
521
|
|
|
528
|
-
const parsed = tool.inputSchema.safeParse(
|
|
522
|
+
const parsed = tool.inputSchema.safeParse(toolCall.input);
|
|
529
523
|
if (!parsed.success) {
|
|
530
524
|
return {
|
|
531
525
|
output: `Invalid input: ${JSON.stringify(parsed.error)}`,
|