botholomew 0.18.7 → 0.19.4

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 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
- # 2. Add your Anthropic key to config/config.json, or export it
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 — no API key required.
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.18.7",
3
+ "version": "0.19.4",
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
- "@anthropic-ai/sdk": "^0.95.2",
31
- "@evantahler/mcpx": "0.21.8",
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.0",
35
+ "commander": "^14.0.3",
34
36
  "gray-matter": "^4.0.3",
35
- "ink": "^7.0.1",
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.15.4",
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": "latest",
49
- "@types/react": "^19.2.0",
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.5.0",
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 type Anthropic from "@anthropic-ai/sdk";
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?: Required<BotholomewConfig>;
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?.role === "user" && typeof m.content === "string") return m.content;
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 end_turn with no tool calls.
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: MessageParam[];
210
+ messages: ModelMessage[];
217
211
  projectDir: string;
218
- config: Required<BotholomewConfig>;
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 client and skip the model-info fetch.
227
- * Production callers should leave both unset. */
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: MessageParam[];
236
+ messages: ModelMessage[];
252
237
  projectDir: string;
253
- config: Required<BotholomewConfig>;
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
- _testClient?: Anthropic;
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 client = input._testClient ?? createLlmClient(config);
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
- const stream = client.messages.stream({
321
- model: config.model,
322
- max_tokens: 4096,
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
- // Collect the full response
330
- let assistantText = "";
331
- const earlyReportedToolIds = new Set<string>();
332
-
333
- stream.on("text", (text) => {
334
- assistantText += text;
335
- callbacks.onToken(text);
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
- stream.on("streamEvent", (event) => {
339
- if (
340
- event.type === "content_block_start" &&
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 response: Message;
317
+ let streamError: unknown = null;
362
318
  try {
363
- response = await stream.finalMessage();
364
- } catch (err) {
365
- if (!(err instanceof APIUserAbortError)) throw err;
366
- // Esc was pressed mid-stream. Persist whatever text the user already saw
367
- // (the `'text'` event has fired for everything reaching us, so
368
- // `assistantText` is the right partial value). Deliberately drop any
369
- // partial tool_use blocks — they would be unmatched on the next turn.
370
- if (assistantText) {
371
- await logInteraction(projectDir, threadId, {
372
- role: "assistant",
373
- kind: "message",
374
- content: assistantText,
375
- durationMs: Date.now() - startTime,
376
- tokenCount: 0,
377
- });
378
- messages.push({ role: "assistant", content: assistantText });
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
- return;
341
+ } catch (err) {
342
+ streamError = err;
381
343
  } finally {
382
- if (session) session.activeStream = null;
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 tokenCount =
386
- response.usage.input_tokens + response.usage.output_tokens;
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
- response.usage.input_tokens +
389
- (response.usage.cache_read_input_tokens ?? 0) +
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
- // Check for tool calls
421
- const toolUseBlocks = response.content.filter(
422
- (block): block is ToolUseBlock => block.type === "tool_use",
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
- // Add assistant response to conversation
432
- messages.push({ role: "assistant", content: response.content });
433
-
434
- // Log all tool_use entries and notify UI
435
- for (const toolUse of toolUseBlocks) {
436
- const toolInput = JSON.stringify(toolUse.input);
437
- if (!earlyReportedToolIds.has(toolUse.id)) {
438
- callbacks.onToolStart(toolUse.id, toolUse.name, toolInput);
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 ${toolUse.name}`,
445
- toolName: toolUse.name,
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
- toolUseBlocks.map(async (toolUse) => {
446
+ collectedToolCalls.map(async (tc) => {
455
447
  const start = Date.now();
456
- const result = await executeChatToolCall(toolUse, {
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?.(toolUse.id, msg)
455
+ ? (msg) => callbacks.onToolNotify?.(tc.id, msg)
464
456
  : undefined,
465
457
  });
466
- const durationMs = Date.now() - start;
467
- const stored = maybeStoreResult(toolUse.name, result.output);
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
- toolUse.id,
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
- // Log results and collect tool_result messages
483
- const toolResults: ToolResultBlockParam[] = [];
484
- for (const { toolUse, result, durationMs, stored } of execResults) {
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: result.output,
489
- toolName: toolUse.name,
481
+ content: exec.output,
482
+ toolName: tc.name,
490
483
  durationMs,
491
484
  });
492
485
 
493
- toolResults.push({
494
- type: "tool_result",
495
- tool_use_id: toolUse.id,
496
- content: stored.text,
497
- is_error: result.isError || undefined,
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: "user", content: toolResults });
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: Required<BotholomewConfig>;
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
- toolUse: ToolUseBlock,
511
+ toolCall: CollectedToolCall,
518
512
  baseCtx: ChatToolCallCtx,
519
513
  ): Promise<{ output: string; isError: boolean }> {
520
- const tool = getTool(toolUse.name);
521
- if (!tool) return { output: `Unknown tool: ${toolUse.name}`, isError: true };
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(toolUse.input);
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)}`,