botholomew 0.12.1 → 0.12.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/package.json +2 -2
- package/src/chat/agent.ts +66 -6
- package/src/chat/session.ts +27 -0
- package/src/tui/App.tsx +41 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botholomew",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.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": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@anthropic-ai/sdk": "^0.92.0",
|
|
30
30
|
"@duckdb/node-api": "^1.5.2-r.1",
|
|
31
|
-
"@evantahler/mcpx": "0.
|
|
31
|
+
"@evantahler/mcpx": "0.21.0",
|
|
32
32
|
"@huggingface/transformers": "^4.2.0",
|
|
33
33
|
"ansis": "^4.2.0",
|
|
34
34
|
"commander": "^14.0.0",
|
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 =
|
|
223
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/chat/session.ts
CHANGED
|
@@ -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 = {
|