neoagent 2.5.2-beta.0 → 2.5.2-beta.10

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