neoagent 2.4.1-beta.36 → 2.4.1-beta.38
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/flutter_app/lib/main_operations.dart +1246 -691
- package/package.json +1 -1
- package/server/db/ftsQuery.js +27 -0
- package/server/middleware/auth.js +1 -40
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +68156 -67730
- package/server/routes/agents.js +1 -1
- package/server/routes/auth.js +3 -1
- package/server/routes/screenHistory.js +14 -8
- package/server/routes/triggers.js +4 -4
- package/server/services/ai/completion.js +44 -0
- package/server/services/ai/engine.js +99 -501
- package/server/services/ai/imageAnalysis.js +9 -5
- package/server/services/ai/logFormat.js +46 -0
- package/server/services/ai/messagingFallback.js +228 -0
- package/server/services/ai/models.js +43 -24
- package/server/services/ai/providerRetry.js +169 -0
- package/server/services/ai/providers/google.js +8 -8
- package/server/services/ai/providers/grok.js +5 -67
- package/server/services/ai/providers/nvidia.js +6 -29
- package/server/services/ai/providers/ollama.js +57 -33
- package/server/services/ai/providers/openai.js +2 -27
- package/server/services/ai/providers/openaiCompatible.js +70 -0
- package/server/services/ai/toolEvidence.js +207 -0
- package/server/services/memory/manager.js +1 -7
- package/server/services/websocket.js +8 -2
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Small, dependency-free helpers for shaping values into safe log lines and
|
|
4
|
+
// for tolerant JSON parsing. Shared across the AI engine and its sibling
|
|
5
|
+
// modules so log formatting stays consistent and testable.
|
|
6
|
+
|
|
7
|
+
function shortenRunId(runId) {
|
|
8
|
+
const value = String(runId || '').trim();
|
|
9
|
+
if (!value) return 'unknown';
|
|
10
|
+
return value.length <= 8 ? value : value.slice(0, 8);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function summarizeForLog(value, maxChars = 220) {
|
|
14
|
+
if (value == null) return '';
|
|
15
|
+
|
|
16
|
+
let text = '';
|
|
17
|
+
if (typeof value === 'string') {
|
|
18
|
+
text = value;
|
|
19
|
+
} else {
|
|
20
|
+
try {
|
|
21
|
+
text = JSON.stringify(value);
|
|
22
|
+
} catch {
|
|
23
|
+
text = String(value);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const normalized = text.replace(/\s+/g, ' ').trim();
|
|
28
|
+
if (normalized.length <= maxChars) return normalized;
|
|
29
|
+
return `${normalized.slice(0, maxChars)}...`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseMaybeJson(value, fallback = null) {
|
|
33
|
+
if (!value) return fallback;
|
|
34
|
+
if (typeof value === 'object') return value;
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(value);
|
|
37
|
+
} catch {
|
|
38
|
+
return fallback;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = {
|
|
43
|
+
shortenRunId,
|
|
44
|
+
summarizeForLog,
|
|
45
|
+
parseMaybeJson,
|
|
46
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Deterministic, model-free helpers for shaping outgoing messages and for
|
|
4
|
+
// constructing honest fallback replies when a run fails or the model returns a
|
|
5
|
+
// blank message. Kept free of engine state so the behavior is pure and unit
|
|
6
|
+
// testable: every function derives its output from its arguments alone.
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
buildPlatformFormattingGuide,
|
|
10
|
+
normalizeOutgoingMessageForPlatform,
|
|
11
|
+
} = require('../messaging/formatting_guides');
|
|
12
|
+
const { summarizeForLog } = require('./logFormat');
|
|
13
|
+
|
|
14
|
+
function normalizeOutgoingMessage(content, platform = null, options = {}) {
|
|
15
|
+
const normalized = normalizeOutgoingMessageForPlatform(platform, content);
|
|
16
|
+
if (options.collapseWhitespace === false) {
|
|
17
|
+
return normalized;
|
|
18
|
+
}
|
|
19
|
+
return normalized.replace(/\s+/g, ' ').trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function clampRunContext(text, maxChars) {
|
|
23
|
+
const value = normalizeOutgoingMessage(text);
|
|
24
|
+
if (!value) return '';
|
|
25
|
+
if (value.length <= maxChars) return value;
|
|
26
|
+
return `${value.slice(0, maxChars)}...`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function joinSentMessages(messages = []) {
|
|
30
|
+
if (!Array.isArray(messages)) return '';
|
|
31
|
+
return messages
|
|
32
|
+
.map((message) => String(message || '').trim())
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.join('\n\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeInterimText(content, platform = null) {
|
|
38
|
+
return normalizeOutgoingMessageForPlatform(platform, content, {
|
|
39
|
+
stripNoResponseMarker: false,
|
|
40
|
+
}).trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildBlankMessagingReplyPrompt(attempt, platform = null) {
|
|
44
|
+
const formattingGuide = buildPlatformFormattingGuide(platform);
|
|
45
|
+
if (attempt <= 1) {
|
|
46
|
+
return `You must send one non-empty reply for the external messaging user right now. Do not call tools. Give either: (a) the concrete outcome, or (b) a clear blocker. If tool work already happened, summarize what you actually tried and where it got blocked. Do not ask the user to repeat the original request. Do not promise future work unless that work already happened in this run or will happen automatically before this reply is sent.\n\n${formattingGuide}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return `Your previous reply was empty. Return one non-empty message now. Do not call tools. If needed, apologize briefly and explain the blocker in one sentence. Use the run evidence already in the conversation instead of asking the user to restate the task. Do not promise future work unless that work already happened in this run or will happen automatically before this reply is sent.\n\n${formattingGuide}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseToolExecutionSummary(item) {
|
|
53
|
+
if (!item?.summary) return null;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(item.summary);
|
|
56
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toolWorkDescription(toolName) {
|
|
63
|
+
const name = String(toolName || '');
|
|
64
|
+
if (name === 'execute_command') return 'ran shell commands';
|
|
65
|
+
if (name === 'read_file' || name === 'search_files' || name === 'list_directory') return 'checked files';
|
|
66
|
+
if (name === 'web_search' || name === 'http_request') return 'looked up supporting information';
|
|
67
|
+
if (name.startsWith('browser_')) return 'checked the browser state';
|
|
68
|
+
if (name.startsWith('android_')) return 'checked the Android state';
|
|
69
|
+
if (name === 'read_health_data' || name.startsWith('recordings_')) return 'checked stored data';
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function summarizeRecentWork(toolExecutions = []) {
|
|
74
|
+
const descriptions = [];
|
|
75
|
+
for (const item of toolExecutions.slice(-6)) {
|
|
76
|
+
const description = toolWorkDescription(item?.toolName);
|
|
77
|
+
if (!description || descriptions.includes(description)) continue;
|
|
78
|
+
descriptions.push(description);
|
|
79
|
+
if (descriptions.length >= 2) break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (descriptions.length === 0) return '';
|
|
83
|
+
if (descriptions.length === 1) return `I ${descriptions[0]}`;
|
|
84
|
+
return `I ${descriptions[0]} and ${descriptions[1]}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hasFailureSignal(text) {
|
|
88
|
+
const normalized = normalizeOutgoingMessage(text);
|
|
89
|
+
if (!normalized) return false;
|
|
90
|
+
return /\b(error|failed|failure|traceback|exception|timed out|timeout|not found|no such file|permission denied|unable to|cannot|could not|module not found)\b/i.test(normalized);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function extractToolFailureMessage(item) {
|
|
94
|
+
const directError = normalizeOutgoingMessage(item?.error || '');
|
|
95
|
+
if (directError) return directError;
|
|
96
|
+
|
|
97
|
+
const summary = parseToolExecutionSummary(item);
|
|
98
|
+
if (!summary) return '';
|
|
99
|
+
|
|
100
|
+
const candidates = [
|
|
101
|
+
summary.message,
|
|
102
|
+
summary.note,
|
|
103
|
+
summary.stderr,
|
|
104
|
+
summary.stdout,
|
|
105
|
+
summary.content,
|
|
106
|
+
summary.excerpt,
|
|
107
|
+
summary.result,
|
|
108
|
+
summary.summary,
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
if (summary.status === 'error') {
|
|
112
|
+
for (const candidate of candidates) {
|
|
113
|
+
const normalized = normalizeOutgoingMessage(candidate || '');
|
|
114
|
+
if (normalized) return normalized;
|
|
115
|
+
}
|
|
116
|
+
if (summary.exitCode != null) {
|
|
117
|
+
return `The last shell command exited with code ${summary.exitCode}`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const candidate of candidates) {
|
|
122
|
+
const normalized = normalizeOutgoingMessage(candidate || '');
|
|
123
|
+
if (hasFailureSignal(normalized)) return normalized;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions = [] }) {
|
|
130
|
+
const workSummary = summarizeRecentWork(toolExecutions);
|
|
131
|
+
const blocker = [...toolExecutions].reverse()
|
|
132
|
+
.map((item) => extractToolFailureMessage(item))
|
|
133
|
+
.find(Boolean);
|
|
134
|
+
|
|
135
|
+
if (workSummary && blocker) {
|
|
136
|
+
return `${workSummary}, but I got blocked: ${blocker}. I do not have a confirmed finished result yet.`;
|
|
137
|
+
}
|
|
138
|
+
if (blocker) {
|
|
139
|
+
return `I got blocked while working on this: ${blocker}. I do not have a confirmed finished result yet.`;
|
|
140
|
+
}
|
|
141
|
+
if (workSummary && stepIndex > 0) {
|
|
142
|
+
return `${workSummary}, but I do not have a confirmed finished result yet.`;
|
|
143
|
+
}
|
|
144
|
+
if (failedStepCount > 0) {
|
|
145
|
+
return 'I ran into a tool problem while working on your request, so I do not have a confirmed finished result yet.';
|
|
146
|
+
}
|
|
147
|
+
if (stepIndex > 0) {
|
|
148
|
+
return 'I completed part of the work, but I do not have a confirmed finished result yet.';
|
|
149
|
+
}
|
|
150
|
+
return 'I could not produce a reliable final reply just now.';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildMessagingFailureScenario({ err, failedStepCount, stepIndex, toolExecutions = [] }) {
|
|
154
|
+
const parts = [];
|
|
155
|
+
const runtimeError = normalizeOutgoingMessage(err?.message || '');
|
|
156
|
+
const workSummary = summarizeRecentWork(toolExecutions);
|
|
157
|
+
const blocker = [...toolExecutions].reverse()
|
|
158
|
+
.map((item) => extractToolFailureMessage(item))
|
|
159
|
+
.find(Boolean);
|
|
160
|
+
|
|
161
|
+
if (runtimeError) {
|
|
162
|
+
parts.push(`Runtime error: ${summarizeForLog(runtimeError, 260)}.`);
|
|
163
|
+
}
|
|
164
|
+
if (workSummary) {
|
|
165
|
+
parts.push(`Observed work before failure: ${workSummary}.`);
|
|
166
|
+
}
|
|
167
|
+
if (blocker) {
|
|
168
|
+
parts.push(`Most specific blocker from run evidence: ${summarizeForLog(blocker, 260)}.`);
|
|
169
|
+
}
|
|
170
|
+
if (stepIndex > 0) {
|
|
171
|
+
parts.push(`Completed steps before failure: ${stepIndex}.`);
|
|
172
|
+
}
|
|
173
|
+
if (failedStepCount > 0) {
|
|
174
|
+
parts.push(`Failed tool steps: ${failedStepCount}.`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return parts.join(' ');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildDeterministicMessagingErrorReply({ err, failedStepCount, stepIndex, toolExecutions = [] }) {
|
|
181
|
+
const message = normalizeOutgoingMessage(err?.message || '');
|
|
182
|
+
if (/no ai providers? are currently available/i.test(message)) {
|
|
183
|
+
return 'I cannot continue right now because no AI provider is available for this account. Please check the provider settings.';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (/(timeout|timed out)/i.test(message)) {
|
|
187
|
+
return 'I hit a timeout while processing your request and could not finish it reliably.';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const blocker = [...toolExecutions].reverse()
|
|
191
|
+
.map((item) => extractToolFailureMessage(item))
|
|
192
|
+
.find(Boolean);
|
|
193
|
+
if (blocker) {
|
|
194
|
+
return `I got blocked while checking this: ${blocker}.`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (message) {
|
|
198
|
+
return `I got blocked while working on this: ${message}.`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function buildModelFailureLoopPrompt({ failedModel, nextModel, errorMessage }) {
|
|
205
|
+
return [
|
|
206
|
+
`The previous model call on "${failedModel}" failed with: ${summarizeForLog(errorMessage, 220)}.`,
|
|
207
|
+
`Continue on "${nextModel}" and recover autonomously.`,
|
|
208
|
+
'If a previous plan depended on that failed call, adjust your approach and proceed end-to-end.',
|
|
209
|
+
'Only ask the user for help if no safe path remains.'
|
|
210
|
+
].join(' ');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
normalizeOutgoingMessage,
|
|
215
|
+
clampRunContext,
|
|
216
|
+
joinSentMessages,
|
|
217
|
+
normalizeInterimText,
|
|
218
|
+
buildBlankMessagingReplyPrompt,
|
|
219
|
+
parseToolExecutionSummary,
|
|
220
|
+
toolWorkDescription,
|
|
221
|
+
summarizeRecentWork,
|
|
222
|
+
hasFailureSignal,
|
|
223
|
+
extractToolFailureMessage,
|
|
224
|
+
buildDeterministicMessagingFallback,
|
|
225
|
+
buildMessagingFailureScenario,
|
|
226
|
+
buildDeterministicMessagingErrorReply,
|
|
227
|
+
buildModelFailureLoopPrompt,
|
|
228
|
+
};
|
|
@@ -161,6 +161,12 @@ const STATIC_MODELS = [
|
|
|
161
161
|
provider: 'google',
|
|
162
162
|
purpose: 'general'
|
|
163
163
|
},
|
|
164
|
+
{
|
|
165
|
+
id: 'gemini-3.1-pro',
|
|
166
|
+
label: 'Gemini 3.1 Pro (High)',
|
|
167
|
+
provider: 'google',
|
|
168
|
+
purpose: 'general'
|
|
169
|
+
},
|
|
164
170
|
{
|
|
165
171
|
id: 'MiniMax-M2.7',
|
|
166
172
|
label: 'MiniMax M2.7 (Coding Plan)',
|
|
@@ -172,9 +178,35 @@ const STATIC_MODELS = [
|
|
|
172
178
|
label: 'Qwen 3.5 4B (Local / Ollama)',
|
|
173
179
|
provider: 'ollama',
|
|
174
180
|
purpose: 'general'
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 'gemma4:12b',
|
|
184
|
+
label: 'Gemma 4 12B (Local / Ollama)',
|
|
185
|
+
provider: 'ollama',
|
|
186
|
+
purpose: 'general'
|
|
175
187
|
}
|
|
176
188
|
];
|
|
177
189
|
|
|
190
|
+
// Maps a provider id to its class and which runtime fields its constructor takes.
|
|
191
|
+
// Adding a provider is a one-line entry here instead of another dispatch branch.
|
|
192
|
+
// `apiKey`/`baseUrl` mirror exactly what each constructor was historically given;
|
|
193
|
+
// they intentionally do not derive from AI_PROVIDER_DEFINITIONS.supportsBaseUrl,
|
|
194
|
+
// which disagrees for github-copilot/openai-codex (those read their base URL from
|
|
195
|
+
// env, not from per-user config).
|
|
196
|
+
const PROVIDER_FACTORIES = Object.freeze({
|
|
197
|
+
grok: { Provider: GrokProvider, apiKey: true, baseUrl: true },
|
|
198
|
+
openai: { Provider: OpenAIProvider, apiKey: true, baseUrl: true },
|
|
199
|
+
anthropic: { Provider: AnthropicProvider, apiKey: true, baseUrl: true },
|
|
200
|
+
google: { Provider: GoogleProvider, apiKey: true, baseUrl: false },
|
|
201
|
+
minimax: { Provider: AnthropicProvider, apiKey: true, baseUrl: true },
|
|
202
|
+
ollama: { Provider: OllamaProvider, apiKey: false, baseUrl: true },
|
|
203
|
+
'github-copilot': { Provider: GithubCopilotProvider, apiKey: true, baseUrl: false },
|
|
204
|
+
'openai-codex': { Provider: OpenAICodexProvider, apiKey: true, baseUrl: false },
|
|
205
|
+
'claude-code': { Provider: ClaudeCodeProvider, apiKey: true, baseUrl: false },
|
|
206
|
+
'grok-oauth': { Provider: GrokOAuthProvider, apiKey: true, baseUrl: false },
|
|
207
|
+
nvidia: { Provider: NvidiaProvider, apiKey: true, baseUrl: true },
|
|
208
|
+
});
|
|
209
|
+
|
|
178
210
|
const dynamicModelsByBaseUrl = new Map();
|
|
179
211
|
const REFRESH_INTERVAL = 30000; // 30 seconds
|
|
180
212
|
|
|
@@ -395,6 +427,11 @@ async function refreshDynamicModels(baseUrl) {
|
|
|
395
427
|
}
|
|
396
428
|
|
|
397
429
|
function createProviderInstance(providerStr, userId = null, configOverrides = {}) {
|
|
430
|
+
const factory = PROVIDER_FACTORIES[providerStr];
|
|
431
|
+
if (!factory) {
|
|
432
|
+
throw new Error(`Unknown provider: ${providerStr}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
398
435
|
const { agentId = null, ...providerOverrides } = configOverrides || {};
|
|
399
436
|
const runtime = getProviderRuntimeConfig(userId, providerStr, agentId);
|
|
400
437
|
|
|
@@ -405,34 +442,16 @@ function createProviderInstance(providerStr, userId = null, configOverrides = {}
|
|
|
405
442
|
throw new Error(`Provider '${providerStr}' is not configured on this deployment.`);
|
|
406
443
|
}
|
|
407
444
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
return new AnthropicProvider({ apiKey: runtime.apiKey, baseUrl: runtime.baseUrl, ...providerOverrides });
|
|
414
|
-
} else if (providerStr === 'google') {
|
|
415
|
-
return new GoogleProvider({ apiKey: runtime.apiKey, ...providerOverrides });
|
|
416
|
-
} else if (providerStr === 'minimax') {
|
|
417
|
-
return new AnthropicProvider({ apiKey: runtime.apiKey, baseUrl: runtime.baseUrl, ...providerOverrides });
|
|
418
|
-
} else if (providerStr === 'ollama') {
|
|
419
|
-
return new OllamaProvider({ baseUrl: runtime.baseUrl, ...providerOverrides });
|
|
420
|
-
} else if (providerStr === 'github-copilot') {
|
|
421
|
-
return new GithubCopilotProvider({ apiKey: runtime.apiKey, ...providerOverrides });
|
|
422
|
-
} else if (providerStr === 'openai-codex') {
|
|
423
|
-
return new OpenAICodexProvider({ apiKey: runtime.apiKey, ...providerOverrides });
|
|
424
|
-
} else if (providerStr === 'claude-code') {
|
|
425
|
-
return new ClaudeCodeProvider({ apiKey: runtime.apiKey, ...providerOverrides });
|
|
426
|
-
} else if (providerStr === 'grok-oauth') {
|
|
427
|
-
return new GrokOAuthProvider({ apiKey: runtime.apiKey, ...providerOverrides });
|
|
428
|
-
} else if (providerStr === 'nvidia') {
|
|
429
|
-
return new NvidiaProvider({ apiKey: runtime.apiKey, baseUrl: runtime.baseUrl, ...providerOverrides });
|
|
430
|
-
}
|
|
431
|
-
throw new Error(`Unknown provider: ${providerStr}`);
|
|
445
|
+
const config = {};
|
|
446
|
+
if (factory.apiKey) config.apiKey = runtime.apiKey;
|
|
447
|
+
if (factory.baseUrl) config.baseUrl = runtime.baseUrl;
|
|
448
|
+
|
|
449
|
+
return new factory.Provider({ ...config, ...providerOverrides });
|
|
432
450
|
}
|
|
433
451
|
|
|
434
452
|
module.exports = {
|
|
435
453
|
AI_PROVIDER_DEFINITIONS,
|
|
454
|
+
PROVIDER_FACTORIES,
|
|
436
455
|
SUPPORTED_MODELS: STATIC_MODELS, // Backward compatibility
|
|
437
456
|
createProviderInstance,
|
|
438
457
|
getProviderCatalog,
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Centralized transient-error retry for AI provider calls.
|
|
4
|
+
//
|
|
5
|
+
// A transient blip (rate limit, provider overload, brief network failure) should
|
|
6
|
+
// retry the SAME model with a short backoff. Only after these retries are
|
|
7
|
+
// exhausted does the engine fall back to a different (often weaker) model. This
|
|
8
|
+
// keeps response quality high and avoids burning the fallback chain on errors a
|
|
9
|
+
// one-second wait would have resolved.
|
|
10
|
+
|
|
11
|
+
const DEFAULTS = {
|
|
12
|
+
maxAttempts: 3, // total attempts including the first
|
|
13
|
+
baseDelayMs: 500,
|
|
14
|
+
maxDelayMs: 8000,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// HTTP statuses worth retrying: request timeout, conflict, rate limit, and the
|
|
18
|
+
// 5xx family including Anthropic's 529 "overloaded" and common CDN edge codes.
|
|
19
|
+
const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504, 520, 521, 522, 524, 529]);
|
|
20
|
+
|
|
21
|
+
// Low-level socket / DNS errors surfaced by Node and undici.
|
|
22
|
+
const RETRYABLE_CODES = new Set([
|
|
23
|
+
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND',
|
|
24
|
+
'ENETUNREACH', 'EHOSTUNREACH', 'EAGAIN',
|
|
25
|
+
'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET', 'UND_ERR_HEADERS_TIMEOUT',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function readNumberEnv(name, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
29
|
+
const raw = process.env[name];
|
|
30
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return fallback;
|
|
31
|
+
const parsed = Number(raw);
|
|
32
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
33
|
+
return Math.min(max, Math.max(min, parsed));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveConfig(overrides = {}) {
|
|
37
|
+
return {
|
|
38
|
+
maxAttempts: overrides.maxAttempts
|
|
39
|
+
?? readNumberEnv('NEOAGENT_AI_RETRY_MAX_ATTEMPTS', DEFAULTS.maxAttempts, { min: 1, max: 8 }),
|
|
40
|
+
baseDelayMs: overrides.baseDelayMs
|
|
41
|
+
?? readNumberEnv('NEOAGENT_AI_RETRY_BASE_MS', DEFAULTS.baseDelayMs, { min: 0, max: 60000 }),
|
|
42
|
+
maxDelayMs: overrides.maxDelayMs
|
|
43
|
+
?? readNumberEnv('NEOAGENT_AI_RETRY_MAX_MS', DEFAULTS.maxDelayMs, { min: 0, max: 120000 }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// SDKs disagree on where they put the HTTP status: OpenAI/Anthropic expose
|
|
48
|
+
// `.status`, raw http clients use `.statusCode`, and some nest it under
|
|
49
|
+
// `.response.status`. Check all of them.
|
|
50
|
+
function getStatus(err) {
|
|
51
|
+
if (!err || typeof err !== 'object') return null;
|
|
52
|
+
const candidates = [err.status, err.statusCode, err.response?.status, err.cause?.status];
|
|
53
|
+
for (const value of candidates) {
|
|
54
|
+
const num = Number(value);
|
|
55
|
+
if (Number.isFinite(num) && num >= 100 && num < 600) return num;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getErrorCode(err) {
|
|
61
|
+
if (!err || typeof err !== 'object') return null;
|
|
62
|
+
return err.code || err.errno || err.cause?.code || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isTransientError(err) {
|
|
66
|
+
if (!err) return false;
|
|
67
|
+
|
|
68
|
+
const status = getStatus(err);
|
|
69
|
+
if (status !== null) return RETRYABLE_STATUS.has(status);
|
|
70
|
+
|
|
71
|
+
const code = getErrorCode(err);
|
|
72
|
+
if (code && RETRYABLE_CODES.has(String(code))) return true;
|
|
73
|
+
|
|
74
|
+
// SDK connection wrappers that don't carry a status or code.
|
|
75
|
+
const name = String(err.name || '');
|
|
76
|
+
if (name === 'APIConnectionError' || name === 'APIConnectionTimeoutError') return true;
|
|
77
|
+
|
|
78
|
+
const message = String(err.message || '').toLowerCase();
|
|
79
|
+
if (!message) return false;
|
|
80
|
+
return /\b(overloaded|rate limit|timed? ?out|timeout|temporarily unavailable|connection (?:reset|refused|error)|socket hang up|network (?:error|timeout)|service unavailable)\b/.test(message);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Honor a server-provided Retry-After when present; it is authoritative over our
|
|
84
|
+
// own backoff. Supports both delta-seconds and `retry-after-ms` style headers.
|
|
85
|
+
function retryAfterMs(err) {
|
|
86
|
+
if (!err || typeof err !== 'object') return null;
|
|
87
|
+
const headers = err.headers || err.response?.headers;
|
|
88
|
+
const read = (name) => {
|
|
89
|
+
if (!headers) return undefined;
|
|
90
|
+
if (typeof headers.get === 'function') return headers.get(name);
|
|
91
|
+
return headers[name] ?? headers[name.toLowerCase()];
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const ms = read('retry-after-ms');
|
|
95
|
+
if (ms !== undefined && ms !== null && String(ms).trim() !== '') {
|
|
96
|
+
const parsed = Number(ms);
|
|
97
|
+
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const after = read('retry-after');
|
|
101
|
+
if (after !== undefined && after !== null && String(after).trim() !== '') {
|
|
102
|
+
const seconds = Number(after);
|
|
103
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
104
|
+
const date = Date.parse(String(after));
|
|
105
|
+
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Exponential backoff with equal-jitter: half the window is fixed, half random,
|
|
112
|
+
// which spreads retries out without ever collapsing the delay to zero.
|
|
113
|
+
function computeBackoffMs(attempt, baseDelayMs, maxDelayMs) {
|
|
114
|
+
const exp = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
|
|
115
|
+
return Math.round(exp / 2 + Math.random() * (exp / 2));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function delay(ms) {
|
|
119
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run `fn` with transient-error retries.
|
|
124
|
+
*
|
|
125
|
+
* @param {(attempt: number) => Promise<any>} fn The provider call to attempt;
|
|
126
|
+
* invoked with the current 1-based attempt number.
|
|
127
|
+
* @param {object} [options]
|
|
128
|
+
* @param {(err: any) => boolean} [options.isRetryable] Override transient classification.
|
|
129
|
+
* @param {(info: {attempt:number, delayMs:number, error:any}) => void} [options.onRetry]
|
|
130
|
+
* Called before each wait so callers can surface progress to the user.
|
|
131
|
+
* @param {string} [options.label] Prefix for diagnostic logs.
|
|
132
|
+
*/
|
|
133
|
+
async function withProviderRetry(fn, options = {}) {
|
|
134
|
+
const { maxAttempts, baseDelayMs, maxDelayMs } = resolveConfig(options);
|
|
135
|
+
const isRetryable = typeof options.isRetryable === 'function' ? options.isRetryable : isTransientError;
|
|
136
|
+
const label = options.label || 'ProviderRetry';
|
|
137
|
+
|
|
138
|
+
let attempt = 0;
|
|
139
|
+
while (true) {
|
|
140
|
+
attempt += 1;
|
|
141
|
+
try {
|
|
142
|
+
return await fn(attempt);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const exhausted = attempt >= maxAttempts;
|
|
145
|
+
if (exhausted || !isRetryable(err)) throw err;
|
|
146
|
+
|
|
147
|
+
const waitMs = retryAfterMs(err) ?? computeBackoffMs(attempt, baseDelayMs, maxDelayMs);
|
|
148
|
+
console.warn(
|
|
149
|
+
`[${label}] transient failure on attempt ${attempt}/${maxAttempts}; retrying in ${waitMs}ms: ${String(err?.message || err).slice(0, 200)}`
|
|
150
|
+
);
|
|
151
|
+
if (typeof options.onRetry === 'function') {
|
|
152
|
+
try {
|
|
153
|
+
options.onRetry({ attempt, delayMs: waitMs, error: err });
|
|
154
|
+
} catch { /* a misbehaving progress callback must not abort the retry */ }
|
|
155
|
+
}
|
|
156
|
+
await delay(waitMs);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
withProviderRetry,
|
|
163
|
+
isTransientError,
|
|
164
|
+
retryAfterMs,
|
|
165
|
+
computeBackoffMs,
|
|
166
|
+
resolveConfig,
|
|
167
|
+
RETRYABLE_STATUS,
|
|
168
|
+
RETRYABLE_CODES,
|
|
169
|
+
};
|
|
@@ -10,14 +10,16 @@ class GoogleProvider extends BaseProvider {
|
|
|
10
10
|
'gemini-2.0-pro',
|
|
11
11
|
'gemini-1.5-pro',
|
|
12
12
|
'gemini-1.5-flash',
|
|
13
|
-
'gemini-3.1-flash-lite-preview'
|
|
13
|
+
'gemini-3.1-flash-lite-preview',
|
|
14
|
+
'gemini-3.1-pro'
|
|
14
15
|
];
|
|
15
16
|
this.contextWindows = {
|
|
16
17
|
'gemini-2.0-flash': 1048576,
|
|
17
18
|
'gemini-2.0-pro': 2097152,
|
|
18
19
|
'gemini-1.5-pro': 2097152,
|
|
19
20
|
'gemini-1.5-flash': 1048576,
|
|
20
|
-
'gemini-3.1-flash-lite-preview': 1048576
|
|
21
|
+
'gemini-3.1-flash-lite-preview': 1048576,
|
|
22
|
+
'gemini-3.1-pro': 2097152
|
|
21
23
|
};
|
|
22
24
|
this.genAI = new GoogleGenerativeAI(config.apiKey || process.env.GOOGLE_AI_KEY);
|
|
23
25
|
}
|
|
@@ -177,14 +179,12 @@ class GoogleProvider extends BaseProvider {
|
|
|
177
179
|
const toolCalls = [];
|
|
178
180
|
|
|
179
181
|
for await (const chunk of result.stream) {
|
|
180
|
-
const text = chunk.text();
|
|
181
|
-
if (text) {
|
|
182
|
-
content += text;
|
|
183
|
-
yield { type: 'content', content: text };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
182
|
for (const candidate of chunk.candidates || []) {
|
|
187
183
|
for (const part of candidate.content?.parts || []) {
|
|
184
|
+
if (part.text) {
|
|
185
|
+
content += part.text;
|
|
186
|
+
yield { type: 'content', content: part.text };
|
|
187
|
+
}
|
|
188
188
|
if (part.functionCall) {
|
|
189
189
|
toolCalls.push({
|
|
190
190
|
id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const OpenAI = require('openai');
|
|
2
|
-
const {
|
|
2
|
+
const { OpenAICompatibleProvider } = require('./openaiCompatible');
|
|
3
3
|
|
|
4
|
-
class GrokProvider extends
|
|
4
|
+
class GrokProvider extends OpenAICompatibleProvider {
|
|
5
5
|
constructor(config = {}) {
|
|
6
6
|
super(config);
|
|
7
7
|
this.name = 'grok';
|
|
@@ -68,7 +68,7 @@ class GrokProvider extends BaseProvider {
|
|
|
68
68
|
|
|
69
69
|
for await (const chunk of stream) {
|
|
70
70
|
if (chunk.usage && (!chunk.choices || chunk.choices.length === 0)) {
|
|
71
|
-
finalUsage = this
|
|
71
|
+
finalUsage = this.normalizeUsage(chunk.usage);
|
|
72
72
|
continue;
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -97,7 +97,7 @@ class GrokProvider extends BaseProvider {
|
|
|
97
97
|
type: 'tool_calls',
|
|
98
98
|
toolCalls,
|
|
99
99
|
content,
|
|
100
|
-
usage: this
|
|
100
|
+
usage: this.normalizeUsage(chunk.usage) || finalUsage
|
|
101
101
|
};
|
|
102
102
|
return;
|
|
103
103
|
}
|
|
@@ -105,7 +105,7 @@ class GrokProvider extends BaseProvider {
|
|
|
105
105
|
yield {
|
|
106
106
|
type: 'done',
|
|
107
107
|
content,
|
|
108
|
-
usage: this
|
|
108
|
+
usage: this.normalizeUsage(chunk.usage) || finalUsage
|
|
109
109
|
};
|
|
110
110
|
return;
|
|
111
111
|
}
|
|
@@ -118,68 +118,6 @@ class GrokProvider extends BaseProvider {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
normalizeResponse(response) {
|
|
122
|
-
const choice = response.choices[0];
|
|
123
|
-
const msg = choice.message;
|
|
124
|
-
return {
|
|
125
|
-
content: msg.content || '',
|
|
126
|
-
toolCalls: msg.tool_calls?.map(tc => ({
|
|
127
|
-
id: tc.id,
|
|
128
|
-
type: 'function',
|
|
129
|
-
function: { name: tc.function.name, arguments: tc.function.arguments }
|
|
130
|
-
})) || [],
|
|
131
|
-
finishReason: choice.finish_reason,
|
|
132
|
-
usage: this.#normalizeUsage(response.usage)
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
#normalizeUsage(usage) {
|
|
137
|
-
if (!usage) {
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
return {
|
|
141
|
-
promptTokens: usage.prompt_tokens ?? usage.promptTokens ?? 0,
|
|
142
|
-
completionTokens: usage.completion_tokens ?? usage.completionTokens ?? 0,
|
|
143
|
-
totalTokens: usage.total_tokens ?? usage.totalTokens ?? 0,
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
formatTools(tools) {
|
|
148
|
-
return tools.map(tool => ({
|
|
149
|
-
type: 'function',
|
|
150
|
-
function: {
|
|
151
|
-
name: tool.name,
|
|
152
|
-
description: tool.description,
|
|
153
|
-
parameters: tool.parameters
|
|
154
|
-
}
|
|
155
|
-
}));
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async analyzeImage(options = {}) {
|
|
159
|
-
const model = options.model || this.getDefaultVisionModel();
|
|
160
|
-
const b64 = BaseProvider.readImageAsBase64(options.imagePath);
|
|
161
|
-
const response = await this.client.chat.completions.create({
|
|
162
|
-
model,
|
|
163
|
-
max_tokens: options.maxTokens || 4096,
|
|
164
|
-
messages: [{
|
|
165
|
-
role: 'user',
|
|
166
|
-
content: [
|
|
167
|
-
{ type: 'text', text: options.question || 'Describe this image in detail.' },
|
|
168
|
-
{
|
|
169
|
-
type: 'image_url',
|
|
170
|
-
image_url: {
|
|
171
|
-
url: `data:${options.mimeType || 'image/jpeg'};base64,${b64}`
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
]
|
|
175
|
-
}]
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
content: response.choices[0]?.message?.content || '',
|
|
180
|
-
model: response.model || model,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
121
|
}
|
|
184
122
|
|
|
185
123
|
module.exports = { GrokProvider };
|