neoagent 2.5.2-beta.8 → 3.0.0

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