botholomew 0.12.1 → 0.12.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/chat/agent.ts CHANGED
@@ -1,4 +1,7 @@
1
+ import type Anthropic from "@anthropic-ai/sdk";
2
+ import { APIUserAbortError } from "@anthropic-ai/sdk";
1
3
  import type {
4
+ Message,
2
5
  MessageParam,
3
6
  ToolResultBlockParam,
4
7
  ToolUseBlock,
@@ -26,6 +29,7 @@ import {
26
29
  loadPersistentContext,
27
30
  STYLE_RULES,
28
31
  } from "../worker/prompt.ts";
32
+ import type { ChatSession } from "./session.ts";
29
33
 
30
34
  registerAllTools();
31
35
 
@@ -176,6 +180,11 @@ export interface ChatTurnCallbacks {
176
180
  isError: boolean,
177
181
  meta?: ToolEndMeta,
178
182
  ) => 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
+ takeInjections?: () => string[];
179
188
  }
180
189
 
181
190
  /**
@@ -205,6 +214,14 @@ export async function runChatTurn(input: {
205
214
  threadId: string;
206
215
  mcpxClient: McpxClient | null;
207
216
  callbacks: ChatTurnCallbacks;
217
+ /** When supplied, the loop honors `session.aborted` (set by Esc in the TUI)
218
+ * and writes the live `MessageStream` to `session.activeStream` so it can
219
+ * be aborted from outside. */
220
+ session?: ChatSession;
221
+ /** Test seam: inject a pre-built client and skip the model-info fetch.
222
+ * Production callers should leave both unset. */
223
+ _testClient?: Anthropic;
224
+ _testMaxInputTokens?: number;
208
225
  }): Promise<void> {
209
226
  const {
210
227
  messages,
@@ -214,18 +231,35 @@ export async function runChatTurn(input: {
214
231
  threadId,
215
232
  mcpxClient,
216
233
  callbacks,
234
+ session,
217
235
  } = input;
218
236
 
219
- const client = createLlmClient(config);
237
+ const client = input._testClient ?? createLlmClient(config);
220
238
 
221
239
  const chatTools = getChatTools();
222
- const maxInputTokens = await getMaxInputTokens(
223
- config.anthropic_api_key,
224
- config.model,
225
- );
240
+ const maxInputTokens =
241
+ input._testMaxInputTokens ??
242
+ (await getMaxInputTokens(config.anthropic_api_key, config.model));
226
243
  const maxTurns = config.max_turns;
227
244
 
228
245
  for (let turn = 0; !maxTurns || turn < maxTurns; turn++) {
246
+ if (session?.aborted) return;
247
+
248
+ // Steering: drain any user messages the TUI queued during the previous
249
+ // iteration so they land in the next LLM call rather than waiting for
250
+ // the whole tool loop to finish.
251
+ const injections = callbacks.takeInjections?.() ?? [];
252
+ for (const text of injections) {
253
+ await withDb(dbPath, (conn) =>
254
+ logInteraction(conn, threadId, {
255
+ role: "user",
256
+ kind: "message",
257
+ content: text,
258
+ }),
259
+ );
260
+ messages.push({ role: "user", content: text });
261
+ }
262
+
229
263
  const startTime = Date.now();
230
264
 
231
265
  // Rebuild the system prompt every iteration so that:
@@ -249,6 +283,7 @@ export async function runChatTurn(input: {
249
283
  messages,
250
284
  tools: chatTools,
251
285
  });
286
+ if (session) session.activeStream = stream;
252
287
 
253
288
  // Collect the full response
254
289
  let assistantText = "";
@@ -282,7 +317,31 @@ export async function runChatTurn(input: {
282
317
  }
283
318
  });
284
319
 
285
- const response = await stream.finalMessage();
320
+ let response: Message;
321
+ try {
322
+ response = await stream.finalMessage();
323
+ } catch (err) {
324
+ if (!(err instanceof APIUserAbortError)) throw err;
325
+ // Esc was pressed mid-stream. Persist whatever text the user already saw
326
+ // (the `'text'` event has fired for everything reaching us, so
327
+ // `assistantText` is the right partial value). Deliberately drop any
328
+ // partial tool_use blocks — they would be unmatched on the next turn.
329
+ if (assistantText) {
330
+ await withDb(dbPath, (conn) =>
331
+ logInteraction(conn, threadId, {
332
+ role: "assistant",
333
+ kind: "message",
334
+ content: assistantText,
335
+ durationMs: Date.now() - startTime,
336
+ tokenCount: 0,
337
+ }),
338
+ );
339
+ messages.push({ role: "assistant", content: assistantText });
340
+ }
341
+ return;
342
+ } finally {
343
+ if (session) session.activeStream = null;
344
+ }
286
345
  const durationMs = Date.now() - startTime;
287
346
  const tokenCount =
288
347
  response.usage.input_tokens + response.usage.output_tokens;
@@ -382,6 +441,7 @@ export async function runChatTurn(input: {
382
441
  }
383
442
 
384
443
  messages.push({ role: "user", content: toolResults });
444
+ if (session?.aborted) return;
385
445
  // Loop to get the model's next response after tool results
386
446
  }
387
447
  }
@@ -1,3 +1,4 @@
1
+ import type { MessageStream } from "@anthropic-ai/sdk/lib/MessageStream";
1
2
  import type { MessageParam } from "@anthropic-ai/sdk/resources/messages";
2
3
  import { loadConfig } from "../config/loader.ts";
3
4
  import type { BotholomewConfig } from "../config/schemas.ts";
@@ -27,6 +28,24 @@ export interface ChatSession {
27
28
  // biome-ignore lint/suspicious/noExplicitAny: mcpx client
28
29
  mcpxClient: any;
29
30
  cleanup: () => Promise<void>;
31
+ /** Set by `runChatTurn` while a `messages.stream(...)` is in flight. */
32
+ activeStream: MessageStream | null;
33
+ /** Esc-driven steer signal — checked at safe points in the chat agent loop. */
34
+ aborted: boolean;
35
+ }
36
+
37
+ /**
38
+ * Abort the in-flight LLM stream (if any) and set the steer flag so the chat
39
+ * agent loop short-circuits before issuing another `messages.stream(...)` call.
40
+ * Safe to call when no stream is active. Returns true if a live stream was aborted.
41
+ */
42
+ export function abortActiveStream(session: ChatSession): boolean {
43
+ session.aborted = true;
44
+ if (session.activeStream && !session.activeStream.aborted) {
45
+ session.activeStream.abort();
46
+ return true;
47
+ }
48
+ return false;
30
49
  }
31
50
 
32
51
  export async function startChatSession(
@@ -96,6 +115,8 @@ export async function startChatSession(
96
115
  skills,
97
116
  mcpxClient,
98
117
  cleanup,
118
+ activeStream: null,
119
+ aborted: false,
99
120
  };
100
121
  }
101
122
 
@@ -104,6 +125,9 @@ export async function sendMessage(
104
125
  userMessage: string,
105
126
  callbacks: ChatTurnCallbacks,
106
127
  ): Promise<void> {
128
+ // Reset steer flag so a previous turn's Esc doesn't poison this one.
129
+ session.aborted = false;
130
+
107
131
  // Hot-reload skills so any skill the agent created/edited last turn (or any
108
132
  // out-of-band edit) is visible to slash-command dispatch this turn.
109
133
  session.skills = await loadSkills(session.projectDir);
@@ -137,6 +161,7 @@ export async function sendMessage(
137
161
  threadId: session.threadId,
138
162
  mcpxClient: session.mcpxClient,
139
163
  callbacks,
164
+ session,
140
165
  });
141
166
  }
142
167
 
@@ -161,5 +186,7 @@ export async function clearChatSession(
161
186
  });
162
187
  session.threadId = newThreadId;
163
188
  session.messages.length = 0;
189
+ session.activeStream = null;
190
+ session.aborted = false;
164
191
  return { previousThreadId, newThreadId };
165
192
  }
package/src/tui/App.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Box, Static, Text, useApp, useInput } from "ink";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import {
4
+ abortActiveStream,
4
5
  type ChatSession,
5
6
  clearChatSession,
6
7
  endChatSession,
@@ -273,8 +274,20 @@ export function App({
273
274
  return;
274
275
  }
275
276
 
276
- // Queue manipulation keybindings (only when queue has items on Chat tab)
277
277
  const tab = activeTabRef.current;
278
+
279
+ // Esc on Chat tab while a turn is in flight: steer / interrupt.
280
+ // Calls MessageStream.abort() at the SDK layer; tools already running
281
+ // finish normally, but no further LLM turn is started.
282
+ if (key.escape && tab === 1 && processingRef.current) {
283
+ const session = sessionRef.current;
284
+ if (session) {
285
+ abortActiveStream(session);
286
+ return;
287
+ }
288
+ }
289
+
290
+ // Queue manipulation keybindings (only when queue has items on Chat tab)
278
291
  const queue = queuedMessagesRef.current;
279
292
  if (tab === 1 && queue.length > 0 && key.ctrl) {
280
293
  if (input === "j") {
@@ -405,8 +418,35 @@ export function App({
405
418
  }
406
419
  setActiveToolCalls([...pendingToolCalls]);
407
420
  },
421
+ takeInjections: () => {
422
+ // Drain queued messages into the running turn so the agent sees
423
+ // them on the next LLM call instead of after the whole tool loop.
424
+ // Finalize the in-flight assistant segment first so the new user
425
+ // bubbles render in the right order in the chat view.
426
+ if (queueRef.current.length === 0) return [];
427
+ if (currentText || pendingToolCalls.length > 0) {
428
+ finalizeSegment();
429
+ }
430
+ const drained = queueRef.current.splice(0);
431
+ syncQueue();
432
+ for (const e of drained) {
433
+ const userMsg: ChatMessage = {
434
+ id: msgId(),
435
+ role: "user",
436
+ content: e.display,
437
+ timestamp: new Date(),
438
+ };
439
+ setMessages((prev) => [...prev, userMsg]);
440
+ }
441
+ return drained.map((e) => e.content);
442
+ },
408
443
  });
409
444
 
445
+ if (sessionRef.current?.aborted) {
446
+ currentText += currentText
447
+ ? "\n\n_(steered — response interrupted)_"
448
+ : "_(steered — no response)_";
449
+ }
410
450
  finalizeSegment();
411
451
  } catch (err) {
412
452
  const errorMsg: ChatMessage = {