@stevederico/dotbot 0.16.0
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/CHANGELOG.md +136 -0
- package/README.md +380 -0
- package/bin/dotbot.js +461 -0
- package/core/agent.js +779 -0
- package/core/compaction.js +261 -0
- package/core/cron_handler.js +262 -0
- package/core/events.js +229 -0
- package/core/failover.js +193 -0
- package/core/gptoss_tool_parser.js +173 -0
- package/core/init.js +154 -0
- package/core/normalize.js +324 -0
- package/core/trigger_handler.js +148 -0
- package/docs/core.md +103 -0
- package/docs/protected-files.md +59 -0
- package/examples/sqlite-session-example.js +69 -0
- package/index.js +341 -0
- package/observer/index.js +164 -0
- package/package.json +42 -0
- package/storage/CronStore.js +145 -0
- package/storage/EventStore.js +71 -0
- package/storage/MemoryStore.js +175 -0
- package/storage/MongoAdapter.js +291 -0
- package/storage/MongoCronAdapter.js +347 -0
- package/storage/MongoTaskAdapter.js +242 -0
- package/storage/MongoTriggerAdapter.js +158 -0
- package/storage/SQLiteAdapter.js +382 -0
- package/storage/SQLiteCronAdapter.js +562 -0
- package/storage/SQLiteEventStore.js +300 -0
- package/storage/SQLiteMemoryAdapter.js +240 -0
- package/storage/SQLiteTaskAdapter.js +419 -0
- package/storage/SQLiteTriggerAdapter.js +262 -0
- package/storage/SessionStore.js +149 -0
- package/storage/TaskStore.js +100 -0
- package/storage/TriggerStore.js +90 -0
- package/storage/cron_constants.js +48 -0
- package/storage/index.js +21 -0
- package/tools/appgen.js +311 -0
- package/tools/browser.js +634 -0
- package/tools/code.js +101 -0
- package/tools/events.js +145 -0
- package/tools/files.js +201 -0
- package/tools/images.js +253 -0
- package/tools/index.js +97 -0
- package/tools/jobs.js +159 -0
- package/tools/memory.js +332 -0
- package/tools/messages.js +135 -0
- package/tools/notify.js +42 -0
- package/tools/tasks.js +404 -0
- package/tools/triggers.js +159 -0
- package/tools/weather.js +82 -0
- package/tools/web.js +283 -0
- package/utils/providers.js +136 -0
package/core/agent.js
ADDED
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
// agent/agent.js
|
|
2
|
+
// Provider-agnostic agent loop. All conversation history is stored in a
|
|
3
|
+
// standard format (see normalize.js). Provider-specific wire formats are
|
|
4
|
+
// produced just-in-time inside buildAgentRequest() via toProviderFormat().
|
|
5
|
+
|
|
6
|
+
import { AI_PROVIDERS } from "../utils/providers.js";
|
|
7
|
+
import { fetchWithFailover, FailoverError } from "./failover.js";
|
|
8
|
+
import { toProviderFormat } from "./normalize.js";
|
|
9
|
+
import { validateEvent, normalizeStatsEvent } from "./events.js";
|
|
10
|
+
import { hasToolCallMarkers, parseToolCalls, stripToolCallMarkers } from "./gptoss_tool_parser.js";
|
|
11
|
+
|
|
12
|
+
const OLLAMA_BASE = "http://localhost:11434";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run the agent loop. Yields events for streaming to the frontend.
|
|
16
|
+
*
|
|
17
|
+
* Events yielded:
|
|
18
|
+
* - { type: "text_delta", text } — incremental text from the model
|
|
19
|
+
* - { type: "tool_start", name, input } — tool call initiated
|
|
20
|
+
* - { type: "tool_result", name, result } — tool call completed
|
|
21
|
+
* - { type: "tool_error", name, error } — tool call failed
|
|
22
|
+
* - { type: "stats", model, eval_count, eval_duration, total_duration }
|
|
23
|
+
* - { type: "done", content } — final answer, loop complete
|
|
24
|
+
* - { type: "max_iterations", message } — agent hit the iteration safety cap
|
|
25
|
+
* - { type: "thinking" } — agent is reasoning about tool results (iteration > 1)
|
|
26
|
+
* - { type: "error", error } — fatal error
|
|
27
|
+
*
|
|
28
|
+
* @param {Object} options
|
|
29
|
+
* @param {string} options.model - Model name (e.g. "llama3.3", "grok-3", "claude-sonnet-4-5")
|
|
30
|
+
* @param {Array} options.messages - Conversation history
|
|
31
|
+
* @param {Array} options.tools - Tool definitions from tools.js
|
|
32
|
+
* @param {AbortSignal} [options.signal] - Optional abort signal
|
|
33
|
+
* @param {Object} [options.provider] - Provider config from AI_PROVIDERS. Defaults to Ollama.
|
|
34
|
+
* @param {Object} [options.context] - Execution context passed to tool execute functions (e.g. databaseManager, dbConfig, userID).
|
|
35
|
+
* @yields {Object} Stream events for the frontend
|
|
36
|
+
*/
|
|
37
|
+
export async function* agentLoop({ model, messages, tools, signal, provider, context, maxTurns }) {
|
|
38
|
+
// Default to Ollama for backward compat (cron, etc.)
|
|
39
|
+
if (!provider) {
|
|
40
|
+
provider = AI_PROVIDERS.ollama;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Helper to log events (fire-and-forget, non-blocking)
|
|
44
|
+
const logEvent = (type, data = {}) => {
|
|
45
|
+
if (context?.eventStore && context?.userID) {
|
|
46
|
+
context.eventStore.logEvent({
|
|
47
|
+
userId: context.userID,
|
|
48
|
+
type,
|
|
49
|
+
data,
|
|
50
|
+
}).catch(() => {}); // Swallow errors to avoid breaking the agent loop
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Log message_sent for the latest user message (first iteration only)
|
|
55
|
+
const lastUserMsg = messages.filter(m => m.role === 'user').slice(-1)[0];
|
|
56
|
+
if (lastUserMsg) {
|
|
57
|
+
const content = typeof lastUserMsg.content === 'string'
|
|
58
|
+
? lastUserMsg.content
|
|
59
|
+
: JSON.stringify(lastUserMsg.content);
|
|
60
|
+
// Full audit log: capture complete message content for debugging
|
|
61
|
+
logEvent('message_sent', { length: content.length, content });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const maxIterations = maxTurns || 10;
|
|
65
|
+
let iteration = 0;
|
|
66
|
+
|
|
67
|
+
while (iteration < maxIterations) {
|
|
68
|
+
iteration++;
|
|
69
|
+
|
|
70
|
+
// Build tool definitions in the format the provider expects
|
|
71
|
+
const toolDefs = tools.map((t) => ({
|
|
72
|
+
type: "function",
|
|
73
|
+
function: {
|
|
74
|
+
name: t.name,
|
|
75
|
+
description: t.description,
|
|
76
|
+
parameters: t.parameters,
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
let response;
|
|
81
|
+
let activeProvider = provider;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build a fetch request for a given target provider.
|
|
85
|
+
* Messages are stored in standard format and converted to provider-specific
|
|
86
|
+
* wire format just-in-time here via toProviderFormat().
|
|
87
|
+
* @param {Object} targetProvider - Provider config from AI_PROVIDERS.
|
|
88
|
+
* @returns {{url: string, headers: Object, body: string}}
|
|
89
|
+
*/
|
|
90
|
+
const buildAgentRequest = (targetProvider) => {
|
|
91
|
+
const targetApiKey = targetProvider.envKey ? process.env[targetProvider.envKey] : null;
|
|
92
|
+
const targetIsAnthropic = targetProvider.id === "anthropic";
|
|
93
|
+
const targetModel = targetProvider === provider ? model : targetProvider.defaultModel;
|
|
94
|
+
|
|
95
|
+
// JIT conversion: standard format → provider wire format
|
|
96
|
+
const targetFormat = targetIsAnthropic ? "anthropic" : "openai";
|
|
97
|
+
const wireMessages = toProviderFormat(messages, targetFormat);
|
|
98
|
+
|
|
99
|
+
if (targetIsAnthropic) {
|
|
100
|
+
const anthropicTools = tools.map((t) => ({
|
|
101
|
+
name: t.name,
|
|
102
|
+
description: t.description,
|
|
103
|
+
input_schema: t.parameters,
|
|
104
|
+
}));
|
|
105
|
+
const systemMsg = wireMessages.find((m) => m.role === "system");
|
|
106
|
+
const chatMessages = wireMessages.filter((m) => m.role !== "system");
|
|
107
|
+
const supportsThinking = targetModel.includes('sonnet') || targetModel.includes('opus');
|
|
108
|
+
const requestBody = {
|
|
109
|
+
model: targetModel,
|
|
110
|
+
max_tokens: supportsThinking ? 16000 : 4096,
|
|
111
|
+
stream: true,
|
|
112
|
+
messages: chatMessages,
|
|
113
|
+
tools: anthropicTools,
|
|
114
|
+
};
|
|
115
|
+
if (supportsThinking) {
|
|
116
|
+
requestBody.thinking = { type: 'enabled', budget_tokens: 10000 };
|
|
117
|
+
}
|
|
118
|
+
if (systemMsg) {
|
|
119
|
+
requestBody.system = systemMsg.content;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
url: `${targetProvider.apiUrl}${targetProvider.endpoint}`,
|
|
123
|
+
headers: targetProvider.headers(targetApiKey),
|
|
124
|
+
body: JSON.stringify(requestBody),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// OpenAI-compatible path
|
|
129
|
+
let finalMessages = wireMessages;
|
|
130
|
+
|
|
131
|
+
// Local providers use text-based tool calls via system prompt, so convert
|
|
132
|
+
// role:"tool" messages to role:"user" and strip tool_calls from assistant
|
|
133
|
+
// messages — unless the model's chat template supports role:"tool" natively
|
|
134
|
+
// (e.g. LFM2.5). Models that support it set supportsToolRole on the provider.
|
|
135
|
+
if (targetProvider.local && !targetProvider.supportsToolRole) {
|
|
136
|
+
finalMessages = [];
|
|
137
|
+
const tcNameMap = {};
|
|
138
|
+
for (const msg of wireMessages) {
|
|
139
|
+
if (msg.role === 'assistant' && msg.tool_calls) {
|
|
140
|
+
for (const tc of msg.tool_calls) {
|
|
141
|
+
tcNameMap[tc.id] = tc.function?.name || 'unknown';
|
|
142
|
+
}
|
|
143
|
+
const { tool_calls, ...rest } = msg;
|
|
144
|
+
finalMessages.push(rest);
|
|
145
|
+
} else if (msg.role === 'tool') {
|
|
146
|
+
const name = tcNameMap[msg.tool_call_id] || 'unknown';
|
|
147
|
+
finalMessages.push({
|
|
148
|
+
role: 'user',
|
|
149
|
+
content: `[Tool Result for ${name}]: ${msg.content}`,
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
finalMessages.push(msg);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const requestBody = {
|
|
158
|
+
model: targetModel,
|
|
159
|
+
messages: finalMessages,
|
|
160
|
+
stream: true,
|
|
161
|
+
max_tokens: 8192,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Include tool definitions for non-local providers and local providers
|
|
165
|
+
// that support native tool calling (e.g., GLM-4.7 via mlx_lm.server v0.30.7+)
|
|
166
|
+
if (!targetProvider.local || targetProvider.supportsToolRole) {
|
|
167
|
+
requestBody.tools = toolDefs;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
url: `${targetProvider.apiUrl}${targetProvider.endpoint}`,
|
|
172
|
+
headers: targetProvider.headers(targetApiKey),
|
|
173
|
+
body: JSON.stringify(requestBody),
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Local providers (ollama, dottie_desktop): direct fetch, no failover
|
|
178
|
+
if (provider.local) {
|
|
179
|
+
const { url, headers, body } = buildAgentRequest(provider);
|
|
180
|
+
response = await fetch(url, { method: "POST", headers, body, signal });
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
const errorEvent = { type: "error", error: `${provider.name} returned ${response.status}: ${await response.text()}` };
|
|
183
|
+
validateEvent(errorEvent);
|
|
184
|
+
yield errorEvent;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
try {
|
|
189
|
+
const result = await fetchWithFailover({ provider, buildRequest: buildAgentRequest, signal });
|
|
190
|
+
response = result.response;
|
|
191
|
+
activeProvider = result.activeProvider;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err.name === 'AbortError') return;
|
|
194
|
+
const msg = err instanceof FailoverError
|
|
195
|
+
? `All providers failed: ${err.attempts.map(a => `${a.provider}(${a.status})`).join(', ')}`
|
|
196
|
+
: err.message;
|
|
197
|
+
const errorEvent = { type: "error", error: msg };
|
|
198
|
+
validateEvent(errorEvent);
|
|
199
|
+
yield errorEvent;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Stream parsing — two paths depending on provider wire format
|
|
205
|
+
let fullContent = "";
|
|
206
|
+
let toolCalls = [];
|
|
207
|
+
|
|
208
|
+
if (activeProvider.id === "anthropic") {
|
|
209
|
+
// Anthropic SSE format: content_block_start, content_block_delta, content_block_stop, message_delta
|
|
210
|
+
const result = yield* parseAnthropicStream(response, fullContent, toolCalls, signal, activeProvider.id);
|
|
211
|
+
fullContent = result.fullContent;
|
|
212
|
+
toolCalls = result.toolCalls;
|
|
213
|
+
} else if (activeProvider.id === "dottie_desktop") {
|
|
214
|
+
// Dottie Desktop serves local models which may use:
|
|
215
|
+
// 1. gpt-oss channel tokens (<|channel|>analysis/final<|message|>)
|
|
216
|
+
// 2. Native reasoning (delta.reasoning from parseOpenAIStream)
|
|
217
|
+
// 3. Plain text (LFM2.5, SmolLM, etc. — no special tokens)
|
|
218
|
+
// Detect format by buffering initial tokens and checking for markers.
|
|
219
|
+
const gen = parseOpenAIStream(response, fullContent, toolCalls, signal, activeProvider.id);
|
|
220
|
+
let rawBuffer = "";
|
|
221
|
+
let finalMarkerFound = false;
|
|
222
|
+
let lastFinalYieldPos = 0;
|
|
223
|
+
let usesNativeReasoning = false;
|
|
224
|
+
let usesPassthrough = false; // Models without channel tokens (LFM, SmolLM, etc.)
|
|
225
|
+
let analysisStarted = false;
|
|
226
|
+
let analysisEnded = false;
|
|
227
|
+
let lastThinkingYieldPos = 0;
|
|
228
|
+
const ANALYSIS_MARKER = "<|channel|>analysis<|message|>";
|
|
229
|
+
const ANALYSIS_END = "<|end|>";
|
|
230
|
+
const FINAL_MARKER = "<|channel|>final<|message|>";
|
|
231
|
+
const CHANNEL_DETECT_THRESHOLD = 200; // chars before assuming no channel tokens
|
|
232
|
+
|
|
233
|
+
while (true) {
|
|
234
|
+
const { value, done } = await gen.next();
|
|
235
|
+
if (done) {
|
|
236
|
+
fullContent = value.fullContent;
|
|
237
|
+
toolCalls = value.toolCalls;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// If parseOpenAIStream yields thinking events, the model uses native reasoning —
|
|
242
|
+
// pass everything through directly (no channel token parsing needed).
|
|
243
|
+
if (value.type === "thinking") {
|
|
244
|
+
usesNativeReasoning = true;
|
|
245
|
+
yield value;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (value.type !== "text_delta") {
|
|
250
|
+
yield value;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Native reasoning mode: pass text_delta through directly
|
|
255
|
+
if (usesNativeReasoning) {
|
|
256
|
+
yield value;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Passthrough mode: model doesn't use channel tokens, stream directly
|
|
261
|
+
if (usesPassthrough) {
|
|
262
|
+
yield value;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Channel token mode: buffer and parse markers, stream thinking incrementally
|
|
267
|
+
rawBuffer += value.text;
|
|
268
|
+
|
|
269
|
+
// Fallback: if enough text accumulated without any channel token,
|
|
270
|
+
// the model doesn't use gpt-oss format (e.g. LFM2.5, SmolLM).
|
|
271
|
+
// Flush buffer and switch to passthrough for remaining tokens.
|
|
272
|
+
if (!analysisStarted && !finalMarkerFound && rawBuffer.length > CHANNEL_DETECT_THRESHOLD) {
|
|
273
|
+
console.log("[dottie_desktop] no channel tokens after", rawBuffer.length, "chars — switching to passthrough");
|
|
274
|
+
usesPassthrough = true;
|
|
275
|
+
const textEvent = { type: "text_delta", text: rawBuffer };
|
|
276
|
+
validateEvent(textEvent);
|
|
277
|
+
yield textEvent;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!finalMarkerFound) {
|
|
282
|
+
// Detect analysis channel start
|
|
283
|
+
if (!analysisStarted) {
|
|
284
|
+
const aIdx = rawBuffer.indexOf(ANALYSIS_MARKER);
|
|
285
|
+
if (aIdx !== -1) {
|
|
286
|
+
analysisStarted = true;
|
|
287
|
+
lastThinkingYieldPos = aIdx + ANALYSIS_MARKER.length;
|
|
288
|
+
console.log("[dottie_desktop] analysis marker found at", aIdx, "| yieldPos:", lastThinkingYieldPos);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Stream thinking text incrementally while inside analysis channel
|
|
293
|
+
if (analysisStarted && !analysisEnded) {
|
|
294
|
+
const endIdx = rawBuffer.indexOf(ANALYSIS_END, lastThinkingYieldPos);
|
|
295
|
+
if (endIdx !== -1) {
|
|
296
|
+
const chunk = rawBuffer.slice(lastThinkingYieldPos, endIdx);
|
|
297
|
+
if (chunk) {
|
|
298
|
+
console.log("[dottie_desktop] thinking (final):", chunk.slice(0, 80));
|
|
299
|
+
const thinkingEvent = {
|
|
300
|
+
type: "thinking",
|
|
301
|
+
text: chunk,
|
|
302
|
+
hasNativeThinking: false, // Channel token simulation
|
|
303
|
+
};
|
|
304
|
+
validateEvent(thinkingEvent);
|
|
305
|
+
yield thinkingEvent;
|
|
306
|
+
}
|
|
307
|
+
lastThinkingYieldPos = endIdx + ANALYSIS_END.length;
|
|
308
|
+
analysisEnded = true;
|
|
309
|
+
} else {
|
|
310
|
+
const chunk = rawBuffer.slice(lastThinkingYieldPos);
|
|
311
|
+
if (chunk) {
|
|
312
|
+
console.log("[dottie_desktop] thinking (incr):", chunk.slice(0, 80));
|
|
313
|
+
const thinkingEvent = {
|
|
314
|
+
type: "thinking",
|
|
315
|
+
text: chunk,
|
|
316
|
+
hasNativeThinking: false, // Channel token simulation
|
|
317
|
+
};
|
|
318
|
+
validateEvent(thinkingEvent);
|
|
319
|
+
yield thinkingEvent;
|
|
320
|
+
}
|
|
321
|
+
lastThinkingYieldPos = rawBuffer.length;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Check for final channel marker
|
|
326
|
+
const fIdx = rawBuffer.indexOf(FINAL_MARKER);
|
|
327
|
+
if (fIdx !== -1) {
|
|
328
|
+
console.log("[dottie_desktop] final marker found at", fIdx, "| bufLen:", rawBuffer.length);
|
|
329
|
+
finalMarkerFound = true;
|
|
330
|
+
lastFinalYieldPos = fIdx + FINAL_MARKER.length;
|
|
331
|
+
const pending = rawBuffer.slice(lastFinalYieldPos);
|
|
332
|
+
if (pending) {
|
|
333
|
+
const textEvent = { type: "text_delta", text: pending };
|
|
334
|
+
validateEvent(textEvent);
|
|
335
|
+
yield textEvent;
|
|
336
|
+
lastFinalYieldPos = rawBuffer.length;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
// In final channel — yield incremental text
|
|
341
|
+
const newText = rawBuffer.slice(lastFinalYieldPos);
|
|
342
|
+
if (newText) {
|
|
343
|
+
const textEvent = { type: "text_delta", text: newText };
|
|
344
|
+
validateEvent(textEvent);
|
|
345
|
+
yield textEvent;
|
|
346
|
+
lastFinalYieldPos = rawBuffer.length;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Clean fullContent for persistence (strip channel tokens)
|
|
352
|
+
if (!usesNativeReasoning && !usesPassthrough) fullContent = stripGptOssTokens(fullContent);
|
|
353
|
+
|
|
354
|
+
// Detect text-based tool calls from <tool_call> markers in model output.
|
|
355
|
+
// Models without native tool_calls support emit tool invocations as text
|
|
356
|
+
// when instructed via system prompt.
|
|
357
|
+
if (hasToolCallMarkers(fullContent)) {
|
|
358
|
+
const textToolCalls = parseToolCalls(fullContent);
|
|
359
|
+
if (textToolCalls.length > 0) {
|
|
360
|
+
toolCalls = textToolCalls;
|
|
361
|
+
fullContent = stripToolCallMarkers(fullContent);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
// OpenAI-compatible SSE format (Ollama, OpenAI, xAI)
|
|
366
|
+
const result = yield* parseOpenAIStream(response, fullContent, toolCalls, signal, activeProvider.id);
|
|
367
|
+
fullContent = result.fullContent;
|
|
368
|
+
toolCalls = result.toolCalls;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Check if the model wants to call tools
|
|
372
|
+
if (toolCalls.length > 0) {
|
|
373
|
+
// Standard format: single assistant message with toolCalls array.
|
|
374
|
+
// toProviderFormat() splits this into the wire format each provider expects.
|
|
375
|
+
const assistantMsg = {
|
|
376
|
+
role: "assistant",
|
|
377
|
+
content: fullContent || "",
|
|
378
|
+
toolCalls: toolCalls.map((tc) => {
|
|
379
|
+
let input = tc.function.arguments;
|
|
380
|
+
if (typeof input === "string") {
|
|
381
|
+
try { input = JSON.parse(input); } catch {}
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
id: tc.id,
|
|
385
|
+
name: tc.function.name,
|
|
386
|
+
input,
|
|
387
|
+
status: "pending",
|
|
388
|
+
};
|
|
389
|
+
}),
|
|
390
|
+
_ts: Date.now(),
|
|
391
|
+
};
|
|
392
|
+
messages.push(assistantMsg);
|
|
393
|
+
|
|
394
|
+
// Execute each tool and update the standard-format toolCalls in place.
|
|
395
|
+
// No separate tool-result messages — results are stored on the toolCall object.
|
|
396
|
+
// toProviderFormat() will expand these into the wire format at request time.
|
|
397
|
+
for (let i = 0; i < assistantMsg.toolCalls.length; i++) {
|
|
398
|
+
const tc = assistantMsg.toolCalls[i];
|
|
399
|
+
const tool = tools.find((t) => t.name === tc.name);
|
|
400
|
+
|
|
401
|
+
const toolStartEvent = { type: "tool_start", name: tc.name, input: tc.input };
|
|
402
|
+
validateEvent(toolStartEvent);
|
|
403
|
+
yield toolStartEvent;
|
|
404
|
+
|
|
405
|
+
if (!tool) {
|
|
406
|
+
const errorResult = `Tool "${tc.name}" not found`;
|
|
407
|
+
const toolErrorEvent = { type: "tool_error", name: tc.name, error: errorResult };
|
|
408
|
+
validateEvent(toolErrorEvent);
|
|
409
|
+
yield toolErrorEvent;
|
|
410
|
+
tc.result = errorResult;
|
|
411
|
+
tc.status = "error";
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
const result = await tool.execute(tc.input, signal, context);
|
|
417
|
+
const resultStr = typeof result === "string" ? result : JSON.stringify(result);
|
|
418
|
+
|
|
419
|
+
const toolResultEvent = { type: "tool_result", name: tc.name, input: tc.input, result: resultStr };
|
|
420
|
+
validateEvent(toolResultEvent);
|
|
421
|
+
yield toolResultEvent;
|
|
422
|
+
|
|
423
|
+
// Check if the result is an image and emit additional image event
|
|
424
|
+
try {
|
|
425
|
+
const parsed = JSON.parse(resultStr);
|
|
426
|
+
if (parsed.type === 'image' && parsed.url) {
|
|
427
|
+
const imageEvent = { type: 'image', url: parsed.url, prompt: parsed.prompt || '' };
|
|
428
|
+
validateEvent(imageEvent);
|
|
429
|
+
yield imageEvent;
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
// Not JSON or not an image result, continue
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
tc.result = resultStr;
|
|
436
|
+
tc.status = "done";
|
|
437
|
+
// Full audit log: capture tool input and output for debugging
|
|
438
|
+
logEvent('tool_call', {
|
|
439
|
+
tool: tc.name,
|
|
440
|
+
success: true,
|
|
441
|
+
input: tc.input,
|
|
442
|
+
result: resultStr,
|
|
443
|
+
});
|
|
444
|
+
} catch (err) {
|
|
445
|
+
const errorResult = `Tool error: ${err.message}`;
|
|
446
|
+
const toolErrorEvent = { type: "tool_error", name: tc.name, error: errorResult };
|
|
447
|
+
validateEvent(toolErrorEvent);
|
|
448
|
+
yield toolErrorEvent;
|
|
449
|
+
tc.result = errorResult;
|
|
450
|
+
tc.status = "error";
|
|
451
|
+
// Full audit log: capture tool input and error for debugging
|
|
452
|
+
logEvent('tool_call', {
|
|
453
|
+
tool: tc.name,
|
|
454
|
+
success: false,
|
|
455
|
+
input: tc.input,
|
|
456
|
+
error: err.message,
|
|
457
|
+
stack: err.stack?.split('\n').slice(0, 5).join('\n'),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
toolCalls = [];
|
|
463
|
+
fullContent = "";
|
|
464
|
+
} else {
|
|
465
|
+
// Extract follow-up suggestion before persisting
|
|
466
|
+
let followup = null;
|
|
467
|
+
const followupMatch = fullContent.match(/<followup>([\s\S]*?)<\/followup>/);
|
|
468
|
+
if (followupMatch) {
|
|
469
|
+
followup = followupMatch[1].trim();
|
|
470
|
+
fullContent = fullContent.replace(/<followup>[\s\S]*?<\/followup>/, '').trim();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Standard format: plain string content, no provider-specific wrapping
|
|
474
|
+
messages.push({ role: "assistant", content: fullContent, _ts: Date.now() });
|
|
475
|
+
// Full audit log: capture complete response content for debugging
|
|
476
|
+
logEvent('message_received', {
|
|
477
|
+
length: fullContent.length,
|
|
478
|
+
content: fullContent,
|
|
479
|
+
});
|
|
480
|
+
if (followup) {
|
|
481
|
+
const followupEvent = { type: "followup", text: followup };
|
|
482
|
+
validateEvent(followupEvent);
|
|
483
|
+
yield followupEvent;
|
|
484
|
+
}
|
|
485
|
+
const doneEvent = { type: "done", content: fullContent };
|
|
486
|
+
validateEvent(doneEvent);
|
|
487
|
+
yield doneEvent;
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const maxIterEvent = { type: "max_iterations", message: `I've reached my reasoning limit (${maxIterations} steps). You can send another message to continue.` };
|
|
493
|
+
validateEvent(maxIterEvent);
|
|
494
|
+
yield maxIterEvent;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Parse an OpenAI-compatible SSE stream (works with Ollama, OpenAI, xAI).
|
|
499
|
+
*
|
|
500
|
+
* Tool calls arrive incrementally across chunks via delta.tool_calls with index-based assembly.
|
|
501
|
+
*
|
|
502
|
+
* @param {Response} response - Fetch response with SSE body
|
|
503
|
+
* @param {string} fullContent - Accumulated text content (passed by reference via return)
|
|
504
|
+
* @param {Array} toolCalls - Accumulated tool calls (passed by reference via return)
|
|
505
|
+
* @param {AbortSignal} [signal] - Optional abort signal to cancel the reader
|
|
506
|
+
* @param {string} [providerId] - Provider ID for stats normalization
|
|
507
|
+
* @yields {Object} text_delta events
|
|
508
|
+
* @returns {{ fullContent: string, toolCalls: Array }}
|
|
509
|
+
*/
|
|
510
|
+
async function* parseOpenAIStream(response, fullContent, toolCalls, signal, providerId) {
|
|
511
|
+
const reader = response.body.getReader();
|
|
512
|
+
const decoder = new TextDecoder();
|
|
513
|
+
let buffer = "";
|
|
514
|
+
const toolCallMap = {};
|
|
515
|
+
|
|
516
|
+
while (true) {
|
|
517
|
+
if (signal?.aborted) {
|
|
518
|
+
await reader.cancel();
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
const { done, value } = await reader.read();
|
|
522
|
+
if (done) break;
|
|
523
|
+
|
|
524
|
+
buffer += decoder.decode(value, { stream: true });
|
|
525
|
+
const lines = buffer.split("\n");
|
|
526
|
+
buffer = lines.pop() || "";
|
|
527
|
+
|
|
528
|
+
for (const line of lines) {
|
|
529
|
+
if (!line.startsWith("data:")) continue;
|
|
530
|
+
const data = line.slice(5).trim();
|
|
531
|
+
if (data === "[DONE]" || !data) continue;
|
|
532
|
+
|
|
533
|
+
try {
|
|
534
|
+
const chunk = JSON.parse(data);
|
|
535
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
536
|
+
if (!delta) continue;
|
|
537
|
+
|
|
538
|
+
// Reasoning/thinking content (gpt-oss, DeepSeek, etc.)
|
|
539
|
+
const reasoning = delta.reasoning_content || delta.reasoning;
|
|
540
|
+
if (reasoning) {
|
|
541
|
+
const thinkingEvent = {
|
|
542
|
+
type: "thinking",
|
|
543
|
+
text: reasoning,
|
|
544
|
+
hasNativeThinking: true, // Native reasoning from provider
|
|
545
|
+
};
|
|
546
|
+
validateEvent(thinkingEvent);
|
|
547
|
+
yield thinkingEvent;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Text content
|
|
551
|
+
if (delta.content) {
|
|
552
|
+
fullContent += delta.content;
|
|
553
|
+
const textEvent = { type: "text_delta", text: delta.content };
|
|
554
|
+
validateEvent(textEvent);
|
|
555
|
+
yield textEvent;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Tool calls — assembled incrementally by index
|
|
559
|
+
if (delta.tool_calls) {
|
|
560
|
+
for (const tc of delta.tool_calls) {
|
|
561
|
+
const idx = tc.index ?? 0;
|
|
562
|
+
if (!toolCallMap[idx]) {
|
|
563
|
+
toolCallMap[idx] = {
|
|
564
|
+
id: tc.id || `call_${idx}`,
|
|
565
|
+
function: { name: "", arguments: "" },
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
if (tc.id) toolCallMap[idx].id = tc.id;
|
|
569
|
+
if (tc.function?.name) toolCallMap[idx].function.name += tc.function.name;
|
|
570
|
+
if (tc.function?.arguments) toolCallMap[idx].function.arguments += tc.function.arguments;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Finish reason — check for stats if present
|
|
575
|
+
if (chunk.choices?.[0]?.finish_reason) {
|
|
576
|
+
// Some providers include usage stats
|
|
577
|
+
if (chunk.usage) {
|
|
578
|
+
const statsEvent = normalizeStatsEvent({
|
|
579
|
+
model: chunk.model,
|
|
580
|
+
prompt_tokens: chunk.usage.prompt_tokens,
|
|
581
|
+
completion_tokens: chunk.usage.completion_tokens,
|
|
582
|
+
}, providerId || 'openai');
|
|
583
|
+
validateEvent(statsEvent);
|
|
584
|
+
yield statsEvent;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
} catch {
|
|
588
|
+
// Skip malformed JSON
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Parse accumulated tool call arguments from JSON strings to objects
|
|
594
|
+
toolCalls = Object.values(toolCallMap).map((tc) => {
|
|
595
|
+
let args = tc.function.arguments;
|
|
596
|
+
try {
|
|
597
|
+
args = JSON.parse(args);
|
|
598
|
+
} catch {
|
|
599
|
+
// Keep as string
|
|
600
|
+
}
|
|
601
|
+
return { id: tc.id, function: { name: tc.function.name, arguments: args } };
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
return { fullContent, toolCalls };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Parse an Anthropic SSE stream.
|
|
609
|
+
*
|
|
610
|
+
* Tool calls arrive via content_block_start (type: "tool_use") + content_block_delta (input_json_delta).
|
|
611
|
+
*
|
|
612
|
+
* @param {Response} response - Fetch response with SSE body
|
|
613
|
+
* @param {string} fullContent - Accumulated text content
|
|
614
|
+
* @param {Array} toolCalls - Accumulated tool calls
|
|
615
|
+
* @param {AbortSignal} [signal] - Optional abort signal to cancel the reader
|
|
616
|
+
* @param {string} [providerId] - Provider ID for stats normalization
|
|
617
|
+
* @yields {Object} text_delta events
|
|
618
|
+
* @returns {{ fullContent: string, toolCalls: Array }}
|
|
619
|
+
*/
|
|
620
|
+
async function* parseAnthropicStream(response, fullContent, toolCalls, signal, providerId) {
|
|
621
|
+
const reader = response.body.getReader();
|
|
622
|
+
const decoder = new TextDecoder();
|
|
623
|
+
let buffer = "";
|
|
624
|
+
const contentBlocks = {};
|
|
625
|
+
|
|
626
|
+
while (true) {
|
|
627
|
+
if (signal?.aborted) {
|
|
628
|
+
await reader.cancel();
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
const { done, value } = await reader.read();
|
|
632
|
+
if (done) break;
|
|
633
|
+
|
|
634
|
+
buffer += decoder.decode(value, { stream: true });
|
|
635
|
+
const lines = buffer.split("\n");
|
|
636
|
+
buffer = lines.pop() || "";
|
|
637
|
+
|
|
638
|
+
for (const line of lines) {
|
|
639
|
+
if (!line.startsWith("data:")) continue;
|
|
640
|
+
const data = line.slice(5).trim();
|
|
641
|
+
if (!data) continue;
|
|
642
|
+
|
|
643
|
+
try {
|
|
644
|
+
const event = JSON.parse(data);
|
|
645
|
+
|
|
646
|
+
if (event.type === "content_block_start") {
|
|
647
|
+
const block = event.content_block;
|
|
648
|
+
const idx = event.index;
|
|
649
|
+
if (block.type === "tool_use") {
|
|
650
|
+
contentBlocks[idx] = {
|
|
651
|
+
type: "tool_use",
|
|
652
|
+
id: block.id,
|
|
653
|
+
name: block.name,
|
|
654
|
+
inputJson: "",
|
|
655
|
+
};
|
|
656
|
+
} else if (block.type === "thinking") {
|
|
657
|
+
contentBlocks[idx] = { type: "thinking", text: "" };
|
|
658
|
+
} else if (block.type === "text") {
|
|
659
|
+
contentBlocks[idx] = { type: "text", text: "" };
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (event.type === "content_block_delta") {
|
|
664
|
+
const idx = event.index;
|
|
665
|
+
const delta = event.delta;
|
|
666
|
+
if (delta.type === "thinking_delta") {
|
|
667
|
+
if (contentBlocks[idx]) contentBlocks[idx].text += delta.thinking;
|
|
668
|
+
const thinkingEvent = {
|
|
669
|
+
type: "thinking",
|
|
670
|
+
text: delta.thinking,
|
|
671
|
+
hasNativeThinking: true, // Native thinking from Anthropic
|
|
672
|
+
};
|
|
673
|
+
validateEvent(thinkingEvent);
|
|
674
|
+
yield thinkingEvent;
|
|
675
|
+
} else if (delta.type === "text_delta") {
|
|
676
|
+
fullContent += delta.text;
|
|
677
|
+
if (contentBlocks[idx]) contentBlocks[idx].text += delta.text;
|
|
678
|
+
const textEvent = { type: "text_delta", text: delta.text };
|
|
679
|
+
validateEvent(textEvent);
|
|
680
|
+
yield textEvent;
|
|
681
|
+
} else if (delta.type === "input_json_delta") {
|
|
682
|
+
if (contentBlocks[idx]) contentBlocks[idx].inputJson += delta.partial_json;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (event.type === "message_delta") {
|
|
687
|
+
if (event.usage) {
|
|
688
|
+
const statsEvent = normalizeStatsEvent({
|
|
689
|
+
model: event.model || "",
|
|
690
|
+
input_tokens: event.usage.input_tokens,
|
|
691
|
+
output_tokens: event.usage.output_tokens,
|
|
692
|
+
}, providerId || 'anthropic');
|
|
693
|
+
validateEvent(statsEvent);
|
|
694
|
+
yield statsEvent;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
} catch {
|
|
698
|
+
// Skip malformed JSON
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Assemble tool calls from content blocks
|
|
704
|
+
toolCalls = Object.values(contentBlocks)
|
|
705
|
+
.filter((b) => b.type === "tool_use")
|
|
706
|
+
.map((b) => {
|
|
707
|
+
let args = {};
|
|
708
|
+
try {
|
|
709
|
+
args = JSON.parse(b.inputJson);
|
|
710
|
+
} catch {
|
|
711
|
+
// Empty or malformed
|
|
712
|
+
}
|
|
713
|
+
return { id: b.id, function: { name: b.name, arguments: args } };
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
return { fullContent, toolCalls };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Check if Ollama is running and list available models.
|
|
721
|
+
*
|
|
722
|
+
* @returns {Promise<{running: boolean, models: Array<{name: string, size: number, modified: string}>}>}
|
|
723
|
+
*/
|
|
724
|
+
export async function getOllamaStatus() {
|
|
725
|
+
try {
|
|
726
|
+
const res = await fetch(`${OLLAMA_BASE}/api/tags`);
|
|
727
|
+
if (!res.ok) return { running: false, models: [] };
|
|
728
|
+
const data = await res.json();
|
|
729
|
+
return {
|
|
730
|
+
running: true,
|
|
731
|
+
models: data.models.map((m) => ({
|
|
732
|
+
name: m.name,
|
|
733
|
+
size: m.size,
|
|
734
|
+
modified: m.modified_at,
|
|
735
|
+
})),
|
|
736
|
+
};
|
|
737
|
+
} catch {
|
|
738
|
+
return { running: false, models: [] };
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Check if Dottie Desktop is running and list available models.
|
|
744
|
+
* Uses the OpenAI-compatible /v1/models endpoint.
|
|
745
|
+
*
|
|
746
|
+
* @returns {Promise<{running: boolean, models: Array<{name: string}>}>}
|
|
747
|
+
*/
|
|
748
|
+
/**
|
|
749
|
+
* Strip gpt-oss channel tokens and extract only the final response content.
|
|
750
|
+
* If the text has a "final" channel, returns only that content.
|
|
751
|
+
* Otherwise strips all `<|...|>` tokens and returns the cleaned text.
|
|
752
|
+
*
|
|
753
|
+
* @param {string} text - Raw model output with channel tokens
|
|
754
|
+
* @returns {string} Cleaned text with tokens removed
|
|
755
|
+
*/
|
|
756
|
+
function stripGptOssTokens(text) {
|
|
757
|
+
const FINAL_RE = /<\|channel\|>final<\|message\|>([\s\S]*)$/;
|
|
758
|
+
const TOKEN_RE = /<\|[^|]*\|>/g;
|
|
759
|
+
|
|
760
|
+
const finalMatch = text.match(FINAL_RE);
|
|
761
|
+
if (finalMatch) {
|
|
762
|
+
return finalMatch[1].replace(TOKEN_RE, "").trim();
|
|
763
|
+
}
|
|
764
|
+
// No channel markers — strip all tokens as fallback
|
|
765
|
+
return text.replace(TOKEN_RE, "").trim();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
export async function getDottieDesktopStatus() {
|
|
769
|
+
const baseUrl = (process.env.DOTTIE_DESKTOP_URL || 'http://localhost:1316/v1').replace(/\/v1$/, '');
|
|
770
|
+
try {
|
|
771
|
+
const res = await fetch(`${baseUrl}/v1/models`);
|
|
772
|
+
if (!res.ok) return { running: false, models: [] };
|
|
773
|
+
const data = await res.json();
|
|
774
|
+
const models = (data.data || []).map((m) => ({ name: m.id }));
|
|
775
|
+
return { running: true, models };
|
|
776
|
+
} catch {
|
|
777
|
+
return { running: false, models: [] };
|
|
778
|
+
}
|
|
779
|
+
}
|