neoagent 1.4.1 → 1.4.3

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,48 +1,25 @@
1
1
  const { v4: uuidv4 } = require('uuid');
2
2
  const fs = require('fs');
3
- const os = require('os');
4
- const path = require('path');
5
3
  const db = require('../../db/database');
6
- const { GrokProvider } = require('./providers/grok');
7
- const { detectPromptInjection } = require('../../utils/security');
4
+ const { compact } = require('./compaction');
5
+ const { getConversationContext, buildSummaryCarrier, refreshConversationSummary } = require('./history');
6
+ const { ensureDefaultAiSettings, getAiSettings } = require('./settings');
7
+ const { selectToolsForTask } = require('./toolSelector');
8
+ const { compactToolResult } = require('./toolResult');
8
9
 
9
- const MODEL = 'grok-4-1-fast-reasoning';
10
-
11
- /**
12
- * Turn a raw task/trigger string into a short, readable run title.
13
- * Strips messaging-trigger boilerplate so the history panel shows
14
- * the actual content instead of "You have received a message from: …"
15
- */
16
10
  function generateTitle(task) {
17
11
  if (!task || typeof task !== 'string') return 'Untitled';
18
- // WhatsApp/messaging pattern: "You have received a message from <sender>: <actual text>"
19
12
  const msgMatch = task.match(/received a (?:message|media|image|video|file|audio)[^:]*:\s*(.+)/is);
20
13
  if (msgMatch) {
21
- const body = msgMatch[1].replace(/\n[\s\S]*/s, '').trim(); // first line only
14
+ const body = msgMatch[1].replace(/\n[\s\S]*/s, '').trim();
22
15
  return body.slice(0, 90) || 'Incoming message';
23
16
  }
24
- // Scheduler / sub-agent trigger may start with a [tag]
25
17
  const cleaned = task.replace(/^\[.*?\]\s*/i, '').replace(/^(system|task|prompt)[:\s]+/i, '').trim();
26
18
  return cleaned.slice(0, 90);
27
19
  }
28
20
 
29
- /**
30
- * Returns a human-readable label for a millisecond gap, or null if < 5 min.
31
- * Injected as system messages between conversation turns so the model stays
32
- * aware of how much real time has elapsed.
33
- */
34
- function timeDeltaLabel(ms) {
35
- const s = Math.round(ms / 1000);
36
- if (s < 900) return null; // < 15 min — not noteworthy
37
- if (s < 3600) return `${Math.round(s / 60)} minutes later`;
38
- if (s < 86400) return `${Math.round(s / 3600)} hour${Math.round(s / 3600) === 1 ? '' : 's'} later`;
39
- if (s < 604800) return `${Math.round(s / 86400)} day${Math.round(s / 86400) === 1 ? '' : 's'} later`;
40
- return `${Math.round(s / 604800)} week${Math.round(s / 604800) === 1 ? '' : 's'} later`;
41
- }
42
-
43
21
  function getProviderForUser(userId, task = '', isSubagent = false, modelOverride = null) {
44
22
  const { SUPPORTED_MODELS, createProviderInstance } = require('./models');
45
- const db = require('../../db/database');
46
23
 
47
24
  let enabledIds = [];
48
25
  let defaultChatModel = 'auto';
@@ -58,40 +35,29 @@ function getProviderForUser(userId, task = '', isSubagent = false, modelOverride
58
35
  let parsedVal = row.value;
59
36
  try {
60
37
  parsedVal = JSON.parse(row.value);
61
- } catch (e) {
62
- // Expected for older plain-string values, keep parsedVal as the original string
63
- }
38
+ } catch { }
64
39
 
65
- if (row.key === 'enabled_models') {
66
- enabledIds = parsedVal;
67
- } else if (row.key === 'default_chat_model') {
68
- defaultChatModel = parsedVal;
69
- } else if (row.key === 'default_subagent_model') {
70
- defaultSubagentModel = parsedVal;
71
- }
40
+ if (row.key === 'enabled_models') enabledIds = parsedVal;
41
+ if (row.key === 'default_chat_model') defaultChatModel = parsedVal;
42
+ if (row.key === 'default_subagent_model') defaultSubagentModel = parsedVal;
72
43
  }
73
44
  } catch (e) {
74
- console.error("Failed to fetch settings from DB. Using default supported models. Error:", e);
45
+ console.error('Failed to fetch model settings:', e.message);
75
46
  }
76
47
 
77
- // Fallback if settings empty or incorrectly parsed: Use all supported models
78
48
  if (!Array.isArray(enabledIds) || enabledIds.length === 0) {
79
- enabledIds = SUPPORTED_MODELS.map(m => m.id);
49
+ enabledIds = SUPPORTED_MODELS.map((m) => m.id);
80
50
  }
81
51
 
82
- // Filter to secure models registry definition
83
- const availableModels = SUPPORTED_MODELS.filter(m => enabledIds.includes(m.id));
84
-
85
- // Absolute fallback in case they disabled everything/corrupted data
52
+ const availableModels = SUPPORTED_MODELS.filter((m) => enabledIds.includes(m.id));
86
53
  const fallbackModel = availableModels.length > 0 ? availableModels[0] : SUPPORTED_MODELS[0];
87
54
  let selectedModelDef = fallbackModel;
88
-
89
55
  const userSelectedDefault = isSubagent ? defaultSubagentModel : defaultChatModel;
90
56
 
91
57
  if (modelOverride && typeof modelOverride === 'string') {
92
- const requestedModel = SUPPORTED_MODELS.find(m => m.id === modelOverride.trim());
93
- if (requestedModel && enabledIds.includes(requestedModel.id)) {
94
- selectedModelDef = requestedModel;
58
+ const requested = SUPPORTED_MODELS.find((m) => m.id === modelOverride.trim());
59
+ if (requested && enabledIds.includes(requested.id)) {
60
+ selectedModelDef = requested;
95
61
  return {
96
62
  provider: createProviderInstance(selectedModelDef.provider),
97
63
  model: selectedModelDef.id,
@@ -101,18 +67,16 @@ function getProviderForUser(userId, task = '', isSubagent = false, modelOverride
101
67
  }
102
68
 
103
69
  if (userSelectedDefault && userSelectedDefault !== 'auto') {
104
- selectedModelDef = SUPPORTED_MODELS.find(m => m.id === userSelectedDefault) || fallbackModel;
70
+ selectedModelDef = SUPPORTED_MODELS.find((m) => m.id === userSelectedDefault) || fallbackModel;
105
71
  } else {
106
72
  const taskStr = String(task || '').toLowerCase();
107
- const isPlanning = taskStr.includes('plan') || taskStr.includes('think') || taskStr.includes('analyze') || taskStr.includes('complex') || taskStr.includes('step by step');
108
-
109
- // Intelligent matching
73
+ const isPlanning = /\b(plan|think|analy[sz]e|complex|step by step)\b/.test(taskStr);
110
74
  if (isPlanning) {
111
- selectedModelDef = availableModels.find(m => m.purpose === 'planning') || fallbackModel;
75
+ selectedModelDef = availableModels.find((m) => m.purpose === 'planning') || fallbackModel;
112
76
  } else if (isSubagent) {
113
- selectedModelDef = availableModels.find(m => m.purpose === 'fast') || fallbackModel;
77
+ selectedModelDef = availableModels.find((m) => m.purpose === 'fast') || fallbackModel;
114
78
  } else {
115
- selectedModelDef = availableModels.find(m => m.purpose === 'general') || fallbackModel;
79
+ selectedModelDef = availableModels.find((m) => m.purpose === 'general') || fallbackModel;
116
80
  }
117
81
  }
118
82
 
@@ -123,35 +87,135 @@ function getProviderForUser(userId, task = '', isSubagent = false, modelOverride
123
87
  };
124
88
  }
125
89
 
90
+ function estimateTokenValue(value) {
91
+ if (!value) return 0;
92
+ if (typeof value === 'string') return Math.ceil(value.length / 4);
93
+ return Math.ceil(JSON.stringify(value).length / 4);
94
+ }
95
+
126
96
  class AgentEngine {
127
97
  constructor(io, services = {}) {
128
98
  this.io = io;
129
- this.maxIterations = 75;
99
+ this.maxIterations = 12;
130
100
  this.activeRuns = new Map();
131
101
  this.browserController = services.browserController || null;
132
102
  this.messagingManager = services.messagingManager || null;
133
103
  this.mcpManager = services.mcpManager || services.mcpClient || null;
134
104
  this.skillRunner = services.skillRunner || null;
135
105
  this.scheduler = services.scheduler || null;
106
+ this.memoryManager = services.memoryManager || null;
107
+ this.learningManager = services.learningManager || null;
136
108
  }
137
109
 
138
110
  async buildSystemPrompt(userId, context = {}) {
139
111
  const { buildSystemPrompt } = require('./systemPrompt');
140
112
  const { MemoryManager } = require('../memory/manager');
141
- const memoryManager = new MemoryManager();
142
- return await buildSystemPrompt(userId, context, memoryManager);
113
+ const memoryManager = this.memoryManager || new MemoryManager();
114
+ return buildSystemPrompt(userId, context, memoryManager);
143
115
  }
144
116
 
145
- getAvailableTools(app) {
117
+ getAvailableTools(app, options = {}) {
146
118
  const { getAvailableTools } = require('./tools');
147
- return getAvailableTools(app);
119
+ return getAvailableTools(app, options);
148
120
  }
149
121
 
150
122
  async executeTool(toolName, args, context) {
151
123
  const { executeTool } = require('./tools');
152
- return await executeTool(toolName, args, context, this);
124
+ return executeTool(toolName, args, context, this);
125
+ }
126
+
127
+ getIterationLimit(triggerType, aiSettings) {
128
+ if (triggerType === 'subagent') return aiSettings.subagent_max_iterations;
129
+ return this.maxIterations;
130
+ }
131
+
132
+ getReasoningEffort(providerName, options = {}) {
133
+ if (providerName === 'google') return undefined;
134
+ return options.reasoningEffort || process.env.REASONING_EFFORT || 'low';
135
+ }
136
+
137
+ buildContextMessages(systemPrompt, summaryMessage, historyMessages, recallMsg) {
138
+ const messages = [{ role: 'system', content: systemPrompt }];
139
+ if (summaryMessage) messages.push(summaryMessage);
140
+ if (Array.isArray(historyMessages)) messages.push(...historyMessages);
141
+ if (recallMsg) messages.push({ role: 'system', content: recallMsg });
142
+ return messages;
143
+ }
144
+
145
+ buildUserMessage(userMessage, options = {}) {
146
+ if (!options.mediaAttachments || options.mediaAttachments.length === 0) {
147
+ return { role: 'user', content: userMessage };
148
+ }
149
+
150
+ const contentArr = [{ type: 'text', text: userMessage }];
151
+ for (const att of options.mediaAttachments) {
152
+ if ((att.type === 'image' || att.type === 'video') && att.path) {
153
+ try {
154
+ if (fs.existsSync(att.path)) {
155
+ const b64 = fs.readFileSync(att.path).toString('base64');
156
+ const mime = att.path.endsWith('.png') ? 'image/png' : att.path.endsWith('.gif') ? 'image/gif' : 'image/jpeg';
157
+ contentArr.push({ type: 'image_url', image_url: { url: `data:${mime};base64,${b64}` } });
158
+ }
159
+ } catch { }
160
+ }
161
+ }
162
+
163
+ return { role: 'user', content: contentArr.length > 1 ? contentArr : userMessage };
164
+ }
165
+
166
+ estimatePromptMetrics(messages, tools) {
167
+ const metrics = {
168
+ systemPromptTokens: 0,
169
+ toolSchemaTokens: estimateTokenValue(tools),
170
+ historyTokens: 0,
171
+ recalledMemoryTokens: 0,
172
+ toolReplayTokens: 0,
173
+ totalEstimatedTokens: 0
174
+ };
175
+
176
+ messages.forEach((msg, index) => {
177
+ const contentTokens = estimateTokenValue(msg.content);
178
+ const callTokens = estimateTokenValue(msg.tool_calls);
179
+ const total = contentTokens + callTokens;
180
+
181
+ if (msg.role === 'tool') {
182
+ metrics.toolReplayTokens += total;
183
+ } else if (msg.role === 'system' && index === 0) {
184
+ metrics.systemPromptTokens += total;
185
+ } else if (msg.role === 'system' && /^\[Recalled memory/.test(msg.content || '')) {
186
+ metrics.recalledMemoryTokens += total;
187
+ } else {
188
+ metrics.historyTokens += total;
189
+ }
190
+ });
191
+
192
+ metrics.totalEstimatedTokens = metrics.systemPromptTokens
193
+ + metrics.toolSchemaTokens
194
+ + metrics.historyTokens
195
+ + metrics.recalledMemoryTokens
196
+ + metrics.toolReplayTokens;
197
+
198
+ return metrics;
153
199
  }
154
200
 
201
+ mergePromptMetrics(summary, metrics, iteration, toolCount) {
202
+ return {
203
+ iterationsObserved: Math.max(summary.iterationsObserved || 0, iteration),
204
+ toolCount,
205
+ maxEstimatedTokens: Math.max(summary.maxEstimatedTokens || 0, metrics.totalEstimatedTokens),
206
+ maxSystemPromptTokens: Math.max(summary.maxSystemPromptTokens || 0, metrics.systemPromptTokens),
207
+ maxToolSchemaTokens: Math.max(summary.maxToolSchemaTokens || 0, metrics.toolSchemaTokens),
208
+ maxHistoryTokens: Math.max(summary.maxHistoryTokens || 0, metrics.historyTokens),
209
+ maxRecalledMemoryTokens: Math.max(summary.maxRecalledMemoryTokens || 0, metrics.recalledMemoryTokens),
210
+ maxToolReplayTokens: Math.max(summary.maxToolReplayTokens || 0, metrics.toolReplayTokens),
211
+ lastEstimate: metrics
212
+ };
213
+ }
214
+
215
+ async persistPromptMetrics(runId, metrics) {
216
+ db.prepare('UPDATE agent_runs SET prompt_metrics = ? WHERE id = ?')
217
+ .run(JSON.stringify(metrics), runId);
218
+ }
155
219
 
156
220
  async run(userId, userMessage, options = {}) {
157
221
  return this.runWithModel(userId, userMessage, options, null);
@@ -159,106 +223,54 @@ class AgentEngine {
159
223
 
160
224
  async runWithModel(userId, userMessage, options = {}, _modelOverride = null) {
161
225
  const triggerType = options.triggerType || 'user';
162
- const { provider, model } = getProviderForUser(userId, userMessage, triggerType === 'subagent', _modelOverride);
226
+ ensureDefaultAiSettings(userId);
227
+ const aiSettings = getAiSettings(userId);
228
+ const { provider, model, providerName } = getProviderForUser(userId, userMessage, triggerType === 'subagent', _modelOverride);
163
229
 
164
230
  const runId = options.runId || uuidv4();
165
231
  const conversationId = options.conversationId;
166
232
  const app = options.app;
167
233
  const triggerSource = options.triggerSource || 'web';
234
+ const historyWindow = aiSettings.chat_history_window;
235
+ const toolReplayBudget = aiSettings.tool_replay_budget_chars;
236
+ const maxIterations = this.getIterationLimit(triggerType, aiSettings);
168
237
 
169
238
  const runTitle = generateTitle(userMessage);
170
239
  db.prepare(`INSERT OR REPLACE INTO agent_runs(id, user_id, title, status, trigger_type, trigger_source, model)
171
- VALUES(?, ?, ?, 'running', ?, ?, ?)`).run(runId, userId, runTitle, triggerType, triggerSource, model);
240
+ VALUES(?, ?, ?, 'running', ?, ?, ?)`).run(runId, userId, runTitle, triggerType, triggerSource, model);
172
241
 
173
242
  this.activeRuns.set(runId, { userId, status: 'running', messagingSent: false, lastToolName: null, lastToolTarget: null });
174
243
  this.emit(userId, 'run:start', { runId, title: runTitle, model, triggerType, triggerSource });
175
244
 
176
245
  const systemPrompt = await this.buildSystemPrompt(userId, { ...(options.context || {}), userMessage });
177
- const tools = this.getAvailableTools(app);
178
-
246
+ const builtInTools = this.getAvailableTools(app);
179
247
  const mcpManager = app?.locals?.mcpManager || app?.locals?.mcpClient || this.mcpManager;
180
- if (mcpManager) {
181
- const mcpTools = mcpManager.getAllTools(userId);
182
- tools.push(...mcpTools);
183
- }
248
+ const mcpTools = mcpManager ? mcpManager.getAllTools(userId) : [];
249
+ const tools = selectToolsForTask(userMessage, builtInTools, mcpTools, options);
184
250
 
185
- // Build recalled-memory context message to inject just before the current user turn.
186
- // Uses raw message content (not the full prompt wrapper) as the recall query.
187
251
  const { MemoryManager } = require('../memory/manager');
188
- const _mm = new MemoryManager();
252
+ const memoryManager = this.memoryManager || new MemoryManager();
189
253
  const recallQuery = options.context?.rawUserMessage || userMessage;
190
- const recallMsg = await _mm.buildRecallMessage(userId, recallQuery);
254
+ const recallMsg = await memoryManager.buildRecallMessage(userId, recallQuery);
191
255
 
192
- let messages = [];
256
+ let summaryMessage = null;
257
+ let historyMessages = [];
193
258
 
194
259
  if (conversationId) {
195
- const existingMessages = db.prepare(
196
- 'SELECT role, content, tool_calls, tool_call_id, name, created_at FROM conversation_messages WHERE conversation_id = ? AND is_compacted = 0 ORDER BY created_at'
197
- ).all(conversationId);
198
-
199
- messages = [{ role: 'system', content: systemPrompt }];
200
- let lastMsgTs = null;
201
- for (const msg of existingMessages) {
202
- // Inject a time-gap marker when significant time passed before a user turn
203
- if (msg.created_at && msg.role === 'user') {
204
- const msgTs = new Date(msg.created_at).getTime();
205
- if (lastMsgTs !== null) {
206
- const label = timeDeltaLabel(msgTs - lastMsgTs);
207
- if (label) {
208
- messages.push({ role: 'system', content: `[${label} — now ${new Date(msgTs).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}]` });
209
- }
210
- }
211
- }
212
- const m = { role: msg.role, content: msg.content };
213
- if (msg.tool_calls) m.tool_calls = JSON.parse(msg.tool_calls);
214
- if (msg.tool_call_id) m.tool_call_id = msg.tool_call_id;
215
- if (msg.name) m.name = msg.name;
216
- messages.push(m);
217
- if (msg.created_at) lastMsgTs = new Date(msg.created_at).getTime();
218
- }
219
-
220
- // Annotate the incoming message if the conversation has been idle
221
- const nowTs = Date.now();
222
- if (lastMsgTs !== null) {
223
- const label = timeDeltaLabel(nowTs - lastMsgTs);
224
- if (label) {
225
- messages.push({ role: 'system', content: `[${label} — now ${new Date(nowTs).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}]` });
226
- }
227
- }
260
+ const conversationContext = getConversationContext(conversationId, historyWindow);
261
+ summaryMessage = buildSummaryCarrier(conversationContext.summary);
262
+ historyMessages = conversationContext.recentMessages;
228
263
  } else {
229
- messages = [{ role: 'system', content: systemPrompt }];
230
- if (options.priorMessages && options.priorMessages.length > 0) {
231
- for (const pm of options.priorMessages) {
232
- if (pm.role && pm.content) messages.push({ role: pm.role, content: pm.content });
233
- }
234
- }
235
- }
236
-
237
- // Inject recalled memories as a system message immediately before the current user turn
238
- if (recallMsg) {
239
- messages.push({ role: 'system', content: recallMsg });
264
+ summaryMessage = buildSummaryCarrier(options.priorSummary || '');
265
+ historyMessages = (options.priorMessages || []).slice(-historyWindow).filter((pm) => pm.role && pm.content);
240
266
  }
241
267
 
242
- if (options.mediaAttachments && options.mediaAttachments.length > 0) {
243
- const contentArr = [{ type: 'text', text: userMessage }];
244
- for (const att of options.mediaAttachments) {
245
- if ((att.type === 'image' || att.type === 'video') && att.path) {
246
- try {
247
- if (fs.existsSync(att.path)) {
248
- const b64 = fs.readFileSync(att.path).toString('base64');
249
- const mime = att.path.endsWith('.png') ? 'image/png' : att.path.endsWith('.gif') ? 'image/gif' : 'image/jpeg';
250
- contentArr.push({ type: 'image_url', image_url: { url: `data:${mime};base64,${b64}` } });
251
- }
252
- } catch { /* skip unreadable */ }
253
- }
254
- }
255
- messages.push({ role: 'user', content: contentArr.length > 1 ? contentArr : userMessage });
256
- } else {
257
- messages.push({ role: 'user', content: userMessage });
258
- }
268
+ let messages = this.buildContextMessages(systemPrompt, summaryMessage, historyMessages, recallMsg);
269
+ messages.push(this.buildUserMessage(userMessage, options));
259
270
 
260
271
  if (conversationId) {
261
- db.prepare('INSERT INTO conversation_messages (conversation_id, role, content) VALUES (?, ?, ?)').run(conversationId, 'user', userMessage);
272
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content) VALUES (?, ?, ?)')
273
+ .run(conversationId, 'user', userMessage);
262
274
  }
263
275
 
264
276
  let iteration = 0;
@@ -266,23 +278,27 @@ class AgentEngine {
266
278
  let lastContent = '';
267
279
  let stepIndex = 0;
268
280
  let forcedFinalResponse = false;
281
+ let promptMetrics = {};
269
282
 
270
283
  try {
271
- while (iteration < this.maxIterations) {
284
+ while (iteration < maxIterations) {
272
285
  iteration++;
273
286
 
274
- const needsCompaction = this.estimateTokens(messages) > provider.getContextWindow(model) * 0.75;
275
- if (needsCompaction) {
276
- const { compact } = require('./compaction');
287
+ let metrics = this.estimatePromptMetrics(messages, tools);
288
+ const contextWindow = provider.getContextWindow(model);
289
+ if (metrics.totalEstimatedTokens > contextWindow * 0.7) {
277
290
  messages = await compact(messages, provider, model);
278
291
  this.emit(userId, 'run:compaction', { runId, iteration });
292
+ metrics = this.estimatePromptMetrics(messages, tools);
279
293
  }
280
294
 
295
+ promptMetrics = this.mergePromptMetrics(promptMetrics, metrics, iteration, tools.length);
296
+ this.persistPromptMetrics(runId, promptMetrics).catch(() => { });
281
297
  this.emit(userId, 'run:thinking', { runId, iteration });
282
298
 
283
299
  let response;
284
300
  let streamContent = '';
285
- const callOptions = { model, reasoningEffort: options.reasoningEffort || process.env.REASONING_EFFORT || undefined };
301
+ const callOptions = { model, reasoningEffort: this.getReasoningEffort(providerName, options) };
286
302
 
287
303
  if (options.stream !== false) {
288
304
  const gen = provider.stream(messages, tools, callOptions);
@@ -318,19 +334,21 @@ class AgentEngine {
318
334
  lastContent = response.content || streamContent || '';
319
335
 
320
336
  const assistantMessage = { role: 'assistant', content: lastContent };
321
- if (response.toolCalls && response.toolCalls.length > 0) {
322
- assistantMessage.tool_calls = response.toolCalls;
323
- }
337
+ if (response.toolCalls?.length) assistantMessage.tool_calls = response.toolCalls;
324
338
  messages.push(assistantMessage);
325
339
 
326
340
  if (conversationId) {
327
341
  db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tool_calls, tokens) VALUES (?, ?, ?, ?, ?)')
328
- .run(conversationId, 'assistant', lastContent, response.toolCalls?.length > 0 ? JSON.stringify(response.toolCalls) : null, response.usage?.totalTokens || 0);
342
+ .run(
343
+ conversationId,
344
+ 'assistant',
345
+ lastContent,
346
+ response.toolCalls?.length ? JSON.stringify(response.toolCalls) : null,
347
+ response.usage?.totalTokens || 0
348
+ );
329
349
  }
330
350
 
331
- if (!response.toolCalls || response.toolCalls.length === 0) {
332
- break;
333
- }
351
+ if (!response.toolCalls || response.toolCalls.length === 0) break;
334
352
 
335
353
  for (const toolCall of response.toolCalls) {
336
354
  stepIndex++;
@@ -347,40 +365,37 @@ class AgentEngine {
347
365
  .run(stepId, runId, stepIndex, this.getStepType(toolName), `${toolName}: ${JSON.stringify(toolArgs).slice(0, 200)} `, 'running', toolName, JSON.stringify(toolArgs));
348
366
 
349
367
  this.emit(userId, 'run:tool_start', {
350
- runId, stepId, stepIndex, toolName, toolArgs: toolArgs,
368
+ runId, stepId, stepIndex, toolName, toolArgs,
351
369
  type: this.getStepType(toolName)
352
370
  });
353
371
 
354
372
  let toolResult;
355
373
  try {
356
- toolResult = await this.executeTool(toolName, toolArgs, { userId, runId, app });
357
-
358
- let screenshotPath = null;
359
- if (toolResult && toolResult.screenshotPath) {
360
- screenshotPath = toolResult.screenshotPath;
361
- }
362
-
374
+ toolResult = await this.executeTool(toolName, toolArgs, {
375
+ userId,
376
+ runId,
377
+ app,
378
+ triggerSource
379
+ });
380
+ const screenshotPath = toolResult?.screenshotPath || null;
363
381
  db.prepare('UPDATE agent_steps SET status = ?, result = ?, screenshot_path = ?, completed_at = datetime(\'now\') WHERE id = ?')
364
382
  .run('completed', JSON.stringify(toolResult).slice(0, 20000), screenshotPath, stepId);
365
-
366
- this.emit(userId, 'run:tool_end', {
367
- runId, stepId, toolName, result: toolResult, screenshotPath,
368
- status: 'completed'
369
- });
383
+ this.emit(userId, 'run:tool_end', { runId, stepId, toolName, result: toolResult, screenshotPath, status: 'completed' });
370
384
  } catch (err) {
371
385
  toolResult = { error: err.message };
372
386
  db.prepare('UPDATE agent_steps SET status = ?, error = ?, completed_at = datetime(\'now\') WHERE id = ?')
373
387
  .run('failed', err.message, stepId);
374
-
375
- this.emit(userId, 'run:tool_end', {
376
- runId, stepId, toolName, error: err.message, status: 'failed'
377
- });
388
+ this.emit(userId, 'run:tool_end', { runId, stepId, toolName, error: err.message, status: 'failed' });
378
389
  }
379
390
 
380
391
  const toolMessage = {
381
392
  role: 'tool',
393
+ name: toolName,
382
394
  tool_call_id: toolCall.id,
383
- content: JSON.stringify(toolResult).slice(0, 15000)
395
+ content: compactToolResult(toolName, toolArgs, toolResult, {
396
+ softLimit: toolReplayBudget,
397
+ hardLimit: 2000
398
+ })
384
399
  };
385
400
  messages.push(toolMessage);
386
401
 
@@ -392,30 +407,19 @@ class AgentEngine {
392
407
  const runMeta = this.activeRuns.get(runId);
393
408
  if (runMeta) {
394
409
  runMeta.lastToolName = toolName;
395
- runMeta.lastToolTarget = (toolName === 'send_message') ? toolArgs.to : null;
410
+ runMeta.lastToolTarget = toolName === 'send_message' ? toolArgs.to : null;
396
411
  }
397
412
  }
398
413
 
399
414
  if (!this.activeRuns.has(runId)) break;
400
415
  }
401
416
 
402
- // ── IF we maxed out iterations and the last step was a tool block,
403
- // force one final generation so the AI speaks instead of ending silently.
404
- // Additionally, IF we organically broke out of the loop (toolCalls.length === 0)
405
- // BUT `lastContent` is empty and we actually ran tools (stepIndex > 0),
406
- // we must force a final generation so the user gets a summary.
407
- if ((iteration >= this.maxIterations && messages[messages.length - 1].role === 'tool') ||
408
- (iteration < this.maxIterations && stepIndex > 0 && !lastContent.trim() && messages[messages.length - 1].role !== 'tool')) {
409
-
410
- const callOptions = { model, reasoningEffort: options.reasoningEffort || process.env.REASONING_EFFORT || undefined };
411
-
412
- // Push an explicit instruction to force the model to summarize its tool results
413
- messages.push({
414
- role: 'system',
415
- content: 'You have finished executing your tools, but you did not provide a final text response. Please provide a final, natural-language summary or response to the user based on your findings.'
417
+ if ((iteration >= maxIterations && messages[messages.length - 1]?.role === 'tool')
418
+ || (iteration < maxIterations && stepIndex > 0 && !lastContent.trim() && messages[messages.length - 1]?.role !== 'tool')) {
419
+ const finalResponse = await provider.chat(messages, [], {
420
+ model,
421
+ reasoningEffort: this.getReasoningEffort(providerName, options)
416
422
  });
417
-
418
- const finalResponse = await provider.chat(messages, [], callOptions);
419
423
  lastContent = finalResponse.content || '';
420
424
  forcedFinalResponse = true;
421
425
 
@@ -433,6 +437,33 @@ class AgentEngine {
433
437
  if (conversationId) {
434
438
  db.prepare('UPDATE conversations SET total_tokens = total_tokens + ?, updated_at = datetime(\'now\') WHERE id = ?')
435
439
  .run(totalTokens, conversationId);
440
+ refreshConversationSummary(conversationId, provider, model, historyWindow).catch((err) => {
441
+ console.error('[AI] Conversation summary refresh failed:', err.message);
442
+ });
443
+ }
444
+
445
+ await this.persistPromptMetrics(runId, {
446
+ ...promptMetrics,
447
+ finalTotalTokens: totalTokens
448
+ });
449
+
450
+ const autoSkillLearning = aiSettings.auto_skill_learning !== false && aiSettings.auto_skill_learning !== 'false';
451
+ if (autoSkillLearning && this.learningManager) {
452
+ const steps = db.prepare('SELECT * FROM agent_steps WHERE run_id = ? ORDER BY step_index ASC').all(runId);
453
+ try {
454
+ this.learningManager.maybeCaptureDraft({
455
+ userId,
456
+ runId,
457
+ triggerSource,
458
+ triggerType,
459
+ task: userMessage,
460
+ title: runTitle,
461
+ finalContent: lastContent,
462
+ steps
463
+ });
464
+ } catch (learningErr) {
465
+ console.error('[AI] Skill draft capture failed:', learningErr.message);
466
+ }
436
467
  }
437
468
 
438
469
  const runMeta = this.activeRuns.get(runId);
@@ -447,15 +478,15 @@ class AgentEngine {
447
478
  if (lastContent && lastContent.trim() && lastContent.trim() !== '[NO RESPONSE]') {
448
479
  const manager = this.messagingManager;
449
480
  if (manager) {
450
- const chunks = lastContent.split(/\n\s*\n/).filter(c => c.trim().length > 0);
481
+ const chunks = lastContent.split(/\n\s*\n/).filter((c) => c.trim().length > 0);
451
482
  (async () => {
452
483
  for (let i = 0; i < chunks.length; i++) {
453
484
  if (i > 0) {
454
485
  const delay = Math.max(1000, Math.min(chunks[i].length * 30, 4000));
455
486
  await manager.sendTyping(userId, options.source, options.chatId, true).catch(() => { });
456
- await new Promise(r => setTimeout(r, delay));
487
+ await new Promise((resolve) => setTimeout(resolve, delay));
457
488
  }
458
- await manager.sendMessage(userId, options.source, options.chatId, chunks[i]).catch(err =>
489
+ await manager.sendMessage(userId, options.source, options.chatId, chunks[i]).catch((err) =>
459
490
  console.error('[Engine] Auto-reply fallback failed:', err.message)
460
491
  );
461
492
  }
@@ -496,21 +527,12 @@ class AgentEngine {
496
527
  if (toolName.startsWith('memory_')) return 'memory';
497
528
  if (toolName === 'send_message') return 'messaging';
498
529
  if (toolName === 'make_call') return 'messaging';
499
- if (toolName === 'http_request') return 'http';
530
+ if (toolName.startsWith('mcp_') || toolName.includes('mcp')) return 'mcp';
531
+ if (toolName.includes('scheduled_task') || toolName === 'schedule_run') return 'scheduler';
500
532
  if (toolName === 'think') return 'thinking';
501
- if (toolName.includes('scheduled_task')) return 'scheduler';
502
533
  return 'tool';
503
534
  }
504
535
 
505
- estimateTokens(messages) {
506
- let total = 0;
507
- for (const msg of messages) {
508
- if (msg.content) total += Math.ceil(msg.content.length / 4);
509
- if (msg.tool_calls) total += Math.ceil(JSON.stringify(msg.tool_calls).length / 4);
510
- }
511
- return total;
512
- }
513
-
514
536
  emit(userId, event, data) {
515
537
  if (this.io) {
516
538
  this.io.to(`user:${userId}`).emit(event, data);
@@ -518,4 +540,4 @@ class AgentEngine {
518
540
  }
519
541
  }
520
542
 
521
- module.exports = { AgentEngine };
543
+ module.exports = { AgentEngine, getProviderForUser };