bloby-bot 0.32.0 → 0.32.1
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.
|
@@ -1,550 +1,112 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Agent harness dispatcher.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Picks the right harness implementation based on `cfg.ai.provider` and
|
|
5
|
+
* forwards every call. The supervisor, channels, and scheduler still import
|
|
6
|
+
* from `./bloby-agent.js` exactly as they did before — this file is a
|
|
7
|
+
* compatibility shim so the harness split is invisible to callers.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Provider routing:
|
|
10
|
+
* anthropic (or empty/default) → Claude Agent SDK harness — unchanged
|
|
11
|
+
* openai → Codex app-server harness — Phase 2
|
|
12
|
+
*
|
|
13
|
+
* Cleanup operations (`endAllConversations`, `endConversation(id)`) are
|
|
14
|
+
* fanned out to *both* harnesses so a stale conversation on a previously
|
|
15
|
+
* active provider can't outlive a provider switch.
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
import { log } from '../shared/logger.js';
|
|
17
|
-
import { WORKSPACE_DIR } from '../shared/paths.js';
|
|
18
|
+
import * as claude from './harnesses/claude.js';
|
|
19
|
+
import * as codex from './harnesses/codex.js';
|
|
20
|
+
import type { Harness, OnAgentMessage, RecentMessage, AgentAttachment } from './harnesses/types.js';
|
|
18
21
|
import type { SavedFile } from './file-saver.js';
|
|
19
|
-
import {
|
|
20
|
-
import { assembleSystemPrompt } from '../worker/prompts/prompt-assembler.js';
|
|
21
|
-
import { buildAgents } from './agents/index.js';
|
|
22
|
-
import { preWarm, claimWarmup, discardWarmup } from './cli-warmup.js';
|
|
23
|
-
|
|
24
|
-
// ── Types ──────────────────────────────────────────────────────────────────
|
|
25
|
-
|
|
26
|
-
export interface RecentMessage {
|
|
27
|
-
role: 'user' | 'assistant';
|
|
28
|
-
content: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface AgentAttachment {
|
|
32
|
-
type: 'image' | 'file';
|
|
33
|
-
name: string;
|
|
34
|
-
mediaType: string;
|
|
35
|
-
data: string; // base64
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ── Async Queue ────────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
interface AsyncQueue<T> extends AsyncIterable<T> {
|
|
41
|
-
push(item: T): void;
|
|
42
|
-
end(): void;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Create an async queue that can be used as an AsyncIterable prompt for the SDK */
|
|
46
|
-
function createAsyncQueue<T>(): AsyncQueue<T> {
|
|
47
|
-
const pending: T[] = [];
|
|
48
|
-
let resolve: ((value: IteratorResult<T>) => void) | null = null;
|
|
49
|
-
let done = false;
|
|
50
|
-
|
|
51
|
-
return {
|
|
52
|
-
push(item: T) {
|
|
53
|
-
if (done) return;
|
|
54
|
-
if (resolve) {
|
|
55
|
-
resolve({ value: item, done: false });
|
|
56
|
-
resolve = null;
|
|
57
|
-
} else {
|
|
58
|
-
pending.push(item);
|
|
59
|
-
}
|
|
60
|
-
},
|
|
61
|
-
end() {
|
|
62
|
-
done = true;
|
|
63
|
-
if (resolve) resolve({ value: undefined as any, done: true });
|
|
64
|
-
},
|
|
65
|
-
[Symbol.asyncIterator]() {
|
|
66
|
-
return {
|
|
67
|
-
next(): Promise<IteratorResult<T>> {
|
|
68
|
-
if (pending.length > 0) {
|
|
69
|
-
return Promise.resolve({ value: pending.shift()!, done: false });
|
|
70
|
-
}
|
|
71
|
-
if (done) return Promise.resolve({ value: undefined as any, done: true });
|
|
72
|
-
return new Promise((r) => { resolve = r; });
|
|
73
|
-
},
|
|
74
|
-
};
|
|
75
|
-
},
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ── Live Conversation Manager ──────────────────────────────────────────────
|
|
80
|
-
|
|
81
|
-
interface LiveConversation {
|
|
82
|
-
id: string;
|
|
83
|
-
inputQueue: AsyncQueue<SDKUserMessage>;
|
|
84
|
-
abortController: AbortController;
|
|
85
|
-
queryHandle: any;
|
|
86
|
-
onMessage: (type: string, data: any) => void;
|
|
87
|
-
/** True while the model is actively processing (between message push and result) */
|
|
88
|
-
busy: boolean;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const liveConversations = new Map<string, LiveConversation>();
|
|
92
|
-
|
|
93
|
-
/** Check if a live conversation exists */
|
|
94
|
-
export function hasConversation(conversationId: string): boolean {
|
|
95
|
-
return liveConversations.has(conversationId);
|
|
96
|
-
}
|
|
22
|
+
import { loadConfig } from '../shared/config.js';
|
|
97
23
|
|
|
98
|
-
|
|
99
|
-
export function endAllConversations(): void {
|
|
100
|
-
for (const convId of liveConversations.keys()) {
|
|
101
|
-
log.info(`[conversation] Ending conversation ${convId} (auth changed)`);
|
|
102
|
-
endConversation(convId);
|
|
103
|
-
}
|
|
104
|
-
// The pre-warmed subprocess was initialized with the old OAuth token — drop it.
|
|
105
|
-
discardWarmup();
|
|
106
|
-
}
|
|
24
|
+
export type { RecentMessage, AgentAttachment };
|
|
107
25
|
|
|
108
|
-
|
|
26
|
+
const HARNESSES: Record<string, Harness> = {
|
|
27
|
+
anthropic: claude,
|
|
28
|
+
openai: codex,
|
|
29
|
+
};
|
|
109
30
|
|
|
110
|
-
/**
|
|
111
|
-
function
|
|
31
|
+
/** Resolve the harness for the currently-configured provider. */
|
|
32
|
+
function activeHarness(): Harness {
|
|
33
|
+
let provider = '';
|
|
112
34
|
try {
|
|
113
|
-
|
|
114
|
-
return content || '(empty)';
|
|
35
|
+
provider = loadConfig().ai?.provider || '';
|
|
115
36
|
} catch {
|
|
116
|
-
|
|
37
|
+
// Pre-onboard or transient config error: fall through to default.
|
|
117
38
|
}
|
|
39
|
+
return HARNESSES[provider] ?? claude;
|
|
118
40
|
}
|
|
119
41
|
|
|
120
|
-
|
|
121
|
-
function readMemoryFiles() {
|
|
122
|
-
return {
|
|
123
|
-
myself: readMemoryFile('MYSELF.md'),
|
|
124
|
-
myhuman: readMemoryFile('MYHUMAN.md'),
|
|
125
|
-
memory: readMemoryFile('MEMORY.md'),
|
|
126
|
-
pulse: readMemoryFile('PULSE.json'),
|
|
127
|
-
crons: readMemoryFile('CRONS.json'),
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Format recent messages as conversation history text */
|
|
132
|
-
function formatConversationHistory(messages: RecentMessage[]): string {
|
|
133
|
-
if (!messages.length) return '';
|
|
134
|
-
return messages.map((m) => `${m.role}: ${m.content}`).join('\n\n');
|
|
135
|
-
}
|
|
42
|
+
/* ── Live conversation API ─────────────────────────────────────────────── */
|
|
136
43
|
|
|
137
|
-
|
|
138
|
-
function loadMcpServers(): Record<string, any> | undefined {
|
|
139
|
-
try {
|
|
140
|
-
const mcpConfigPath = path.join(WORKSPACE_DIR, 'MCP.json');
|
|
141
|
-
const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8'));
|
|
142
|
-
if (mcpConfig && typeof mcpConfig === 'object' && !Array.isArray(mcpConfig) && Object.keys(mcpConfig).length) {
|
|
143
|
-
return mcpConfig;
|
|
144
|
-
} else if (Array.isArray(mcpConfig) && mcpConfig.length) {
|
|
145
|
-
return Object.assign({}, ...mcpConfig);
|
|
146
|
-
}
|
|
147
|
-
} catch {}
|
|
148
|
-
return undefined;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Build an SDKUserMessage from text + optional attachments */
|
|
152
|
-
function buildUserMessage(text: string, attachments?: AgentAttachment[], savedFiles?: SavedFile[]): SDKUserMessage {
|
|
153
|
-
const content: any[] = [];
|
|
154
|
-
|
|
155
|
-
if (attachments?.length) {
|
|
156
|
-
for (const att of attachments) {
|
|
157
|
-
if (att.type === 'image') {
|
|
158
|
-
content.push({
|
|
159
|
-
type: 'image',
|
|
160
|
-
source: { type: 'base64', media_type: att.mediaType, data: att.data },
|
|
161
|
-
});
|
|
162
|
-
} else {
|
|
163
|
-
content.push({
|
|
164
|
-
type: 'document',
|
|
165
|
-
source: { type: 'base64', media_type: att.mediaType, data: att.data },
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
let promptText = text || '(attached files)';
|
|
172
|
-
if (savedFiles?.length) {
|
|
173
|
-
const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
|
|
174
|
-
promptText += `\n\n[Attached files saved to disk]\n${lines.join('\n')}\nYou can read or reference these files using the paths above (relative to your cwd).`;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
content.push({ type: 'text', text: promptText });
|
|
178
|
-
|
|
179
|
-
return {
|
|
180
|
-
type: 'user' as const,
|
|
181
|
-
message: { role: 'user' as const, content },
|
|
182
|
-
parent_tool_use_id: null,
|
|
183
|
-
} as SDKUserMessage;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// ── Live Conversation API ──────────────────────────────────────────────────
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Build the options for a live conversation's query(). Shared by
|
|
190
|
-
* `startConversation` and the boot-time pre-warmer so a warmed subprocess
|
|
191
|
-
* has byte-identical options.
|
|
192
|
-
*/
|
|
193
|
-
async function buildConversationOptions(
|
|
194
|
-
model: string,
|
|
195
|
-
oauthToken: string,
|
|
196
|
-
names?: { botName: string; humanName: string },
|
|
197
|
-
recentMessages?: RecentMessage[],
|
|
198
|
-
): Promise<Omit<Options, 'abortController' | 'stderr'>> {
|
|
199
|
-
const memoryFiles = readMemoryFiles();
|
|
200
|
-
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
|
|
201
|
-
let systemPrompt = basePrompt;
|
|
202
|
-
systemPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
|
|
203
|
-
|
|
204
|
-
try {
|
|
205
|
-
const { loadConfig: loadCfg } = await import('../shared/config.js');
|
|
206
|
-
const cfg = loadCfg();
|
|
207
|
-
const channels = (cfg as any).channels;
|
|
208
|
-
if (channels) {
|
|
209
|
-
systemPrompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``;
|
|
210
|
-
}
|
|
211
|
-
} catch {}
|
|
212
|
-
|
|
213
|
-
if (recentMessages?.length) {
|
|
214
|
-
systemPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const agents = buildAgents();
|
|
218
|
-
const mcpServers = loadMcpServers();
|
|
219
|
-
|
|
220
|
-
return {
|
|
221
|
-
model,
|
|
222
|
-
cwd: WORKSPACE_DIR,
|
|
223
|
-
permissionMode: 'bypassPermissions',
|
|
224
|
-
allowDangerouslySkipPermissions: true,
|
|
225
|
-
systemPrompt,
|
|
226
|
-
mcpServers,
|
|
227
|
-
agents,
|
|
228
|
-
agentProgressSummaries: true,
|
|
229
|
-
env: {
|
|
230
|
-
...process.env as Record<string, string>,
|
|
231
|
-
CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
|
|
232
|
-
CLAUDE_CODE_BUBBLEWRAP: '1',
|
|
233
|
-
},
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Pre-warm the Claude CLI subprocess for the next live conversation. Call
|
|
239
|
-
* fire-and-forget at supervisor boot (and after a conversation ends) so the
|
|
240
|
-
* first user message doesn't pay CLI startup latency.
|
|
241
|
-
*/
|
|
242
|
-
export async function warmUpForLiveConversation(
|
|
243
|
-
model: string,
|
|
244
|
-
names?: { botName: string; humanName: string },
|
|
245
|
-
): Promise<void> {
|
|
246
|
-
if (!model) return;
|
|
247
|
-
try {
|
|
248
|
-
const oauthToken = await getClaudeAccessToken();
|
|
249
|
-
if (!oauthToken) return;
|
|
250
|
-
const options = await buildConversationOptions(model, oauthToken, names);
|
|
251
|
-
await preWarm(options);
|
|
252
|
-
} catch (err: any) {
|
|
253
|
-
log.warn(`[conversation] Warm-up skipped: ${err?.message || err}`);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Start a long-lived conversation.
|
|
259
|
-
* Creates a single query() with an async input queue.
|
|
260
|
-
* Messages are pushed via pushMessage(). The query stays alive until endConversation().
|
|
261
|
-
*/
|
|
262
|
-
export async function startConversation(
|
|
44
|
+
export function startConversation(
|
|
263
45
|
conversationId: string,
|
|
264
46
|
model: string,
|
|
265
|
-
onMessage:
|
|
47
|
+
onMessage: OnAgentMessage,
|
|
266
48
|
names?: { botName: string; humanName: string },
|
|
267
49
|
recentMessages?: RecentMessage[],
|
|
268
50
|
): Promise<boolean> {
|
|
269
|
-
|
|
270
|
-
log.info(`[conversation] Conv ID: ${conversationId}`);
|
|
271
|
-
log.info(`[conversation] Model: ${model}`);
|
|
272
|
-
|
|
273
|
-
// End any existing conversation with this ID
|
|
274
|
-
if (liveConversations.has(conversationId)) {
|
|
275
|
-
log.info(`[conversation] Ending existing conversation ${conversationId} before starting new one`);
|
|
276
|
-
endConversation(conversationId);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const oauthToken = await getClaudeAccessToken();
|
|
280
|
-
if (!oauthToken) {
|
|
281
|
-
log.warn('[conversation] No OAuth token — cannot start');
|
|
282
|
-
onMessage('bot:error', { conversationId, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' });
|
|
283
|
-
return false;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const baseOptions = await buildConversationOptions(model, oauthToken, names, recentMessages);
|
|
287
|
-
const systemPromptLen = typeof baseOptions.systemPrompt === 'string' ? baseOptions.systemPrompt.length : 0;
|
|
288
|
-
log.info(`[conversation] Loaded ${Object.keys(baseOptions.agents || {}).length} sub-agent(s): ${Object.keys(baseOptions.agents || {}).join(', ')}`);
|
|
289
|
-
if (baseOptions.mcpServers) {
|
|
290
|
-
log.info(`[conversation] MCP servers: ${Object.keys(baseOptions.mcpServers).join(', ')}`);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Try to claim a pre-warmed subprocess — its abortController is the one
|
|
294
|
-
// baked into the warm query and must be reused for end/abort to reach it.
|
|
295
|
-
const claimed = claimWarmup(baseOptions);
|
|
296
|
-
const abortController = claimed?.abortController ?? new AbortController();
|
|
297
|
-
|
|
298
|
-
// Create the async input queue
|
|
299
|
-
const inputQueue = createAsyncQueue<SDKUserMessage>();
|
|
300
|
-
|
|
301
|
-
// Store the conversation
|
|
302
|
-
const conv: LiveConversation = {
|
|
303
|
-
id: conversationId,
|
|
304
|
-
inputQueue,
|
|
305
|
-
abortController,
|
|
306
|
-
queryHandle: null,
|
|
307
|
-
onMessage,
|
|
308
|
-
busy: false,
|
|
309
|
-
};
|
|
310
|
-
liveConversations.set(conversationId, conv);
|
|
311
|
-
|
|
312
|
-
log.info(`[conversation] System prompt: ${systemPromptLen} chars`);
|
|
313
|
-
log.info(`[conversation] Starting long-lived query... (${claimed ? 'warm' : 'cold'})`);
|
|
314
|
-
|
|
315
|
-
// Run the for-await loop in the background (fire and forget)
|
|
316
|
-
(async () => {
|
|
317
|
-
let fullText = '';
|
|
318
|
-
const usedTools = new Set<string>();
|
|
319
|
-
let stderrBuf = '';
|
|
320
|
-
|
|
321
|
-
try {
|
|
322
|
-
const claudeQuery = claimed
|
|
323
|
-
? claimed.warmQuery.query(inputQueue)
|
|
324
|
-
: query({
|
|
325
|
-
prompt: inputQueue,
|
|
326
|
-
options: {
|
|
327
|
-
...baseOptions,
|
|
328
|
-
abortController,
|
|
329
|
-
stderr: (chunk: string) => { stderrBuf += chunk; },
|
|
330
|
-
},
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
conv.queryHandle = claudeQuery;
|
|
334
|
-
log.info(`[conversation] ──── QUERY LOOP STARTED ────`);
|
|
335
|
-
|
|
336
|
-
for await (const msg of claudeQuery) {
|
|
337
|
-
if (abortController.signal.aborted) {
|
|
338
|
-
log.info(`[conversation] Query aborted — exiting loop`);
|
|
339
|
-
break;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
switch (msg.type) {
|
|
343
|
-
case 'assistant': {
|
|
344
|
-
const assistantMsg = msg.message;
|
|
345
|
-
if (!assistantMsg?.content) break;
|
|
346
|
-
|
|
347
|
-
for (const block of assistantMsg.content) {
|
|
348
|
-
if (block.type === 'text' && block.text) {
|
|
349
|
-
if (fullText && !fullText.endsWith('\n')) {
|
|
350
|
-
fullText += '\n\n';
|
|
351
|
-
onMessage('bot:token', { conversationId, token: '\n\n' });
|
|
352
|
-
}
|
|
353
|
-
fullText += block.text;
|
|
354
|
-
onMessage('bot:token', { conversationId, token: block.text });
|
|
355
|
-
} else if (block.type === 'tool_use') {
|
|
356
|
-
usedTools.add(block.name);
|
|
357
|
-
onMessage('bot:tool', { conversationId, name: block.name, input: block.input });
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
break;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
case 'result': {
|
|
364
|
-
// Agent finished processing the current message
|
|
365
|
-
log.info(`[conversation] ──── TURN COMPLETE ────`);
|
|
366
|
-
log.info(`[conversation] Response length: ${fullText.length} chars`);
|
|
367
|
-
log.info(`[conversation] Tools used this turn: ${Array.from(usedTools).join(', ') || 'none'}`);
|
|
368
|
-
|
|
369
|
-
if (fullText) {
|
|
370
|
-
onMessage('bot:response', { conversationId, content: fullText });
|
|
371
|
-
fullText = '';
|
|
372
|
-
} else if (msg.subtype?.startsWith('error')) {
|
|
373
|
-
const errorText = (msg as any).errors?.join('; ') || 'Agent turn failed';
|
|
374
|
-
log.warn(`[conversation] Turn error: ${errorText}`);
|
|
375
|
-
onMessage('bot:error', { conversationId, error: errorText });
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Signal turn complete — backend restart + UI update
|
|
379
|
-
const FILE_TOOLS = ['Write', 'Edit'];
|
|
380
|
-
const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t));
|
|
381
|
-
onMessage('bot:turn-complete', { conversationId, usedFileTools });
|
|
382
|
-
|
|
383
|
-
// Reset per-turn state
|
|
384
|
-
usedTools.clear();
|
|
385
|
-
conv.busy = false;
|
|
386
|
-
log.info(`[conversation] Agent idle — waiting for next message`);
|
|
387
|
-
break;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
case 'tool_progress':
|
|
391
|
-
onMessage('bot:tool', {
|
|
392
|
-
conversationId,
|
|
393
|
-
name: (msg as any).tool_name || 'working',
|
|
394
|
-
status: 'running',
|
|
395
|
-
});
|
|
396
|
-
break;
|
|
397
|
-
|
|
398
|
-
// ── Background sub-agent events ──
|
|
399
|
-
case 'system': {
|
|
400
|
-
const sysMsg = msg as any;
|
|
401
|
-
if (sysMsg.subtype === 'task_started') {
|
|
402
|
-
log.info(`[conversation] ──── SUB-AGENT STARTED ────`);
|
|
403
|
-
log.info(`[conversation] Task ID: ${sysMsg.task_id}`);
|
|
404
|
-
log.info(`[conversation] Description: ${sysMsg.description}`);
|
|
405
|
-
onMessage('bot:task-created', {
|
|
406
|
-
conversationId,
|
|
407
|
-
taskId: sysMsg.task_id,
|
|
408
|
-
description: sysMsg.description,
|
|
409
|
-
type: sysMsg.task_type,
|
|
410
|
-
});
|
|
411
|
-
} else if (sysMsg.subtype === 'task_progress') {
|
|
412
|
-
const summary = sysMsg.summary || sysMsg.last_tool_name || 'working';
|
|
413
|
-
log.info(`[conversation] Sub-agent ${sysMsg.task_id} | ${summary} | Tools: ${sysMsg.usage?.tool_uses || 0} | ${Math.round((sysMsg.usage?.duration_ms || 0) / 1000)}s`);
|
|
414
|
-
onMessage('bot:task-progress', {
|
|
415
|
-
conversationId,
|
|
416
|
-
taskId: sysMsg.task_id,
|
|
417
|
-
summary,
|
|
418
|
-
lastTool: sysMsg.last_tool_name,
|
|
419
|
-
usage: sysMsg.usage,
|
|
420
|
-
});
|
|
421
|
-
} else if (sysMsg.subtype === 'task_notification') {
|
|
422
|
-
log.info(`[conversation] ──── SUB-AGENT ${sysMsg.status?.toUpperCase()} ────`);
|
|
423
|
-
log.info(`[conversation] Task ID: ${sysMsg.task_id}`);
|
|
424
|
-
log.info(`[conversation] Status: ${sysMsg.status}`);
|
|
425
|
-
log.info(`[conversation] Summary: ${sysMsg.summary?.slice(0, 200)}`);
|
|
426
|
-
log.info(`[conversation] Tokens: ${sysMsg.usage?.total_tokens || 0} | Tools: ${sysMsg.usage?.tool_uses || 0} | Duration: ${Math.round((sysMsg.usage?.duration_ms || 0) / 1000)}s`);
|
|
427
|
-
onMessage('bot:task-done', {
|
|
428
|
-
conversationId,
|
|
429
|
-
taskId: sysMsg.task_id,
|
|
430
|
-
status: sysMsg.status,
|
|
431
|
-
summary: sysMsg.summary,
|
|
432
|
-
usage: sysMsg.usage,
|
|
433
|
-
});
|
|
434
|
-
// Sub-agent completion may have written files
|
|
435
|
-
if (sysMsg.status === 'completed') {
|
|
436
|
-
onMessage('bot:turn-complete', { conversationId, usedFileTools: true });
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
break;
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
log.info(`[conversation] ──── QUERY LOOP ENDED ────`);
|
|
445
|
-
|
|
446
|
-
// Send any remaining text
|
|
447
|
-
if (fullText && !abortController.signal.aborted) {
|
|
448
|
-
onMessage('bot:response', { conversationId, content: fullText });
|
|
449
|
-
}
|
|
450
|
-
} catch (err: any) {
|
|
451
|
-
if (!abortController.signal.aborted) {
|
|
452
|
-
const detail = stderrBuf.trim();
|
|
453
|
-
const errMsg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message;
|
|
454
|
-
log.warn(`[conversation] Query error: ${errMsg}`);
|
|
455
|
-
onMessage('bot:error', { conversationId, error: errMsg });
|
|
456
|
-
}
|
|
457
|
-
} finally {
|
|
458
|
-
log.info(`[conversation] Cleaning up conversation ${conversationId}`);
|
|
459
|
-
liveConversations.delete(conversationId);
|
|
460
|
-
onMessage('bot:conversation-ended', { conversationId });
|
|
461
|
-
// Pre-warm a fresh subprocess for the next live conversation (fire-and-forget).
|
|
462
|
-
warmUpForLiveConversation(model, names);
|
|
463
|
-
}
|
|
464
|
-
})();
|
|
465
|
-
|
|
466
|
-
return true;
|
|
51
|
+
return activeHarness().startConversation(conversationId, model, onMessage, names, recentMessages);
|
|
467
52
|
}
|
|
468
53
|
|
|
469
|
-
/**
|
|
470
|
-
* Push a user message into an existing live conversation.
|
|
471
|
-
* The agent will process it as part of the ongoing conversation.
|
|
472
|
-
*/
|
|
473
54
|
export function pushMessage(
|
|
474
55
|
conversationId: string,
|
|
475
56
|
content: string,
|
|
476
57
|
attachments?: AgentAttachment[],
|
|
477
58
|
savedFiles?: SavedFile[],
|
|
478
59
|
): boolean {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
60
|
+
// Push to whichever harness owns this conversation; routing by current
|
|
61
|
+
// config alone could miss a conversation that was started under a previous
|
|
62
|
+
// provider (rare but possible during a switch).
|
|
63
|
+
for (const h of Object.values(HARNESSES)) {
|
|
64
|
+
if (h.hasConversation(conversationId)) {
|
|
65
|
+
return h.pushMessage(conversationId, content, attachments, savedFiles);
|
|
66
|
+
}
|
|
483
67
|
}
|
|
68
|
+
return activeHarness().pushMessage(conversationId, content, attachments, savedFiles);
|
|
69
|
+
}
|
|
484
70
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
log.info(`[conversation] Content: "${content.slice(0, 100)}..."`);
|
|
488
|
-
log.info(`[conversation] Attachments: ${attachments?.length || 0}`);
|
|
489
|
-
log.info(`[conversation] Agent busy: ${conv.busy}`);
|
|
490
|
-
|
|
491
|
-
const userMessage = buildUserMessage(content, attachments, savedFiles);
|
|
492
|
-
conv.busy = true;
|
|
493
|
-
conv.inputQueue.push(userMessage);
|
|
494
|
-
|
|
495
|
-
// Emit typing indicator
|
|
496
|
-
conv.onMessage('bot:typing', { conversationId });
|
|
497
|
-
|
|
498
|
-
return true;
|
|
71
|
+
export function hasConversation(conversationId: string): boolean {
|
|
72
|
+
return Object.values(HARNESSES).some((h) => h.hasConversation(conversationId));
|
|
499
73
|
}
|
|
500
74
|
|
|
501
|
-
/** End a live conversation */
|
|
502
75
|
export function endConversation(conversationId: string): void {
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
log.info(`[conversation] ──── ENDING CONVERSATION ────`);
|
|
507
|
-
log.info(`[conversation] Conv: ${conversationId}`);
|
|
76
|
+
for (const h of Object.values(HARNESSES)) h.endConversation(conversationId);
|
|
77
|
+
}
|
|
508
78
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
liveConversations.delete(conversationId);
|
|
79
|
+
export function endAllConversations(): void {
|
|
80
|
+
for (const h of Object.values(HARNESSES)) h.endAllConversations();
|
|
512
81
|
}
|
|
513
82
|
|
|
514
|
-
/** Check if the agent is currently busy processing a message */
|
|
515
83
|
export function isConversationBusy(conversationId: string): boolean {
|
|
516
|
-
return
|
|
84
|
+
return Object.values(HARNESSES).some((h) => h.isConversationBusy(conversationId));
|
|
517
85
|
}
|
|
518
86
|
|
|
519
|
-
/** Stop a specific background sub-agent task */
|
|
520
87
|
export async function stopSubAgentTask(conversationId: string, taskId: string): Promise<void> {
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
log.warn(`[conversation] Cannot stop task ${taskId} — no live conversation ${conversationId}`);
|
|
88
|
+
for (const h of Object.values(HARNESSES)) {
|
|
89
|
+
if (h.hasConversation(conversationId)) {
|
|
90
|
+
await h.stopSubAgentTask(conversationId, taskId);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
527
93
|
}
|
|
528
94
|
}
|
|
529
95
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
96
|
+
export function warmUpForLiveConversation(
|
|
97
|
+
model: string,
|
|
98
|
+
names?: { botName: string; humanName: string },
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
return activeHarness().warmUpForLiveConversation(model, names);
|
|
535
101
|
}
|
|
536
102
|
|
|
537
|
-
|
|
103
|
+
/* ── One-shot API ──────────────────────────────────────────────────────── */
|
|
538
104
|
|
|
539
|
-
|
|
540
|
-
* Run a one-shot Agent SDK query (classic request-response).
|
|
541
|
-
* Used for customer-facing messages and scheduler triggers.
|
|
542
|
-
*/
|
|
543
|
-
export async function startBlobyAgentQuery(
|
|
105
|
+
export function startBlobyAgentQuery(
|
|
544
106
|
conversationId: string,
|
|
545
107
|
prompt: string,
|
|
546
108
|
model: string,
|
|
547
|
-
onMessage:
|
|
109
|
+
onMessage: OnAgentMessage,
|
|
548
110
|
attachments?: AgentAttachment[],
|
|
549
111
|
savedFiles?: SavedFile[],
|
|
550
112
|
names?: { botName: string; humanName: string },
|
|
@@ -552,142 +114,12 @@ export async function startBlobyAgentQuery(
|
|
|
552
114
|
supportPrompt?: string,
|
|
553
115
|
maxTurns?: number,
|
|
554
116
|
): Promise<void> {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
const abortController = new AbortController();
|
|
562
|
-
const memoryFiles = readMemoryFiles();
|
|
563
|
-
|
|
564
|
-
let enrichedPrompt: string;
|
|
565
|
-
if (supportPrompt) {
|
|
566
|
-
enrichedPrompt = supportPrompt;
|
|
567
|
-
} else {
|
|
568
|
-
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
|
|
569
|
-
enrichedPrompt = basePrompt;
|
|
570
|
-
enrichedPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
|
|
571
|
-
|
|
572
|
-
try {
|
|
573
|
-
const { loadConfig: loadCfg } = await import('../shared/config.js');
|
|
574
|
-
const cfg = loadCfg();
|
|
575
|
-
const channels = (cfg as any).channels;
|
|
576
|
-
if (channels) {
|
|
577
|
-
enrichedPrompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``;
|
|
578
|
-
}
|
|
579
|
-
} catch {}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
if (recentMessages?.length) {
|
|
583
|
-
enrichedPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
activeQueries.set(conversationId, { abortController });
|
|
587
|
-
|
|
588
|
-
let fullText = '';
|
|
589
|
-
const usedTools = new Set<string>();
|
|
590
|
-
let stderrBuf = '';
|
|
591
|
-
|
|
592
|
-
let plainPrompt = prompt;
|
|
593
|
-
if (savedFiles?.length && !attachments?.length) {
|
|
594
|
-
const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
|
|
595
|
-
plainPrompt += `\n\n[Attached files saved to disk]\n${lines.join('\n')}\nYou can read or reference these files using the paths above (relative to your cwd).`;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
const sdkPrompt: string | AsyncIterable<SDKUserMessage> =
|
|
599
|
-
attachments?.length ? (async function* () {
|
|
600
|
-
yield buildUserMessage(prompt, attachments, savedFiles);
|
|
601
|
-
})() : plainPrompt;
|
|
602
|
-
|
|
603
|
-
try {
|
|
604
|
-
const mcpServers = loadMcpServers();
|
|
605
|
-
const effectiveMaxTurns = maxTurns ?? 50;
|
|
606
|
-
|
|
607
|
-
log.info(`[bloby-agent] One-shot query: conv=${conversationId}, maxTurns=${effectiveMaxTurns}`);
|
|
608
|
-
|
|
609
|
-
const claudeQuery = query({
|
|
610
|
-
prompt: sdkPrompt,
|
|
611
|
-
options: {
|
|
612
|
-
model,
|
|
613
|
-
cwd: WORKSPACE_DIR,
|
|
614
|
-
permissionMode: 'bypassPermissions',
|
|
615
|
-
allowDangerouslySkipPermissions: true,
|
|
616
|
-
maxTurns: effectiveMaxTurns,
|
|
617
|
-
abortController,
|
|
618
|
-
systemPrompt: enrichedPrompt,
|
|
619
|
-
mcpServers,
|
|
620
|
-
stderr: (chunk: string) => { stderrBuf += chunk; },
|
|
621
|
-
env: {
|
|
622
|
-
...process.env as Record<string, string>,
|
|
623
|
-
CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
|
|
624
|
-
CLAUDE_CODE_BUBBLEWRAP: '1',
|
|
625
|
-
},
|
|
626
|
-
},
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
onMessage('bot:typing', { conversationId });
|
|
630
|
-
|
|
631
|
-
for await (const msg of claudeQuery) {
|
|
632
|
-
if (abortController.signal.aborted) break;
|
|
633
|
-
|
|
634
|
-
switch (msg.type) {
|
|
635
|
-
case 'assistant': {
|
|
636
|
-
const assistantMsg = msg.message;
|
|
637
|
-
if (!assistantMsg?.content) break;
|
|
638
|
-
for (const block of assistantMsg.content) {
|
|
639
|
-
if (block.type === 'text' && block.text) {
|
|
640
|
-
if (fullText && !fullText.endsWith('\n')) {
|
|
641
|
-
fullText += '\n\n';
|
|
642
|
-
onMessage('bot:token', { conversationId, token: '\n\n' });
|
|
643
|
-
}
|
|
644
|
-
fullText += block.text;
|
|
645
|
-
onMessage('bot:token', { conversationId, token: block.text });
|
|
646
|
-
} else if (block.type === 'tool_use') {
|
|
647
|
-
usedTools.add(block.name);
|
|
648
|
-
onMessage('bot:tool', { conversationId, name: block.name, input: block.input });
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
break;
|
|
652
|
-
}
|
|
653
|
-
case 'result': {
|
|
654
|
-
if (fullText) {
|
|
655
|
-
onMessage('bot:response', { conversationId, content: fullText });
|
|
656
|
-
fullText = '';
|
|
657
|
-
} else if (msg.subtype?.startsWith('error')) {
|
|
658
|
-
onMessage('bot:error', { conversationId, error: (msg as any).errors?.join('; ') || 'Agent query failed' });
|
|
659
|
-
}
|
|
660
|
-
break;
|
|
661
|
-
}
|
|
662
|
-
case 'tool_progress':
|
|
663
|
-
onMessage('bot:tool', { conversationId, name: (msg as any).tool_name || 'working', status: 'running' });
|
|
664
|
-
break;
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
if (fullText && !abortController.signal.aborted) {
|
|
669
|
-
onMessage('bot:response', { conversationId, content: fullText });
|
|
670
|
-
}
|
|
671
|
-
} catch (err: any) {
|
|
672
|
-
if (!abortController.signal.aborted) {
|
|
673
|
-
const detail = stderrBuf.trim();
|
|
674
|
-
const errMsg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message;
|
|
675
|
-
log.warn(`Bloby agent error (${conversationId}): ${errMsg}`);
|
|
676
|
-
onMessage('bot:error', { conversationId, error: errMsg });
|
|
677
|
-
}
|
|
678
|
-
} finally {
|
|
679
|
-
activeQueries.delete(conversationId);
|
|
680
|
-
const FILE_TOOLS = ['Write', 'Edit'];
|
|
681
|
-
const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t));
|
|
682
|
-
onMessage('bot:done', { conversationId, usedFileTools });
|
|
683
|
-
}
|
|
117
|
+
return activeHarness().startBlobyAgentQuery(
|
|
118
|
+
conversationId, prompt, model, onMessage, attachments, savedFiles,
|
|
119
|
+
names, recentMessages, supportPrompt, maxTurns,
|
|
120
|
+
);
|
|
684
121
|
}
|
|
685
122
|
|
|
686
|
-
/** Stop a one-shot query */
|
|
687
123
|
export function stopBlobyAgentQuery(conversationId: string): void {
|
|
688
|
-
const
|
|
689
|
-
if (q) {
|
|
690
|
-
q.abortController.abort();
|
|
691
|
-
activeQueries.delete(conversationId);
|
|
692
|
-
}
|
|
124
|
+
for (const h of Object.values(HARNESSES)) h.stopBlobyAgentQuery(conversationId);
|
|
693
125
|
}
|