neoagent 3.2.1-beta.7 → 3.2.1-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,15 @@
1
1
  'use strict';
2
2
 
3
+ const { isAbortError } = require('../../utils/abort');
4
+ const {
5
+ getErrorCode,
6
+ getHttpStatus,
7
+ isTransientIoError,
8
+ retryAfterMilliseconds,
9
+ } = require('../../utils/retry');
10
+
3
11
  const DEFAULT_COOLDOWN_MS = 15 * 60 * 1000;
12
+ const DEFAULT_RECOVERY_COOLDOWN_MS = 60 * 1000;
4
13
  const failures = new Map();
5
14
 
6
15
  function cacheKey(userId, agentId, modelSelectionId) {
@@ -11,28 +20,57 @@ function cacheKey(userId, agentId, modelSelectionId) {
11
20
  ].join(':');
12
21
  }
13
22
 
14
- function readCooldownMs() {
23
+ function readPermanentCooldownMs() {
15
24
  const configured = Number(process.env.NEOAGENT_MODEL_NOT_FOUND_COOLDOWN_MS);
16
25
  if (!Number.isFinite(configured)) return DEFAULT_COOLDOWN_MS;
17
26
  return Math.max(1_000, Math.min(configured, 24 * 60 * 60 * 1000));
18
27
  }
19
28
 
29
+ function readRecoveryCooldownMs() {
30
+ const configured = Number(process.env.NEOAGENT_MODEL_RECOVERY_COOLDOWN_MS);
31
+ if (!Number.isFinite(configured)) return DEFAULT_RECOVERY_COOLDOWN_MS;
32
+ return Math.max(1_000, Math.min(configured, 15 * 60 * 1000));
33
+ }
34
+
20
35
  function isPermanentModelFailure(error) {
21
- const status = Number(
22
- error?.status
23
- ?? error?.statusCode
24
- ?? error?.response?.status,
25
- );
36
+ const status = getHttpStatus(error);
26
37
  if (status === 404) return true;
27
38
  return /\b404\b.*\b(model|nim|provider|request)\b|\b(model|nim)\b.*\b404\b/i
28
39
  .test(String(error?.message || ''));
29
40
  }
30
41
 
42
+ function isRecoverableModelFailure(error) {
43
+ if (!error || isAbortError(error) || isPermanentModelFailure(error)) return false;
44
+ const status = getHttpStatus(error);
45
+ if (status === 401 || status === 403 || status === 429 || (status >= 500 && status < 600)) {
46
+ return true;
47
+ }
48
+ const code = String(getErrorCode(error) || '');
49
+ if (code === 'MODEL_EMPTY_RESPONSE' || code === 'MODEL_CALL_TIMEOUT') return true;
50
+ if (isTransientIoError(error)) return true;
51
+ return /\bempty response|temporarily unavailable|overloaded|rate.?limit|timed? ?out|network error\b/i
52
+ .test(String(error?.message || ''));
53
+ }
54
+
55
+ function resolveCooldownMs(error, now) {
56
+ if (isPermanentModelFailure(error)) return readPermanentCooldownMs();
57
+ if (!isRecoverableModelFailure(error)) return 0;
58
+
59
+ const configured = readRecoveryCooldownMs();
60
+ const retryAfter = retryAfterMilliseconds(
61
+ error?.headers || error?.response?.headers,
62
+ now,
63
+ );
64
+ if (!Number.isFinite(retryAfter)) return configured;
65
+ return Math.max(configured, Math.min(retryAfter, 15 * 60 * 1000));
66
+ }
67
+
31
68
  function recordModelFailure(userId, agentId, modelSelectionId, error, now = Date.now()) {
32
69
  const modelId = String(modelSelectionId || '').trim();
33
- if (!modelId || !isPermanentModelFailure(error)) return false;
70
+ const cooldownMs = resolveCooldownMs(error, now);
71
+ if (!modelId || cooldownMs <= 0) return false;
34
72
  failures.set(cacheKey(userId, agentId, modelId), {
35
- expiresAt: now + readCooldownMs(),
73
+ expiresAt: now + cooldownMs,
36
74
  });
37
75
  return true;
38
76
  }
@@ -64,6 +102,7 @@ module.exports = {
64
102
  clearModelFailureCache,
65
103
  isModelCoolingDown,
66
104
  isPermanentModelFailure,
105
+ isRecoverableModelFailure,
67
106
  recordModelFailure,
68
107
  recordModelSuccess,
69
108
  };
@@ -1,7 +1,63 @@
1
- const { GoogleGenerativeAI } = require('@google/generative-ai');
1
+ 'use strict';
2
+
3
+ const { GoogleGenAI } = require('@google/genai');
2
4
  const { BaseProvider } = require('./base');
3
5
  const { fetchResponseText } = require('../../network/http');
4
6
 
7
+ function parseToolArguments(value) {
8
+ try {
9
+ const parsed = JSON.parse(value || '{}');
10
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
11
+ ? parsed
12
+ : {};
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
17
+
18
+ function normalizeUsage(usage) {
19
+ if (!usage) return null;
20
+ return {
21
+ inputTokens: usage.promptTokenCount || 0,
22
+ outputTokens: usage.candidatesTokenCount || 0,
23
+ reasoningTokens: usage.thoughtsTokenCount || 0,
24
+ cachedReadTokens: usage.cachedContentTokenCount || 0,
25
+ cacheWriteTokens: 0,
26
+ promptTokens: usage.promptTokenCount || 0,
27
+ completionTokens: usage.candidatesTokenCount || 0,
28
+ totalTokens: usage.totalTokenCount || 0
29
+ };
30
+ }
31
+
32
+ function collectResponseParts(response, toolCalls, seenToolCalls = null) {
33
+ let content = '';
34
+ for (const candidate of response?.candidates || []) {
35
+ for (const part of candidate.content?.parts || []) {
36
+ if (part.text && part.thought !== true) content += part.text;
37
+ if (!part.functionCall?.name) continue;
38
+
39
+ const args = part.functionCall.args || {};
40
+ const providerCallId = String(part.functionCall.id || '').trim();
41
+ const signature = providerCallId
42
+ || `${part.functionCall.name}:${JSON.stringify(args)}`;
43
+ if (seenToolCalls?.has(signature)) continue;
44
+ seenToolCalls?.add(signature);
45
+
46
+ toolCalls.push({
47
+ id: providerCallId
48
+ || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
49
+ type: 'function',
50
+ function: {
51
+ name: part.functionCall.name,
52
+ arguments: JSON.stringify(args),
53
+ thought_signature: part.thoughtSignature
54
+ }
55
+ });
56
+ }
57
+ }
58
+ return content;
59
+ }
60
+
5
61
  class GoogleProvider extends BaseProvider {
6
62
  constructor(config = {}) {
7
63
  super(config);
@@ -29,7 +85,7 @@ class GoogleProvider extends BaseProvider {
29
85
  'gemini-1.5-flash': 1048576,
30
86
  };
31
87
  this.apiKey = config.apiKey || process.env.GOOGLE_AI_KEY;
32
- this.genAI = new GoogleGenerativeAI(this.apiKey);
88
+ this.genAI = new GoogleGenAI({ apiKey: this.apiKey });
33
89
  }
34
90
 
35
91
  async listModels(signal = null) {
@@ -78,11 +134,23 @@ class GoogleProvider extends BaseProvider {
78
134
  functionDeclarations: tools.map(tool => ({
79
135
  name: tool.name,
80
136
  description: tool.description,
81
- parameters: tool.parameters || { type: 'object', properties: {} }
137
+ parametersJsonSchema: tool.parameters || { type: 'object', properties: {} }
82
138
  }))
83
139
  }];
84
140
  }
85
141
 
142
+ buildGenerateConfig(systemInstruction, tools, options) {
143
+ const config = {};
144
+ if (systemInstruction) config.systemInstruction = systemInstruction;
145
+ if (tools.length > 0) config.tools = this.formatTools(tools);
146
+ if (options.signal) config.abortSignal = options.signal;
147
+ const maxOutputTokens = Number(options.maxTokens);
148
+ if (Number.isFinite(maxOutputTokens) && maxOutputTokens > 0) {
149
+ config.maxOutputTokens = Math.floor(maxOutputTokens);
150
+ }
151
+ return config;
152
+ }
153
+
86
154
  convertMessages(messages) {
87
155
  let systemInstruction = '';
88
156
  const history = [];
@@ -93,16 +161,16 @@ class GoogleProvider extends BaseProvider {
93
161
  continue;
94
162
  }
95
163
  if (msg.role === 'tool') {
164
+ const functionResponse = {
165
+ name: msg.name || 'tool',
166
+ response: { result: msg.content }
167
+ };
168
+ if (msg.tool_call_id) functionResponse.id = msg.tool_call_id;
96
169
  history.push({
97
- // Gemini chat history only accepts user/model roles. Tool results are
98
- // represented as functionResponse parts on a user turn.
170
+ // The current Gemini API represents tool results as functionResponse
171
+ // parts on a user turn.
99
172
  role: 'user',
100
- parts: [{
101
- functionResponse: {
102
- name: msg.name || 'tool',
103
- response: { result: msg.content }
104
- }
105
- }]
173
+ parts: [{ functionResponse }]
106
174
  });
107
175
  continue;
108
176
  }
@@ -113,9 +181,10 @@ class GoogleProvider extends BaseProvider {
113
181
  const functionCallPart = {
114
182
  functionCall: {
115
183
  name: tc.function.name,
116
- args: JSON.parse(tc.function.arguments || '{}')
184
+ args: parseToolArguments(tc.function.arguments)
117
185
  }
118
186
  };
187
+ if (tc.id) functionCallPart.functionCall.id = tc.id;
119
188
  if (tc.function.thought_signature) {
120
189
  functionCallPart.thoughtSignature = tc.function.thought_signature;
121
190
  }
@@ -149,8 +218,8 @@ class GoogleProvider extends BaseProvider {
149
218
  normalizedHistory.push({ role: currentRole, parts: currentParts });
150
219
  }
151
220
 
152
- // Structured helpers can consist entirely of system instructions. Gemini's
153
- // chat API still requires a user turn to trigger generation.
221
+ // Structured helpers can consist entirely of system instructions. Gemini
222
+ // still requires a user turn to trigger generation.
154
223
  if (
155
224
  normalizedHistory.length === 0
156
225
  || normalizedHistory[normalizedHistory.length - 1].role === 'model'
@@ -174,53 +243,18 @@ class GoogleProvider extends BaseProvider {
174
243
  async chat(messages, tools = [], options = {}) {
175
244
  const model = options.model || this.config.model || this.getDefaultModel();
176
245
  const { systemInstruction, history } = this.convertMessages(messages);
177
-
178
- const genModel = this.genAI.getGenerativeModel({
246
+ const response = await this.genAI.models.generateContent({
179
247
  model,
180
- systemInstruction: systemInstruction || undefined,
181
- tools: tools.length > 0 ? this.formatTools(tools) : undefined
248
+ contents: history,
249
+ config: this.buildGenerateConfig(systemInstruction, tools, options)
182
250
  });
183
-
184
- const lastMessage = history.pop();
185
- const chat = genModel.startChat({ history });
186
- const result = await chat.sendMessage(lastMessage.parts, { signal: options.signal });
187
- const response = result.response;
188
-
189
- let content = '';
190
251
  const toolCalls = [];
191
-
192
- for (const candidate of response.candidates || []) {
193
- for (const part of candidate.content?.parts || []) {
194
- if (part.text) content += part.text;
195
- if (part.functionCall) {
196
- toolCalls.push({
197
- id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
198
- type: 'function',
199
- function: {
200
- name: part.functionCall.name,
201
- arguments: JSON.stringify(part.functionCall.args || {}),
202
- thought_signature: part.thoughtSignature
203
- }
204
- });
205
- }
206
- }
207
- }
208
-
209
- const usage = response.usageMetadata;
252
+ const content = collectResponseParts(response, toolCalls);
210
253
  return {
211
254
  content,
212
255
  toolCalls,
213
256
  finishReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
214
- usage: usage ? {
215
- inputTokens: usage.promptTokenCount || 0,
216
- outputTokens: usage.candidatesTokenCount || 0,
217
- reasoningTokens: usage.thoughtsTokenCount || 0,
218
- cachedReadTokens: usage.cachedContentTokenCount || 0,
219
- cacheWriteTokens: 0,
220
- promptTokens: usage.promptTokenCount || 0,
221
- completionTokens: usage.candidatesTokenCount || 0,
222
- totalTokens: usage.totalTokenCount || 0
223
- } : null,
257
+ usage: normalizeUsage(response.usageMetadata),
224
258
  model
225
259
  };
226
260
  }
@@ -228,60 +262,32 @@ class GoogleProvider extends BaseProvider {
228
262
  async *stream(messages, tools = [], options = {}) {
229
263
  const model = options.model || this.config.model || this.getDefaultModel();
230
264
  const { systemInstruction, history } = this.convertMessages(messages);
231
-
232
- const genModel = this.genAI.getGenerativeModel({
265
+ const responseStream = await this.genAI.models.generateContentStream({
233
266
  model,
234
- systemInstruction: systemInstruction || undefined,
235
- tools: tools.length > 0 ? this.formatTools(tools) : undefined
267
+ contents: history,
268
+ config: this.buildGenerateConfig(systemInstruction, tools, options)
236
269
  });
237
-
238
- const lastMessage = history.pop();
239
- const chat = genModel.startChat({ history });
240
- const result = await chat.sendMessageStream(lastMessage.parts, { signal: options.signal });
241
-
242
270
  let content = '';
243
271
  const toolCalls = [];
272
+ const seenToolCalls = new Set();
273
+ let usage = null;
244
274
 
245
- for await (const chunk of result.stream) {
246
- for (const candidate of chunk.candidates || []) {
247
- for (const part of candidate.content?.parts || []) {
248
- if (part.text) {
249
- content += part.text;
250
- yield { type: 'content', content: part.text };
251
- }
252
- if (part.functionCall) {
253
- toolCalls.push({
254
- id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
255
- type: 'function',
256
- function: {
257
- name: part.functionCall.name,
258
- arguments: JSON.stringify(part.functionCall.args || {}),
259
- thought_signature: part.thoughtSignature
260
- }
261
- });
262
- }
263
- }
275
+ for await (const chunk of responseStream) {
276
+ const chunkContent = collectResponseParts(chunk, toolCalls, seenToolCalls);
277
+ if (chunkContent) {
278
+ content += chunkContent;
279
+ yield { type: 'content', content: chunkContent };
264
280
  }
281
+ if (chunk.usageMetadata) usage = chunk.usageMetadata;
265
282
  }
266
283
 
267
- const finalResponse = await result.response;
268
- const usage = finalResponse.usageMetadata;
269
-
270
284
  yield {
271
285
  type: 'done',
272
286
  content,
273
287
  toolCalls,
274
288
  finishReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
275
- usage: usage ? {
276
- inputTokens: usage.promptTokenCount || 0,
277
- outputTokens: usage.candidatesTokenCount || 0,
278
- reasoningTokens: usage.thoughtsTokenCount || 0,
279
- cachedReadTokens: usage.cachedContentTokenCount || 0,
280
- cacheWriteTokens: 0,
281
- promptTokens: usage.promptTokenCount || 0,
282
- completionTokens: usage.candidatesTokenCount || 0,
283
- totalTokens: usage.totalTokenCount || 0
284
- } : null
289
+ usage: normalizeUsage(usage),
290
+ model
285
291
  };
286
292
  }
287
293
  }
@@ -24,6 +24,7 @@ const ANALYSIS_SCHEMA_EXAMPLE = {
24
24
  draft_reply: '',
25
25
  goal: 'Answer the user accurately.',
26
26
  success_criteria: ['Final reply is correct and specific.'],
27
+ research_targets: [],
27
28
  suggested_tools: ['web_search', 'browser_navigate'],
28
29
  complexity: 'standard',
29
30
  autonomy_level: 'normal',
@@ -59,6 +60,8 @@ const ANALYSIS_PROMPT_INSTRUCTIONS = [
59
60
  'Do not suggest create_task, update_task, delete_task, or list_tasks for ordinary immediate work. Use task tools only when the user asks for future, recurring, scheduled, monitored, background, or existing-task management behavior.',
60
61
  'Set parallel_work=true when independent tool calls or subagents can materially reduce latency.',
61
62
  'Set completion_confidence_required="high" when wrong completion would be costly, state-changing, user-visible, or hard to recover.',
63
+ 'For research, product/device comparisons, reviews, fact-checks, or multi-entity look-into requests, use mode="execute" or mode="plan_execute", never direct_answer. Set needs_verification=true, freshness_risk="possible" or higher, and completion_confidence_required="high".',
64
+ 'When the user asks to look into multiple devices, products, options, or entities, put each named target into research_targets and success_criteria and keep them exact. Prefer suggested_tools that can open primary sources (web_search plus browser_navigate/http_request) rather than answering from memory.',
62
65
  ];
63
66
  const PLAN_PROMPT_INSTRUCTIONS = [
64
67
  'Create a concise execution plan for the current task.',
@@ -83,9 +86,12 @@ const VERIFIER_PROMPT_INSTRUCTIONS = [
83
86
  'A successful create_task or update_task tool call is required before claiming a task schedule changed.',
84
87
  'If external evidence conflicts with memory, history, or another tool result, preserve the uncertainty instead of flattening it into a single confident claim.',
85
88
  'When the draft reply is already correct and fully supported by the evidence, return it unchanged. Do not rewrite for style.',
89
+ 'For multi-entity research or comparison replies, every compared target needs supporting tool evidence. If a target was never searched or opened, mark insufficient_evidence and rewrite the reply so it does not invent that target\'s specs.',
90
+ 'Search-result snippets alone are weak evidence for concrete product claims. Prefer opened pages, fetched docs, or other primary tool output. If only snippets exist, either keep the claim clearly provisional or mark missing_evidence.',
86
91
  ];
87
92
  const EXECUTION_GUIDANCE_ACTION_LINES = [
88
93
  'Act end-to-end. Run independent searches or inspections in parallel when possible. Prefer native integration tools and structured APIs over browser automation or shell scraping. Use exact IDs and required parameters; list or search first when you do not have them.',
94
+ 'For research and multi-entity comparisons, cover each requested target with its own search/open path. Do not stop after one partial lead or invent the remaining devices from memory. Open primary sources before stating concrete specs.',
89
95
  'For GitHub issue implementation or PR work, fetch the issue once, then establish or reuse a writable local checkout, create a task branch, inspect/edit/test locally, and push/open the PR. Use direct GitHub file mutation tools only as a fallback when a local checkout is unavailable.',
90
96
  'Prefer the highest-level available tool for the job. If a tool accepts normal text, JSON, file paths, or line ranges, pass those directly instead of reconstructing equivalent data through shell commands.',
91
97
  'Your shell (execute_command) starts in your workspace, and the file tools (read_file, read_files, write_file, edit_file, replace_file_range, list_directory, search_files) operate on that same workspace. Keep source checkouts and generated files in the shared workspace, then prefer file tools for inspection and edits instead of shell snippets. Clone a repo once and reuse it; do not re-clone or re-list the same tree.',
@@ -317,6 +323,13 @@ function normalizeTaskAnalysis(raw = {}, fallback = {}) {
317
323
  'successCriteria',
318
324
  TASK_ANALYSIS_SUCCESS_CRITERIA_LIMIT,
319
325
  );
326
+ const researchTargets = resolveAliasedStringList(
327
+ raw,
328
+ fallback,
329
+ 'research_targets',
330
+ 'researchTargets',
331
+ TASK_ANALYSIS_SUCCESS_CRITERIA_LIMIT,
332
+ );
320
333
 
321
334
  const draftReply = resolveAliasedText(raw, fallback, 'draft_reply', 'draftReply', '');
322
335
  const initialMode = pickEnum(
@@ -398,6 +411,7 @@ function normalizeTaskAnalysis(raw = {}, fallback = {}) {
398
411
  draft_reply: draftReply,
399
412
  goal: resolveAliasedText(raw, fallback, 'goal', 'goal', ''),
400
413
  success_criteria: successCriteria,
414
+ research_targets: researchTargets,
401
415
  complexity: mode === 'plan_execute' ? 'complex' : normalizedComplexity,
402
416
  autonomy_level: mode === 'plan_execute' ? 'high' : normalizedAutonomyLevel,
403
417
  progress_update_policy: pickEnum(
@@ -567,6 +581,7 @@ function buildExecutionGuidance({ analysis, plan = null, capabilityHealth }) {
567
581
  `Execution mode: ${analysis.mode}.`,
568
582
  analysis.goal ? `Goal: ${analysis.goal}` : '',
569
583
  formatBulletSection('Success criteria', analysis.success_criteria),
584
+ formatBulletSection('Research targets', analysis.research_targets),
570
585
  analysis.suggested_tools?.length
571
586
  ? `Advisory tool suggestions: ${analysis.suggested_tools.join(', ')}`
572
587
  : '',
@@ -10,7 +10,13 @@ function normalizeReply(content) {
10
10
  }
11
11
 
12
12
  function hasExternalBlocker(text) {
13
- return /\b(?:blocked|cannot|can't|could not|couldn't|unable to|do not have access|don't have access|missing (?:access|credentials|permission|information)|need you to|requires? your|waiting for your|please (?:provide|send|choose|confirm|authorize|approve))\b/.test(text);
13
+ return /\b(?:blocked|cannot|can't|could not|couldn't|unable to|do not have access|don't have access|missing (?:access|credentials|permission|information)|need you to|requires? your|waiting for your|please (?:provide|send|choose|confirm|authorize|approve)|blockiert|kann nicht|konnte nicht|mir fehlt|ich brauche von dir|warte auf dein(?:e|en)?|bitte (?:gib|schick|sende|nenn|bestätige|waehle|wähle|genehmige))\b/.test(text);
14
+ }
15
+
16
+ function isTerminalQuestionOrBlockerReply(content) {
17
+ const text = normalizeReply(content);
18
+ if (!text) return false;
19
+ return /[??]/.test(text) || hasExternalBlocker(text);
14
20
  }
15
21
 
16
22
  /**
@@ -36,10 +42,16 @@ function isDeferredWorkReply(content) {
36
42
  /^(?:working on it|checking now|on it)[.!…]*$/,
37
43
  /\bi(?:'ll| will)\s+(?:get back to you|update you|report back|let you know|keep you posted)\b/,
38
44
  /\b(?:i(?:'ll| will)\s+follow up|stay tuned)\b/,
45
+ /^(?:(?:klar|okay|ok|alles klar)[,.!]?\s+)?ich\s+(?:(?:arbeite|pruefe|prüfe|schaue|untersuche|teste|debugge|starte)\s+(?:(?:gerade|aktuell|jetzt|noch)\b|(?:das|dies|die logs?|den code|deine anfrage|deinen auftrag)\b)|(?:kuemmere|kümmere)\s+mich\s+(?:gerade|aktuell|jetzt|noch)\b)/,
46
+ /^(?:(?:klar|okay|ok|alles klar)[,.!]?\s+)?ich\s+(?:werde|wuerde|würde)\s+(?:jetzt\s+)?(?:pruefen|prüfen|nachsehen|untersuchen|testen|debuggen|starten|fixen|erledigen)\b/,
47
+ /^(?:(?:klar|okay|ok|alles klar)[,.!]?\s+)?(?:lass|lasst)\s+mich\s+(?:(?:das|dies|die logs?|den code)\s+)?(?:pruefen|prüfen|nachsehen|untersuchen|testen|debuggen)\b/,
48
+ /^(?:gib mir|gebt mir)\s+(?:einen\s+)?(?:moment|augenblick)|^(?:bin dran|mache ich)[.!…]*$/,
49
+ /\bich\s+(?:melde mich|gebe dir bescheid|halte dich auf dem laufenden)\b/,
39
50
  ];
40
51
  return patterns.some((pattern) => pattern.test(text));
41
52
  }
42
53
 
43
54
  module.exports = {
44
55
  isDeferredWorkReply,
56
+ isTerminalQuestionOrBlockerReply,
45
57
  };