bloby-bot 0.47.0 → 0.47.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/bin/cli.js +42 -8
- package/package.json +3 -2
- package/scripts/postinstall.js +19 -2
- package/scripts/sync-pi-models.ts +146 -0
- package/shared/config.ts +1 -1
- package/supervisor/bloby-agent.ts +2 -0
- package/supervisor/harnesses/pi/async-queue.ts +45 -0
- package/supervisor/harnesses/pi/index.ts +474 -0
- package/supervisor/harnesses/pi/models-catalog.generated.ts +579 -0
- package/supervisor/harnesses/pi/providers/stream-google.ts +156 -0
- package/supervisor/harnesses/pi/providers/stream.ts +21 -0
- package/supervisor/harnesses/pi/providers/types.ts +60 -0
- package/supervisor/harnesses/pi/session.ts +140 -0
- package/supervisor/harnesses/pi/sub-providers.ts +34 -48
- package/supervisor/index.ts +5 -4
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bloby pi harness — public surface (mirrors `harnesses/claude.ts`).
|
|
3
|
+
*
|
|
4
|
+
* The dispatcher in `supervisor/bloby-agent.ts` imports this module as
|
|
5
|
+
* `* as pi` and selects it when `cfg.ai.provider === 'pi'`. The surface
|
|
6
|
+
* matches the Claude harness so the dispatcher needs no provider-specific
|
|
7
|
+
* code.
|
|
8
|
+
*
|
|
9
|
+
* Phase 1 scope: live conversation + one-shot text only (no tools). The
|
|
10
|
+
* non-blocking feel — user keeps typing while the model is still answering —
|
|
11
|
+
* comes from the same `AsyncQueue` pattern Claude uses; see `async-queue.ts`.
|
|
12
|
+
*/
|
|
13
|
+
import { log } from '../../../shared/logger.js';
|
|
14
|
+
import { WORKSPACE_DIR } from '../../../shared/paths.js';
|
|
15
|
+
import type { SavedFile } from '../file-saver.js';
|
|
16
|
+
import { assembleSystemPrompt } from '../../../worker/prompts/prompt-assembler.js';
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import type {
|
|
20
|
+
RecentMessage,
|
|
21
|
+
AgentAttachment,
|
|
22
|
+
AgentQueryRequest,
|
|
23
|
+
AgentQueryResult,
|
|
24
|
+
} from '../types.js';
|
|
25
|
+
export type { RecentMessage, AgentAttachment };
|
|
26
|
+
|
|
27
|
+
import { createAsyncQueue, type AsyncQueue } from './async-queue.js';
|
|
28
|
+
import { createPiSession, type PiSessionEvent } from './session.js';
|
|
29
|
+
import { getPiSubProvider } from './sub-providers.js';
|
|
30
|
+
import { readPiAuth } from './auth-storage.js';
|
|
31
|
+
import { streamProvider } from './providers/stream.js';
|
|
32
|
+
import type { PiMessage } from './providers/types.js';
|
|
33
|
+
|
|
34
|
+
// ── Live conversation state ────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
interface LiveConversation {
|
|
37
|
+
id: string;
|
|
38
|
+
inputQueue: AsyncQueue<PiMessage>;
|
|
39
|
+
abortController: AbortController;
|
|
40
|
+
onMessage: (type: string, data: any) => void;
|
|
41
|
+
busy: boolean;
|
|
42
|
+
loopDone: Promise<void> | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const liveConversations = new Map<string, LiveConversation>();
|
|
46
|
+
|
|
47
|
+
export function hasConversation(conversationId: string): boolean {
|
|
48
|
+
return liveConversations.has(conversationId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function endAllConversations(): void {
|
|
52
|
+
for (const id of liveConversations.keys()) {
|
|
53
|
+
log.info(`[pi/conversation] Ending conversation ${id} (bulk end)`);
|
|
54
|
+
endConversation(id);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function readMemoryFile(filename: string): string {
|
|
61
|
+
try {
|
|
62
|
+
const content = fs.readFileSync(path.join(WORKSPACE_DIR, filename), 'utf-8').trim();
|
|
63
|
+
return content || '(empty)';
|
|
64
|
+
} catch {
|
|
65
|
+
return '(empty)';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readMemoryFiles() {
|
|
70
|
+
return {
|
|
71
|
+
myself: readMemoryFile('MYSELF.md'),
|
|
72
|
+
myhuman: readMemoryFile('MYHUMAN.md'),
|
|
73
|
+
memory: readMemoryFile('MEMORY.md'),
|
|
74
|
+
pulse: readMemoryFile('PULSE.json'),
|
|
75
|
+
crons: readMemoryFile('CRONS.json'),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatConversationHistory(messages: RecentMessage[]): string {
|
|
80
|
+
if (!messages.length) return '';
|
|
81
|
+
return messages.map((m) => `${m.role}: ${m.content}`).join('\n\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function buildSystemPrompt(
|
|
85
|
+
names?: { botName: string; humanName: string },
|
|
86
|
+
recentMessages?: RecentMessage[],
|
|
87
|
+
): Promise<string> {
|
|
88
|
+
const memoryFiles = readMemoryFiles();
|
|
89
|
+
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
|
|
90
|
+
let systemPrompt = basePrompt;
|
|
91
|
+
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}`;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const { loadConfig: loadCfg } = await import('../../../shared/config.js');
|
|
95
|
+
const cfg = loadCfg();
|
|
96
|
+
const channels = (cfg as any).channels;
|
|
97
|
+
if (channels) {
|
|
98
|
+
systemPrompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``;
|
|
99
|
+
}
|
|
100
|
+
} catch {}
|
|
101
|
+
|
|
102
|
+
if (recentMessages?.length) {
|
|
103
|
+
systemPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
|
|
104
|
+
}
|
|
105
|
+
return systemPrompt;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Resolve sub-provider, base url, api key, model id from saved pi-auth.json. */
|
|
109
|
+
function resolveAuth(): {
|
|
110
|
+
ok: true;
|
|
111
|
+
flavor: ReturnType<typeof getPiSubProvider> extends undefined ? never : NonNullable<ReturnType<typeof getPiSubProvider>>['flavor'];
|
|
112
|
+
modelId: string;
|
|
113
|
+
baseUrl: string;
|
|
114
|
+
apiKey: string;
|
|
115
|
+
} | { ok: false; error: string } {
|
|
116
|
+
const auth = readPiAuth();
|
|
117
|
+
if (!auth) return { ok: false, error: 'Bloby provider is not configured. Run the onboarding wizard.' };
|
|
118
|
+
const sub = getPiSubProvider(auth.subProvider);
|
|
119
|
+
if (!sub) return { ok: false, error: `Unknown sub-provider in pi-auth.json: ${auth.subProvider}` };
|
|
120
|
+
const baseUrl = (auth.baseUrl || sub.baseUrl || '').replace(/\/+$/, '');
|
|
121
|
+
if (!baseUrl) return { ok: false, error: `No base URL configured for ${sub.id}` };
|
|
122
|
+
const modelId = auth.modelId || sub.defaultModel || '';
|
|
123
|
+
if (!modelId) return { ok: false, error: `No model selected for ${sub.id}` };
|
|
124
|
+
if (sub.needsApiKey && !auth.apiKey) return { ok: false, error: `Missing API key for ${sub.id}` };
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
flavor: sub.flavor,
|
|
128
|
+
modelId,
|
|
129
|
+
baseUrl,
|
|
130
|
+
apiKey: auth.apiKey || '',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Convert a saved RecentMessage[] into the provider-neutral PiMessage[]. */
|
|
135
|
+
function recentToPiMessages(messages: RecentMessage[] | undefined): PiMessage[] {
|
|
136
|
+
if (!messages?.length) return [];
|
|
137
|
+
return messages.map((m) => ({
|
|
138
|
+
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
139
|
+
content: [{ type: 'text', text: m.content }],
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Wrap a raw user input into a PiMessage with text + optional image blocks. */
|
|
144
|
+
function buildUserMessage(text: string, attachments?: AgentAttachment[], savedFiles?: SavedFile[]): PiMessage {
|
|
145
|
+
const content: PiMessage['content'] = [];
|
|
146
|
+
if (attachments?.length) {
|
|
147
|
+
for (const att of attachments) {
|
|
148
|
+
if (att.type === 'image') {
|
|
149
|
+
content.push({ type: 'image', mediaType: att.mediaType, data: att.data });
|
|
150
|
+
} else {
|
|
151
|
+
// Documents aren't directly supported across all sub-providers yet.
|
|
152
|
+
// Surface their existence in the text body instead.
|
|
153
|
+
content.push({ type: 'text', text: `[Attached document: ${att.name} (${att.mediaType})]` });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
let prompt = text || '(attached files)';
|
|
158
|
+
if (savedFiles?.length) {
|
|
159
|
+
const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
|
|
160
|
+
prompt += `\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).`;
|
|
161
|
+
}
|
|
162
|
+
content.push({ type: 'text', text: prompt });
|
|
163
|
+
return { role: 'user', content };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Live Conversation API ──────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
export async function startConversation(
|
|
169
|
+
conversationId: string,
|
|
170
|
+
_model: string,
|
|
171
|
+
onMessage: (type: string, data: any) => void,
|
|
172
|
+
names?: { botName: string; humanName: string },
|
|
173
|
+
recentMessages?: RecentMessage[],
|
|
174
|
+
): Promise<boolean> {
|
|
175
|
+
log.info(`[pi/conversation] ──── STARTING CONVERSATION ────`);
|
|
176
|
+
log.info(`[pi/conversation] Conv ID: ${conversationId}`);
|
|
177
|
+
|
|
178
|
+
if (liveConversations.has(conversationId)) {
|
|
179
|
+
log.info(`[pi/conversation] Ending existing conversation ${conversationId} before starting new one`);
|
|
180
|
+
endConversation(conversationId);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const auth = resolveAuth();
|
|
184
|
+
if (!auth.ok) {
|
|
185
|
+
log.warn(`[pi/conversation] Cannot start: ${auth.error}`);
|
|
186
|
+
onMessage('bot:error', { conversationId, error: auth.error });
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
log.info(`[pi/conversation] Sub-provider: ${auth.flavor} · model: ${auth.modelId}`);
|
|
191
|
+
|
|
192
|
+
const systemPrompt = await buildSystemPrompt(names, recentMessages);
|
|
193
|
+
log.info(`[pi/conversation] System prompt: ${systemPrompt.length} chars`);
|
|
194
|
+
|
|
195
|
+
const inputQueue = createAsyncQueue<PiMessage>();
|
|
196
|
+
const abortController = new AbortController();
|
|
197
|
+
|
|
198
|
+
const conv: LiveConversation = {
|
|
199
|
+
id: conversationId,
|
|
200
|
+
inputQueue,
|
|
201
|
+
abortController,
|
|
202
|
+
onMessage,
|
|
203
|
+
busy: false,
|
|
204
|
+
loopDone: null,
|
|
205
|
+
};
|
|
206
|
+
liveConversations.set(conversationId, conv);
|
|
207
|
+
|
|
208
|
+
const session = createPiSession({
|
|
209
|
+
flavor: auth.flavor,
|
|
210
|
+
modelId: auth.modelId,
|
|
211
|
+
baseUrl: auth.baseUrl,
|
|
212
|
+
apiKey: auth.apiKey,
|
|
213
|
+
systemPrompt,
|
|
214
|
+
abortController,
|
|
215
|
+
onEvent: (evt: PiSessionEvent) => {
|
|
216
|
+
translateAndEmit(conv, evt);
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
conv.loopDone = (async () => {
|
|
221
|
+
try {
|
|
222
|
+
await session.run(inputQueue);
|
|
223
|
+
log.info(`[pi/conversation] ──── QUERY LOOP ENDED ────`);
|
|
224
|
+
} catch (err: any) {
|
|
225
|
+
if (!abortController.signal.aborted) {
|
|
226
|
+
log.warn(`[pi/conversation] Loop error: ${err?.message || err}`);
|
|
227
|
+
onMessage('bot:error', { conversationId, error: err?.message || String(err) });
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
log.info(`[pi/conversation] Cleaning up conversation ${conversationId}`);
|
|
231
|
+
liveConversations.delete(conversationId);
|
|
232
|
+
onMessage('bot:conversation-ended', { conversationId });
|
|
233
|
+
}
|
|
234
|
+
})();
|
|
235
|
+
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Map session-level events back into bloby's `bot:*` vocabulary. */
|
|
240
|
+
function translateAndEmit(conv: LiveConversation, evt: PiSessionEvent) {
|
|
241
|
+
switch (evt.type) {
|
|
242
|
+
case 'turn_started':
|
|
243
|
+
// No bloby event for this — `bot:typing` is already emitted by pushMessage().
|
|
244
|
+
break;
|
|
245
|
+
case 'text_delta':
|
|
246
|
+
conv.onMessage('bot:token', { conversationId: conv.id, token: evt.delta });
|
|
247
|
+
break;
|
|
248
|
+
case 'text_end':
|
|
249
|
+
conv.onMessage('bot:response', { conversationId: conv.id, content: evt.text });
|
|
250
|
+
break;
|
|
251
|
+
case 'tool_use':
|
|
252
|
+
conv.onMessage('bot:tool', { conversationId: conv.id, name: evt.name, input: evt.input });
|
|
253
|
+
break;
|
|
254
|
+
case 'turn_complete':
|
|
255
|
+
conv.busy = false;
|
|
256
|
+
conv.onMessage('bot:turn-complete', { conversationId: conv.id, usedFileTools: evt.usedFileTools });
|
|
257
|
+
log.info(`[pi/conversation] ──── TURN COMPLETE ──── busy=false`);
|
|
258
|
+
break;
|
|
259
|
+
case 'error':
|
|
260
|
+
conv.busy = false;
|
|
261
|
+
conv.onMessage('bot:error', { conversationId: conv.id, error: evt.error });
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function pushMessage(
|
|
267
|
+
conversationId: string,
|
|
268
|
+
content: string,
|
|
269
|
+
attachments?: AgentAttachment[],
|
|
270
|
+
savedFiles?: SavedFile[],
|
|
271
|
+
): boolean {
|
|
272
|
+
const conv = liveConversations.get(conversationId);
|
|
273
|
+
if (!conv) {
|
|
274
|
+
log.warn(`[pi/conversation] pushMessage — no live conversation ${conversationId}`);
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
log.info(`[pi/conversation] ──── PUSH MESSAGE ──── busy=${conv.busy}`);
|
|
279
|
+
conv.busy = true;
|
|
280
|
+
conv.inputQueue.push(buildUserMessage(content, attachments, savedFiles));
|
|
281
|
+
conv.onMessage('bot:typing', { conversationId });
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function endConversation(conversationId: string): void {
|
|
286
|
+
const conv = liveConversations.get(conversationId);
|
|
287
|
+
if (!conv) return;
|
|
288
|
+
|
|
289
|
+
log.info(`[pi/conversation] ──── ENDING CONVERSATION ${conversationId} ────`);
|
|
290
|
+
conv.inputQueue.end();
|
|
291
|
+
conv.abortController.abort();
|
|
292
|
+
liveConversations.delete(conversationId);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function isConversationBusy(conversationId: string): boolean {
|
|
296
|
+
return liveConversations.get(conversationId)?.busy || false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Pi has no sub-agents yet; provided for interface compatibility. */
|
|
300
|
+
export async function stopSubAgentTask(_conversationId: string, _taskId: string): Promise<void> {
|
|
301
|
+
// no-op for Phase 1
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Pi has no pre-warm step (no subprocess), but the interface requires this. */
|
|
305
|
+
export async function warmUpForLiveConversation(
|
|
306
|
+
_model: string,
|
|
307
|
+
_names?: { botName: string; humanName: string },
|
|
308
|
+
): Promise<void> {
|
|
309
|
+
// no-op
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── One-shot API (customer WhatsApp, scheduler, /api/agent/query) ──────────
|
|
313
|
+
|
|
314
|
+
const activeQueries = new Map<string, AbortController>();
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* One-shot text query — used by customer WhatsApp + scheduler. Uses the
|
|
318
|
+
* provider stream directly (no async queue), drains it, emits the same
|
|
319
|
+
* bloby events the live path does.
|
|
320
|
+
*/
|
|
321
|
+
export async function startBlobyAgentQuery(
|
|
322
|
+
conversationId: string,
|
|
323
|
+
prompt: string,
|
|
324
|
+
_model: string,
|
|
325
|
+
onMessage: (type: string, data: any) => void,
|
|
326
|
+
attachments?: AgentAttachment[],
|
|
327
|
+
savedFiles?: SavedFile[],
|
|
328
|
+
names?: { botName: string; humanName: string },
|
|
329
|
+
recentMessages?: RecentMessage[],
|
|
330
|
+
supportPrompt?: string,
|
|
331
|
+
_maxTurns?: number,
|
|
332
|
+
): Promise<void> {
|
|
333
|
+
const auth = resolveAuth();
|
|
334
|
+
if (!auth.ok) {
|
|
335
|
+
onMessage('bot:error', { conversationId, error: auth.error });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const abortController = new AbortController();
|
|
340
|
+
activeQueries.set(conversationId, abortController);
|
|
341
|
+
|
|
342
|
+
let systemPrompt: string;
|
|
343
|
+
if (supportPrompt) {
|
|
344
|
+
systemPrompt = supportPrompt;
|
|
345
|
+
} else {
|
|
346
|
+
systemPrompt = await buildSystemPrompt(names, recentMessages);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const messages: PiMessage[] = recentToPiMessages(recentMessages);
|
|
350
|
+
messages.push(buildUserMessage(prompt, attachments, savedFiles));
|
|
351
|
+
|
|
352
|
+
onMessage('bot:typing', { conversationId });
|
|
353
|
+
|
|
354
|
+
let accumulated = '';
|
|
355
|
+
const usedTools = new Set<string>();
|
|
356
|
+
let errored = false;
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
const stream = streamProvider(auth.flavor, {
|
|
360
|
+
modelId: auth.modelId,
|
|
361
|
+
baseUrl: auth.baseUrl,
|
|
362
|
+
apiKey: auth.apiKey,
|
|
363
|
+
systemPrompt,
|
|
364
|
+
messages,
|
|
365
|
+
signal: abortController.signal,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
for await (const evt of stream) {
|
|
369
|
+
if (abortController.signal.aborted) break;
|
|
370
|
+
switch (evt.type) {
|
|
371
|
+
case 'text_delta':
|
|
372
|
+
accumulated += evt.delta;
|
|
373
|
+
onMessage('bot:token', { conversationId, token: evt.delta });
|
|
374
|
+
break;
|
|
375
|
+
case 'text_end':
|
|
376
|
+
accumulated = evt.text;
|
|
377
|
+
break;
|
|
378
|
+
case 'tool_use':
|
|
379
|
+
usedTools.add(evt.name);
|
|
380
|
+
onMessage('bot:tool', { conversationId, name: evt.name, input: evt.input });
|
|
381
|
+
break;
|
|
382
|
+
case 'error':
|
|
383
|
+
errored = true;
|
|
384
|
+
onMessage('bot:error', { conversationId, error: evt.error });
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (accumulated && !errored) {
|
|
389
|
+
onMessage('bot:response', { conversationId, content: accumulated });
|
|
390
|
+
}
|
|
391
|
+
} catch (err: any) {
|
|
392
|
+
if (!abortController.signal.aborted) {
|
|
393
|
+
log.warn(`[pi/bloby-agent] one-shot error: ${err?.message || err}`);
|
|
394
|
+
onMessage('bot:error', { conversationId, error: err?.message || String(err) });
|
|
395
|
+
}
|
|
396
|
+
} finally {
|
|
397
|
+
activeQueries.delete(conversationId);
|
|
398
|
+
const FILE_TOOL_NAMES = ['Write', 'Edit', 'write', 'edit'];
|
|
399
|
+
const usedFileTools = FILE_TOOL_NAMES.some((t) => usedTools.has(t));
|
|
400
|
+
onMessage('bot:done', { conversationId, usedFileTools });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function stopBlobyAgentQuery(conversationId: string): void {
|
|
405
|
+
const ctl = activeQueries.get(conversationId);
|
|
406
|
+
if (ctl) {
|
|
407
|
+
ctl.abort();
|
|
408
|
+
activeQueries.delete(conversationId);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Workspace agent endpoint (POST /api/agent/query) ──────────────────────
|
|
413
|
+
|
|
414
|
+
export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryResult> {
|
|
415
|
+
const auth = resolveAuth();
|
|
416
|
+
if (!auth.ok) return { ok: false, error: auth.error };
|
|
417
|
+
|
|
418
|
+
const timeout = Math.min(Math.max(req.timeout || 120_000, 5_000), 300_000);
|
|
419
|
+
const abortController = new AbortController();
|
|
420
|
+
const timeoutHandle = setTimeout(() => abortController.abort(), timeout);
|
|
421
|
+
|
|
422
|
+
const systemPrompt = req.systemPrompt ?? '';
|
|
423
|
+
const messages: PiMessage[] = [{
|
|
424
|
+
role: 'user',
|
|
425
|
+
content: [{ type: 'text', text: req.message }],
|
|
426
|
+
}];
|
|
427
|
+
|
|
428
|
+
let fullText = '';
|
|
429
|
+
const usedTools = new Set<string>();
|
|
430
|
+
let errored = false;
|
|
431
|
+
let errorMsg = '';
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
const stream = streamProvider(auth.flavor, {
|
|
435
|
+
modelId: auth.modelId,
|
|
436
|
+
baseUrl: auth.baseUrl,
|
|
437
|
+
apiKey: auth.apiKey,
|
|
438
|
+
systemPrompt,
|
|
439
|
+
messages,
|
|
440
|
+
signal: abortController.signal,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
for await (const evt of stream) {
|
|
444
|
+
if (abortController.signal.aborted) break;
|
|
445
|
+
switch (evt.type) {
|
|
446
|
+
case 'text_delta':
|
|
447
|
+
fullText += evt.delta;
|
|
448
|
+
break;
|
|
449
|
+
case 'text_end':
|
|
450
|
+
fullText = evt.text;
|
|
451
|
+
break;
|
|
452
|
+
case 'tool_use':
|
|
453
|
+
usedTools.add(evt.name);
|
|
454
|
+
break;
|
|
455
|
+
case 'error':
|
|
456
|
+
errored = true;
|
|
457
|
+
errorMsg = evt.error;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
} catch (err: any) {
|
|
462
|
+
if (abortController.signal.aborted) {
|
|
463
|
+
return { ok: false, error: 'Query timed out.' };
|
|
464
|
+
}
|
|
465
|
+
return { ok: false, error: err?.message || String(err) };
|
|
466
|
+
} finally {
|
|
467
|
+
clearTimeout(timeoutHandle);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (errored) return { ok: false, error: errorMsg || 'Agent query failed' };
|
|
471
|
+
|
|
472
|
+
const usedFileTools = ['Write', 'Edit', 'write', 'edit'].some((t) => usedTools.has(t));
|
|
473
|
+
return { ok: true, response: fullText, toolsUsed: Array.from(usedTools), usedFileTools };
|
|
474
|
+
}
|