@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/events.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE Event Schema Definitions
|
|
3
|
+
*
|
|
4
|
+
* All events emitted by the agent loop conform to these schemas.
|
|
5
|
+
* Provider-specific differences are normalized before emission.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Text delta event - incremental text from the model
|
|
10
|
+
* @typedef {Object} TextDeltaEvent
|
|
11
|
+
* @property {'text_delta'} type
|
|
12
|
+
* @property {string} text - Incremental text chunk
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Thinking event - model's internal reasoning
|
|
17
|
+
* @typedef {Object} ThinkingEvent
|
|
18
|
+
* @property {'thinking'} type
|
|
19
|
+
* @property {string} text - Reasoning text (empty string if no thinking available)
|
|
20
|
+
* @property {boolean} hasNativeThinking - True if provider natively supports thinking
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Tool start event - tool execution beginning
|
|
25
|
+
* @typedef {Object} ToolStartEvent
|
|
26
|
+
* @property {'tool_start'} type
|
|
27
|
+
* @property {string} name - Tool name
|
|
28
|
+
* @property {Object} input - Tool input parameters (already parsed)
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Tool result event - tool execution completed successfully
|
|
33
|
+
* @typedef {Object} ToolResultEvent
|
|
34
|
+
* @property {'tool_result'} type
|
|
35
|
+
* @property {string} name - Tool name
|
|
36
|
+
* @property {Object} input - Tool input parameters
|
|
37
|
+
* @property {string} result - Tool result (JSON string if object)
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Tool error event - tool execution failed
|
|
42
|
+
* @typedef {Object} ToolErrorEvent
|
|
43
|
+
* @property {'tool_error'} type
|
|
44
|
+
* @property {string} name - Tool name
|
|
45
|
+
* @property {string} error - Error message
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Done event - agent loop completed
|
|
50
|
+
* @typedef {Object} DoneEvent
|
|
51
|
+
* @property {'done'} type
|
|
52
|
+
* @property {string} content - Final assistant response text
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Max iterations event - loop exhausted iteration limit
|
|
57
|
+
* @typedef {Object} MaxIterationsEvent
|
|
58
|
+
* @property {'max_iterations'} type
|
|
59
|
+
* @property {string} message - Warning message
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Error event - fatal error occurred
|
|
64
|
+
* @typedef {Object} ErrorEvent
|
|
65
|
+
* @property {'error'} type
|
|
66
|
+
* @property {string} error - Error message
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Stats event - token usage statistics (standardized across providers)
|
|
71
|
+
* @typedef {Object} StatsEvent
|
|
72
|
+
* @property {'stats'} type
|
|
73
|
+
* @property {string} model - Model name
|
|
74
|
+
* @property {number} inputTokens - Input tokens consumed
|
|
75
|
+
* @property {number} outputTokens - Output tokens generated
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Followup event - suggested followup question
|
|
80
|
+
* @typedef {Object} FollowupEvent
|
|
81
|
+
* @property {'followup'} type
|
|
82
|
+
* @property {string} text - Suggested followup text
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Image event - generated image from tool
|
|
87
|
+
* @typedef {Object} ImageEvent
|
|
88
|
+
* @property {'image'} type
|
|
89
|
+
* @property {string} url - Image URL
|
|
90
|
+
* @property {string} prompt - Generation prompt
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @typedef {TextDeltaEvent|ThinkingEvent|ToolStartEvent|ToolResultEvent|ToolErrorEvent|DoneEvent|MaxIterationsEvent|ErrorEvent|StatsEvent|FollowupEvent|ImageEvent} AgentEvent
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate an event against the schema
|
|
99
|
+
* @param {AgentEvent} event - Event to validate
|
|
100
|
+
* @returns {boolean} True if valid
|
|
101
|
+
* @throws {Error} If validation fails
|
|
102
|
+
*/
|
|
103
|
+
export function validateEvent(event) {
|
|
104
|
+
if (!event || typeof event !== 'object') {
|
|
105
|
+
throw new Error('Event must be an object');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!event.type) {
|
|
109
|
+
throw new Error('Event must have a type property');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
switch (event.type) {
|
|
113
|
+
case 'text_delta':
|
|
114
|
+
if (typeof event.text !== 'string') {
|
|
115
|
+
throw new Error('text_delta event must have text string');
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
|
|
119
|
+
case 'thinking':
|
|
120
|
+
if (typeof event.text !== 'string') {
|
|
121
|
+
throw new Error('thinking event must have text string');
|
|
122
|
+
}
|
|
123
|
+
if (typeof event.hasNativeThinking !== 'boolean') {
|
|
124
|
+
throw new Error('thinking event must have hasNativeThinking boolean');
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
|
|
128
|
+
case 'tool_start':
|
|
129
|
+
if (typeof event.name !== 'string') {
|
|
130
|
+
throw new Error('tool_start event must have name string');
|
|
131
|
+
}
|
|
132
|
+
if (typeof event.input !== 'object') {
|
|
133
|
+
throw new Error('tool_start event must have input object');
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
|
|
137
|
+
case 'tool_result':
|
|
138
|
+
if (typeof event.name !== 'string') {
|
|
139
|
+
throw new Error('tool_result event must have name string');
|
|
140
|
+
}
|
|
141
|
+
if (typeof event.input !== 'object') {
|
|
142
|
+
throw new Error('tool_result event must have input object');
|
|
143
|
+
}
|
|
144
|
+
if (typeof event.result !== 'string') {
|
|
145
|
+
throw new Error('tool_result event must have result string');
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
|
|
149
|
+
case 'tool_error':
|
|
150
|
+
if (typeof event.name !== 'string') {
|
|
151
|
+
throw new Error('tool_error event must have name string');
|
|
152
|
+
}
|
|
153
|
+
if (typeof event.error !== 'string') {
|
|
154
|
+
throw new Error('tool_error event must have error string');
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
|
|
158
|
+
case 'done':
|
|
159
|
+
if (typeof event.content !== 'string') {
|
|
160
|
+
throw new Error('done event must have content string');
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
|
|
164
|
+
case 'max_iterations':
|
|
165
|
+
if (typeof event.message !== 'string') {
|
|
166
|
+
throw new Error('max_iterations event must have message string');
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
|
|
170
|
+
case 'error':
|
|
171
|
+
if (typeof event.error !== 'string') {
|
|
172
|
+
throw new Error('error event must have error string');
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
|
|
176
|
+
case 'stats':
|
|
177
|
+
if (typeof event.model !== 'string') {
|
|
178
|
+
throw new Error('stats event must have model string');
|
|
179
|
+
}
|
|
180
|
+
if (typeof event.inputTokens !== 'number') {
|
|
181
|
+
throw new Error('stats event must have inputTokens number');
|
|
182
|
+
}
|
|
183
|
+
if (typeof event.outputTokens !== 'number') {
|
|
184
|
+
throw new Error('stats event must have outputTokens number');
|
|
185
|
+
}
|
|
186
|
+
break;
|
|
187
|
+
|
|
188
|
+
case 'followup':
|
|
189
|
+
if (typeof event.text !== 'string') {
|
|
190
|
+
throw new Error('followup event must have text string');
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
|
|
194
|
+
case 'compaction':
|
|
195
|
+
// Compaction events don't have strict schema requirements
|
|
196
|
+
break;
|
|
197
|
+
|
|
198
|
+
case 'image':
|
|
199
|
+
if (typeof event.url !== 'string') {
|
|
200
|
+
throw new Error('image event must have url string');
|
|
201
|
+
}
|
|
202
|
+
if (typeof event.prompt !== 'string') {
|
|
203
|
+
throw new Error('image event must have prompt string');
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
|
|
207
|
+
default:
|
|
208
|
+
throw new Error(`Unknown event type: ${event.type}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Normalize a stats event from provider-specific format to standard format
|
|
216
|
+
* @param {Object} stats - Raw stats from provider
|
|
217
|
+
* @param {string} provider - Provider ID ('anthropic', 'openai', 'xai', 'ollama')
|
|
218
|
+
* @returns {StatsEvent} Standardized stats event
|
|
219
|
+
*/
|
|
220
|
+
export function normalizeStatsEvent(stats, provider) {
|
|
221
|
+
const isAnthropic = provider === 'anthropic';
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
type: 'stats',
|
|
225
|
+
model: stats.model,
|
|
226
|
+
inputTokens: isAnthropic ? stats.input_tokens : stats.prompt_tokens,
|
|
227
|
+
outputTokens: isAnthropic ? stats.output_tokens : stats.completion_tokens,
|
|
228
|
+
};
|
|
229
|
+
}
|
package/core/failover.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// failover.js
|
|
2
|
+
// Model failover: retry on transient errors, fall back to alternative providers.
|
|
3
|
+
|
|
4
|
+
import { AI_PROVIDERS } from "../utils/providers.js";
|
|
5
|
+
|
|
6
|
+
/** Ordered list of cloud providers to try during failover. Local providers excluded. */
|
|
7
|
+
const FALLBACK_ORDER = ['xai', 'anthropic', 'openai'];
|
|
8
|
+
|
|
9
|
+
/** How long (ms) a failed provider stays cooled down. */
|
|
10
|
+
const COOLDOWN_MS = 5 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
/** Default retry delay (ms) when Retry-After header is absent. */
|
|
13
|
+
const DEFAULT_RETRY_DELAY_MS = 1500;
|
|
14
|
+
|
|
15
|
+
/** HTTP status codes that warrant a retry/failover. */
|
|
16
|
+
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
|
|
17
|
+
|
|
18
|
+
/** In-memory cooldown map: providerId -> expiresAt timestamp. Resets on restart. */
|
|
19
|
+
const cooldownMap = new Map();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Custom error thrown when all providers (primary + fallbacks) are exhausted.
|
|
23
|
+
* @extends Error
|
|
24
|
+
*/
|
|
25
|
+
class FailoverError extends Error {
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} message - Error summary.
|
|
28
|
+
* @param {Array<{provider: string, status: number, body: string}>} attempts - Record of each failed attempt.
|
|
29
|
+
*/
|
|
30
|
+
constructor(message, attempts) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'FailoverError';
|
|
33
|
+
this.attempts = attempts;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check whether a provider is currently in cooldown.
|
|
39
|
+
* @param {string} providerId - Provider identifier (e.g. 'anthropic').
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
function isProviderCooledDown(providerId) {
|
|
43
|
+
const expiresAt = cooldownMap.get(providerId);
|
|
44
|
+
if (!expiresAt) return false;
|
|
45
|
+
if (Date.now() >= expiresAt) {
|
|
46
|
+
cooldownMap.delete(providerId);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mark a provider as failed, placing it in cooldown for COOLDOWN_MS.
|
|
54
|
+
* @param {string} providerId - Provider identifier.
|
|
55
|
+
*/
|
|
56
|
+
function markProviderFailed(providerId) {
|
|
57
|
+
cooldownMap.set(providerId, Date.now() + COOLDOWN_MS);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Sleep for a given duration, respecting an AbortSignal.
|
|
62
|
+
* @param {number} ms - Milliseconds to sleep.
|
|
63
|
+
* @param {AbortSignal} [signal] - Optional abort signal.
|
|
64
|
+
* @returns {Promise<void>}
|
|
65
|
+
*/
|
|
66
|
+
function abortableSleep(ms, signal) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
if (signal?.aborted) {
|
|
69
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const timer = setTimeout(resolve, ms);
|
|
73
|
+
const onAbort = () => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
76
|
+
};
|
|
77
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Attempt a fetch with retry and cross-provider failover.
|
|
83
|
+
*
|
|
84
|
+
* On retryable HTTP errors (429, 5xx): waits Retry-After or 1.5s, retries once.
|
|
85
|
+
* If still failing: marks the provider cooled down, tries the next cloud provider.
|
|
86
|
+
* On non-retryable errors (400, 401, 403): throws immediately (no failover).
|
|
87
|
+
* On all providers exhausted: throws FailoverError with attempts array.
|
|
88
|
+
*
|
|
89
|
+
* @param {Object} options
|
|
90
|
+
* @param {Object} options.provider - Primary provider config from AI_PROVIDERS.
|
|
91
|
+
* @param {function(Object): {url: string, headers: Object, body: string}} options.buildRequest
|
|
92
|
+
* Callback that builds fetch params for any target provider.
|
|
93
|
+
* @param {AbortSignal} [options.signal] - Optional abort signal.
|
|
94
|
+
* @param {Object} [options.logger] - Optional logger with .info() and .error().
|
|
95
|
+
* @returns {Promise<{response: Response, activeProvider: Object}>}
|
|
96
|
+
* The successful HTTP response and the provider that served it.
|
|
97
|
+
* @throws {FailoverError} When all providers are exhausted.
|
|
98
|
+
* @throws {DOMException} When aborted via signal (name: 'AbortError').
|
|
99
|
+
*/
|
|
100
|
+
async function fetchWithFailover({ provider, buildRequest, signal, logger }) {
|
|
101
|
+
const attempts = [];
|
|
102
|
+
|
|
103
|
+
// Build ordered list: primary first, then fallbacks (skip local, skip duplicates)
|
|
104
|
+
const providersToTry = [provider];
|
|
105
|
+
for (const id of FALLBACK_ORDER) {
|
|
106
|
+
if (id === provider.id) continue;
|
|
107
|
+
const p = AI_PROVIDERS[id];
|
|
108
|
+
if (p && !p.local) providersToTry.push(p);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const targetProvider of providersToTry) {
|
|
112
|
+
// Skip cooled-down providers (unless it's the primary — always try primary once)
|
|
113
|
+
if (targetProvider !== provider && isProviderCooledDown(targetProvider.id)) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check that the target provider has an API key configured
|
|
118
|
+
if (targetProvider.envKey && !process.env[targetProvider.envKey]) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const { url, headers, body } = buildRequest(targetProvider);
|
|
123
|
+
let lastStatus = 0;
|
|
124
|
+
let lastBody = '';
|
|
125
|
+
|
|
126
|
+
// Up to 2 attempts per provider (initial + 1 retry)
|
|
127
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
128
|
+
try {
|
|
129
|
+
const response = await fetch(url, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers,
|
|
132
|
+
body,
|
|
133
|
+
signal,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (response.ok) {
|
|
137
|
+
return { response, activeProvider: targetProvider };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
lastStatus = response.status;
|
|
141
|
+
lastBody = await response.text();
|
|
142
|
+
|
|
143
|
+
// Non-retryable — throw immediately, no failover
|
|
144
|
+
if (!RETRYABLE_STATUSES.has(lastStatus)) {
|
|
145
|
+
console.error(`[failover] ${targetProvider.name} returned ${lastStatus}`);
|
|
146
|
+
console.error(`[failover] Error body:`, lastBody);
|
|
147
|
+
console.error(`[failover] Request URL:`, url);
|
|
148
|
+
console.error(`[failover] Request body:`, body.slice(0, 500));
|
|
149
|
+
throw new FailoverError(
|
|
150
|
+
`${targetProvider.name} returned ${lastStatus}: ${lastBody}`,
|
|
151
|
+
[{ provider: targetProvider.id, status: lastStatus, body: lastBody }]
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Retryable — wait and retry (only on first attempt)
|
|
156
|
+
if (attempt === 0) {
|
|
157
|
+
const retryAfter = response.headers.get('retry-after');
|
|
158
|
+
const delayMs = retryAfter
|
|
159
|
+
? Math.min(parseInt(retryAfter, 10) * 1000 || DEFAULT_RETRY_DELAY_MS, 10000)
|
|
160
|
+
: DEFAULT_RETRY_DELAY_MS;
|
|
161
|
+
|
|
162
|
+
if (logger) {
|
|
163
|
+
logger.info(`[failover] ${targetProvider.name} returned ${lastStatus}, retrying in ${delayMs}ms`);
|
|
164
|
+
}
|
|
165
|
+
await abortableSleep(delayMs, signal);
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// Re-throw abort errors and non-retryable FailoverErrors
|
|
169
|
+
if (err.name === 'AbortError' || err instanceof FailoverError) throw err;
|
|
170
|
+
|
|
171
|
+
// Network error — treat as retryable
|
|
172
|
+
lastStatus = 0;
|
|
173
|
+
lastBody = err.message;
|
|
174
|
+
if (attempt === 0) {
|
|
175
|
+
await abortableSleep(DEFAULT_RETRY_DELAY_MS, signal);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Both attempts failed for this provider
|
|
181
|
+
attempts.push({ provider: targetProvider.id, status: lastStatus, body: lastBody });
|
|
182
|
+
|
|
183
|
+
markProviderFailed(targetProvider.id);
|
|
184
|
+
|
|
185
|
+
if (logger) {
|
|
186
|
+
logger.error(`[failover] ${targetProvider.name} exhausted (${lastStatus}), trying next provider`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
throw new FailoverError('All providers exhausted', attempts);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export { fetchWithFailover, FailoverError };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-Based Tool Call Parser
|
|
3
|
+
*
|
|
4
|
+
* Parses tool calls from model output in four formats:
|
|
5
|
+
*
|
|
6
|
+
* 1. Instructed format (via system prompt):
|
|
7
|
+
* <tool_call>{"name": "tool_name", "arguments": {"key": "value"}}</tool_call>
|
|
8
|
+
*
|
|
9
|
+
* 2. Native gpt-oss format (from model fine-tuning):
|
|
10
|
+
* commentary to=tool_name json{"key": "value"}
|
|
11
|
+
*
|
|
12
|
+
* 3. LFM2.5 native format with markers:
|
|
13
|
+
* <|tool_call_start|>[tool_name(arg1="value1")]<|tool_call_end|>
|
|
14
|
+
*
|
|
15
|
+
* 4. LFM2.5 bare Pythonic format (markers stripped by mlx_lm.server):
|
|
16
|
+
* [tool_name(arg1="value1", arg2="value2")]
|
|
17
|
+
*
|
|
18
|
+
* Used when the model doesn't support native OpenAI-style tool calling
|
|
19
|
+
* (e.g., mlx_lm.server) and tool definitions are injected via system prompt.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const TOOL_CALL_RE = /<tool_call>([\s\S]*?)<\/tool_call>/g;
|
|
23
|
+
const NATIVE_TOOL_CALL_RE = /commentary\s+to=(\w+)\s+json(\{[\s\S]*?\})(?:\s|$)/g;
|
|
24
|
+
const LFM_TOOL_CALL_RE = /<\|tool_call_start\|>\[(\w+)\(([\s\S]*?)\)\]<\|tool_call_end\|>/g;
|
|
25
|
+
|
|
26
|
+
// Bare Pythonic: [func_name(key="val")] or [func_name(key='val')]
|
|
27
|
+
// Requires at least one key=quoted_value pair to avoid false positives on markdown links
|
|
28
|
+
const BARE_PYTHONIC_RE = /\[(\w+)\((\w+\s*=\s*(?:"[^"]*"|'[^']*')(?:\s*,\s*\w+\s*=\s*(?:"[^"]*"|'[^']*'|[\w.+-]+))*)\)\]/g;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Detect if text contains tool call markers in any supported format.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} text - Model output text
|
|
34
|
+
* @returns {boolean} True if at least one tool call pattern is found
|
|
35
|
+
*/
|
|
36
|
+
export function hasToolCallMarkers(text) {
|
|
37
|
+
if (text.includes('<tool_call>') && text.includes('</tool_call>')) return true;
|
|
38
|
+
if (/commentary\s+to=\w+\s+json\{/.test(text)) return true;
|
|
39
|
+
if (text.includes('<|tool_call_start|>') && text.includes('<|tool_call_end|>')) return true;
|
|
40
|
+
// Bare Pythonic: [word(word="...")]
|
|
41
|
+
if (/\[\w+\(\w+\s*=\s*["']/.test(text)) return true;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse Pythonic keyword arguments from LFM tool call format.
|
|
47
|
+
* Handles: key="value", key='value', key=123, key=true
|
|
48
|
+
*
|
|
49
|
+
* @param {string} argsStr - Raw arguments string (e.g., 'location="New York", units="fahrenheit"')
|
|
50
|
+
* @returns {Object} Parsed key-value pairs
|
|
51
|
+
*/
|
|
52
|
+
function parsePythonicArgs(argsStr) {
|
|
53
|
+
const args = {};
|
|
54
|
+
if (!argsStr || !argsStr.trim()) return args;
|
|
55
|
+
|
|
56
|
+
// Match key=value pairs where value can be quoted string, number, or boolean
|
|
57
|
+
const argRe = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([\w.+-]+))/g;
|
|
58
|
+
let m;
|
|
59
|
+
while ((m = argRe.exec(argsStr)) !== null) {
|
|
60
|
+
const key = m[1];
|
|
61
|
+
// Prefer double-quoted, then single-quoted, then unquoted
|
|
62
|
+
const val = m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4];
|
|
63
|
+
args[key] = val;
|
|
64
|
+
}
|
|
65
|
+
return args;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extract all tool calls from text, returning them in the same shape
|
|
70
|
+
* that parseOpenAIStream produces so the existing execution loop works unchanged.
|
|
71
|
+
* Handles four formats: `<tool_call>` XML, gpt-oss `commentary to=`,
|
|
72
|
+
* LFM `<|tool_call_start|>`, and bare Pythonic `[func(args)]`.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} text - Model output containing tool call patterns
|
|
75
|
+
* @returns {Array<{id: string, function: {name: string, arguments: Object}}>}
|
|
76
|
+
*/
|
|
77
|
+
export function parseToolCalls(text) {
|
|
78
|
+
const calls = [];
|
|
79
|
+
let idx = 0;
|
|
80
|
+
|
|
81
|
+
// Format 1: <tool_call>{"name":"...","arguments":{...}}</tool_call>
|
|
82
|
+
TOOL_CALL_RE.lastIndex = 0;
|
|
83
|
+
let match;
|
|
84
|
+
while ((match = TOOL_CALL_RE.exec(text)) !== null) {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(match[1].trim());
|
|
87
|
+
const name = parsed.name || parsed.function;
|
|
88
|
+
let args = parsed.arguments || parsed.params || parsed.input || {};
|
|
89
|
+
if (typeof args === 'string') {
|
|
90
|
+
try { args = JSON.parse(args); } catch {}
|
|
91
|
+
}
|
|
92
|
+
calls.push({
|
|
93
|
+
id: `text_call_${idx}`,
|
|
94
|
+
function: { name, arguments: args },
|
|
95
|
+
});
|
|
96
|
+
idx++;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.warn('[tool_parser] Failed to parse <tool_call> JSON:', match[1], err.message);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Format 2: commentary to=TOOL_NAME json{...} (gpt-oss native)
|
|
103
|
+
if (calls.length === 0) {
|
|
104
|
+
NATIVE_TOOL_CALL_RE.lastIndex = 0;
|
|
105
|
+
while ((match = NATIVE_TOOL_CALL_RE.exec(text)) !== null) {
|
|
106
|
+
try {
|
|
107
|
+
const name = match[1];
|
|
108
|
+
const args = JSON.parse(match[2]);
|
|
109
|
+
calls.push({
|
|
110
|
+
id: `native_call_${idx}`,
|
|
111
|
+
function: { name, arguments: args },
|
|
112
|
+
});
|
|
113
|
+
idx++;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.warn('[tool_parser] Failed to parse native tool call:', match[0], err.message);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Format 3: <|tool_call_start|>[func_name(args)]<|tool_call_end|> (LFM with markers)
|
|
121
|
+
if (calls.length === 0) {
|
|
122
|
+
LFM_TOOL_CALL_RE.lastIndex = 0;
|
|
123
|
+
while ((match = LFM_TOOL_CALL_RE.exec(text)) !== null) {
|
|
124
|
+
try {
|
|
125
|
+
const name = match[1];
|
|
126
|
+
const args = parsePythonicArgs(match[2]);
|
|
127
|
+
calls.push({
|
|
128
|
+
id: `lfm_call_${idx}`,
|
|
129
|
+
function: { name, arguments: args },
|
|
130
|
+
});
|
|
131
|
+
idx++;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.warn('[tool_parser] Failed to parse LFM tool call:', match[0], err.message);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Format 4: [func_name(key="val")] (bare Pythonic, markers stripped by mlx_lm.server)
|
|
139
|
+
if (calls.length === 0) {
|
|
140
|
+
BARE_PYTHONIC_RE.lastIndex = 0;
|
|
141
|
+
while ((match = BARE_PYTHONIC_RE.exec(text)) !== null) {
|
|
142
|
+
try {
|
|
143
|
+
const name = match[1];
|
|
144
|
+
const args = parsePythonicArgs(match[2]);
|
|
145
|
+
calls.push({
|
|
146
|
+
id: `lfm_call_${idx}`,
|
|
147
|
+
function: { name, arguments: args },
|
|
148
|
+
});
|
|
149
|
+
idx++;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.warn('[tool_parser] Failed to parse bare Pythonic tool call:', match[0], err.message);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return calls;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Remove all tool call patterns from text (all four formats),
|
|
161
|
+
* leaving only the surrounding natural language content.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} text - Model output containing tool call markers
|
|
164
|
+
* @returns {string} Text with all tool call blocks removed
|
|
165
|
+
*/
|
|
166
|
+
export function stripToolCallMarkers(text) {
|
|
167
|
+
let cleaned = text;
|
|
168
|
+
cleaned = cleaned.replace(TOOL_CALL_RE, '');
|
|
169
|
+
cleaned = cleaned.replace(NATIVE_TOOL_CALL_RE, '');
|
|
170
|
+
cleaned = cleaned.replace(LFM_TOOL_CALL_RE, '');
|
|
171
|
+
cleaned = cleaned.replace(BARE_PYTHONIC_RE, '');
|
|
172
|
+
return cleaned.trim();
|
|
173
|
+
}
|