neoagent 2.5.2-beta.2 → 2.5.2-beta.21

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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/server/public/.last_build_id +1 -1
  3. package/server/public/flutter_bootstrap.js +1 -1
  4. package/server/public/main.dart.js +4 -4
  5. package/server/services/ai/deliverables/artifact_helpers.js +109 -16
  6. package/server/services/ai/deliverables/selector.js +17 -0
  7. package/server/services/ai/engine.js +2 -4312
  8. package/server/services/ai/loop/agent_engine_core.js +1590 -0
  9. package/server/services/ai/loop/blank_recovery.js +37 -0
  10. package/server/services/ai/loop/callbacks.js +151 -0
  11. package/server/services/ai/loop/completion_judge.js +252 -0
  12. package/server/services/ai/loop/conversation_loop.js +2398 -0
  13. package/server/services/ai/loop/delivery_state.js +27 -0
  14. package/server/services/ai/loop/error_recovery.js +38 -0
  15. package/server/services/ai/loop/iteration_budget.js +24 -0
  16. package/server/services/ai/loop/messaging_delivery.js +296 -0
  17. package/server/services/ai/loop/model_io.js +258 -0
  18. package/server/services/ai/loop/progress_classification.js +177 -0
  19. package/server/services/ai/loop/progress_monitor.js +81 -0
  20. package/server/services/ai/loop/run_state.js +356 -0
  21. package/server/services/ai/loop/tool_dispatch.js +230 -0
  22. package/server/services/ai/loopPolicy.js +13 -6
  23. package/server/services/ai/messagingFallback.js +20 -0
  24. package/server/services/ai/repetitionGuard.js +47 -2
  25. package/server/services/ai/systemPrompt.js +5 -0
  26. package/server/services/ai/taskAnalysis.js +6 -0
  27. package/server/services/ai/toolEvidence.js +8 -1
  28. package/server/services/ai/tools.js +82 -11
  29. package/server/services/integrations/github/repos.js +39 -29
  30. package/server/services/messaging/manager.js +7 -0
  31. package/server/services/runtime/backends/local-vm.js +7 -7
  32. package/server/services/runtime/docker-vm-manager.js +21 -1
  33. package/server/services/workspace/manager.js +1 -0
@@ -0,0 +1,2398 @@
1
+ 'use strict';
2
+
3
+ const { v4: uuidv4 } = require('uuid');
4
+ const fs = require('fs');
5
+ const db = require('../../../db/database');
6
+ const { compact } = require('../compaction');
7
+ const { compactPayloadForModel } = require('../preModelCompaction');
8
+ const {
9
+ getConversationContext,
10
+ buildSummaryCarrier,
11
+ refreshConversationSummary,
12
+ sanitizeConversationMessages
13
+ } = require('../history');
14
+ const { ensureDefaultAiSettings, getAiSettings } = require('../settings');
15
+ const {
16
+ buildToolCatalog,
17
+ selectInitialTools,
18
+ selectToolsForTask,
19
+ } = require('../toolSelector');
20
+ const { compactToolResult } = require('../toolResult');
21
+ const { salvageTextToolCalls } = require('../toolCallSalvage');
22
+ const { sanitizeModelOutput } = require('../outputSanitizer');
23
+ const {
24
+ buildAnalysisPrompt,
25
+ buildExecutionGuidance,
26
+ buildPlanPrompt,
27
+ buildVerifierPrompt,
28
+ isDirectAnswerEligibleAnalysis,
29
+ normalizeExecutionPlan,
30
+ normalizeTaskAnalysis,
31
+ normalizeVerificationResult,
32
+ parseJsonObject,
33
+ promoteAnalysisMode,
34
+ shouldRunVerifier,
35
+ } = require('../taskAnalysis');
36
+ const { getCapabilityHealth, summarizeCapabilityHealth } = require('../capabilityHealth');
37
+ const {
38
+ buildPlatformFormattingGuide,
39
+ } = require('../../messaging/formatting_guides');
40
+ const {
41
+ buildInterimSignature,
42
+ normalizeInterimKind,
43
+ } = require('../interim');
44
+ const {
45
+ buildDeliverableWorkflowGuidance,
46
+ DeliverableValidationError,
47
+ extractArtifactsFromResult,
48
+ getDeliverableWorkflow,
49
+ selectDeliverableWorkflow,
50
+ validateDeliverableExecution,
51
+ } = require('../deliverables');
52
+ const { buildLoopPolicy, resolveToolResultLimits } = require('../loopPolicy');
53
+ const { globalHooks } = require('../hooks');
54
+ const { normalizeCompletionConfidence, shouldAcceptTaskComplete } = require('../completion');
55
+ const { enforceRateLimits } = require('../rate_limits');
56
+ const { ToolRepetitionGuard } = require('../repetitionGuard');
57
+ const { shortenRunId, summarizeForLog } = require('../logFormat');
58
+ const { IterationBudget } = require('./iteration_budget');
59
+ const {
60
+ buildBlankAfterToolFailureGuidance,
61
+ shouldContinueAfterBlankToolFailure,
62
+ } = require('./blank_recovery');
63
+ const {
64
+ shouldSendMessagingErrorFallback,
65
+ } = require('./error_recovery');
66
+ const {
67
+ buildCompletionDecisionPrompt,
68
+ buildGoalContractPrompt,
69
+ goalContractFromAnalysis,
70
+ goalContractFromPlan,
71
+ mergeGoalContracts,
72
+ normalizeCompletionDecision,
73
+ normalizeGoalContract,
74
+ resolveRunGoalContext,
75
+ } = require('./completion_judge');
76
+ const {
77
+ activateToolsForRun: activateToolsForRunImpl,
78
+ applyQueuedSteering: applyQueuedSteeringImpl,
79
+ applyQueuedSystemSteering: applyQueuedSystemSteeringImpl,
80
+ attachProcessToRun: attachProcessToRunImpl,
81
+ buildProgressLedgerSnapshot: buildProgressLedgerSnapshotImpl,
82
+ detachProcessFromRun: detachProcessFromRunImpl,
83
+ enqueueSteering: enqueueSteeringImpl,
84
+ enqueueSystemSteering: enqueueSystemSteeringImpl,
85
+ findActiveRunForUser: findActiveRunForUserImpl,
86
+ findSteerableRunForUser: findSteerableRunForUserImpl,
87
+ getActiveTools: getActiveToolsImpl,
88
+ initializeToolRuntime: initializeToolRuntimeImpl,
89
+ isRunStopped: isRunStoppedImpl,
90
+ markRunFinalDelivery: markRunFinalDeliveryImpl,
91
+ markRunVisibleProgress: markRunVisibleProgressImpl,
92
+ persistProgressLedger: persistProgressLedgerImpl,
93
+ persistRunMetadata: persistRunMetadataImpl,
94
+ recordRunEventSafe,
95
+ updateRunGoalContract: updateRunGoalContractImpl,
96
+ updateRunProgress: updateRunProgressImpl,
97
+ } = require('./run_state');
98
+ const {
99
+ buildInitialProgressLedger,
100
+ } = require('./progress_monitor');
101
+ const {
102
+ deliverMessagingFinalFallback: deliverMessagingFinalFallbackImpl,
103
+ requireSuccessfulMessagingDelivery,
104
+ sendRuntimeMessagingHeartbeat: sendRuntimeMessagingHeartbeatImpl,
105
+ shouldSendMessagingFinalFallback: shouldSendMessagingFinalFallbackImpl,
106
+ startMessagingProgressSupervisor: startMessagingProgressSupervisorImpl,
107
+ stopMessagingProgressSupervisor: stopMessagingProgressSupervisorImpl,
108
+ tickMessagingProgressSupervisor: tickMessagingProgressSupervisorImpl,
109
+ } = require('./messaging_delivery');
110
+ const {
111
+ createDeliveryState,
112
+ } = require('./delivery_state');
113
+ const {
114
+ requestModelResponse: requestModelResponseImpl,
115
+ requestStructuredJson: requestStructuredJsonImpl,
116
+ withModelCallTimeout,
117
+ } = require('./model_io');
118
+ const {
119
+ publishInterimUpdate: publishInterimUpdateImpl,
120
+ } = require('./callbacks');
121
+ const {
122
+ executeReadOnlyBatch: executeReadOnlyBatchImpl,
123
+ executeTool: executeToolImpl,
124
+ getAvailableTools: getAvailableToolsImpl,
125
+ isReadOnlyToolCall: isReadOnlyToolCallImpl,
126
+ } = require('./tool_dispatch');
127
+ const {
128
+ isProgressToolCall,
129
+ } = require('./progress_classification');
130
+ const {
131
+ normalizeOutgoingMessage,
132
+ clampRunContext,
133
+ joinSentMessages,
134
+ buildBlankMessagingReplyPrompt,
135
+ buildMaxIterationWrapupPrompt,
136
+ buildProgressUpdatePrompt,
137
+ buildDeterministicMessagingFallback,
138
+ buildMessagingFailureScenario,
139
+ buildDeterministicMessagingErrorReply,
140
+ buildModelFailureLoopPrompt,
141
+ } = require('../messagingFallback');
142
+ const {
143
+ classifyToolExecution,
144
+ summarizeToolExecutions,
145
+ summarizeAvailableTools,
146
+ inferToolFailureMessage,
147
+ } = require('../toolEvidence');
148
+ const {
149
+ buildMemoryConsolidationInstructions,
150
+ normalizeMemoryCandidates,
151
+ } = require('../../memory/consolidation');
152
+ const {
153
+ buildPlannerPrompt,
154
+ buildRerankerPrompt,
155
+ mergeRetrievalResults,
156
+ normalizeRerankResult,
157
+ normalizeRetrievalPlan,
158
+ shouldEnhanceRetrieval,
159
+ } = require('../../memory/retrieval_reasoning');
160
+
161
+ function generateTitle(task) {
162
+ if (!task || typeof task !== 'string') return 'Untitled';
163
+ const msgMatch = task.match(/received a (?:message|media|image|video|file|audio)[^:]*:\s*(.+)/is);
164
+ if (msgMatch) {
165
+ const body = msgMatch[1].replace(/\n[\s\S]*/s, '').trim();
166
+ return body.slice(0, 90) || 'Incoming message';
167
+ }
168
+ const cleaned = task.replace(/^\[.*?\]\s*/i, '').replace(/^(system|task|prompt)[:\s]+/i, '').trim();
169
+ return cleaned.slice(0, 90);
170
+ }
171
+
172
+ function buildInitialRunMetadata(options = {}) {
173
+ const metadata = {};
174
+ if (options.taskId != null && String(options.taskId).trim()) {
175
+ metadata.taskId = options.taskId;
176
+ }
177
+ if (options.widgetId != null && String(options.widgetId).trim()) {
178
+ metadata.widgetId = options.widgetId;
179
+ }
180
+ return metadata;
181
+ }
182
+
183
+ function isoNow() {
184
+ return new Date().toISOString();
185
+ }
186
+
187
+ function normalizeErrorKey(errorMsg) {
188
+ const msg = String(errorMsg || '').toLowerCase();
189
+ if (/outside.*(workspace|per-user)/i.test(msg)) return 'outside_workspace';
190
+ if (/eisdir|illegal operation on a directory/i.test(msg)) return 'eisdir';
191
+ if (/enoent|no such file/i.test(msg)) return 'enoent';
192
+ if (/can.?t cd to|no such directory/i.test(msg)) return 'bad_cwd';
193
+ if (/not found/i.test(msg)) return 'not_found';
194
+ if (/owner_repo.*format|must be.*owner.*repo|owner.*repo.*string|owner.*repo.*combined/i.test(msg)) return 'owner_repo_format';
195
+ return msg.slice(0, 60);
196
+ }
197
+
198
+ function trackErrorPattern(errorMsg, runMeta) {
199
+ if (!errorMsg) return;
200
+ const key = normalizeErrorKey(errorMsg);
201
+ if (!runMeta.errorPatterns) runMeta.errorPatterns = new Map();
202
+ runMeta.errorPatterns.set(key, (runMeta.errorPatterns.get(key) || 0) + 1);
203
+ }
204
+
205
+ function buildErrorPatternGuidance(key, count) {
206
+ // Immediate guidance on first occurrence for high-signal patterns that waste
207
+ // multiple iterations before self-correcting.
208
+ const immediateGuides = {
209
+ eisdir: 'That path is a directory (or a VM-only path like /tmp that read_file cannot reach). Use execute_command with `cat <path>` to read files inside VMs, or list_directory to inspect a directory.',
210
+ owner_repo_format: 'The parameter "owner_repo" expects a single combined string like "NeoLabs-Systems/NeoAgent" — not separate owner/repo fields. Pass the full "owner/repo" as one value.',
211
+ };
212
+ if (immediateGuides[key]) {
213
+ const prefix = count > 1 ? `REPEATED ERROR (${count}×): ` : 'ERROR GUIDANCE: ';
214
+ return `${prefix}${immediateGuides[key]}`;
215
+ }
216
+
217
+ if (count < 3) return null;
218
+ const guides = {
219
+ outside_workspace: 'read_file cannot access /tmp paths. Use execute_command with `cat <path>` instead.',
220
+ enoent: 'That path does not exist. Use execute_command with `find . -name "..."` to locate the correct path first.',
221
+ bad_cwd: 'The VM home directory is not ~/. Use absolute paths starting from /tmp or discover the workspace root first.',
222
+ not_found: 'This path or resource was not found. Try listing the parent directory or checking with a broader search first.',
223
+ };
224
+ const guide = guides[key];
225
+ if (!guide) return null;
226
+ return `REPEATED ERROR (${count}×): ${guide}`;
227
+ }
228
+
229
+ const OUTPUT_FINGERPRINT_TOOLS = /^(list_|search_|read_|get_|find_|github_list|github_get|github_search)/;
230
+
231
+ function fingerprintOutput(toolName, result, toolArgs = {}) {
232
+ const name = String(toolName || '');
233
+ if (
234
+ !name
235
+ || (
236
+ !OUTPUT_FINGERPRINT_TOOLS.test(name)
237
+ && !(name === 'execute_command' && !isProgressToolCall(name, toolArgs))
238
+ )
239
+ ) {
240
+ return null;
241
+ }
242
+ const raw = typeof result === 'string' ? result : JSON.stringify(result ?? '');
243
+ if (raw.length < 200) return null;
244
+ // djb2 hash over first 3000 chars — fast, collision-unlikely for our sizes
245
+ let h = 5381;
246
+ const limit = Math.min(raw.length, 3000);
247
+ for (let i = 0; i < limit; i++) h = ((h << 5) + h) ^ raw.charCodeAt(i);
248
+ return h >>> 0;
249
+ }
250
+
251
+ function cloneInterimHistory(history = []) {
252
+ if (!Array.isArray(history)) return [];
253
+ return history.map((item) => ({
254
+ content: String(item?.content || '').trim(),
255
+ kind: normalizeInterimKind(item?.kind),
256
+ expectsReply: item?.expectsReply === true,
257
+ deferFollowUp: item?.deferFollowUp === true,
258
+ createdAt: item?.createdAt || isoNow(),
259
+ })).filter((item) => item.content);
260
+ }
261
+
262
+ function createInterimSignatureSet(history = [], platform = null) {
263
+ const signatures = new Set();
264
+ for (const item of cloneInterimHistory(history)) {
265
+ signatures.add(buildInterimSignature({
266
+ content: item.content,
267
+ kind: item.kind,
268
+ expectsReply: item.expectsReply === true,
269
+ platform,
270
+ }));
271
+ }
272
+ return signatures;
273
+ }
274
+
275
+ function hasVisibleInterimActivity(runMeta) {
276
+ return Boolean(
277
+ runMeta?.lastInterimMessage
278
+ || (Array.isArray(runMeta?.interimMessages) && runMeta.interimMessages.length > 0)
279
+ || Number(runMeta?.progressLedger?.heartbeatCount || 0) > 0
280
+ );
281
+ }
282
+
283
+ function planningDepthForForceMode(forceMode) {
284
+ return forceMode === 'plan_execute' ? 'deep' : 'light';
285
+ }
286
+
287
+ function buildSkipTaskAnalysisResult(forceMode) {
288
+ return {
289
+ mode: forceMode === 'plan_execute' ? 'plan_execute' : 'execute',
290
+ reply_mode: 'task',
291
+ freshness_risk: 'none',
292
+ verification_need: 'none',
293
+ planning_depth: planningDepthForForceMode(forceMode),
294
+ confidence: 0.5,
295
+ suggested_tools: [],
296
+ needs_subagents: false,
297
+ draft_reply: '',
298
+ goal: 'Complete the user request accurately.',
299
+ success_criteria: [],
300
+ complexity: forceMode === 'plan_execute' ? 'complex' : 'standard',
301
+ autonomy_level: forceMode === 'plan_execute' ? 'high' : 'normal',
302
+ progress_update_policy: 'optional',
303
+ parallel_work: false,
304
+ completion_confidence_required: forceMode === 'plan_execute' ? 'high' : 'medium',
305
+ };
306
+ }
307
+
308
+ function buildAnalyzeTaskFallback(forceMode, userMessage = '') {
309
+ return {
310
+ mode: forceMode || 'execute',
311
+ verification_need: 'light',
312
+ planning_depth: planningDepthForForceMode(forceMode),
313
+ goal: userMessage ? String(userMessage).trim().slice(0, 300) : '',
314
+ complexity: forceMode === 'plan_execute' ? 'complex' : 'standard',
315
+ autonomy_level: forceMode === 'plan_execute' ? 'high' : 'normal',
316
+ progress_update_policy: 'optional',
317
+ parallel_work: false,
318
+ completion_confidence_required: forceMode === 'plan_execute' ? 'high' : 'medium',
319
+ };
320
+ }
321
+
322
+ function applyForcedAnalysisMode(analysis, forceMode) {
323
+ if (!analysis || typeof analysis !== 'object') return analysis;
324
+ if (forceMode !== 'plan_execute') return analysis;
325
+ return {
326
+ ...analysis,
327
+ mode: 'plan_execute',
328
+ planning_depth: 'deep',
329
+ complexity: 'complex',
330
+ autonomy_level: 'high',
331
+ completion_confidence_required: analysis.completion_confidence_required || 'high',
332
+ };
333
+ }
334
+
335
+ function buildAutonomyPolicyFromAnalysis(analysis = {}) {
336
+ return {
337
+ complexity: analysis.complexity || 'standard',
338
+ autonomy_level: analysis.autonomy_level || 'normal',
339
+ progress_update_policy: analysis.progress_update_policy || 'optional',
340
+ parallel_work: analysis.parallel_work === true,
341
+ completion_confidence_required: analysis.completion_confidence_required || 'medium',
342
+ };
343
+ }
344
+
345
+ async function getProviderForUser(userId, task = '', isSubagent = false, modelOverride = null, providerConfig = {}) {
346
+ const { getSupportedModels, createProviderInstance } = require('../models');
347
+ const agentId = providerConfig.agentId || null;
348
+ const aiSettings = getAiSettings(userId, agentId);
349
+ const models = await getSupportedModels(userId, agentId);
350
+
351
+ let enabledIds = Array.isArray(aiSettings.enabled_models) ? aiSettings.enabled_models : [];
352
+ const defaultChatModel = aiSettings.default_chat_model || 'auto';
353
+ const defaultSubagentModel = aiSettings.default_subagent_model || 'auto';
354
+ const smarterSelection = aiSettings.smarter_model_selector !== false && aiSettings.smarter_model_selector !== 'false';
355
+
356
+ const knownModelIds = new Set(models.map((m) => m.id));
357
+ const selectableModels = models.filter((m) => m.available !== false);
358
+
359
+ enabledIds = Array.isArray(enabledIds)
360
+ ? enabledIds
361
+ .map((id) => String(id))
362
+ .filter((id) => knownModelIds.has(id))
363
+ : [];
364
+
365
+ let availableModels = selectableModels.filter((m) => enabledIds.includes(m.id));
366
+ if (availableModels.length === 0) {
367
+ enabledIds = selectableModels.map((m) => m.id);
368
+ availableModels = [...selectableModels];
369
+ }
370
+
371
+ const fallbackModel = availableModels.length > 0 ? availableModels[0] : selectableModels[0];
372
+
373
+ if (!fallbackModel) {
374
+ throw new Error('No AI providers are currently available. Open Settings and configure at least one provider.');
375
+ }
376
+
377
+ let selectedModelDef = fallbackModel;
378
+ const userSelectedDefault = isSubagent ? defaultSubagentModel : defaultChatModel;
379
+
380
+ if (modelOverride && typeof modelOverride === 'string') {
381
+ const requested = models.find((m) => m.id === modelOverride.trim());
382
+ if (requested && requested.available !== false && enabledIds.includes(requested.id)) {
383
+ selectedModelDef = requested;
384
+ return {
385
+ provider: createProviderInstance(selectedModelDef.provider, userId, providerConfig),
386
+ model: selectedModelDef.id,
387
+ providerName: selectedModelDef.provider
388
+ };
389
+ }
390
+ }
391
+
392
+ if (userSelectedDefault && userSelectedDefault !== 'auto') {
393
+ selectedModelDef = models.find((m) => m.id === userSelectedDefault) || fallbackModel;
394
+ } else {
395
+ const selectionHint = providerConfig.selectionHint && typeof providerConfig.selectionHint === 'object'
396
+ ? providerConfig.selectionHint
397
+ : {};
398
+ const preferredPurpose = String(selectionHint.purpose || '').trim().toLowerCase();
399
+ const highAutonomy = selectionHint.autonomyLevel === 'high' || selectionHint.complexity === 'complex';
400
+ const requiredConfidence = String(selectionHint.requiredConfidence || '').trim().toLowerCase();
401
+ const costMode = String(selectionHint.costMode || aiSettings.cost_mode || 'balanced_auto').trim().toLowerCase();
402
+ const requestedPurpose = ['planning', 'coding', 'general', 'fast'].includes(preferredPurpose)
403
+ ? preferredPurpose
404
+ : '';
405
+ const priceRank = { free: 0, cheap: 1, medium: 2, expensive: 3 };
406
+ const chooseForPurpose = (purpose) => {
407
+ const candidates = availableModels.filter((model) => model.purpose === purpose);
408
+ if (candidates.length === 0) return null;
409
+ if (['economy', 'cost_saver', 'lowest_cost'].includes(costMode)) {
410
+ return [...candidates].sort((left, right) => (
411
+ (priceRank[left.priceTier] ?? 99) - (priceRank[right.priceTier] ?? 99)
412
+ ))[0];
413
+ }
414
+ if (['quality', 'highest_quality'].includes(costMode) || requiredConfidence === 'high') {
415
+ return candidates.find((model) => model.priceTier !== 'free' && model.priceTier !== 'cheap') || candidates[0];
416
+ }
417
+ return candidates[0];
418
+ };
419
+
420
+ if (smarterSelection && requestedPurpose) {
421
+ selectedModelDef = chooseForPurpose(requestedPurpose) || fallbackModel;
422
+ } else if (smarterSelection && highAutonomy) {
423
+ selectedModelDef = chooseForPurpose('planning') || chooseForPurpose('general') || fallbackModel;
424
+ } else if (isSubagent) {
425
+ selectedModelDef = chooseForPurpose('fast') || fallbackModel;
426
+ } else {
427
+ selectedModelDef = chooseForPurpose('general') || fallbackModel;
428
+ }
429
+ }
430
+
431
+ return {
432
+ provider: createProviderInstance(selectedModelDef.provider, userId, providerConfig),
433
+ model: selectedModelDef.id,
434
+ providerName: selectedModelDef.provider
435
+ };
436
+ }
437
+
438
+ async function getFailureFallbackModelId(userId, agentId, currentModelId, preferredFallbackId = null, failureError = null) {
439
+ const { getSupportedModels } = require('../models');
440
+ const aiSettings = getAiSettings(userId, agentId);
441
+ const models = await getSupportedModels(userId, agentId);
442
+ const availableModels = models.filter((model) => model.available !== false);
443
+ const knownIds = new Set(availableModels.map((model) => model.id));
444
+ const enabledIds = Array.isArray(aiSettings.enabled_models)
445
+ ? aiSettings.enabled_models.map((id) => String(id)).filter((id) => knownIds.has(id))
446
+ : [];
447
+ const pool = enabledIds.length > 0
448
+ ? availableModels.filter((model) => enabledIds.includes(model.id))
449
+ : availableModels;
450
+ const currentModel = pool.find((model) => model.id === currentModelId)
451
+ || availableModels.find((model) => model.id === currentModelId)
452
+ || null;
453
+
454
+ // When the failure is a provider-level rate limit, the preferred fallback is
455
+ // likely on the same provider and will hit the same limit. Skip it and prefer
456
+ // a fallback from a different provider instead.
457
+ const isProviderRateLimit = /429|rate.?limit|free-models-per/i.test(String(failureError?.message || ''));
458
+
459
+ if (preferredFallbackId && preferredFallbackId !== currentModelId && !isProviderRateLimit) {
460
+ const preferred = pool.find((model) => model.id === preferredFallbackId)
461
+ || availableModels.find((model) => model.id === preferredFallbackId);
462
+ if (preferred) return preferred.id;
463
+ }
464
+
465
+ if (currentModel?.provider) {
466
+ const differentProvider = pool.find((model) => model.id !== currentModelId && model.provider !== currentModel.provider)
467
+ || availableModels.find((model) => model.id !== currentModelId && model.provider !== currentModel.provider);
468
+ if (differentProvider) return differentProvider.id;
469
+ }
470
+
471
+ // If no different-provider model exists, still try the preferred fallback
472
+ // even on rate limits (it's better than nothing).
473
+ if (preferredFallbackId && preferredFallbackId !== currentModelId) {
474
+ const preferred = pool.find((model) => model.id === preferredFallbackId)
475
+ || availableModels.find((model) => model.id === preferredFallbackId);
476
+ if (preferred) return preferred.id;
477
+ }
478
+
479
+ const differentModel = pool.find((model) => model.id !== currentModelId)
480
+ || availableModels.find((model) => model.id !== currentModelId);
481
+ return differentModel?.id || null;
482
+ }
483
+
484
+ function estimateTokenValue(value) {
485
+ if (!value) return 0;
486
+ if (typeof value === 'string') return Math.ceil(value.length / 4);
487
+ return Math.ceil(JSON.stringify(value).length / 4);
488
+ }
489
+
490
+ async function runConversation(engine, userId, userMessage, options = {}, _modelOverride = null) {
491
+ const triggerType = options.triggerType || 'user';
492
+ const { resolveAgentId } = require('../../agents/manager');
493
+ const agentId = resolveAgentId(userId, options.agentId || options.agent_id || null);
494
+ ensureDefaultAiSettings(userId, agentId);
495
+ const aiSettings = getAiSettings(userId, agentId);
496
+
497
+ enforceRateLimits(userId);
498
+
499
+ const runId = options.runId || uuidv4();
500
+ const conversationId = options.conversationId;
501
+ const app = options.app || engine.app;
502
+ const triggerSource = options.triggerSource || 'web';
503
+ const historyWindow = Math.max(
504
+ 1,
505
+ Number(options.historyWindow || aiSettings.chat_history_window) || aiSettings.chat_history_window,
506
+ );
507
+ // loopPolicy is built after task analysis so analysisMode can be passed in;
508
+ // we build a provisional policy now (with default mode) and rebuild after
509
+ // analysis when the mode is known. See the post-analysis policy rebuild below.
510
+ let loopPolicy = buildLoopPolicy(aiSettings, triggerType, 'execute', options);
511
+ let maxIterations = loopPolicy.maxIterations;
512
+ const providerStatusConfig = {
513
+ agentId,
514
+ onStatus: (status) => {
515
+ if (!status?.message) return;
516
+ engine.emit(userId, 'run:interim', {
517
+ runId,
518
+ message: status.message,
519
+ phase: status.phase
520
+ });
521
+ }
522
+ };
523
+ const selectedProvider = await getProviderForUser(
524
+ userId,
525
+ userMessage,
526
+ triggerType === 'subagent',
527
+ _modelOverride,
528
+ providerStatusConfig
529
+ );
530
+ let provider = selectedProvider.provider;
531
+ let model = selectedProvider.model;
532
+ let providerName = selectedProvider.providerName;
533
+ const switchToFallbackModel = async (failedModel, error, phase) => {
534
+ const fallbackModelId = await getFailureFallbackModelId(userId, agentId, failedModel, aiSettings.fallback_model_id, error);
535
+ if (!fallbackModelId || fallbackModelId === failedModel) return false;
536
+ console.log(`[Engine] ${phase} failed on ${failedModel}; attempting fallback to: ${fallbackModelId}`);
537
+ engine.emit(userId, 'run:interim', {
538
+ runId,
539
+ message: `Model service failed on ${failedModel}; retrying with ${fallbackModelId}.`,
540
+ phase: 'model_fallback'
541
+ });
542
+ const fallback = await getProviderForUser(
543
+ userId,
544
+ userMessage,
545
+ triggerType === 'subagent',
546
+ fallbackModelId,
547
+ providerStatusConfig
548
+ );
549
+ provider = fallback.provider;
550
+ model = fallback.model;
551
+ providerName = fallback.providerName;
552
+ return true;
553
+ };
554
+ const runWithModelFallback = async (phase, fn) => {
555
+ try {
556
+ return await fn();
557
+ } catch (err) {
558
+ const failedModel = model;
559
+ const switched = await switchToFallbackModel(failedModel, err, phase);
560
+ if (!switched) throw err;
561
+ return await fn();
562
+ }
563
+ };
564
+
565
+ const runTitle = generateTitle(userMessage);
566
+ const initialRunMetadata = buildInitialRunMetadata(options);
567
+ db.prepare(`INSERT INTO agent_runs(
568
+ id, user_id, agent_id, title, status, trigger_type, trigger_source, model, metadata_json
569
+ ) VALUES(?, ?, ?, ?, 'running', ?, ?, ?, ?)
570
+ ON CONFLICT(id) DO UPDATE SET
571
+ status = 'running',
572
+ model = excluded.model,
573
+ updated_at = datetime('now'),
574
+ completed_at = NULL,
575
+ error = NULL,
576
+ metadata_json = COALESCE(agent_runs.metadata_json, excluded.metadata_json)`).run(
577
+ runId,
578
+ userId,
579
+ agentId,
580
+ runTitle,
581
+ triggerType,
582
+ triggerSource,
583
+ model,
584
+ Object.keys(initialRunMetadata).length ? JSON.stringify(initialRunMetadata) : null,
585
+ );
586
+
587
+ const retryMessagingState = options.messagingRetryState || {};
588
+ const carriedFinalMessage = String(retryMessagingState.lastFinalMessage || '').trim();
589
+ const carriedExplicitMessageSent = retryMessagingState.explicitMessageSent === true;
590
+ const carriedInterimHistory = cloneInterimHistory(retryMessagingState.interimHistory);
591
+ const carriedLastInterimMessage = carriedInterimHistory[carriedInterimHistory.length - 1]?.content || '';
592
+ const carriedGoalContract = mergeGoalContracts(
593
+ normalizeGoalContract({
594
+ goal: clampRunContext(userMessage, 1200),
595
+ }),
596
+ retryMessagingState.goalContract,
597
+ );
598
+ const startedAtIso = isoNow();
599
+ const progressLedger = buildInitialProgressLedger({
600
+ startedAt: startedAtIso,
601
+ retryState: retryMessagingState,
602
+ });
603
+
604
+ engine.activeRuns.set(runId, {
605
+ userId,
606
+ agentId,
607
+ title: runTitle,
608
+ status: 'running',
609
+ aborted: false,
610
+ messagingSent: false,
611
+ noResponse: false,
612
+ explicitMessageSent: carriedExplicitMessageSent,
613
+ finalDeliverySent: carriedExplicitMessageSent,
614
+ lastSentMessage: carriedExplicitMessageSent ? carriedFinalMessage : '',
615
+ sentMessages: [],
616
+ widgetSnapshotSaved: false,
617
+ triggerType,
618
+ triggerSource,
619
+ startedAt: Date.now(),
620
+ startedAtIso,
621
+ lastToolName: null,
622
+ lastToolTarget: null,
623
+ lastInterimMessage: carriedExplicitMessageSent ? '' : carriedLastInterimMessage,
624
+ interimMessages: carriedExplicitMessageSent ? [] : carriedInterimHistory,
625
+ interimSignatures: carriedExplicitMessageSent
626
+ ? new Set()
627
+ : createInterimSignatureSet(carriedInterimHistory, options.source || null),
628
+ terminalInterim: null,
629
+ voiceSessionId: options.voiceSessionId || null,
630
+ steeringQueue: [],
631
+ systemSteeringQueue: [],
632
+ toolPids: new Set(),
633
+ repetitionGuard: new ToolRepetitionGuard(),
634
+ seenOutputHashes: new Map(),
635
+ consecutiveReadOnlyIterations: 0,
636
+ messagingContext: triggerSource === 'messaging'
637
+ ? {
638
+ platform: options.source || null,
639
+ chatId: options.chatId || null,
640
+ }
641
+ : null,
642
+ goalContract: carriedGoalContract,
643
+ progressLedger,
644
+ deliveryState: createDeliveryState({
645
+ alreadySent: carriedInterimHistory.length > 0 || carriedExplicitMessageSent,
646
+ finalResponseSent: carriedExplicitMessageSent,
647
+ finalContentDelivered: carriedExplicitMessageSent,
648
+ }),
649
+ });
650
+ engine.persistRunMetadata(runId, {
651
+ progressLedger,
652
+ goalContract: carriedGoalContract,
653
+ });
654
+ engine.startMessagingProgressSupervisor(runId);
655
+ engine.emit(userId, 'run:start', { runId, agentId, title: runTitle, model, triggerType, triggerSource });
656
+ engine.recordRunEvent(userId, runId, 'run_started', {
657
+ title: runTitle,
658
+ model,
659
+ triggerType,
660
+ triggerSource,
661
+ }, { agentId });
662
+ console.info(
663
+ `[Run ${shortenRunId(runId)}] started trigger=${triggerSource} type=${triggerType} model=${model} title=${summarizeForLog(runTitle, 120)}`
664
+ );
665
+
666
+ const systemPrompt = await engine.buildSystemPrompt(userId, {
667
+ ...(options.context || {}),
668
+ userMessage,
669
+ agentId,
670
+ triggerSource,
671
+ });
672
+ // Pass short descriptions so the model always knows every available tool.
673
+ // compactToolDefinition caps tool desc at 120 chars, param desc at 70 chars.
674
+ const builtInTools = engine.getAvailableTools(app, {
675
+ includeDescriptions: true,
676
+ userId,
677
+ agentId,
678
+ triggerType,
679
+ triggerSource,
680
+ widgetId: options.widgetId || null,
681
+ });
682
+ const mcpManager = app?.locals?.mcpManager || app?.locals?.mcpClient || engine.mcpManager;
683
+ const integrationManager = app?.locals?.integrationManager || null;
684
+ const mcpTools = mcpManager ? mcpManager.getAllTools(userId, { agentId }) : [];
685
+ const allTools = selectToolsForTask(userMessage, builtInTools, mcpTools, options);
686
+ let tools = allTools;
687
+ const toolNames = allTools.map((tool) => tool.name).filter(Boolean);
688
+ const coreToolStatus = {
689
+ send_message: toolNames.includes('send_message'),
690
+ create_task: toolNames.includes('create_task'),
691
+ list_tasks: toolNames.includes('list_tasks'),
692
+ update_task: toolNames.includes('update_task'),
693
+ delete_task: toolNames.includes('delete_task'),
694
+ };
695
+ engine.recordRunEvent(userId, runId, 'tool_inventory', {
696
+ total: toolNames.length,
697
+ builtInTotal: builtInTools.length,
698
+ mcpTotal: mcpTools.length,
699
+ core: coreToolStatus,
700
+ }, { agentId });
701
+ console.info(
702
+ `[Run ${shortenRunId(runId)}] tools total=${toolNames.length} builtIns=${builtInTools.length} mcp=${mcpTools.length} core=${JSON.stringify(coreToolStatus)}`
703
+ );
704
+ const capabilityHealth = await getCapabilityHealth({ userId, agentId, app, engine });
705
+ const capabilitySummary = summarizeCapabilityHealth(capabilityHealth);
706
+ const integrationSummary = integrationManager?.summarizeConnectedProviders?.(userId, agentId) || '';
707
+
708
+ const { MemoryManager } = require('../../memory/manager');
709
+ const memoryManager = engine.memoryManager || new MemoryManager();
710
+ const recallQuery = options.context?.rawUserMessage || userMessage;
711
+ const recallMsg = options.skipGlobalRecall === true
712
+ ? null
713
+ : await engine.buildMemoryRecall({
714
+ memoryManager,
715
+ userId,
716
+ agentId,
717
+ query: recallQuery,
718
+ provider,
719
+ providerName,
720
+ model,
721
+ runId,
722
+ options,
723
+ });
724
+
725
+ let summaryMessage = null;
726
+ let historyMessages = [];
727
+
728
+ if (conversationId && options.skipConversationHistory !== true) {
729
+ const conversationContext = getConversationContext(conversationId, historyWindow);
730
+ summaryMessage = buildSummaryCarrier(conversationContext.summary || options.priorSummary || '');
731
+ historyMessages = conversationContext.recentMessages.length > 0
732
+ ? conversationContext.recentMessages
733
+ : (options.priorMessages || []).slice(-historyWindow).filter((pm) => pm.role && pm.content);
734
+ } else {
735
+ summaryMessage = buildSummaryCarrier(options.priorSummary || '');
736
+ historyMessages = (options.priorMessages || []).slice(-historyWindow).filter((pm) => pm.role && pm.content);
737
+ }
738
+
739
+ let messages = engine.buildContextMessages(systemPrompt, summaryMessage, historyMessages, recallMsg);
740
+ if (capabilitySummary) {
741
+ messages.push({ role: 'system', content: `[Capability health]\n${capabilitySummary}` });
742
+ }
743
+ if (integrationSummary) {
744
+ messages.push({ role: 'system', content: `[Official integrations]\n${integrationSummary}` });
745
+ }
746
+ const threadStateMessage = conversationId ? memoryManager.buildConversationStateMessage(conversationId) : null;
747
+ if (threadStateMessage) {
748
+ messages.push({ role: 'system', content: threadStateMessage });
749
+ }
750
+ if (carriedGoalContract) {
751
+ messages.push({
752
+ role: 'system',
753
+ content: buildGoalContractPrompt(carriedGoalContract, 'Persisted run goal'),
754
+ });
755
+ }
756
+ engine.recordRunEvent(userId, runId, 'memory_injected', {
757
+ hasRecallContext: Boolean(recallMsg),
758
+ hasThreadState: Boolean(threadStateMessage),
759
+ recallPreview: recallMsg ? String(recallMsg).slice(0, 240) : '',
760
+ }, { agentId });
761
+ messages.push(engine.buildUserMessage(userMessage, options));
762
+ messages = sanitizeConversationMessages(messages);
763
+
764
+ if (conversationId) {
765
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content) VALUES (?, ?, ?)')
766
+ .run(conversationId, 'user', userMessage);
767
+ }
768
+
769
+ let iteration = 0;
770
+ let totalTokens = 0;
771
+ let lastContent = '';
772
+ let stepIndex = 0;
773
+ let failedStepCount = 0;
774
+ let modelFailureRecoveries = 0;
775
+ let promptMetrics = {};
776
+ let toolExecutions = [];
777
+ let compactionMetrics = [];
778
+ let analysis = null;
779
+ let plan = null;
780
+
781
+ // Model-authored progress updates: the messaging supervisor calls this to get a
782
+ // dynamic "what I'm doing right now" line instead of a hard-coded string. It runs
783
+ // a tiny tool-less model call over the current run activity. `provider`/`model` are
784
+ // `let` (closure tracks fallbacks) and `toolExecutions` is push-only.
785
+ if (triggerSource === 'messaging') {
786
+ const runMetaForCompose = engine.getRunMeta(runId);
787
+ if (runMetaForCompose) {
788
+ runMetaForCompose.composeProgressUpdate = async ({ stalled = false } = {}) => {
789
+ try {
790
+ const rm = engine.getRunMeta(runId);
791
+ const ledger = rm?.progressLedger || {};
792
+ const recent = toolExecutions.slice(-5).map((item) => {
793
+ const name = item.toolName || item.tool || 'step';
794
+ return `- ${name}${item.ok === false ? ' (failed)' : ''}`;
795
+ }).join('\n') || '(no tool activity yet)';
796
+ const priorUpdate = String(rm?.lastInterimMessage || '').trim();
797
+ const contextBlock = [
798
+ buildProgressUpdatePrompt(),
799
+ stalled ? 'This step has been running a while with no new progress; reassure the user you are still on it.' : '',
800
+ '',
801
+ `Original request: ${summarizeForLog(userMessage, 320)}`,
802
+ `Doing now: ${ledger.currentTool ? `using ${ledger.currentTool}` : (ledger.currentPhase || 'thinking')}`,
803
+ `Recent steps:\n${recent}`,
804
+ priorUpdate ? `Your previous update (say something different): ${summarizeForLog(priorUpdate, 160)}` : '',
805
+ ].filter(Boolean).join('\n');
806
+ // Reuse the run's real system prompt so the update follows the same voice and
807
+ // formatting guidelines as every other message (single source of truth).
808
+ const sysContent = [systemPrompt?.stable, systemPrompt?.dynamic].filter(Boolean).join('\n\n')
809
+ || 'You are a helpful assistant.';
810
+ const resp = await withModelCallTimeout(
811
+ provider.chat(
812
+ [
813
+ { role: 'system', content: sysContent },
814
+ { role: 'user', content: contextBlock },
815
+ ],
816
+ [],
817
+ { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
818
+ ),
819
+ options,
820
+ 'Progress update compose',
821
+ );
822
+ return sanitizeModelOutput(resp.content || '', { model });
823
+ } catch (composeErr) {
824
+ console.warn(`[Run ${shortenRunId(runId)}] progress_update_compose failed: ${summarizeForLog(composeErr?.message || composeErr, 140)}`);
825
+ return '';
826
+ }
827
+ };
828
+ }
829
+ }
830
+ let verification = null;
831
+ let deliverableWorkflow = null;
832
+ let deliverablePlan = null;
833
+ let deliverableArtifacts = [];
834
+ let deliverableValidation = null;
835
+ let directAnswerEligible = false;
836
+ let analysisUsage = 0;
837
+
838
+ try {
839
+ if (options.skipTaskAnalysis === true) {
840
+ analysis = buildSkipTaskAnalysisResult(options.forceMode);
841
+ } else {
842
+ const analysisResult = await runWithModelFallback('task analysis', () => engine.analyzeTask({
843
+ provider,
844
+ providerName,
845
+ model,
846
+ messages,
847
+ tools,
848
+ capabilityHealth,
849
+ forceMode: options.forceMode || null,
850
+ userMessage,
851
+ options: { ...options, triggerSource, runId, userId, agentId },
852
+ }));
853
+ analysisUsage = analysisResult.usage || 0;
854
+ totalTokens += analysisUsage;
855
+ analysis = applyForcedAnalysisMode({ ...analysisResult.analysis }, options.forceMode);
856
+ if (!analysis.goal && userMessage) {
857
+ analysis.goal = String(userMessage).trim().slice(0, 300);
858
+ }
859
+ analysis.mode = promoteAnalysisMode(analysis.mode, {
860
+ verificationNeed: analysis.verification_need,
861
+ freshnessRisk: analysis.freshness_risk,
862
+ draftReply: analysis.draft_reply,
863
+ planningDepth: analysis.planning_depth,
864
+ });
865
+
866
+ stepIndex += 1;
867
+ const analysisStepId = uuidv4();
868
+ db.prepare(`INSERT INTO agent_steps
869
+ (id, run_id, step_index, type, description, status, result, started_at, completed_at)
870
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`)
871
+ .run(
872
+ analysisStepId,
873
+ runId,
874
+ stepIndex,
875
+ 'analysis',
876
+ 'Task analysis contract',
877
+ 'completed',
878
+ JSON.stringify(analysis).slice(0, 20000)
879
+ );
880
+ engine.persistRunMetadata(runId, {
881
+ taskAnalysis: analysis,
882
+ capabilityHealth,
883
+ });
884
+ engine.updateRunGoalContract(runId, goalContractFromAnalysis(analysis));
885
+ engine.emit(userId, 'run:analysis', {
886
+ runId,
887
+ ...analysis,
888
+ capabilitySummary,
889
+ });
890
+
891
+ }
892
+
893
+ tools = selectInitialTools(allTools, analysis.suggested_tools, {
894
+ widgetId: options.widgetId || null,
895
+ });
896
+ engine.initializeToolRuntime(runId, allTools, tools, options);
897
+ messages.push({
898
+ role: 'system',
899
+ content: [
900
+ '[Available tool catalog]',
901
+ buildToolCatalog(allTools),
902
+ '',
903
+ `Active tools: ${tools.map((tool) => tool.name).join(', ')}`,
904
+ 'Use activate_tools with exact catalog names when another schema is required.',
905
+ ].join('\n'),
906
+ });
907
+ engine.recordRunEvent(userId, runId, 'tool_selection_applied', {
908
+ activeToolNames: tools.map((tool) => tool.name),
909
+ catalogSize: allTools.length,
910
+ }, { agentId });
911
+
912
+ const activeDefaultModelSetting = triggerType === 'subagent'
913
+ ? aiSettings.default_subagent_model
914
+ : aiSettings.default_chat_model;
915
+ if (!_modelOverride && activeDefaultModelSetting === 'auto' && aiSettings.smarter_model_selector !== false) {
916
+ const requestedPurpose = analysis?.mode === 'plan_execute' || analysis?.complexity === 'complex' || analysis?.autonomy_level === 'high'
917
+ ? 'planning'
918
+ : triggerType === 'subagent'
919
+ ? 'fast'
920
+ : '';
921
+ if (requestedPurpose) {
922
+ const selectedAfterAnalysis = await getProviderForUser(
923
+ userId,
924
+ userMessage,
925
+ triggerType === 'subagent',
926
+ null,
927
+ {
928
+ ...providerStatusConfig,
929
+ selectionHint: {
930
+ purpose: requestedPurpose,
931
+ complexity: analysis?.complexity,
932
+ autonomyLevel: analysis?.autonomy_level,
933
+ requiredConfidence: analysis?.completion_confidence_required,
934
+ costMode: aiSettings.cost_mode,
935
+ },
936
+ }
937
+ );
938
+ if (selectedAfterAnalysis.model !== model) {
939
+ provider = selectedAfterAnalysis.provider;
940
+ model = selectedAfterAnalysis.model;
941
+ providerName = selectedAfterAnalysis.providerName;
942
+ db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
943
+ .run(model, runId);
944
+ engine.emit(userId, 'run:interim', {
945
+ runId,
946
+ message: `Switched to ${model} for this run after task analysis.`,
947
+ phase: 'model_selection'
948
+ });
949
+ }
950
+ }
951
+ }
952
+
953
+ // Rebuild loop policy with the resolved analysis mode. Runs in both the
954
+ // normal path and the skipTaskAnalysis path so that forceMode='plan_execute'
955
+ // (or any mode set by buildSkipTaskAnalysisResult) raises the iteration
956
+ // ceiling correctly.
957
+ loopPolicy = buildLoopPolicy(aiSettings, triggerType, analysis.mode || 'execute', {
958
+ ...options,
959
+ autonomyPolicy: buildAutonomyPolicyFromAnalysis(analysis),
960
+ });
961
+ maxIterations = loopPolicy.maxIterations;
962
+
963
+ if (options.skipDeliverableWorkflow !== true) {
964
+ try {
965
+ const deliverableSelectionResult = await selectDeliverableWorkflow({
966
+ engine,
967
+ provider,
968
+ providerName,
969
+ model,
970
+ messages,
971
+ tools,
972
+ options: { ...options, runId, userId, agentId },
973
+ });
974
+ totalTokens += deliverableSelectionResult.usage || 0;
975
+ const selectedWorkflow = getDeliverableWorkflow(deliverableSelectionResult.selection.type);
976
+ if (selectedWorkflow?.canHandle(deliverableSelectionResult.selection)) {
977
+ deliverableWorkflow = {
978
+ workflow: selectedWorkflow,
979
+ selection: deliverableSelectionResult.selection,
980
+ request: selectedWorkflow.normalizeRequest({
981
+ ...deliverableSelectionResult.selection,
982
+ userMessage,
983
+ }),
984
+ };
985
+ deliverablePlan = selectedWorkflow.buildExecutionPlan(deliverableWorkflow.request, {
986
+ analysis,
987
+ tools,
988
+ options,
989
+ });
990
+ await selectedWorkflow.run(deliverablePlan, {
991
+ engine,
992
+ userId,
993
+ agentId,
994
+ runId,
995
+ app,
996
+ });
997
+ engine.persistRunMetadata(runId, {
998
+ deliverableWorkflow: {
999
+ ...deliverableWorkflow.selection,
1000
+ plan: deliverablePlan,
1001
+ },
1002
+ });
1003
+ engine.updateRunGoalContract(runId, {
1004
+ goal: deliverableWorkflow.selection.goal,
1005
+ });
1006
+ engine.recordRunEvent(userId, runId, 'deliverable_workflow_selected', {
1007
+ type: deliverableWorkflow.selection.type,
1008
+ confidence: deliverableWorkflow.selection.confidence,
1009
+ goal: deliverableWorkflow.selection.goal,
1010
+ requestedOutputs: deliverableWorkflow.selection.requestedOutputs,
1011
+ }, { agentId });
1012
+ }
1013
+ } catch (error) {
1014
+ engine.recordRunEvent(userId, runId, 'deliverable_workflow_skipped', {
1015
+ reason: summarizeForLog(error?.message || error, 240),
1016
+ }, { agentId });
1017
+ messages.push({
1018
+ role: 'system',
1019
+ content: 'The optional deliverable workflow classifier failed. Continue with the normal agent loop; do not stop or retry the whole run just because this classifier failed.',
1020
+ });
1021
+ }
1022
+ }
1023
+
1024
+ if (analysis.mode === 'plan_execute') {
1025
+ const planResult = await runWithModelFallback('execution planning', () => engine.createExecutionPlan({
1026
+ provider,
1027
+ providerName,
1028
+ model,
1029
+ messages,
1030
+ analysis,
1031
+ capabilitySummary,
1032
+ options: { ...options, runId, userId, agentId },
1033
+ }));
1034
+ totalTokens += planResult.usage || 0;
1035
+ plan = planResult.plan;
1036
+ stepIndex += 1;
1037
+ const planStepId = uuidv4();
1038
+ db.prepare(`INSERT INTO agent_steps
1039
+ (id, run_id, step_index, type, description, status, result, started_at, completed_at)
1040
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`)
1041
+ .run(
1042
+ planStepId,
1043
+ runId,
1044
+ stepIndex,
1045
+ 'planning',
1046
+ 'Execution plan',
1047
+ 'completed',
1048
+ JSON.stringify(plan).slice(0, 20000)
1049
+ );
1050
+ engine.persistRunMetadata(runId, { executionPlan: plan });
1051
+ engine.updateRunGoalContract(runId, goalContractFromPlan(plan));
1052
+ engine.emit(userId, 'run:plan', {
1053
+ runId,
1054
+ steps: plan.steps,
1055
+ successCriteria: plan.success_criteria,
1056
+ verificationFocus: plan.verification_focus,
1057
+ });
1058
+ }
1059
+
1060
+ const runGoalContract = engine.getRunMeta(runId)?.goalContract || null;
1061
+ if (runGoalContract) {
1062
+ messages.push({
1063
+ role: 'system',
1064
+ content: buildGoalContractPrompt(runGoalContract, 'Run goal contract'),
1065
+ });
1066
+ }
1067
+ messages.push({
1068
+ role: 'system',
1069
+ content: buildExecutionGuidance({
1070
+ analysis,
1071
+ plan,
1072
+ capabilityHealth: capabilitySummary,
1073
+ }),
1074
+ });
1075
+ if (deliverablePlan) {
1076
+ messages.push({
1077
+ role: 'system',
1078
+ content: buildDeliverableWorkflowGuidance(deliverablePlan),
1079
+ });
1080
+ engine.recordRunEvent(userId, runId, 'deliverable_execution_started', {
1081
+ type: deliverableWorkflow?.selection?.type,
1082
+ preferredTools: deliverablePlan.preferredTools || [],
1083
+ expectedOutputs: deliverablePlan.expectedOutputs || [],
1084
+ }, { agentId });
1085
+ }
1086
+ messages = sanitizeConversationMessages(messages);
1087
+
1088
+ directAnswerEligible = isDirectAnswerEligibleAnalysis(analysis)
1089
+ && Boolean(normalizeOutgoingMessage(analysis.draft_reply));
1090
+
1091
+ if (directAnswerEligible) {
1092
+ iteration = 1;
1093
+ lastContent = analysis.draft_reply.trim();
1094
+ messages.push({ role: 'assistant', content: lastContent });
1095
+ if (conversationId) {
1096
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
1097
+ .run(conversationId, 'assistant', lastContent, analysisUsage);
1098
+ }
1099
+ }
1100
+
1101
+ // BUG FIX: consecutiveToolFailures was previously declared INSIDE the
1102
+ // while loop (resetting each iteration). It is now tracked across the
1103
+ // full run so the failure guard fires correctly after 5 consecutive failures
1104
+ // regardless of which iteration they fall in.
1105
+ let consecutiveToolFailures = 0;
1106
+ const iterationBudget = new IterationBudget(maxIterations);
1107
+
1108
+ while (!directAnswerEligible && iterationBudget.consume()) {
1109
+ if (engine.isRunStopped(runId)) break;
1110
+ iteration = iterationBudget.used;
1111
+
1112
+ const systemSteeringAtLoopStart = engine.applyQueuedSystemSteering(runId, messages);
1113
+ messages = systemSteeringAtLoopStart.messages;
1114
+ const steeringAtLoopStart = engine.applyQueuedSteering(runId, messages, {
1115
+ userId,
1116
+ conversationId
1117
+ });
1118
+ messages = steeringAtLoopStart.messages;
1119
+ messages = sanitizeConversationMessages(messages);
1120
+
1121
+ // Analysis-paralysis gate: fire at the start of every iteration where
1122
+ // the agent has spent N turns only reading/listing/searching without
1123
+ // taking any concrete action. Escalates in urgency each turn.
1124
+ if (analysis.mode === 'execute' || analysis.mode === 'plan_execute') {
1125
+ const readOnlyCount = engine.getRunMeta(runId)?.consecutiveReadOnlyIterations || 0;
1126
+ if (readOnlyCount >= 3) {
1127
+ const urgency = readOnlyCount >= 6 ? 'CRITICAL' : 'ACTION REQUIRED';
1128
+ messages.push({
1129
+ role: 'system',
1130
+ content: `${urgency} — ${readOnlyCount} consecutive read-only turns: You have been gathering information for ${readOnlyCount} turns without implementation progress such as writing, editing, creating a branch, running verification, or delivering a final/blocker. Switch method now: establish or reuse a writable checkout, create a task branch, edit files, run verification, open/update a PR, or call task_complete with the real blocker. If the user has been waiting, send one concise interim update, but that does not satisfy the method switch. Do not do more remote tree/list/content scraping first. Do not assume tool results exist at /tmp paths unless a tool explicitly returned that readable path.`,
1131
+ });
1132
+ }
1133
+ }
1134
+
1135
+ engine.updateRunProgress(runId, {
1136
+ currentPhase: 'model',
1137
+ currentStep: `model:${iteration}`,
1138
+ currentTool: null,
1139
+ currentStepStartedAt: isoNow(),
1140
+ });
1141
+
1142
+ let metrics = engine.estimatePromptMetrics(messages, tools);
1143
+ const contextWindow = provider.getContextWindow(model);
1144
+ if (metrics.totalEstimatedTokens > contextWindow * loopPolicy.compactionThreshold) {
1145
+ messages = await withModelCallTimeout(
1146
+ compact(messages, provider, model, contextWindow),
1147
+ options,
1148
+ `Context compaction before iteration ${iteration}`,
1149
+ );
1150
+ messages = sanitizeConversationMessages(messages);
1151
+ engine.emit(userId, 'run:compaction', { runId, iteration });
1152
+ metrics = engine.estimatePromptMetrics(messages, tools);
1153
+ }
1154
+
1155
+ promptMetrics = engine.mergePromptMetrics(promptMetrics, metrics, iteration, tools.length);
1156
+ engine.persistPromptMetrics(runId, promptMetrics).catch(() => { });
1157
+ engine.emit(userId, 'run:thinking', { runId, iteration });
1158
+ engine.recordRunEvent(userId, runId, 'model_turn_started', {
1159
+ iteration,
1160
+ toolCount: tools.length,
1161
+ }, { agentId });
1162
+
1163
+ let response;
1164
+ let responseModel = model;
1165
+ let streamContent = '';
1166
+
1167
+ const tryModelCall = async (retryForFallback = true) => {
1168
+ try {
1169
+ const modelCall = await engine.requestModelResponse({
1170
+ provider,
1171
+ providerName,
1172
+ model,
1173
+ messages,
1174
+ tools,
1175
+ options: { ...options, userId, agentId, runId, phase: 'model_turn' },
1176
+ runId,
1177
+ iteration,
1178
+ });
1179
+ response = modelCall.response;
1180
+ responseModel = modelCall.responseModel;
1181
+ streamContent = modelCall.streamContent;
1182
+ } catch (err) {
1183
+ console.error(`[Engine] Model call failed (${model}):`, err.message);
1184
+ const fallbackModelId = retryForFallback
1185
+ ? await getFailureFallbackModelId(userId, agentId, model, aiSettings.fallback_model_id, err)
1186
+ : null;
1187
+ if (fallbackModelId) {
1188
+ const failedModel = model;
1189
+ console.log(`[Engine] Attempting fallback to: ${fallbackModelId}`);
1190
+ const fallback = await getProviderForUser(
1191
+ userId,
1192
+ userMessage,
1193
+ triggerType === 'subagent',
1194
+ fallbackModelId,
1195
+ providerStatusConfig
1196
+ );
1197
+ provider = fallback.provider;
1198
+ model = fallback.model;
1199
+ providerName = fallback.providerName;
1200
+
1201
+ const retryMessages = sanitizeConversationMessages([
1202
+ ...messages,
1203
+ {
1204
+ role: 'system',
1205
+ content: buildModelFailureLoopPrompt({
1206
+ failedModel,
1207
+ nextModel: model,
1208
+ errorMessage: err.message
1209
+ })
1210
+ }
1211
+ ]);
1212
+
1213
+ const fallbackCall = await engine.requestModelResponse({
1214
+ provider,
1215
+ providerName,
1216
+ model,
1217
+ messages: retryMessages,
1218
+ tools,
1219
+ options: { ...options, userId },
1220
+ runId,
1221
+ iteration,
1222
+ });
1223
+ response = fallbackCall.response;
1224
+ responseModel = fallbackCall.responseModel;
1225
+ streamContent = fallbackCall.streamContent;
1226
+ } else {
1227
+ throw err;
1228
+ }
1229
+ }
1230
+ };
1231
+
1232
+ try {
1233
+ await tryModelCall();
1234
+ } catch (err) {
1235
+ const modelError = String(err?.message || 'Model call failed');
1236
+
1237
+ if (modelFailureRecoveries < loopPolicy.maxModelFailureRecoveries) {
1238
+ const failedModel = model;
1239
+ const switched = await switchToFallbackModel(failedModel, err, 'model turn');
1240
+ if (!switched) throw err;
1241
+ modelFailureRecoveries += 1;
1242
+ failedStepCount += 1;
1243
+ messages.push({
1244
+ role: 'system',
1245
+ content: buildModelFailureLoopPrompt({
1246
+ failedModel,
1247
+ nextModel: model,
1248
+ errorMessage: modelError
1249
+ })
1250
+ });
1251
+ engine.emit(userId, 'run:interim', {
1252
+ runId,
1253
+ message: 'Model call failed; adapting and retrying autonomously.',
1254
+ phase: 'recovering'
1255
+ });
1256
+ continue;
1257
+ }
1258
+
1259
+ throw err;
1260
+ }
1261
+
1262
+ if (!response) {
1263
+ response = { content: streamContent, toolCalls: [], finishReason: 'stop', usage: null };
1264
+ }
1265
+
1266
+ if (response.usage) {
1267
+ totalTokens += response.usage.totalTokens || 0;
1268
+ }
1269
+
1270
+ lastContent = sanitizeModelOutput(response.content || streamContent || '', { model: responseModel });
1271
+
1272
+ if ((!response.toolCalls || response.toolCalls.length === 0) && lastContent) {
1273
+ const salvaged = salvageTextToolCalls(lastContent, tools);
1274
+ if (salvaged.toolCalls.length > 0) {
1275
+ response.toolCalls = salvaged.toolCalls;
1276
+ response.finishReason = 'tool_calls';
1277
+ response.content = salvaged.content;
1278
+ lastContent = salvaged.content;
1279
+ }
1280
+ }
1281
+
1282
+ engine.recordRunEvent(userId, runId, 'model_turn_completed', {
1283
+ iteration,
1284
+ toolCallCount: response.toolCalls?.length || 0,
1285
+ contentPreview: String(lastContent || streamContent || '').slice(0, 240),
1286
+ }, { agentId });
1287
+ engine.updateRunProgress(runId, {}, {
1288
+ verified: true,
1289
+ });
1290
+
1291
+ const assistantMessage = { role: 'assistant', content: lastContent };
1292
+ if (response.toolCalls?.length) assistantMessage.tool_calls = response.toolCalls;
1293
+ if (response.providerContentBlocks?.length) assistantMessage.providerContentBlocks = response.providerContentBlocks;
1294
+ messages.push(assistantMessage);
1295
+
1296
+ if (conversationId) {
1297
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tool_calls, tokens) VALUES (?, ?, ?, ?, ?)')
1298
+ .run(
1299
+ conversationId,
1300
+ 'assistant',
1301
+ lastContent,
1302
+ response.toolCalls?.length ? JSON.stringify(response.toolCalls) : null,
1303
+ response.usage?.totalTokens || 0
1304
+ );
1305
+ }
1306
+
1307
+ if (!response.toolCalls || response.toolCalls.length === 0) {
1308
+ engine.updateRunProgress(runId, {
1309
+ currentPhase: 'idle',
1310
+ currentStep: null,
1311
+ currentTool: null,
1312
+ currentStepStartedAt: null,
1313
+ });
1314
+ // Check for queued steering first — if something was injected while the
1315
+ // model was responding (e.g. a heartbeat nudge), give the model a chance
1316
+ // to act on it before we treat this as a final answer.
1317
+ const systemSteeringAfterResponse = engine.applyQueuedSystemSteering(runId, messages);
1318
+ messages = systemSteeringAfterResponse.messages;
1319
+ if (systemSteeringAfterResponse.appliedCount > 0) {
1320
+ iterationBudget.refund();
1321
+ iteration = iterationBudget.used;
1322
+ lastContent = '';
1323
+ continue;
1324
+ }
1325
+ const steeringAfterResponse = engine.applyQueuedSteering(runId, messages, {
1326
+ userId,
1327
+ conversationId
1328
+ });
1329
+ messages = steeringAfterResponse.messages;
1330
+ if (steeringAfterResponse.appliedCount > 0) {
1331
+ iterationBudget.refund();
1332
+ iteration = iterationBudget.used;
1333
+ lastContent = '';
1334
+ continue;
1335
+ }
1336
+ if (engine.shouldFastCompleteVoiceReply({
1337
+ options,
1338
+ toolExecutions,
1339
+ failedStepCount,
1340
+ messagingSent: engine.activeRuns.get(runId)?.messagingSent || false,
1341
+ lastReply: lastContent,
1342
+ })) {
1343
+ break;
1344
+ }
1345
+ if (shouldContinueAfterBlankToolFailure({
1346
+ lastContent,
1347
+ failedStepCount,
1348
+ remainingIterations: iterationBudget.remaining,
1349
+ toolExecutions,
1350
+ })) {
1351
+ engine.recordRunEvent(userId, runId, 'blank_after_tool_failure_continued', {
1352
+ iteration,
1353
+ remainingIterations: iterationBudget.remaining,
1354
+ }, { agentId });
1355
+ messages.push({
1356
+ role: 'system',
1357
+ content: buildBlankAfterToolFailureGuidance(toolExecutions),
1358
+ });
1359
+ lastContent = '';
1360
+ continue;
1361
+ }
1362
+ const loopDecision = await engine.decideLoopState({
1363
+ provider,
1364
+ providerName,
1365
+ model,
1366
+ messages,
1367
+ analysis,
1368
+ plan,
1369
+ tools,
1370
+ toolExecutions,
1371
+ lastReply: lastContent,
1372
+ iteration,
1373
+ maxIterations,
1374
+ options: { ...options, triggerSource, runId, userId, agentId },
1375
+ messagingSent: engine.activeRuns.get(runId)?.messagingSent || false,
1376
+ });
1377
+ totalTokens += loopDecision.usage || 0;
1378
+ engine.recordRunEvent(userId, runId, 'loop_completion_checked', {
1379
+ status: loopDecision.decision.status,
1380
+ reason: loopDecision.decision.reason,
1381
+ iteration,
1382
+ }, { agentId });
1383
+ if (loopDecision.decision.status === 'continue') {
1384
+ messages.push({
1385
+ role: 'system',
1386
+ content: [
1387
+ 'The run self-check determined the latest assistant text is not terminal.',
1388
+ 'Continue with the next safe tool/model step.',
1389
+ 'If the text was only a progress note, do not repeat it; either make progress or provide a real final/blocker reply.',
1390
+ ].join(' '),
1391
+ });
1392
+ lastContent = '';
1393
+ continue;
1394
+ }
1395
+ directAnswerEligible = true;
1396
+ break;
1397
+ }
1398
+
1399
+ const canRunParallelBatch = (
1400
+ response.toolCalls.length > 1
1401
+ && response.toolCalls.every((toolCall) => engine.isReadOnlyToolCall(toolCall))
1402
+ );
1403
+ if (canRunParallelBatch) {
1404
+ const parallelToolNames = response.toolCalls
1405
+ .map((toolCall) => toolCall.function?.name)
1406
+ .filter(Boolean);
1407
+ engine.updateRunProgress(runId, {
1408
+ currentPhase: 'tool',
1409
+ currentStep: `parallel:${iteration}`,
1410
+ currentTool: parallelToolNames.join(', ') || 'parallel tools',
1411
+ currentStepStartedAt: isoNow(),
1412
+ });
1413
+ const batch = await engine.executeReadOnlyBatch(response.toolCalls, {
1414
+ userId,
1415
+ runId,
1416
+ agentId,
1417
+ app,
1418
+ triggerType,
1419
+ triggerSource,
1420
+ conversationId,
1421
+ startingStepIndex: stepIndex,
1422
+ iteration,
1423
+ options,
1424
+ });
1425
+ stepIndex = batch.endingStepIndex;
1426
+ for (const item of batch.results) {
1427
+ const execution = classifyToolExecution(
1428
+ item.toolName,
1429
+ item.toolArgs,
1430
+ item.result,
1431
+ item.error || '',
1432
+ );
1433
+ execution.input = item.toolArgs;
1434
+ execution.artifacts = await extractArtifactsFromResult(item.toolName, item.result);
1435
+ toolExecutions.push(execution);
1436
+ engine.getRunMeta(runId)?.repetitionGuard?.observe(item.toolName, item.toolArgs, item.result);
1437
+ if (item.error) failedStepCount += 1;
1438
+ const modelPayload = compactPayloadForModel(item.toolName, item.result);
1439
+ const toolResultLimits = resolveToolResultLimits(item.toolName, loopPolicy);
1440
+ const toolMessage = {
1441
+ role: 'tool',
1442
+ name: item.toolName,
1443
+ tool_call_id: item.toolCall.id,
1444
+ content: compactToolResult(item.toolName, item.toolArgs, modelPayload.result, {
1445
+ softLimit: toolResultLimits.softLimit,
1446
+ hardLimit: toolResultLimits.hardLimit,
1447
+ }),
1448
+ };
1449
+ messages.push(toolMessage);
1450
+ if (conversationId) {
1451
+ db.prepare(
1452
+ `INSERT INTO conversation_messages (
1453
+ conversation_id, role, content, tool_call_id, name
1454
+ ) VALUES (?, 'tool', ?, ?, ?)`
1455
+ ).run(conversationId, toolMessage.content, item.toolCall.id, item.toolName);
1456
+ }
1457
+ }
1458
+ engine.persistRunMetadata(runId, {
1459
+ evidenceSources: [...new Set(toolExecutions.map((item) => item.evidenceSource).filter(Boolean))],
1460
+ subagentState: engine.listSubagents(runId),
1461
+ deliverableArtifacts,
1462
+ compactionMetrics: compactionMetrics.slice(-20),
1463
+ });
1464
+ engine.updateRunProgress(runId, {
1465
+ currentPhase: 'idle',
1466
+ currentStep: null,
1467
+ currentTool: null,
1468
+ currentStepStartedAt: null,
1469
+ }, {
1470
+ verified: true,
1471
+ });
1472
+ continue;
1473
+ }
1474
+
1475
+ for (const toolCall of response.toolCalls) {
1476
+ if (engine.isRunStopped(runId)) break;
1477
+ stepIndex++;
1478
+ const stepId = uuidv4();
1479
+ const toolName = toolCall.function.name;
1480
+ const stepStartedAt = Date.now();
1481
+ let toolArgs;
1482
+ try {
1483
+ toolArgs = JSON.parse(toolCall.function.arguments || '{}');
1484
+ } catch {
1485
+ toolArgs = {};
1486
+ }
1487
+
1488
+ // ── task_complete: AI explicitly signals the task is fully done ──
1489
+ if (toolName === 'task_complete') {
1490
+ const finalMessage = String(toolArgs.message || '').trim();
1491
+ const completionDecision = await engine.evaluateTaskCompleteSignal({
1492
+ provider,
1493
+ providerName,
1494
+ model,
1495
+ messages,
1496
+ analysis,
1497
+ plan,
1498
+ tools,
1499
+ toolExecutions,
1500
+ finalMessage,
1501
+ confidence: toolArgs.confidence,
1502
+ iteration,
1503
+ maxIterations,
1504
+ options: { ...options, triggerSource, runId, userId, agentId },
1505
+ messagingSent: engine.activeRuns.get(runId)?.messagingSent || false,
1506
+ });
1507
+ totalTokens += completionDecision.usage || 0;
1508
+ engine.recordRunEvent(userId, runId, 'task_complete_signaled', {
1509
+ accepted: completionDecision.accepted,
1510
+ status: completionDecision.status,
1511
+ reason: completionDecision.reason,
1512
+ iteration,
1513
+ messageLength: finalMessage.length,
1514
+ }, { agentId });
1515
+ if (!completionDecision.accepted) {
1516
+ const rejectedResult = {
1517
+ tool: 'task_complete',
1518
+ status: 'continue',
1519
+ reason: completionDecision.reason || 'The completion self-check determined more work is still required.',
1520
+ };
1521
+ messages.push({
1522
+ role: 'tool',
1523
+ name: toolName,
1524
+ tool_call_id: toolCall.id,
1525
+ content: JSON.stringify(rejectedResult),
1526
+ });
1527
+ messages.push({
1528
+ role: 'system',
1529
+ content: 'The task_complete signal was rejected by the run self-check. Continue autonomously with the next safe step, or provide a truthful blocker only if no safe next step remains.',
1530
+ });
1531
+ continue;
1532
+ }
1533
+ console.info(
1534
+ `[Run ${shortenRunId(runId)}] task_complete accepted status=${completionDecision.status} iteration=${iteration}`
1535
+ );
1536
+ lastContent = finalMessage;
1537
+ directAnswerEligible = true;
1538
+ break;
1539
+ }
1540
+
1541
+ const repetitionGuard = engine.getRunMeta(runId)?.repetitionGuard;
1542
+ if (repetitionGuard?.shouldBlock(toolName, toolArgs)) {
1543
+ const blockedResult = {
1544
+ tool: toolName,
1545
+ status: 'blocked',
1546
+ reason: 'The same tool call already returned an unchanged result twice. Change the approach or complete with the available evidence.',
1547
+ };
1548
+ messages.push({
1549
+ role: 'tool',
1550
+ name: toolName,
1551
+ tool_call_id: toolCall.id,
1552
+ content: JSON.stringify(blockedResult),
1553
+ });
1554
+ engine.recordRunEvent(userId, runId, 'repetition_blocked', {
1555
+ toolName,
1556
+ toolArgs,
1557
+ }, { agentId });
1558
+ engine.emit(userId, 'run:tool_end', {
1559
+ runId,
1560
+ toolName,
1561
+ status: 'blocked',
1562
+ result: blockedResult,
1563
+ });
1564
+ messages.push({
1565
+ role: 'system',
1566
+ content: 'The repeated call was blocked because it made no progress. Use a different tool, change the arguments, or finish with a truthful result.',
1567
+ });
1568
+ continue;
1569
+ }
1570
+
1571
+ // ── before_tool_call hook ──
1572
+ // Plugins can block a tool call (e.g. security policy) or mutate args.
1573
+ if (globalHooks.has('before_tool_call')) {
1574
+ const hookCtx = { toolName, toolArgs, runId, userId, agentId, iteration };
1575
+ const hookResult = await globalHooks.run('before_tool_call', hookCtx);
1576
+ if (hookResult.block) {
1577
+ const blockReason = hookResult.reason || 'Blocked by policy.';
1578
+ const blockedBy = hookResult.blocked_by || 'policy';
1579
+ console.warn(`[Run ${shortenRunId(runId)}] before_tool_call hook blocked tool=${toolName} reason="${blockReason}"`);
1580
+ messages.push({
1581
+ role: 'tool',
1582
+ name: toolName,
1583
+ tool_call_id: toolCall.id,
1584
+ content: JSON.stringify({ tool: toolName, status: 'blocked', reason: blockReason, blocked_by: blockedBy }),
1585
+ });
1586
+ continue;
1587
+ }
1588
+ if (hookResult.toolArgs) toolArgs = hookResult.toolArgs;
1589
+ }
1590
+
1591
+ db.prepare('INSERT INTO agent_steps (id, run_id, step_index, type, description, status, tool_name, tool_input, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime(\'now\'))')
1592
+ .run(stepId, runId, stepIndex, engine.getStepType(toolName), `${toolName}: ${JSON.stringify(toolArgs).slice(0, 200)} `, 'running', toolName, JSON.stringify(toolArgs));
1593
+ engine.updateRunProgress(runId, {
1594
+ currentPhase: 'tool',
1595
+ currentStep: stepId,
1596
+ currentTool: toolName,
1597
+ currentStepStartedAt: isoNow(),
1598
+ }, {
1599
+ stepId,
1600
+ });
1601
+
1602
+ engine.emit(userId, 'run:tool_start', {
1603
+ runId, stepId, stepIndex, toolName, toolArgs,
1604
+ type: engine.getStepType(toolName)
1605
+ });
1606
+ engine.recordRunEvent(userId, runId, 'tool_started', {
1607
+ stepIndex,
1608
+ toolName,
1609
+ toolArgs,
1610
+ type: engine.getStepType(toolName),
1611
+ }, { agentId, stepId });
1612
+ console.info(
1613
+ `[Run ${shortenRunId(runId)}] step=${stepIndex} start tool=${toolName} args=${summarizeForLog(toolArgs)}`
1614
+ );
1615
+
1616
+ let toolResult;
1617
+ let toolErrorMessage = '';
1618
+ try {
1619
+ toolResult = await engine.executeTool(toolName, toolArgs, {
1620
+ userId,
1621
+ runId,
1622
+ agentId,
1623
+ app,
1624
+ triggerType,
1625
+ triggerSource,
1626
+ conversationId,
1627
+ source: options.source || null,
1628
+ chatId: options.chatId || null,
1629
+ taskId: options.taskId || null,
1630
+ widgetId: options.widgetId || null,
1631
+ deliveryState: options.deliveryState || null,
1632
+ allowMultipleProactiveMessages: options.allowMultipleProactiveMessages === true,
1633
+ allowExternalSideEffects: options.allowExternalSideEffects === true,
1634
+ });
1635
+ engine.detachProcessFromRun(runId, toolResult?.pid);
1636
+ toolErrorMessage = inferToolFailureMessage(toolName, toolResult);
1637
+ if (toolErrorMessage) {
1638
+ failedStepCount++;
1639
+ }
1640
+ const screenshotPath = toolResult?.screenshotPath || null;
1641
+ const stepStatus = engine.isRunStopped(runId) ? 'stopped' : (toolErrorMessage ? 'failed' : 'completed');
1642
+ db.prepare('UPDATE agent_steps SET status = ?, result = ?, error = ?, screenshot_path = ?, completed_at = datetime(\'now\') WHERE id = ?')
1643
+ .run(stepStatus, JSON.stringify(toolResult).slice(0, 20000), toolErrorMessage || null, screenshotPath, stepId);
1644
+ if (toolErrorMessage) {
1645
+ engine.emit(userId, 'run:tool_end', { runId, stepId, toolName, error: toolErrorMessage, result: toolResult, screenshotPath, status: stepStatus });
1646
+ engine.recordRunEvent(userId, runId, 'tool_failed', {
1647
+ toolName,
1648
+ status: stepStatus,
1649
+ error: toolErrorMessage,
1650
+ durationMs: Date.now() - stepStartedAt,
1651
+ resultPreview: summarizeForLog(toolResult),
1652
+ }, { agentId, stepId });
1653
+ console.warn(
1654
+ `[Run ${shortenRunId(runId)}] step=${stepIndex} failed tool=${toolName} durationMs=${Date.now() - stepStartedAt} error=${summarizeForLog(toolErrorMessage, 160)}`
1655
+ );
1656
+ } else {
1657
+ engine.emit(userId, 'run:tool_end', { runId, stepId, toolName, result: toolResult, screenshotPath, status: stepStatus });
1658
+ engine.recordRunEvent(userId, runId, 'tool_completed', {
1659
+ toolName,
1660
+ status: stepStatus,
1661
+ durationMs: Date.now() - stepStartedAt,
1662
+ resultPreview: summarizeForLog(toolResult),
1663
+ }, { agentId, stepId });
1664
+ console.info(
1665
+ `[Run ${shortenRunId(runId)}] step=${stepIndex} done tool=${toolName} status=${stepStatus} durationMs=${Date.now() - stepStartedAt} result=${summarizeForLog(toolResult)}`
1666
+ );
1667
+ }
1668
+ } catch (err) {
1669
+ toolResult = { error: err.message };
1670
+ toolErrorMessage = String(err.message || 'Tool execution failed');
1671
+ failedStepCount++;
1672
+ engine.detachProcessFromRun(runId, toolResult?.pid);
1673
+ db.prepare('UPDATE agent_steps SET status = ?, error = ?, completed_at = datetime(\'now\') WHERE id = ?')
1674
+ .run('failed', err.message, stepId);
1675
+ engine.emit(userId, 'run:tool_end', { runId, stepId, toolName, error: err.message, status: 'failed' });
1676
+ engine.recordRunEvent(userId, runId, 'tool_failed', {
1677
+ toolName,
1678
+ status: 'failed',
1679
+ error: err.message,
1680
+ durationMs: Date.now() - stepStartedAt,
1681
+ }, { agentId, stepId });
1682
+ console.warn(
1683
+ `[Run ${shortenRunId(runId)}] step=${stepIndex} failed tool=${toolName} durationMs=${Date.now() - stepStartedAt} error=${summarizeForLog(err.message, 160)}`
1684
+ );
1685
+ }
1686
+
1687
+ const execution = classifyToolExecution(toolName, toolArgs, toolResult, toolErrorMessage);
1688
+ execution.input = toolArgs;
1689
+ repetitionGuard?.observe(toolName, toolArgs, toolResult);
1690
+ execution.artifacts = await extractArtifactsFromResult(toolName, toolResult);
1691
+ toolExecutions.push(execution);
1692
+ if (deliverableWorkflow && Array.isArray(execution.artifacts) && execution.artifacts.length > 0) {
1693
+ for (const artifact of execution.artifacts) {
1694
+ if (!deliverableArtifacts.some((existing) => (
1695
+ (existing.path && artifact.path && existing.path === artifact.path)
1696
+ || (existing.uri && artifact.uri && existing.uri === artifact.uri)
1697
+ ))) {
1698
+ deliverableArtifacts.push(artifact);
1699
+ engine.recordRunEvent(userId, runId, 'deliverable_artifact_produced', {
1700
+ type: deliverableWorkflow.selection.type,
1701
+ toolName,
1702
+ artifact,
1703
+ }, { agentId, stepId });
1704
+ }
1705
+ }
1706
+ }
1707
+ engine.persistRunMetadata(runId, {
1708
+ evidenceSources: [...new Set(toolExecutions.map((item) => item.evidenceSource).filter(Boolean))],
1709
+ subagentState: engine.listSubagents(runId),
1710
+ deliverableArtifacts,
1711
+ compactionMetrics: compactionMetrics.slice(-20),
1712
+ });
1713
+ const modelPayload = compactPayloadForModel(toolName, toolResult);
1714
+ if (modelPayload.metrics?.applied) {
1715
+ const metric = {
1716
+ toolName,
1717
+ stepId,
1718
+ ...modelPayload.metrics,
1719
+ createdAt: new Date().toISOString(),
1720
+ };
1721
+ compactionMetrics.push(metric);
1722
+ engine.persistRunMetadata(runId, {
1723
+ compactionMetrics: compactionMetrics.slice(-20),
1724
+ });
1725
+ engine.recordRunEvent(userId, runId, 'pre_model_compaction_applied', {
1726
+ toolName,
1727
+ metrics: modelPayload.metrics,
1728
+ }, { agentId, stepId });
1729
+ }
1730
+
1731
+ const toolResultLimits = resolveToolResultLimits(toolName, loopPolicy);
1732
+ const toolMessage = {
1733
+ role: 'tool',
1734
+ name: toolName,
1735
+ tool_call_id: toolCall.id,
1736
+ content: compactToolResult(toolName, toolArgs, modelPayload.result, {
1737
+ softLimit: toolResultLimits.softLimit,
1738
+ hardLimit: toolResultLimits.hardLimit,
1739
+ })
1740
+ };
1741
+ messages.push(toolMessage);
1742
+ if (toolName === 'activate_tools' && !toolErrorMessage) {
1743
+ tools = engine.getActiveTools(runId);
1744
+ }
1745
+
1746
+ if (toolErrorMessage) {
1747
+ consecutiveToolFailures += 1;
1748
+ const currentRunMeta = engine.getRunMeta(runId);
1749
+ trackErrorPattern(toolErrorMessage, currentRunMeta);
1750
+ const errorKey = normalizeErrorKey(toolErrorMessage);
1751
+ const errorCount = currentRunMeta?.errorPatterns?.get(errorKey) || 0;
1752
+ const patternGuide = buildErrorPatternGuidance(errorKey, errorCount);
1753
+ const alternativeTools = summarizeAvailableTools(tools, { exclude: toolName });
1754
+ messages.push({
1755
+ role: 'system',
1756
+ content: [
1757
+ `Tool "${toolName}" failed with error: ${summarizeForLog(toolErrorMessage, 240)}.`,
1758
+ 'This tool failure is not, by itself, a user-facing blocker.',
1759
+ 'Continue autonomously: retry with corrected arguments, try an alternative tool/path, or verify the outcome using other available tools.',
1760
+ alternativeTools ? `Other available tools in this run: ${alternativeTools}.` : '',
1761
+ patternGuide || '',
1762
+ 'Only stop and tell the user you are blocked if the remaining issue truly requires an external dependency or user action outside this run.'
1763
+ ].filter(Boolean).join(' ')
1764
+ });
1765
+
1766
+ if (consecutiveToolFailures >= loopPolicy.maxConsecutiveToolFailures) {
1767
+ messages.push({
1768
+ role: 'system',
1769
+ content: `There were ${consecutiveToolFailures} consecutive tool failures. Stop calling tools now and return a clear blocker response that summarizes attempted actions and concrete errors.`
1770
+ });
1771
+ break;
1772
+ }
1773
+ } else {
1774
+ consecutiveToolFailures = 0;
1775
+ // Output fingerprint guard: steer away from re-fetching data already seen.
1776
+ if (!toolErrorMessage) {
1777
+ const currentRunMeta = engine.getRunMeta(runId);
1778
+ const fp = fingerprintOutput(toolName, toolResult, toolArgs);
1779
+ if (fp !== null && currentRunMeta?.seenOutputHashes) {
1780
+ const prior = currentRunMeta.seenOutputHashes.get(fp);
1781
+ if (prior) {
1782
+ messages.push({
1783
+ role: 'system',
1784
+ content: `DUPLICATE DATA: This response is identical to what "${prior.toolName}" returned in iteration ${prior.iteration}. You already have this information. Stop fetching and use what you have — proceed to the next concrete action.`,
1785
+ });
1786
+ } else {
1787
+ currentRunMeta.seenOutputHashes.set(fp, { toolName, iteration });
1788
+ }
1789
+ }
1790
+ }
1791
+ }
1792
+
1793
+ if (toolName === 'send_interim_update') {
1794
+ messages.push({
1795
+ role: 'system',
1796
+ content: 'An interim user-visible update was already sent. Do not later output meta commentary about having already replied. When you have the final answer, give the answer itself. If you need to deliver that final answer to the user in messaging, use send_message.'
1797
+ });
1798
+ }
1799
+
1800
+ if (toolName === 'execute_command' && (toolResult?.timedOut || toolResult?.killed)) {
1801
+ messages.push({
1802
+ role: 'system',
1803
+ content: 'The previous shell command did not finish cleanly. Keep working until you rerun it with enough time or verify the requested outcome with follow-up commands.'
1804
+ });
1805
+ }
1806
+
1807
+ if (
1808
+ toolName === 'execute_command'
1809
+ && toolResult?.exitCode !== undefined
1810
+ && toolResult.exitCode !== 0
1811
+ ) {
1812
+ messages.push({
1813
+ role: 'system',
1814
+ content: 'The previous shell command exited non-zero. Treat its output as partial evidence only. If it chained multiple shell segments, later segments may not have run. Do not summarize missing sections as observed facts; rerun or verify them separately first.'
1815
+ });
1816
+ }
1817
+
1818
+ if (conversationId) {
1819
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tool_call_id, name) VALUES (?, ?, ?, ?, ?)')
1820
+ .run(conversationId, 'tool', toolMessage.content, toolCall.id, toolName);
1821
+ }
1822
+
1823
+ engine.updateRunProgress(runId, {
1824
+ currentPhase: 'idle',
1825
+ currentStep: null,
1826
+ currentTool: null,
1827
+ currentStepStartedAt: null,
1828
+ }, {
1829
+ verified: true,
1830
+ stepId,
1831
+ });
1832
+
1833
+ const runMeta = engine.activeRuns.get(runId);
1834
+ if (runMeta) {
1835
+ runMeta.lastToolName = toolName;
1836
+ runMeta.lastToolTarget = toolName === 'send_message' ? toolArgs.to : null;
1837
+ if (toolName === 'save_widget_snapshot' && !toolErrorMessage) {
1838
+ runMeta.widgetSnapshotSaved = true;
1839
+ }
1840
+ }
1841
+
1842
+ if (toolName === 'save_widget_snapshot' && !toolErrorMessage) {
1843
+ lastContent = 'Widget snapshot updated.';
1844
+ break;
1845
+ }
1846
+
1847
+ if (runMeta?.terminalInterim) {
1848
+ break;
1849
+ }
1850
+ }
1851
+
1852
+ // Update analysis-paralysis counter after each iteration's tool calls.
1853
+ // Resets to 0 when any progress tool was called; otherwise increments.
1854
+ if (!directAnswerEligible && response?.toolCalls?.length > 0
1855
+ && (analysis.mode === 'execute' || analysis.mode === 'plan_execute')) {
1856
+ const iterMeta = engine.getRunMeta(runId);
1857
+ if (iterMeta) {
1858
+ const calledProgress = response.toolCalls.some((tc) => {
1859
+ let parsedArgs = {};
1860
+ try { parsedArgs = JSON.parse(tc.function?.arguments || '{}'); } catch {}
1861
+ return isProgressToolCall(tc.function?.name || '', parsedArgs);
1862
+ });
1863
+ iterMeta.consecutiveReadOnlyIterations = calledProgress
1864
+ ? 0
1865
+ : (iterMeta.consecutiveReadOnlyIterations || 0) + 1;
1866
+ }
1867
+ }
1868
+
1869
+ if (engine.isRunStopped(runId)) break;
1870
+ if (engine.getRunMeta(runId)?.terminalInterim) break;
1871
+ if (engine.getRunMeta(runId)?.widgetSnapshotSaved) break;
1872
+ if (!engine.activeRuns.has(runId)) break;
1873
+ }
1874
+
1875
+ if (engine.isRunStopped(runId)) {
1876
+ db.prepare('UPDATE agent_runs SET status = ?, updated_at = datetime(\'now\'), completed_at = datetime(\'now\') WHERE id = ?')
1877
+ .run('stopped', runId);
1878
+ console.warn(
1879
+ `[Run ${shortenRunId(runId)}] stopped trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
1880
+ );
1881
+ engine.stopMessagingProgressSupervisor(runId);
1882
+ engine.activeRuns.delete(runId);
1883
+ engine.emit(userId, 'run:stopped', { runId, triggerSource });
1884
+ engine.recordRunEvent(userId, runId, 'run_stopped', {
1885
+ triggerSource,
1886
+ totalTokens,
1887
+ iterations: iteration,
1888
+ }, { agentId });
1889
+ return { runId, content: '', totalTokens, iterations: iteration, status: 'stopped' };
1890
+ }
1891
+
1892
+ const runMeta = engine.activeRuns.get(runId);
1893
+ if (runMeta?.terminalInterim) {
1894
+ lastContent = '';
1895
+ }
1896
+ if (runMeta?.widgetSnapshotSaved && !lastContent) {
1897
+ lastContent = 'Widget snapshot updated.';
1898
+ }
1899
+ const messagingSent = runMeta?.messagingSent || false;
1900
+ const lastToolWasMessaging = runMeta?.lastToolName === 'send_message' || runMeta?.lastToolName === 'make_call';
1901
+
1902
+ // Hermes _handle_max_iterations: if the run exhausted its step budget without a
1903
+ // judged completion, the model's last text is usually a mid-thought fragment
1904
+ // ("let me inline everything:"). Do one tool-less wrap-up call so the user gets a
1905
+ // real final answer instead of that fragment.
1906
+ const budgetExhaustedWithoutCompletion = triggerSource === 'messaging'
1907
+ && !directAnswerEligible
1908
+ && !messagingSent
1909
+ && !runMeta?.terminalInterim
1910
+ && iteration >= maxIterations;
1911
+ if (budgetExhaustedWithoutCompletion) {
1912
+ console.warn(`[Run ${shortenRunId(runId)}] max_iteration_wrapup model=${model} iteration=${iteration}/${maxIterations}`);
1913
+ try {
1914
+ const wrapResponse = await withModelCallTimeout(
1915
+ provider.chat(
1916
+ sanitizeConversationMessages([
1917
+ ...messages,
1918
+ { role: 'system', content: buildMaxIterationWrapupPrompt(options?.source || null) },
1919
+ ]),
1920
+ [],
1921
+ { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
1922
+ ),
1923
+ options,
1924
+ 'Max-iteration wrap-up',
1925
+ );
1926
+ totalTokens += wrapResponse.usage?.totalTokens || 0;
1927
+ const wrapText = sanitizeModelOutput(wrapResponse.content || '', { model });
1928
+ // On budget exhaustion the model's last text is an untrustworthy mid-thought
1929
+ // fragment. Replace it with the wrap-up answer, or a clean deterministic
1930
+ // fallback if the wrap-up came back empty — never deliver the fragment.
1931
+ const usableWrap = normalizeOutgoingMessage(wrapText, options?.source || null);
1932
+ lastContent = usableWrap
1933
+ ? wrapText
1934
+ : buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
1935
+ messages.push({ role: 'assistant', content: lastContent });
1936
+ if (conversationId) {
1937
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
1938
+ .run(conversationId, 'assistant', lastContent, usableWrap ? (wrapResponse.usage?.totalTokens || 0) : 0);
1939
+ }
1940
+ engine.recordRunEvent(userId, runId, 'max_iteration_wrapup_delivered', {
1941
+ iteration, maxIterations, stepIndex, source: usableWrap ? 'model' : 'deterministic',
1942
+ }, { agentId });
1943
+ } catch (wrapErr) {
1944
+ console.warn(`[Run ${shortenRunId(runId)}] max_iteration_wrapup failed: ${summarizeForLog(wrapErr?.message || wrapErr, 180)}`);
1945
+ lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
1946
+ messages.push({ role: 'assistant', content: lastContent });
1947
+ }
1948
+ }
1949
+
1950
+ if (triggerSource === 'messaging' && !normalizeOutgoingMessage(lastContent, options?.source || null) && !messagingSent) {
1951
+ // Simplified blank reply recovery: one model call with direct instruction,
1952
+ // then fall back to a deterministic message. No multi-attempt LLM loop.
1953
+ console.warn(`[Run ${shortenRunId(runId)}] blank_reply_recovery model=${model}`);
1954
+ let recoveredTokens = 0;
1955
+ try {
1956
+ const recoveryResponse = await withModelCallTimeout(
1957
+ provider.chat(
1958
+ sanitizeConversationMessages([
1959
+ ...messages,
1960
+ {
1961
+ role: 'system',
1962
+ content: buildBlankMessagingReplyPrompt(1, options?.source || null)
1963
+ }
1964
+ ]),
1965
+ [],
1966
+ {
1967
+ model,
1968
+ reasoningEffort: engine.getReasoningEffort(providerName, options)
1969
+ }
1970
+ ),
1971
+ options,
1972
+ 'Blank messaging reply recovery',
1973
+ );
1974
+ recoveredTokens = recoveryResponse.usage?.totalTokens || 0;
1975
+ lastContent = sanitizeModelOutput(recoveryResponse.content || '', { model });
1976
+ } catch (recoverErr) {
1977
+ console.warn(`[Run ${shortenRunId(runId)}] blank_reply_recovery failed: ${summarizeForLog(recoverErr?.message || recoverErr, 180)}`);
1978
+ }
1979
+ totalTokens += recoveredTokens;
1980
+ // The loop has already exited, so we cannot keep working: deliver the model's
1981
+ // own wrap-up (it summarizes what it tried / where it got blocked from the run
1982
+ // evidence) instead of second-guessing it into a generic blob. Only fall back to
1983
+ // a deterministic message when the model returned nothing usable.
1984
+ const recoveredVisible = Boolean(normalizeOutgoingMessage(lastContent, options?.source || null));
1985
+ if (!recoveredVisible) {
1986
+ lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
1987
+ }
1988
+ if (normalizeOutgoingMessage(lastContent, options?.source || null)) {
1989
+ engine.recordRunEvent(userId, runId, 'blank_reply_recovery_delivered', {
1990
+ source: recoveredVisible ? 'model' : 'deterministic',
1991
+ stepIndex,
1992
+ failedStepCount,
1993
+ }, { agentId });
1994
+ messages.push({ role: 'assistant', content: lastContent });
1995
+ if (conversationId) {
1996
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
1997
+ .run(conversationId, 'assistant', lastContent, recoveredVisible ? recoveredTokens : 0);
1998
+ }
1999
+ }
2000
+ }
2001
+
2002
+ if (
2003
+ !normalizeOutgoingMessage(lastContent, options?.source || null)
2004
+ && !messagingSent
2005
+ && runMeta?.widgetSnapshotSaved !== true
2006
+ ) {
2007
+ const explicitNoResponse = (
2008
+ runMeta?.noResponse === true
2009
+ || options.deliveryState?.noResponse === true
2010
+ );
2011
+ if (
2012
+ (triggerSource === 'schedule' || triggerSource === 'tasks')
2013
+ && !explicitNoResponse
2014
+ ) {
2015
+ throw new Error(
2016
+ 'Background run ended without producing a result or an explicit no-response decision.',
2017
+ );
2018
+ }
2019
+ if (iteration >= maxIterations) {
2020
+ engine.recordRunEvent(userId, runId, 'loop_budget_exhausted', {
2021
+ maxIterations,
2022
+ stepIndex,
2023
+ failedStepCount,
2024
+ }, { agentId });
2025
+ if (triggerSource === 'messaging') {
2026
+ lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
2027
+ messages.push({ role: 'assistant', content: lastContent });
2028
+ if (conversationId) {
2029
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
2030
+ .run(conversationId, 'assistant', lastContent, 0);
2031
+ }
2032
+ } else {
2033
+ throw new Error(`Iteration budget exhausted before judged completion after ${maxIterations} iterations.`);
2034
+ }
2035
+ }
2036
+ if (stepIndex > 0 && !lastToolWasMessaging && iteration < maxIterations) {
2037
+ if (triggerSource === 'messaging') {
2038
+ lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
2039
+ messages.push({ role: 'assistant', content: lastContent });
2040
+ if (conversationId) {
2041
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
2042
+ .run(conversationId, 'assistant', lastContent, 0);
2043
+ }
2044
+ } else {
2045
+ throw new Error('Run ended without an explicit completion or blocker reply.');
2046
+ }
2047
+ }
2048
+ }
2049
+
2050
+ const sentMessageText = joinSentMessages(runMeta?.sentMessages);
2051
+ const normalizedLastContent = normalizeOutgoingMessage(lastContent, options?.source || null);
2052
+ let finalResponseText = messagingSent
2053
+ ? (sentMessageText || (normalizedLastContent ? lastContent.trim() : ''))
2054
+ : (normalizedLastContent ? lastContent.trim() : sentMessageText);
2055
+ const lastFinalDeliveryMessage = normalizeOutgoingMessage(
2056
+ runMeta?.lastSentMessage
2057
+ || (Array.isArray(runMeta?.sentMessages) ? runMeta.sentMessages[runMeta.sentMessages.length - 1] : '')
2058
+ || '',
2059
+ options?.source || null
2060
+ );
2061
+
2062
+ if (
2063
+ options.skipVerifier !== true
2064
+ && shouldRunVerifier({
2065
+ analysis,
2066
+ toolExecutions,
2067
+ finalReply: finalResponseText,
2068
+ })) {
2069
+ const verificationResult = await runWithModelFallback('final verification', () => engine.verifyFinalResponse({
2070
+ provider,
2071
+ providerName,
2072
+ model,
2073
+ messages,
2074
+ analysis,
2075
+ tools,
2076
+ toolExecutions,
2077
+ finalReply: finalResponseText,
2078
+ options: { ...options, runId, userId, agentId },
2079
+ }));
2080
+ totalTokens += verificationResult.usage || 0;
2081
+ verification = verificationResult.verification;
2082
+ if (verification.final_reply) {
2083
+ finalResponseText = verification.final_reply;
2084
+ lastContent = verification.final_reply;
2085
+ }
2086
+
2087
+ stepIndex += 1;
2088
+ const verificationStepId = uuidv4();
2089
+ db.prepare(`INSERT INTO agent_steps
2090
+ (id, run_id, step_index, type, description, status, result, started_at, completed_at)
2091
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`)
2092
+ .run(
2093
+ verificationStepId,
2094
+ runId,
2095
+ stepIndex,
2096
+ 'verification',
2097
+ 'Evidence verification',
2098
+ verification.status === 'verified' ? 'completed' : 'failed',
2099
+ JSON.stringify(verification).slice(0, 20000)
2100
+ );
2101
+ engine.persistRunMetadata(runId, {
2102
+ verification,
2103
+ evidenceSources: verificationResult.evidenceSources,
2104
+ });
2105
+ engine.emit(userId, 'run:verification', {
2106
+ runId,
2107
+ ...verification,
2108
+ evidenceSources: verificationResult.evidenceSources,
2109
+ });
2110
+ }
2111
+
2112
+ if (deliverableWorkflow && deliverablePlan) {
2113
+ engine.recordRunEvent(userId, runId, 'deliverable_validation_started', {
2114
+ type: deliverableWorkflow.selection.type,
2115
+ artifactCount: deliverableArtifacts.length,
2116
+ }, { agentId });
2117
+ const validationResult = await validateDeliverableExecution({
2118
+ workflow: deliverableWorkflow.workflow,
2119
+ request: deliverableWorkflow.request,
2120
+ plan: deliverablePlan,
2121
+ finalReply: finalResponseText,
2122
+ artifacts: deliverableArtifacts,
2123
+ toolExecutions,
2124
+ runId,
2125
+ });
2126
+ deliverableValidation = validationResult.validation;
2127
+ engine.persistRunMetadata(runId, {
2128
+ deliverable: validationResult.result,
2129
+ });
2130
+ if (deliverableValidation.status !== 'passed') {
2131
+ engine.recordRunEvent(userId, runId, 'deliverable_validation_failed', {
2132
+ type: deliverableWorkflow.selection.type,
2133
+ errors: deliverableValidation.errors,
2134
+ warnings: deliverableValidation.warnings,
2135
+ }, { agentId });
2136
+ throw new DeliverableValidationError(
2137
+ deliverableValidation.summary || `Deliverable validation failed for ${deliverableWorkflow.selection.type}.`,
2138
+ {
2139
+ validation: deliverableValidation,
2140
+ result: validationResult.result,
2141
+ },
2142
+ );
2143
+ }
2144
+ await engine.persistDeliverableMemory(userId, runId, agentId, validationResult.result);
2145
+ engine.recordRunEvent(userId, runId, 'deliverable_completed', {
2146
+ type: deliverableWorkflow.selection.type,
2147
+ artifactCount: validationResult.result.artifacts.length,
2148
+ summary: validationResult.result.summary,
2149
+ }, { agentId });
2150
+ }
2151
+
2152
+ if (conversationId) {
2153
+ db.prepare('UPDATE conversations SET total_tokens = total_tokens + ?, updated_at = datetime(\'now\') WHERE id = ?')
2154
+ .run(totalTokens, conversationId);
2155
+ if (options.skipConversationMaintenance !== true) {
2156
+ refreshConversationSummary(conversationId, provider, model, historyWindow).catch((err) => {
2157
+ console.error('[AI] Conversation summary refresh failed:', err.message);
2158
+ });
2159
+ }
2160
+ }
2161
+
2162
+ await engine.persistPromptMetrics(runId, {
2163
+ ...promptMetrics,
2164
+ finalTotalTokens: totalTokens
2165
+ });
2166
+
2167
+ await engine.persistRunContext(userId, {
2168
+ triggerSource,
2169
+ runTitle,
2170
+ userMessage,
2171
+ lastContent: finalResponseText,
2172
+ stepIndex,
2173
+ skipPersistence: options.skipRunContextPersistence === true
2174
+ });
2175
+
2176
+ // Fallback: if this was a messaging-triggered run and no final delivery
2177
+ // was already sent in this run, auto-send the final assistant text.
2178
+ // Interim progress updates do not suppress this final delivery.
2179
+ if (triggerSource === 'messaging' && options.source && options.chatId) {
2180
+ if (engine.shouldSendMessagingFinalFallback(runMeta, lastContent || '', options.source) && !lastFinalDeliveryMessage) {
2181
+ await engine.deliverMessagingFinalFallback({
2182
+ runId,
2183
+ userId,
2184
+ agentId,
2185
+ platform: options.source,
2186
+ chatId: options.chatId,
2187
+ content: lastContent || '',
2188
+ });
2189
+ }
2190
+ }
2191
+
2192
+ db.prepare('UPDATE agent_runs SET status = ?, total_tokens = ?, final_response = ?, updated_at = datetime(\'now\'), completed_at = datetime(\'now\') WHERE id = ?')
2193
+ .run('completed', totalTokens, finalResponseText || null, runId);
2194
+
2195
+ if (conversationId && options.skipConversationMaintenance !== true) {
2196
+ await engine.refreshConversationState({
2197
+ conversationId,
2198
+ runId,
2199
+ provider,
2200
+ providerName,
2201
+ model,
2202
+ finalReply: finalResponseText,
2203
+ analysis,
2204
+ verification,
2205
+ historyWindow,
2206
+ options: { ...options, userId, agentId },
2207
+ }).catch((err) => {
2208
+ console.error('[AI] Conversation working state refresh failed:', err.message);
2209
+ });
2210
+ }
2211
+
2212
+ console.info(
2213
+ `[Run ${shortenRunId(runId)}] completed trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens} durationMs=${runMeta?.startedAt ? Date.now() - runMeta.startedAt : 0} finalResponse=${finalResponseText ? 'yes' : 'no'} sentMessages=${runMeta?.sentMessages?.length || 0}`
2214
+ );
2215
+ engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2216
+ engine.stopMessagingProgressSupervisor(runId);
2217
+ engine.activeRuns.delete(runId);
2218
+ engine.emit(userId, 'run:complete', {
2219
+ runId,
2220
+ content: lastContent,
2221
+ totalTokens,
2222
+ iterations: iteration,
2223
+ triggerSource,
2224
+ executionMode: analysis?.mode || 'execute',
2225
+ verificationStatus: verification?.status || 'skipped',
2226
+ });
2227
+ engine.recordRunEvent(userId, runId, 'run_completed', {
2228
+ contentPreview: String(finalResponseText || lastContent || '').slice(0, 240),
2229
+ totalTokens,
2230
+ iterations: iteration,
2231
+ triggerSource,
2232
+ executionMode: analysis?.mode || 'execute',
2233
+ verificationStatus: verification?.status || 'skipped',
2234
+ }, { agentId });
2235
+ // ── on_loop_end hook ──
2236
+ // Fire-and-forget: plugins can use this for self-improvement, memory
2237
+ // consolidation, analytics, or other post-run housekeeping.
2238
+ if (globalHooks.has('on_loop_end')) {
2239
+ globalHooks.run('on_loop_end', {
2240
+ userId, runId, agentId, status: 'completed',
2241
+ iterations: iteration, totalTokens,
2242
+ taskAnalysis: analysis,
2243
+ finalContent: finalResponseText,
2244
+ }).catch(() => {});
2245
+ }
2246
+ if (engine.learningManager) {
2247
+ try {
2248
+ const learningSteps = db.prepare(
2249
+ `SELECT tool_name, tool_input, result, status
2250
+ FROM agent_steps WHERE run_id = ? ORDER BY step_index ASC`
2251
+ ).all(runId);
2252
+ engine.learningManager.maybeCaptureDraft({
2253
+ userId,
2254
+ agentId,
2255
+ runId,
2256
+ triggerSource,
2257
+ triggerType,
2258
+ task: userMessage,
2259
+ title: runTitle,
2260
+ finalContent: finalResponseText || lastContent,
2261
+ steps: learningSteps,
2262
+ });
2263
+ } catch (learningError) {
2264
+ console.warn('[Engine] Skill reflection failed:', learningError.message);
2265
+ }
2266
+ }
2267
+
2268
+ return { runId, content: lastContent, totalTokens, iterations: iteration, status: 'completed' };
2269
+ } catch (err) {
2270
+ if (engine.isRunStopped(runId)) {
2271
+ db.prepare('UPDATE agent_runs SET status = ?, updated_at = datetime(\'now\'), completed_at = datetime(\'now\') WHERE id = ?')
2272
+ .run('stopped', runId);
2273
+ console.warn(
2274
+ `[Run ${shortenRunId(runId)}] stopped trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
2275
+ );
2276
+ engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2277
+ engine.stopMessagingProgressSupervisor(runId);
2278
+ engine.activeRuns.delete(runId);
2279
+ engine.emit(userId, 'run:stopped', { runId, triggerSource });
2280
+ engine.recordRunEvent(userId, runId, 'run_stopped', {
2281
+ triggerSource,
2282
+ totalTokens,
2283
+ iterations: iteration,
2284
+ }, { agentId });
2285
+ return { runId, content: '', totalTokens, iterations: iteration, status: 'stopped' };
2286
+ }
2287
+
2288
+ const runMeta = engine.activeRuns.get(runId);
2289
+
2290
+ const deliverableFailureResponse = err?.deliverableResult?.summary
2291
+ || err?.deliverableValidation?.summary
2292
+ || '';
2293
+ let messagingFailureContent = '';
2294
+ let sendSucceeded = false;
2295
+ if (shouldSendMessagingErrorFallback({ triggerSource, options, runMeta })) {
2296
+ const manager = engine.messagingManager;
2297
+ if (manager) {
2298
+ const failureScenario = buildMessagingFailureScenario({
2299
+ err,
2300
+ failedStepCount,
2301
+ stepIndex,
2302
+ toolExecutions,
2303
+ });
2304
+ try {
2305
+ const failedMessage = sanitizeConversationMessages([
2306
+ ...messages,
2307
+ {
2308
+ role: 'system',
2309
+ content: `The run encountered a runtime error and cannot continue reliably. Use the actual run scenario below to explain the blocker naturally.\n\nScenario:\n${failureScenario || 'No additional scenario details were captured.'}\n\nDo not call tools. Write exactly one short user message. Do not ask the user to resend or restate the same task. Only ask the user for something if a specific external input, permission, or configuration change is actually required. Do not promise future work unless it will happen automatically before this reply is sent.\n\n${buildPlatformFormattingGuide(options?.source || null)}`
2310
+ }
2311
+ ]);
2312
+ const modelReply = await withModelCallTimeout(
2313
+ provider.chat(failedMessage, [], {
2314
+ model,
2315
+ reasoningEffort: engine.getReasoningEffort(providerName, options)
2316
+ }),
2317
+ options,
2318
+ 'Messaging failure reply',
2319
+ );
2320
+ const drafted = sanitizeModelOutput(modelReply.content || '', { model });
2321
+ if (normalizeOutgoingMessage(drafted, options?.source || null)) {
2322
+ messagingFailureContent = drafted.trim();
2323
+ }
2324
+ } catch {
2325
+ // Fall back to deterministic text below.
2326
+ }
2327
+
2328
+ if (!messagingFailureContent) {
2329
+ messagingFailureContent = buildDeterministicMessagingErrorReply({
2330
+ err,
2331
+ failedStepCount,
2332
+ stepIndex,
2333
+ toolExecutions,
2334
+ });
2335
+ }
2336
+
2337
+ try {
2338
+ const deliveryResult = await manager.sendMessage(
2339
+ userId,
2340
+ options.source,
2341
+ options.chatId,
2342
+ messagingFailureContent,
2343
+ { runId, agentId },
2344
+ );
2345
+ requireSuccessfulMessagingDelivery(deliveryResult, 'Messaging failure delivery');
2346
+ sendSucceeded = true;
2347
+ if (runMeta) {
2348
+ runMeta.lastSentMessage = messagingFailureContent;
2349
+ if (!Array.isArray(runMeta.sentMessages)) runMeta.sentMessages = [];
2350
+ runMeta.sentMessages.push(messagingFailureContent);
2351
+ }
2352
+ engine.markRunFinalDelivery(runId, messagingFailureContent);
2353
+ } catch (sendErr) {
2354
+ console.error('[Engine] Messaging error fallback failed:', sendErr.message);
2355
+ messagingFailureContent = '';
2356
+ }
2357
+ }
2358
+ }
2359
+
2360
+ db.prepare('UPDATE agent_runs SET status = ?, error = ?, final_response = ?, updated_at = datetime(\'now\') WHERE id = ?')
2361
+ .run(
2362
+ 'failed',
2363
+ err.message,
2364
+ sendSucceeded
2365
+ ? (messagingFailureContent || null)
2366
+ : (deliverableFailureResponse || null),
2367
+ runId,
2368
+ );
2369
+ console.error(
2370
+ `[Run ${shortenRunId(runId)}] failed trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens} error=${summarizeForLog(err.message, 180)}`
2371
+ );
2372
+
2373
+ engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2374
+ engine.stopMessagingProgressSupervisor(runId);
2375
+ engine.activeRuns.delete(runId);
2376
+ engine.emit(userId, 'run:error', { runId, error: err.message });
2377
+ engine.recordRunEvent(userId, runId, 'run_failed', {
2378
+ error: err.message,
2379
+ totalTokens,
2380
+ iterations: iteration,
2381
+ deliverableType: deliverableWorkflow?.selection?.type || null,
2382
+ }, { agentId });
2383
+
2384
+ if (messagingFailureContent) {
2385
+ return {
2386
+ runId,
2387
+ content: messagingFailureContent,
2388
+ totalTokens,
2389
+ iterations: iteration,
2390
+ status: 'failed'
2391
+ };
2392
+ }
2393
+
2394
+ throw err;
2395
+ }
2396
+ }
2397
+
2398
+ module.exports = { runConversation };