neoagent 2.5.2-beta.2 → 2.5.2-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/server/public/.last_build_id +1 -1
  3. package/server/public/flutter_bootstrap.js +1 -1
  4. package/server/public/main.dart.js +4 -4
  5. package/server/services/ai/deliverables/artifact_helpers.js +109 -16
  6. package/server/services/ai/deliverables/selector.js +17 -0
  7. package/server/services/ai/engine.js +2 -4312
  8. package/server/services/ai/loop/agent_engine_core.js +1590 -0
  9. package/server/services/ai/loop/blank_recovery.js +37 -0
  10. package/server/services/ai/loop/callbacks.js +151 -0
  11. package/server/services/ai/loop/completion_judge.js +252 -0
  12. package/server/services/ai/loop/conversation_loop.js +2322 -0
  13. package/server/services/ai/loop/delivery_state.js +27 -0
  14. package/server/services/ai/loop/error_recovery.js +38 -0
  15. package/server/services/ai/loop/iteration_budget.js +24 -0
  16. package/server/services/ai/loop/messaging_delivery.js +250 -0
  17. package/server/services/ai/loop/model_io.js +258 -0
  18. package/server/services/ai/loop/progress_classification.js +177 -0
  19. package/server/services/ai/loop/progress_monitor.js +81 -0
  20. package/server/services/ai/loop/run_state.js +356 -0
  21. package/server/services/ai/loop/tool_dispatch.js +230 -0
  22. package/server/services/ai/loopPolicy.js +3 -3
  23. package/server/services/ai/repetitionGuard.js +47 -2
  24. package/server/services/ai/systemPrompt.js +5 -0
  25. package/server/services/ai/taskAnalysis.js +5 -0
  26. package/server/services/ai/toolEvidence.js +8 -1
  27. package/server/services/ai/tools.js +82 -11
  28. package/server/services/integrations/github/repos.js +39 -29
  29. package/server/services/messaging/manager.js +7 -0
  30. package/server/services/runtime/backends/local-vm.js +7 -7
@@ -1,4313 +1,3 @@
1
- const { v4: uuidv4 } = require('uuid');
2
- const fs = require('fs');
3
- const db = require('../../db/database');
4
- const { compact } = require('./compaction');
5
- const { compactPayloadForModel } = require('./preModelCompaction');
6
- const {
7
- getConversationContext,
8
- buildSummaryCarrier,
9
- refreshConversationSummary,
10
- sanitizeConversationMessages
11
- } = require('./history');
12
- const { ensureDefaultAiSettings, getAiSettings } = require('./settings');
13
- const {
14
- activateTools,
15
- buildToolCatalog,
16
- selectInitialTools,
17
- selectToolsForTask,
18
- } = require('./toolSelector');
19
- const { compactToolResult } = require('./toolResult');
20
- const { salvageTextToolCalls } = require('./toolCallSalvage');
21
- const { sanitizeModelOutput } = require('./outputSanitizer');
22
- const {
23
- buildAnalysisPrompt,
24
- buildExecutionGuidance,
25
- buildPlanPrompt,
26
- buildVerifierPrompt,
27
- isDirectAnswerEligibleAnalysis,
28
- normalizeExecutionPlan,
29
- normalizeTaskAnalysis,
30
- normalizeVerificationResult,
31
- parseJsonObject,
32
- promoteAnalysisMode,
33
- shouldRunVerifier,
34
- } = require('./taskAnalysis');
35
- const { getCapabilityHealth, summarizeCapabilityHealth } = require('./capabilityHealth');
36
- const {
37
- buildPlatformFormattingGuide,
38
- splitOutgoingMessageForPlatform,
39
- } = require('../messaging/formatting_guides');
40
- const {
41
- buildInterimMetadata,
42
- buildInterimSignature,
43
- normalizeInterimKind,
44
- } = require('./interim');
45
- const { recordRunEvent } = require('./runEvents');
46
- const {
47
- buildDeliverableWorkflowGuidance,
48
- DeliverableValidationError,
49
- extractArtifactsFromResult,
50
- getDeliverableWorkflow,
51
- selectDeliverableWorkflow,
52
- validateDeliverableExecution,
53
- } = require('./deliverables');
54
- const { buildLoopPolicy, resolveToolResultLimits } = require('./loopPolicy');
55
- const { globalHooks } = require('./hooks');
56
- const { withProviderRetry, isTransientError } = require('./providerRetry');
57
- const { normalizeCompletionConfidence, shouldAcceptTaskComplete } = require('./completion');
58
- const { normalizeUsage, recordModelUsage } = require('./usage');
59
- const { enforceRateLimits } = require('./rate_limits');
60
- const { ToolRepetitionGuard } = require('./repetitionGuard');
61
- const { shortenRunId, summarizeForLog, parseMaybeJson } = require('./logFormat');
62
- const {
63
- normalizeOutgoingMessage,
64
- clampRunContext,
65
- joinSentMessages,
66
- normalizeInterimText,
67
- buildBlankMessagingReplyPrompt,
68
- buildDeterministicMessagingFallback,
69
- buildMessagingFailureScenario,
70
- buildDeterministicMessagingErrorReply,
71
- buildModelFailureLoopPrompt,
72
- } = require('./messagingFallback');
73
- const {
74
- classifyToolExecution,
75
- summarizeToolExecutions,
76
- summarizeAvailableTools,
77
- inferToolFailureMessage,
78
- buildAutonomousRecoveryContext,
79
- } = require('./toolEvidence');
80
- const {
81
- buildMemoryConsolidationInstructions,
82
- normalizeMemoryCandidates,
83
- } = require('../memory/consolidation');
84
- const {
85
- buildPlannerPrompt,
86
- buildRerankerPrompt,
87
- mergeRetrievalResults,
88
- normalizeRerankResult,
89
- normalizeRetrievalPlan,
90
- shouldEnhanceRetrieval,
91
- } = require('../memory/retrieval_reasoning');
1
+ 'use strict';
92
2
 
93
- function generateTitle(task) {
94
- if (!task || typeof task !== 'string') return 'Untitled';
95
- const msgMatch = task.match(/received a (?:message|media|image|video|file|audio)[^:]*:\s*(.+)/is);
96
- if (msgMatch) {
97
- const body = msgMatch[1].replace(/\n[\s\S]*/s, '').trim();
98
- return body.slice(0, 90) || 'Incoming message';
99
- }
100
- const cleaned = task.replace(/^\[.*?\]\s*/i, '').replace(/^(system|task|prompt)[:\s]+/i, '').trim();
101
- return cleaned.slice(0, 90);
102
- }
103
-
104
- function buildInitialRunMetadata(options = {}) {
105
- const metadata = {};
106
- if (options.taskId != null && String(options.taskId).trim()) {
107
- metadata.taskId = options.taskId;
108
- }
109
- if (options.widgetId != null && String(options.widgetId).trim()) {
110
- metadata.widgetId = options.widgetId;
111
- }
112
- return metadata;
113
- }
114
-
115
- const MESSAGING_PROGRESS_FIRST_UPDATE_MS = 60 * 1000;
116
- const MESSAGING_PROGRESS_REPEAT_MS = 90 * 1000;
117
- const MESSAGING_PROGRESS_STALL_MS = 240 * 1000;
118
- const MESSAGING_PROGRESS_TICK_MS = 15 * 1000;
119
-
120
- function isoNow() {
121
- return new Date().toISOString();
122
- }
123
-
124
- function timestampMs(value, fallback = 0) {
125
- const resolved = value ? Date.parse(value) : NaN;
126
- return Number.isFinite(resolved) ? resolved : fallback;
127
- }
128
-
129
- function formatElapsedDuration(durationMs) {
130
- const totalSeconds = Math.max(1, Math.floor(Number(durationMs || 0) / 1000));
131
- if (totalSeconds < 60) return `${totalSeconds}s`;
132
- const minutes = Math.floor(totalSeconds / 60);
133
- const seconds = totalSeconds % 60;
134
- if (seconds === 0) return `${minutes}m`;
135
- return `${minutes}m ${seconds}s`;
136
- }
137
-
138
- function cloneInterimHistory(history = []) {
139
- if (!Array.isArray(history)) return [];
140
- return history.map((item) => ({
141
- content: String(item?.content || '').trim(),
142
- kind: normalizeInterimKind(item?.kind),
143
- expectsReply: item?.expectsReply === true,
144
- deferFollowUp: item?.deferFollowUp === true,
145
- createdAt: item?.createdAt || isoNow(),
146
- })).filter((item) => item.content);
147
- }
148
-
149
- function createInterimSignatureSet(history = [], platform = null) {
150
- const signatures = new Set();
151
- for (const item of cloneInterimHistory(history)) {
152
- signatures.add(buildInterimSignature({
153
- content: item.content,
154
- kind: item.kind,
155
- expectsReply: item.expectsReply === true,
156
- platform,
157
- }));
158
- }
159
- return signatures;
160
- }
161
-
162
- function buildInitialProgressLedger({ startedAt, retryState = {} } = {}) {
163
- const startedAtIso = startedAt || isoNow();
164
- const interimHistory = cloneInterimHistory(retryState.interimHistory);
165
- const lastInterimMessage = interimHistory[interimHistory.length - 1]?.content || '';
166
- const lastVisibleAt = retryState.lastUserVisibleUpdateAt || (lastInterimMessage ? startedAtIso : null);
167
- return {
168
- currentStep: retryState.currentStep || null,
169
- currentTool: retryState.currentTool || null,
170
- currentStepStartedAt: retryState.currentStepStartedAt || null,
171
- lastVerifiedProgressAt: retryState.lastVerifiedProgressAt || startedAtIso,
172
- lastUserVisibleUpdateAt: lastVisibleAt,
173
- lastFinalDeliveryAt: retryState.lastFinalDeliveryAt || null,
174
- heartbeatCount: Number(retryState.heartbeatCount || 0),
175
- stallNotifiedAt: retryState.stallNotifiedAt || null,
176
- progressState: retryState.progressState || 'active',
177
- currentPhase: retryState.currentPhase || 'idle',
178
- };
179
- }
180
-
181
- function hasVisibleInterimActivity(runMeta) {
182
- return Boolean(
183
- runMeta?.lastInterimMessage
184
- || (Array.isArray(runMeta?.interimMessages) && runMeta.interimMessages.length > 0)
185
- || Number(runMeta?.progressLedger?.heartbeatCount || 0) > 0
186
- );
187
- }
188
-
189
- function planningDepthForForceMode(forceMode) {
190
- return forceMode === 'plan_execute' ? 'deep' : 'light';
191
- }
192
-
193
- function buildSkipTaskAnalysisResult(forceMode) {
194
- return {
195
- mode: forceMode === 'plan_execute' ? 'plan_execute' : 'execute',
196
- reply_mode: 'task',
197
- freshness_risk: 'none',
198
- verification_need: 'none',
199
- planning_depth: planningDepthForForceMode(forceMode),
200
- confidence: 0.5,
201
- suggested_tools: [],
202
- needs_subagents: false,
203
- draft_reply: '',
204
- goal: 'Complete the user request accurately.',
205
- success_criteria: [],
206
- complexity: forceMode === 'plan_execute' ? 'complex' : 'standard',
207
- autonomy_level: forceMode === 'plan_execute' ? 'high' : 'normal',
208
- progress_update_policy: 'optional',
209
- parallel_work: false,
210
- completion_confidence_required: forceMode === 'plan_execute' ? 'high' : 'medium',
211
- };
212
- }
213
-
214
- function buildAnalyzeTaskFallback(forceMode, userMessage = '') {
215
- return {
216
- mode: forceMode || 'execute',
217
- verification_need: 'light',
218
- planning_depth: planningDepthForForceMode(forceMode),
219
- goal: userMessage ? String(userMessage).trim().slice(0, 300) : '',
220
- complexity: forceMode === 'plan_execute' ? 'complex' : 'standard',
221
- autonomy_level: forceMode === 'plan_execute' ? 'high' : 'normal',
222
- progress_update_policy: 'optional',
223
- parallel_work: false,
224
- completion_confidence_required: forceMode === 'plan_execute' ? 'high' : 'medium',
225
- };
226
- }
227
-
228
- function applyForcedAnalysisMode(analysis, forceMode) {
229
- if (!analysis || typeof analysis !== 'object') return analysis;
230
- if (forceMode !== 'plan_execute') return analysis;
231
- return {
232
- ...analysis,
233
- mode: 'plan_execute',
234
- planning_depth: 'deep',
235
- complexity: 'complex',
236
- autonomy_level: 'high',
237
- completion_confidence_required: analysis.completion_confidence_required || 'high',
238
- };
239
- }
240
-
241
- function buildAutonomyPolicyFromAnalysis(analysis = {}) {
242
- return {
243
- complexity: analysis.complexity || 'standard',
244
- autonomy_level: analysis.autonomy_level || 'normal',
245
- progress_update_policy: analysis.progress_update_policy || 'optional',
246
- parallel_work: analysis.parallel_work === true,
247
- completion_confidence_required: analysis.completion_confidence_required || 'medium',
248
- };
249
- }
250
-
251
- async function getProviderForUser(userId, task = '', isSubagent = false, modelOverride = null, providerConfig = {}) {
252
- const { getSupportedModels, createProviderInstance } = require('./models');
253
- const agentId = providerConfig.agentId || null;
254
- const aiSettings = getAiSettings(userId, agentId);
255
- const models = await getSupportedModels(userId, agentId);
256
-
257
- let enabledIds = Array.isArray(aiSettings.enabled_models) ? aiSettings.enabled_models : [];
258
- const defaultChatModel = aiSettings.default_chat_model || 'auto';
259
- const defaultSubagentModel = aiSettings.default_subagent_model || 'auto';
260
- const smarterSelection = aiSettings.smarter_model_selector !== false && aiSettings.smarter_model_selector !== 'false';
261
-
262
- const knownModelIds = new Set(models.map((m) => m.id));
263
- const selectableModels = models.filter((m) => m.available !== false);
264
-
265
- enabledIds = Array.isArray(enabledIds)
266
- ? enabledIds
267
- .map((id) => String(id))
268
- .filter((id) => knownModelIds.has(id))
269
- : [];
270
-
271
- let availableModels = selectableModels.filter((m) => enabledIds.includes(m.id));
272
- if (availableModels.length === 0) {
273
- enabledIds = selectableModels.map((m) => m.id);
274
- availableModels = [...selectableModels];
275
- }
276
-
277
- const fallbackModel = availableModels.length > 0 ? availableModels[0] : selectableModels[0];
278
-
279
- if (!fallbackModel) {
280
- throw new Error('No AI providers are currently available. Open Settings and configure at least one provider.');
281
- }
282
-
283
- let selectedModelDef = fallbackModel;
284
- const userSelectedDefault = isSubagent ? defaultSubagentModel : defaultChatModel;
285
-
286
- if (modelOverride && typeof modelOverride === 'string') {
287
- const requested = models.find((m) => m.id === modelOverride.trim());
288
- if (requested && requested.available !== false && enabledIds.includes(requested.id)) {
289
- selectedModelDef = requested;
290
- return {
291
- provider: createProviderInstance(selectedModelDef.provider, userId, providerConfig),
292
- model: selectedModelDef.id,
293
- providerName: selectedModelDef.provider
294
- };
295
- }
296
- }
297
-
298
- if (userSelectedDefault && userSelectedDefault !== 'auto') {
299
- selectedModelDef = models.find((m) => m.id === userSelectedDefault) || fallbackModel;
300
- } else {
301
- const selectionHint = providerConfig.selectionHint && typeof providerConfig.selectionHint === 'object'
302
- ? providerConfig.selectionHint
303
- : {};
304
- const preferredPurpose = String(selectionHint.purpose || '').trim().toLowerCase();
305
- const highAutonomy = selectionHint.autonomyLevel === 'high' || selectionHint.complexity === 'complex';
306
- const requiredConfidence = String(selectionHint.requiredConfidence || '').trim().toLowerCase();
307
- const costMode = String(selectionHint.costMode || aiSettings.cost_mode || 'balanced_auto').trim().toLowerCase();
308
- const requestedPurpose = ['planning', 'coding', 'general', 'fast'].includes(preferredPurpose)
309
- ? preferredPurpose
310
- : '';
311
- const priceRank = { free: 0, cheap: 1, medium: 2, expensive: 3 };
312
- const chooseForPurpose = (purpose) => {
313
- const candidates = availableModels.filter((model) => model.purpose === purpose);
314
- if (candidates.length === 0) return null;
315
- if (['economy', 'cost_saver', 'lowest_cost'].includes(costMode)) {
316
- return [...candidates].sort((left, right) => (
317
- (priceRank[left.priceTier] ?? 99) - (priceRank[right.priceTier] ?? 99)
318
- ))[0];
319
- }
320
- if (['quality', 'highest_quality'].includes(costMode) || requiredConfidence === 'high') {
321
- return candidates.find((model) => model.priceTier !== 'free' && model.priceTier !== 'cheap') || candidates[0];
322
- }
323
- return candidates[0];
324
- };
325
-
326
- if (smarterSelection && requestedPurpose) {
327
- selectedModelDef = chooseForPurpose(requestedPurpose) || fallbackModel;
328
- } else if (smarterSelection && highAutonomy) {
329
- selectedModelDef = chooseForPurpose('planning') || chooseForPurpose('general') || fallbackModel;
330
- } else if (isSubagent) {
331
- selectedModelDef = chooseForPurpose('fast') || fallbackModel;
332
- } else {
333
- selectedModelDef = chooseForPurpose('general') || fallbackModel;
334
- }
335
- }
336
-
337
- return {
338
- provider: createProviderInstance(selectedModelDef.provider, userId, providerConfig),
339
- model: selectedModelDef.id,
340
- providerName: selectedModelDef.provider
341
- };
342
- }
343
-
344
- async function getFailureFallbackModelId(userId, agentId, currentModelId, preferredFallbackId = null, failureError = null) {
345
- const { getSupportedModels } = require('./models');
346
- const aiSettings = getAiSettings(userId, agentId);
347
- const models = await getSupportedModels(userId, agentId);
348
- const availableModels = models.filter((model) => model.available !== false);
349
- const knownIds = new Set(availableModels.map((model) => model.id));
350
- const enabledIds = Array.isArray(aiSettings.enabled_models)
351
- ? aiSettings.enabled_models.map((id) => String(id)).filter((id) => knownIds.has(id))
352
- : [];
353
- const pool = enabledIds.length > 0
354
- ? availableModels.filter((model) => enabledIds.includes(model.id))
355
- : availableModels;
356
- const currentModel = pool.find((model) => model.id === currentModelId)
357
- || availableModels.find((model) => model.id === currentModelId)
358
- || null;
359
-
360
- // When the failure is a provider-level rate limit, the preferred fallback is
361
- // likely on the same provider and will hit the same limit. Skip it and prefer
362
- // a fallback from a different provider instead.
363
- const isProviderRateLimit = /429|rate.?limit|free-models-per/i.test(String(failureError?.message || ''));
364
-
365
- if (preferredFallbackId && preferredFallbackId !== currentModelId && !isProviderRateLimit) {
366
- const preferred = pool.find((model) => model.id === preferredFallbackId)
367
- || availableModels.find((model) => model.id === preferredFallbackId);
368
- if (preferred) return preferred.id;
369
- }
370
-
371
- if (currentModel?.provider) {
372
- const differentProvider = pool.find((model) => model.id !== currentModelId && model.provider !== currentModel.provider)
373
- || availableModels.find((model) => model.id !== currentModelId && model.provider !== currentModel.provider);
374
- if (differentProvider) return differentProvider.id;
375
- }
376
-
377
- // If no different-provider model exists, still try the preferred fallback
378
- // even on rate limits (it's better than nothing).
379
- if (preferredFallbackId && preferredFallbackId !== currentModelId) {
380
- const preferred = pool.find((model) => model.id === preferredFallbackId)
381
- || availableModels.find((model) => model.id === preferredFallbackId);
382
- if (preferred) return preferred.id;
383
- }
384
-
385
- const differentModel = pool.find((model) => model.id !== currentModelId)
386
- || availableModels.find((model) => model.id !== currentModelId);
387
- return differentModel?.id || null;
388
- }
389
-
390
- function estimateTokenValue(value) {
391
- if (!value) return 0;
392
- if (typeof value === 'string') return Math.ceil(value.length / 4);
393
- return Math.ceil(JSON.stringify(value).length / 4);
394
- }
395
-
396
- class AgentEngine {
397
- constructor(io, services = {}) {
398
- this.io = io;
399
- this.activeRuns = new Map();
400
- this.subagents = new Map();
401
- this.app = services.app || null;
402
- this.browserController = services.browserController || null;
403
- this.androidController = services.androidController || null;
404
- this.runtimeManager = services.runtimeManager || null;
405
- this.workspaceManager = services.workspaceManager || null;
406
- this.messagingManager = services.messagingManager || null;
407
- this.mcpManager = services.mcpManager || services.mcpClient || null;
408
- this.skillRunner = services.skillRunner || null;
409
- this.taskRuntime = services.taskRuntime || null;
410
- this.memoryManager = services.memoryManager || null;
411
- this.voiceRuntimeManager = services.voiceRuntimeManager || null;
412
- }
413
-
414
- async buildSystemPrompt(userId, context = {}) {
415
- const { buildSystemPromptSections } = require('./systemPrompt');
416
- const { MemoryManager } = require('../memory/manager');
417
- const memoryManager = this.memoryManager || new MemoryManager();
418
- const promptSections = await buildSystemPromptSections(userId, context, memoryManager);
419
- const skillRunner = context.skillRunner || this.skillRunner || null;
420
- const skillsPrompt = skillRunner?.getSkillsForPrompt?.({
421
- maxTotalChars: 9000,
422
- maxDescriptionChars: 180,
423
- maxTriggerChars: 100,
424
- }) || '';
425
- return {
426
- stable: [promptSections.stable, skillsPrompt].filter(Boolean).join('\n\n'),
427
- dynamic: promptSections.dynamic,
428
- };
429
- }
430
-
431
- async buildMemoryRecall({
432
- memoryManager,
433
- userId,
434
- agentId,
435
- query,
436
- provider,
437
- providerName,
438
- model,
439
- runId,
440
- stepId = null,
441
- options = {},
442
- returnDetails = false,
443
- }) {
444
- const initial = await memoryManager.recallMemory(userId, query, 12, { agentId });
445
-
446
- const pendingChunks = memoryManager.getPendingExtractionChunks?.(userId, agentId, 5) || [];
447
- if (pendingChunks.length) {
448
- this.extractPendingChunks(pendingChunks, {
449
- userId,
450
- agentId,
451
- provider,
452
- providerName,
453
- model,
454
- memoryManager,
455
- }).catch((err) => console.warn('[Memory] Background chunk extraction failed:', err.message));
456
- }
457
-
458
- const decision = shouldEnhanceRetrieval(initial);
459
- if (!decision.enhance) {
460
- const message = await memoryManager.buildRecallMessage(userId, query, {
461
- agentId,
462
- recalled: initial.slice(0, 5),
463
- });
464
- return returnDetails
465
- ? { message, results: initial.slice(0, 12), enhanced: false, reason: decision.reason }
466
- : message;
467
- }
468
-
469
- const stats = memoryManager.getMemoryStats?.(userId, { agentId })
470
- || { total: initial.length };
471
- if (!Number(stats.total || 0)) {
472
- return returnDetails
473
- ? { message: null, results: [], enhanced: false, reason: 'empty_memory' }
474
- : null;
475
- }
476
-
477
- const startedAt = Date.now();
478
- let plan = null;
479
- let merged = initial;
480
- let reranked = initial;
481
- try {
482
- const planned = await this.requestStructuredJson({
483
- provider,
484
- providerName,
485
- model,
486
- messages: [],
487
- prompt: buildPlannerPrompt(query, initial, new Date().toISOString()),
488
- maxTokens: 650,
489
- normalize: (raw) => normalizeRetrievalPlan(raw, query),
490
- fallback: normalizeRetrievalPlan({}, query),
491
- reasoningEffort: this.getReasoningEffort(providerName, options),
492
- telemetry: { runId, stepId, userId, agentId },
493
- phase: 'memory_retrieval_plan',
494
- });
495
- plan = planned.value;
496
- const resultSets = [initial];
497
- for (const variant of plan.queryVariants) {
498
- if (variant === query && initial.length) continue;
499
- resultSets.push(await memoryManager.recallMemory(userId, variant, 20, {
500
- agentId,
501
- validAt: plan.validAt,
502
- includeHistory: plan.temporalMode === 'historical',
503
- }));
504
- }
505
- merged = mergeRetrievalResults(resultSets, 30);
506
- if (merged.length > 1) {
507
- const rerankResponse = await this.requestStructuredJson({
508
- provider,
509
- providerName,
510
- model,
511
- messages: [],
512
- prompt: buildRerankerPrompt(query, plan, merged.slice(0, 24)),
513
- maxTokens: 1200,
514
- normalize: (raw) => normalizeRerankResult(raw, merged),
515
- fallback: merged,
516
- reasoningEffort: this.getReasoningEffort(providerName, options),
517
- telemetry: { runId, stepId, userId, agentId },
518
- phase: 'memory_retrieval_rerank',
519
- });
520
- reranked = rerankResponse.value;
521
- } else {
522
- reranked = merged;
523
- }
524
- } catch (error) {
525
- console.warn('[Memory] Retrieval enhancement failed:', error.message);
526
- plan = null;
527
- merged = initial;
528
- reranked = initial;
529
- }
530
-
531
- memoryManager.recordRetrievalEnhancement?.(userId, {
532
- query,
533
- reason: decision.reason,
534
- plan,
535
- initialCount: initial.length,
536
- mergedCount: merged.length,
537
- resultIds: reranked.slice(0, 5).map((result) => result.id),
538
- latencyMs: Date.now() - startedAt,
539
- }, { agentId, runId });
540
-
541
- const message = await memoryManager.buildRecallMessage(userId, query, {
542
- agentId,
543
- recalled: reranked.slice(0, 5),
544
- });
545
- return returnDetails
546
- ? {
547
- message,
548
- results: reranked.slice(0, 12),
549
- enhanced: plan !== null,
550
- reason: decision.reason,
551
- plan,
552
- }
553
- : message;
554
- }
555
-
556
- async extractPendingChunks(chunks, {
557
- userId,
558
- agentId,
559
- provider,
560
- providerName,
561
- model,
562
- memoryManager,
563
- }) {
564
- const ids = chunks.map((c) => c.id);
565
- memoryManager.markChunksExtracted?.(ids, { success: true });
566
-
567
- const consolidationSchema = JSON.stringify({
568
- memory_candidates: [{
569
- memory: 'Concise standalone fact.',
570
- subject: 'Canonical entity or person.',
571
- predicate: 'Normalized relationship or attribute.',
572
- object: 'Current atomic value.',
573
- relation: 'new | updates | extends | derives',
574
- category: 'identity | preferences | projects | contacts | events | tasks | episodic | assistant_self',
575
- confidence: 0.8,
576
- importance: 5,
577
- is_static: false,
578
- valid_from: null,
579
- valid_to: null,
580
- forget_after: null,
581
- evidence: 'Short source-grounded quote.',
582
- }],
583
- }, null, 2);
584
-
585
- for (const chunk of chunks) {
586
- try {
587
- const result = await this.requestStructuredJson({
588
- provider,
589
- providerName,
590
- model,
591
- messages: [],
592
- prompt: [
593
- 'Return JSON only. Extract durable memory facts from the document chunk below.',
594
- buildMemoryConsolidationInstructions(new Date().toISOString()),
595
- `Source type: ${chunk.sourceType || 'document'}`,
596
- chunk.title ? `Document title: ${chunk.title}` : '',
597
- `Content:\n${String(chunk.content || '').slice(0, 2400)}`,
598
- `Schema:\n${consolidationSchema}`,
599
- ].filter(Boolean).join('\n\n'),
600
- maxTokens: 800,
601
- normalize: (raw) => normalizeMemoryCandidates(raw?.memory_candidates || []),
602
- fallback: [],
603
- phase: 'document_extraction',
604
- });
605
-
606
- const candidates = Array.isArray(result.value) ? result.value : [];
607
- if (candidates.length) {
608
- await memoryManager.consolidateMemoryCandidates(userId, candidates, {
609
- agentId,
610
- metadata: {
611
- trustLevel: 'external_source',
612
- sourceChunkMemoryId: chunk.id,
613
- },
614
- });
615
- }
616
- } catch (err) {
617
- memoryManager.markChunksExtracted?.([chunk.id], { success: false });
618
- console.warn('[Memory] Document chunk extraction failed:', err.message);
619
- }
620
- }
621
- }
622
-
623
- persistRunMetadata(runId, patch = {}) {
624
- if (!runId || !patch || typeof patch !== 'object') return;
625
- const existing = db.prepare('SELECT metadata_json FROM agent_runs WHERE id = ?').get(runId);
626
- const current = parseMaybeJson(existing?.metadata_json, {}) || {};
627
- const next = { ...current, ...patch };
628
- db.prepare('UPDATE agent_runs SET metadata_json = ? WHERE id = ?')
629
- .run(JSON.stringify(next), runId);
630
- }
631
-
632
- buildProgressLedgerSnapshot(runMeta) {
633
- if (!runMeta?.progressLedger) return null;
634
- return {
635
- currentStep: runMeta.progressLedger.currentStep || null,
636
- currentTool: runMeta.progressLedger.currentTool || null,
637
- currentStepStartedAt: runMeta.progressLedger.currentStepStartedAt || null,
638
- lastVerifiedProgressAt: runMeta.progressLedger.lastVerifiedProgressAt || null,
639
- lastUserVisibleUpdateAt: runMeta.progressLedger.lastUserVisibleUpdateAt || null,
640
- lastFinalDeliveryAt: runMeta.progressLedger.lastFinalDeliveryAt || null,
641
- heartbeatCount: Number(runMeta.progressLedger.heartbeatCount || 0),
642
- stallNotifiedAt: runMeta.progressLedger.stallNotifiedAt || null,
643
- progressState: runMeta.progressLedger.progressState || 'active',
644
- currentPhase: runMeta.progressLedger.currentPhase || 'idle',
645
- };
646
- }
647
-
648
- persistProgressLedger(runId) {
649
- const runMeta = this.getRunMeta(runId);
650
- if (!runMeta?.progressLedger) return;
651
- this.persistRunMetadata(runId, {
652
- progressLedger: this.buildProgressLedgerSnapshot(runMeta),
653
- });
654
- }
655
-
656
- updateRunProgress(runId, patch = {}, options = {}) {
657
- const runMeta = this.getRunMeta(runId);
658
- if (!runMeta) return null;
659
- if (!runMeta.progressLedger) {
660
- runMeta.progressLedger = buildInitialProgressLedger({
661
- startedAt: runMeta.startedAtIso || isoNow(),
662
- });
663
- }
664
-
665
- const previousState = runMeta.progressLedger.progressState || 'active';
666
- runMeta.progressLedger = {
667
- ...runMeta.progressLedger,
668
- ...patch,
669
- };
670
-
671
- if (options.verified === true) {
672
- runMeta.progressLedger.lastVerifiedProgressAt = options.timestamp || isoNow();
673
- runMeta.progressLedger.progressState = 'active';
674
- runMeta.progressLedger.stallNotifiedAt = null;
675
- this.recordRunEvent(runMeta.userId, runId, 'progress_verified', {
676
- phase: runMeta.progressLedger.currentPhase || 'idle',
677
- currentStep: runMeta.progressLedger.currentStep || null,
678
- currentTool: runMeta.progressLedger.currentTool || null,
679
- }, { agentId: runMeta.agentId, stepId: options.stepId || null });
680
- if (previousState === 'stalled') {
681
- this.recordRunEvent(runMeta.userId, runId, 'progress_resumed', {
682
- phase: runMeta.progressLedger.currentPhase || 'idle',
683
- currentStep: runMeta.progressLedger.currentStep || null,
684
- currentTool: runMeta.progressLedger.currentTool || null,
685
- }, { agentId: runMeta.agentId, stepId: options.stepId || null });
686
- }
687
- }
688
-
689
- if (options.persist !== false) {
690
- this.persistProgressLedger(runId);
691
- }
692
- return runMeta.progressLedger;
693
- }
694
-
695
- markRunVisibleProgress(runId, timestamp = isoNow()) {
696
- const runMeta = this.getRunMeta(runId);
697
- if (!runMeta) return null;
698
- const ledger = this.updateRunProgress(runId, {
699
- lastUserVisibleUpdateAt: timestamp,
700
- }, {
701
- persist: false,
702
- });
703
- this.persistProgressLedger(runId);
704
- return ledger;
705
- }
706
-
707
- markRunFinalDelivery(runId, content = '', timestamp = isoNow()) {
708
- const runMeta = this.getRunMeta(runId);
709
- if (!runMeta) return null;
710
- runMeta.finalDeliverySent = true;
711
- runMeta.lastSentMessage = String(content || '').trim() || runMeta.lastSentMessage || '';
712
- const ledger = this.updateRunProgress(runId, {
713
- lastUserVisibleUpdateAt: timestamp,
714
- lastFinalDeliveryAt: timestamp,
715
- progressState: 'complete',
716
- }, {
717
- persist: false,
718
- });
719
- this.persistProgressLedger(runId);
720
- return ledger;
721
- }
722
-
723
- recordRunEvent(userId, runId, eventType, payload = {}, options = {}) {
724
- try {
725
- return recordRunEvent({
726
- runId,
727
- userId,
728
- agentId: options.agentId || null,
729
- eventType,
730
- requestId: options.requestId || null,
731
- stepId: options.stepId || null,
732
- payload,
733
- });
734
- } catch {
735
- return null;
736
- }
737
- }
738
-
739
- async persistDeliverableMemory(userId, runId, agentId, deliverableResult) {
740
- if (!this.memoryManager?.saveMemory || !deliverableResult?.summary) return;
741
- try {
742
- await this.memoryManager.saveMemory(
743
- userId,
744
- deliverableResult.summary,
745
- 'tasks',
746
- deliverableResult.validation?.status === 'passed' ? 7 : 5,
747
- {
748
- agentId,
749
- sourceRef: {
750
- sourceType: 'deliverable_run',
751
- sourceId: runId,
752
- sourceLabel: deliverableResult.type || 'deliverable',
753
- },
754
- metadata: {
755
- deliverableType: deliverableResult.type,
756
- status: deliverableResult.status,
757
- artifactCount: Array.isArray(deliverableResult.artifacts)
758
- ? deliverableResult.artifacts.length
759
- : 0,
760
- artifacts: Array.isArray(deliverableResult.artifacts)
761
- ? deliverableResult.artifacts.slice(0, 6)
762
- : [],
763
- },
764
- },
765
- );
766
- } catch (error) {
767
- console.error('[Engine] Failed to persist deliverable memory:', error?.message || error);
768
- }
769
- }
770
-
771
- async publishInterimUpdate({
772
- userId,
773
- runId,
774
- agentId = null,
775
- triggerSource = 'web',
776
- conversationId = null,
777
- platform = null,
778
- chatId = null,
779
- content,
780
- kind,
781
- expectsReply = false,
782
- deferFollowUp = false,
783
- } = {}) {
784
- const runMeta = this.getRunMeta(runId);
785
- if (!runMeta || runMeta.aborted) {
786
- return { sent: false, skipped: true, reason: 'Run is no longer active.' };
787
- }
788
-
789
- const normalizedKind = normalizeInterimKind(kind);
790
- const normalizedContent = normalizeInterimText(
791
- content,
792
- triggerSource === 'messaging' ? platform : null
793
- );
794
- if (!normalizedContent || normalizedContent.toUpperCase() === '[NO RESPONSE]') {
795
- return { sent: false, skipped: true, reason: 'Interim content must be non-empty.' };
796
- }
797
-
798
- const signature = buildInterimSignature({
799
- content: normalizedContent,
800
- kind: normalizedKind,
801
- expectsReply,
802
- platform: triggerSource === 'messaging' ? platform : 'web',
803
- });
804
- if (runMeta.interimSignatures?.has(signature)) {
805
- return { sent: false, skipped: true, duplicate: true };
806
- }
807
-
808
- const metadata = buildInterimMetadata({
809
- kind: normalizedKind,
810
- expectsReply,
811
- });
812
- if (deferFollowUp === true) {
813
- metadata.defer_follow_up = true;
814
- }
815
- const createdAt = new Date().toISOString();
816
-
817
- if (triggerSource === 'messaging') {
818
- if (!platform || !chatId || !this.messagingManager) {
819
- return { sent: false, skipped: true, reason: 'Messaging context is not available.' };
820
- }
821
- await this.messagingManager.sendMessage(userId, platform, chatId, normalizedContent, {
822
- agentId,
823
- runId,
824
- persistConversation: true,
825
- metadata,
826
- deliveryKind: 'interim',
827
- });
828
- } else if (triggerSource === 'voice_live') {
829
- const voiceSessionId = runMeta.voiceSessionId || null;
830
- const manager = this.voiceRuntimeManager || this.app?.locals?.voiceRuntimeManager || null;
831
- if (!voiceSessionId || !manager || typeof manager.publishInterimUpdate !== 'function') {
832
- return { sent: false, skipped: true, reason: 'Voice session context is not available.' };
833
- }
834
- await manager.publishInterimUpdate({
835
- sessionId: voiceSessionId,
836
- content: normalizedContent,
837
- kind: normalizedKind,
838
- expectsReply,
839
- deferFollowUp,
840
- });
841
- } else {
842
- db.prepare(
843
- 'INSERT INTO conversation_history (user_id, agent_id, agent_run_id, role, content, metadata) VALUES (?, ?, ?, ?, ?, ?)'
844
- ).run(userId, agentId, runId, 'assistant', normalizedContent, JSON.stringify(metadata));
845
-
846
- if (conversationId) {
847
- db.prepare('INSERT INTO conversation_messages (conversation_id, role, content) VALUES (?, ?, ?)')
848
- .run(conversationId, 'assistant', normalizedContent);
849
- }
850
- }
851
-
852
- if (!runMeta.interimSignatures) runMeta.interimSignatures = new Set();
853
- if (!Array.isArray(runMeta.interimMessages)) runMeta.interimMessages = [];
854
- runMeta.interimSignatures.add(signature);
855
- runMeta.interimMessages.push({
856
- content: normalizedContent,
857
- kind: normalizedKind,
858
- expectsReply: expectsReply === true,
859
- deferFollowUp: deferFollowUp === true,
860
- createdAt,
861
- });
862
- runMeta.lastInterimMessage = normalizedContent;
863
- this.markRunVisibleProgress(runId, createdAt);
864
-
865
- this.emit(userId, 'run:assistant_interim', {
866
- runId,
867
- content: normalizedContent,
868
- kind: normalizedKind,
869
- expectsReply: expectsReply === true,
870
- deferFollowUp: deferFollowUp === true,
871
- triggerSource,
872
- platform: triggerSource === 'messaging' ? platform : 'web',
873
- });
874
-
875
- const terminalInterim = expectsReply === true;
876
- if (terminalInterim) {
877
- runMeta.terminalInterim = {
878
- kind: normalizedKind,
879
- content: normalizedContent,
880
- createdAt,
881
- };
882
- }
883
- this.persistRunMetadata(runId, {
884
- latestInterim: {
885
- kind: normalizedKind,
886
- expectsReply: expectsReply === true,
887
- deferFollowUp: deferFollowUp === true,
888
- content: normalizedContent,
889
- createdAt,
890
- },
891
- progressLedger: this.buildProgressLedgerSnapshot(runMeta),
892
- terminalInterim: terminalInterim
893
- ? { kind: normalizedKind, content: normalizedContent, createdAt }
894
- : null,
895
- });
896
-
897
- return {
898
- sent: true,
899
- kind: normalizedKind,
900
- expectsReply: expectsReply === true,
901
- deferFollowUp: deferFollowUp === true,
902
- content: normalizedContent,
903
- terminal: terminalInterim,
904
- };
905
- }
906
-
907
- async requestStructuredJson({
908
- provider,
909
- providerName,
910
- model,
911
- messages,
912
- prompt,
913
- maxTokens = 1400,
914
- normalize,
915
- fallback = {},
916
- reasoningEffort,
917
- telemetry = null,
918
- phase = 'structured',
919
- }) {
920
- const startedAt = Date.now();
921
- const response = await withProviderRetry(
922
- () => provider.chat(
923
- sanitizeConversationMessages([
924
- ...messages,
925
- { role: 'system', content: prompt },
926
- ]),
927
- [],
928
- {
929
- model,
930
- maxTokens,
931
- reasoningEffort: reasoningEffort || this.getReasoningEffort(providerName, {}),
932
- }
933
- ),
934
- { label: `Engine ${model} (structured)` }
935
- );
936
- if (telemetry?.runId && telemetry?.userId) {
937
- recordModelUsage({
938
- runId: telemetry.runId,
939
- stepId: telemetry.stepId || null,
940
- userId: telemetry.userId,
941
- agentId: telemetry.agentId || null,
942
- provider: providerName,
943
- model,
944
- phase,
945
- usage: response.usage,
946
- latencyMs: Date.now() - startedAt,
947
- });
948
- }
949
-
950
- const parsed = parseJsonObject(response.content || '');
951
- const normalizedUsage = normalizeUsage(response.usage);
952
- return {
953
- value: normalize(parsed || {}, fallback),
954
- raw: response.content || '',
955
- usage: normalizedUsage?.totalTokens || 0,
956
- };
957
- }
958
-
959
- async requestModelResponse({
960
- provider,
961
- providerName,
962
- model,
963
- messages,
964
- tools,
965
- options,
966
- runId,
967
- iteration,
968
- }) {
969
- const startedAt = Date.now();
970
- const requestMessages = sanitizeConversationMessages(messages);
971
- const callOptions = {
972
- model,
973
- reasoningEffort: this.getReasoningEffort(providerName, options),
974
- };
975
-
976
- const attemptModelCall = async () => {
977
- let response = null;
978
- let streamContent = '';
979
-
980
- if (options.stream !== false) {
981
- let emittedContent = false;
982
- const stream = provider.stream(requestMessages, tools, callOptions);
983
- try {
984
- for await (const chunk of stream) {
985
- if (chunk.type === 'content') {
986
- emittedContent = true;
987
- streamContent += chunk.content;
988
- this.emit(options.userId, 'run:stream', {
989
- runId,
990
- content: sanitizeModelOutput(streamContent, { model }),
991
- iteration,
992
- });
993
- }
994
- if (chunk.type === 'done') {
995
- response = chunk;
996
- }
997
- if (chunk.type === 'tool_calls') {
998
- response = {
999
- content: chunk.content || streamContent,
1000
- toolCalls: chunk.toolCalls,
1001
- providerContentBlocks: chunk.providerContentBlocks || null,
1002
- finishReason: 'tool_calls',
1003
- usage: chunk.usage || null,
1004
- };
1005
- }
1006
- }
1007
- } catch (err) {
1008
- // Once tokens have streamed to the client a retry would duplicate
1009
- // output, so only the pre-stream window is safe to replay.
1010
- if (emittedContent) err.__providerRetryUnsafe = true;
1011
- throw err;
1012
- }
1013
- } else {
1014
- response = await provider.chat(requestMessages, tools, callOptions);
1015
- }
1016
-
1017
- return { response, streamContent };
1018
- };
1019
-
1020
- const { response, streamContent } = await withProviderRetry(attemptModelCall, {
1021
- ...(options.retry || {}),
1022
- label: `Engine ${model}`,
1023
- isRetryable: (err) => !err?.__providerRetryUnsafe && isTransientError(err),
1024
- onRetry: ({ attempt, delayMs }) => {
1025
- this.emit(options.userId, 'run:interim', {
1026
- runId,
1027
- message: `Model service busy; retrying (attempt ${attempt}) in ${Math.max(1, Math.round(delayMs / 1000))}s.`,
1028
- phase: 'recovering',
1029
- });
1030
- },
1031
- });
1032
-
1033
- const resolvedResponse = response || {
1034
- content: streamContent,
1035
- toolCalls: [],
1036
- finishReason: 'stop',
1037
- usage: null,
1038
- };
1039
- const hasContent = Boolean(String(resolvedResponse.content || streamContent || '').trim());
1040
- const hasToolCalls = Array.isArray(resolvedResponse.toolCalls) && resolvedResponse.toolCalls.length > 0;
1041
- if (!hasContent && !hasToolCalls) {
1042
- const error = new Error(`Model ${model} returned an empty response.`);
1043
- error.code = 'MODEL_EMPTY_RESPONSE';
1044
- throw error;
1045
- }
1046
- if (options.runId && options.userId) {
1047
- recordModelUsage({
1048
- runId: options.runId,
1049
- stepId: options.stepId || null,
1050
- userId: options.userId,
1051
- agentId: options.agentId || null,
1052
- provider: providerName,
1053
- model,
1054
- phase: options.phase || 'model_turn',
1055
- usage: resolvedResponse.usage,
1056
- latencyMs: Date.now() - startedAt,
1057
- metadata: { iteration },
1058
- });
1059
- }
1060
-
1061
- return {
1062
- response: resolvedResponse,
1063
- responseModel: model,
1064
- streamContent,
1065
- };
1066
- }
1067
-
1068
- async analyzeTask({
1069
- provider,
1070
- providerName,
1071
- model,
1072
- messages,
1073
- tools,
1074
- capabilityHealth,
1075
- forceMode,
1076
- userMessage,
1077
- options,
1078
- }) {
1079
- const summary = summarizeCapabilityHealth(capabilityHealth);
1080
- const response = await this.requestStructuredJson({
1081
- provider,
1082
- providerName,
1083
- model,
1084
- messages,
1085
- prompt: buildAnalysisPrompt({
1086
- capabilityHealth: summary,
1087
- tools,
1088
- forceMode,
1089
- }),
1090
- maxTokens: 1100,
1091
- normalize: normalizeTaskAnalysis,
1092
- fallback: buildAnalyzeTaskFallback(forceMode, userMessage),
1093
- reasoningEffort: this.getReasoningEffort(providerName, options),
1094
- telemetry: options,
1095
- phase: 'task_analysis',
1096
- });
1097
-
1098
- return {
1099
- analysis: response.value,
1100
- raw: response.raw,
1101
- usage: response.usage,
1102
- capabilitySummary: summary,
1103
- };
1104
- }
1105
-
1106
- async createExecutionPlan({
1107
- provider,
1108
- providerName,
1109
- model,
1110
- messages,
1111
- analysis,
1112
- capabilitySummary,
1113
- options,
1114
- }) {
1115
- const response = await this.requestStructuredJson({
1116
- provider,
1117
- providerName,
1118
- model,
1119
- messages,
1120
- prompt: buildPlanPrompt(analysis, capabilitySummary),
1121
- maxTokens: 1400,
1122
- normalize: normalizeExecutionPlan,
1123
- fallback: {
1124
- success_criteria: analysis.success_criteria,
1125
- },
1126
- reasoningEffort: this.getReasoningEffort(providerName, options),
1127
- telemetry: options,
1128
- phase: 'execution_plan',
1129
- });
1130
-
1131
- return {
1132
- plan: response.value,
1133
- raw: response.raw,
1134
- usage: response.usage,
1135
- };
1136
- }
1137
-
1138
- async decideLoopState({
1139
- provider,
1140
- providerName,
1141
- model,
1142
- messages,
1143
- tools,
1144
- analysis,
1145
- plan,
1146
- toolExecutions,
1147
- lastReply,
1148
- triggerSource,
1149
- messagingSent,
1150
- iteration,
1151
- maxIterations,
1152
- options,
1153
- fallbackStatus,
1154
- }) {
1155
- const successCriteria = Array.isArray(plan?.success_criteria)
1156
- ? plan.success_criteria
1157
- .map((item) => String(item || '').trim())
1158
- .filter(Boolean)
1159
- .slice(0, 6)
1160
- : [];
1161
-
1162
- const response = await this.requestStructuredJson({
1163
- provider,
1164
- providerName,
1165
- model,
1166
- messages,
1167
- prompt: [
1168
- 'Return JSON only.',
1169
- 'Decide whether this run should continue autonomously or stop now.',
1170
- 'Schema: {"status":"continue|complete|blocked","reason":"short concrete reason"}',
1171
- 'Rules:',
1172
- '- Use "continue" whenever any safe next step remains in this same run.',
1173
- '- Use "complete" only when the requested outcome is actually achieved or a truthful final user reply is already ready now.',
1174
- '- Use "blocked" only when a specific external dependency outside this run is required.',
1175
- '- If the latest draft asks the user for a missing required value, confirmation, or choice needed to proceed, use "blocked" so the run waits instead of repeating the same ask.',
1176
- '- A progress update is not complete.',
1177
- '- A single failed tool attempt is not blocked if another safe retry, verification step, or alternative path remains.',
1178
- '- A tool-specific API error, timeout, rate limit, or missing result inside this run is usually "continue", not "blocked", if any other available tool could still make progress.',
1179
- '- If completion_confidence_required is high and the latest draft depends on unverified assumptions, use "continue" so the run can gather evidence, inspect state, or narrow the reply.',
1180
- triggerSource === 'messaging' && messagingSent
1181
- ? '- A reply was already delivered to the user via send_message. Use "complete" unless there is concrete remaining work (e.g., a tool call you still need to make) before the task is truly done. Do not send follow-up elaborations or re-introductions.'
1182
- : triggerSource === 'messaging'
1183
- ? '- For messaging, do not stop on a partial status message. Continue unless the task is actually complete or externally blocked. If you already asked for missing user input, choose "blocked" and wait.'
1184
- : '- Do not stop just because you wrote a status update. Continue unless the task is actually complete or externally blocked.',
1185
- analysis?.goal ? `Goal: ${analysis.goal}` : '',
1186
- `Autonomy contract: complexity=${analysis?.complexity || 'standard'}; autonomy_level=${analysis?.autonomy_level || 'normal'}; progress_update_policy=${analysis?.progress_update_policy || 'optional'}; parallel_work=${analysis?.parallel_work === true}; completion_confidence_required=${analysis?.completion_confidence_required || 'medium'}.`,
1187
- successCriteria.length > 0 ? `Success criteria:\n${successCriteria.map((item, index) => `${index + 1}. ${item}`).join('\n')}` : '',
1188
- `Current iteration: ${iteration} of ${maxIterations}.`,
1189
- `Available tools in this run: ${summarizeAvailableTools(tools) || 'none'}`,
1190
- `Recent tool evidence:\n${summarizeToolExecutions(toolExecutions, 8) || 'none'}`,
1191
- `Latest draft reply:\n${normalizeOutgoingMessage(lastReply) || '(empty)'}`,
1192
- ].filter(Boolean).join('\n'),
1193
- maxTokens: 320,
1194
- normalize: (raw) => {
1195
- const allowed = new Set(['continue', 'complete', 'blocked']);
1196
- const requestedStatus = String(raw.status || '').trim().toLowerCase();
1197
- return {
1198
- status: allowed.has(requestedStatus) ? requestedStatus : fallbackStatus,
1199
- reason: String(raw.reason || '').trim().slice(0, 400),
1200
- };
1201
- },
1202
- fallback: { status: fallbackStatus },
1203
- reasoningEffort: this.getReasoningEffort(providerName, options),
1204
- telemetry: options,
1205
- phase: 'loop_decision',
1206
- });
1207
-
1208
- return {
1209
- decision: response.value,
1210
- usage: response.usage,
1211
- };
1212
- }
1213
-
1214
- async verifyFinalResponse({
1215
- provider,
1216
- providerName,
1217
- model,
1218
- messages,
1219
- analysis,
1220
- tools,
1221
- toolExecutions,
1222
- finalReply,
1223
- options,
1224
- }) {
1225
- const evidenceSources = [...new Set(
1226
- toolExecutions
1227
- .map((item) => item.evidenceSource)
1228
- .filter(Boolean)
1229
- )];
1230
- const response = await this.requestStructuredJson({
1231
- provider,
1232
- providerName,
1233
- model,
1234
- messages,
1235
- prompt: buildVerifierPrompt({
1236
- analysis,
1237
- tools,
1238
- toolExecutionSummary: summarizeToolExecutions(toolExecutions),
1239
- evidenceSources,
1240
- finalReply,
1241
- }),
1242
- maxTokens: 1200,
1243
- normalize: (raw) => normalizeVerificationResult(raw, finalReply),
1244
- fallback: {
1245
- status: analysis.freshness_risk === 'none' ? 'verified' : 'insufficient_evidence',
1246
- final_reply: finalReply,
1247
- },
1248
- reasoningEffort: this.getReasoningEffort(providerName, options),
1249
- telemetry: options,
1250
- phase: 'verification',
1251
- });
1252
-
1253
- return {
1254
- verification: response.value,
1255
- raw: response.raw,
1256
- usage: response.usage,
1257
- evidenceSources,
1258
- };
1259
- }
1260
-
1261
- async refreshConversationState({
1262
- conversationId,
1263
- runId,
1264
- provider,
1265
- providerName,
1266
- model,
1267
- finalReply,
1268
- analysis,
1269
- verification,
1270
- historyWindow,
1271
- options,
1272
- }) {
1273
- if (!conversationId) return null;
1274
- const { MemoryManager } = require('../memory/manager');
1275
- const memoryManager = this.memoryManager || new MemoryManager();
1276
- const context = getConversationContext(conversationId, Math.max(historyWindow, 8));
1277
- const existingState = memoryManager.getConversationState(conversationId);
1278
- const promptMessages = [
1279
- {
1280
- role: 'system',
1281
- content: [
1282
- 'Return JSON only. Distill the current thread working state. Keep it concise and concrete.',
1283
- 'Track summary, open_commitments, unresolved_questions, referenced_entities, and last_verified_facts. Do not invent facts.',
1284
- buildMemoryConsolidationInstructions(new Date().toISOString()),
1285
- 'Schema:',
1286
- JSON.stringify({
1287
- summary: '',
1288
- open_commitments: [],
1289
- unresolved_questions: [],
1290
- referenced_entities: [],
1291
- last_verified_facts: [],
1292
- memory_candidates: [{
1293
- memory: 'Concise standalone fact for future context.',
1294
- subject: 'Canonical entity or person.',
1295
- predicate: 'Normalized relationship or attribute.',
1296
- object: 'Current atomic value.',
1297
- relation: 'new | updates | extends | derives',
1298
- category: 'identity | preferences | projects | contacts | events | tasks | episodic | assistant_self',
1299
- confidence: 0.9,
1300
- importance: 7,
1301
- is_static: false,
1302
- valid_from: null,
1303
- valid_to: null,
1304
- forget_after: null,
1305
- evidence: 'Short source-grounded evidence.',
1306
- }],
1307
- }, null, 2),
1308
- ].join('\n\n')
1309
- },
1310
- {
1311
- role: 'user',
1312
- content: [
1313
- existingState?.summary ? `Existing state:\n${JSON.stringify(existingState, null, 2)}` : 'Existing state: none',
1314
- context.summary ? `Conversation summary:\n${context.summary}` : 'Conversation summary: none',
1315
- `Recent thread messages:\n${JSON.stringify(context.recentMessages.slice(-8), null, 2)}`,
1316
- `Latest final reply:\n${finalReply || '(empty)'}`,
1317
- verification?.status ? `Verification status: ${verification.status}` : '',
1318
- verification?.final_reply && verification.final_reply !== finalReply ? `Verified reply:\n${verification.final_reply}` : '',
1319
- analysis?.goal ? `Thread goal: ${analysis.goal}` : '',
1320
- ].filter(Boolean).join('\n\n')
1321
- }
1322
- ];
1323
-
1324
- const response = await provider.chat(promptMessages, [], {
1325
- model,
1326
- maxTokens: 800,
1327
- reasoningEffort: this.getReasoningEffort(providerName, options),
1328
- });
1329
- const parsed = parseJsonObject(response.content || '') || {};
1330
- const nextState = {
1331
- summary: String(parsed.summary || existingState?.summary || '').trim(),
1332
- open_commitments: Array.isArray(parsed.open_commitments) ? parsed.open_commitments.slice(0, 8).map((item) => String(item || '').trim()).filter(Boolean) : [],
1333
- unresolved_questions: Array.isArray(parsed.unresolved_questions) ? parsed.unresolved_questions.slice(0, 8).map((item) => String(item || '').trim()).filter(Boolean) : [],
1334
- referenced_entities: Array.isArray(parsed.referenced_entities) ? parsed.referenced_entities.slice(0, 12).map((item) => String(item || '').trim()).filter(Boolean) : [],
1335
- last_verified_facts: Array.isArray(parsed.last_verified_facts) ? parsed.last_verified_facts.slice(0, 10).map((item) => String(item || '').trim()).filter(Boolean) : [],
1336
- };
1337
-
1338
- if (verification?.status === 'verified' && String(finalReply || '').trim()) {
1339
- nextState.last_verified_facts = [...new Set([
1340
- ...nextState.last_verified_facts,
1341
- clampRunContext(verification.final_reply || finalReply, 280),
1342
- ])].slice(-10);
1343
- }
1344
-
1345
- memoryManager.updateConversationState(conversationId, nextState);
1346
- const memoryCandidates = normalizeMemoryCandidates(parsed.memory_candidates);
1347
- if (memoryCandidates.length) {
1348
- await memoryManager.consolidateMemoryCandidates(
1349
- options.userId,
1350
- memoryCandidates,
1351
- {
1352
- agentId: options.agentId || null,
1353
- conversationId,
1354
- runId,
1355
- },
1356
- );
1357
- const { invalidateSystemPromptCache } = require('./systemPrompt');
1358
- invalidateSystemPromptCache(options.userId, options.agentId || null);
1359
- }
1360
- return nextState;
1361
- }
1362
-
1363
- async recoverBlankMessagingReply({
1364
- userId,
1365
- runId,
1366
- messages,
1367
- provider,
1368
- model,
1369
- providerName,
1370
- options,
1371
- stepIndex,
1372
- failedStepCount,
1373
- toolExecutions = [],
1374
- tools = []
1375
- }) {
1376
- const attempts = 3;
1377
- let recoveredContent = '';
1378
- let totalTokens = 0;
1379
-
1380
- for (let attempt = 1; attempt <= attempts; attempt++) {
1381
- console.warn(
1382
- `[Run ${shortenRunId(runId)}] blank_reply_recovery attempt=${attempt} model=${model}`
1383
- );
1384
- try {
1385
- const response = await provider.chat(
1386
- sanitizeConversationMessages([
1387
- ...messages,
1388
- {
1389
- role: 'system',
1390
- content: buildBlankMessagingReplyPrompt(attempt, options?.source || null)
1391
- }
1392
- ]),
1393
- [],
1394
- {
1395
- model,
1396
- reasoningEffort: this.getReasoningEffort(providerName, options)
1397
- }
1398
- );
1399
- totalTokens += response.usage?.totalTokens || 0;
1400
- recoveredContent = sanitizeModelOutput(response.content || '', { model });
1401
- if (normalizeOutgoingMessage(recoveredContent)) {
1402
- console.info(
1403
- `[Run ${shortenRunId(runId)}] blank_reply_recovery succeeded attempt=${attempt}`
1404
- );
1405
- return { content: recoveredContent, tokens: totalTokens, recovered: true };
1406
- }
1407
- } catch (recoverErr) {
1408
- console.warn(
1409
- `[Run ${shortenRunId(runId)}] blank_reply_recovery attempt=${attempt} failed: ${summarizeForLog(recoverErr?.message || recoverErr, 180)}`
1410
- );
1411
- }
1412
- }
1413
-
1414
- const error = new Error(
1415
- buildDeterministicMessagingFallback({
1416
- failedStepCount,
1417
- stepIndex,
1418
- toolExecutions,
1419
- })
1420
- );
1421
- error.code = 'BLANK_MESSAGING_REPLY';
1422
- error.recoveryTokens = totalTokens;
1423
- throw error;
1424
- }
1425
-
1426
- getAvailableTools(app, options = {}) {
1427
- const { getAvailableTools } = require('./tools');
1428
- return getAvailableTools(app, options);
1429
- }
1430
-
1431
- async executeTool(toolName, args, context) {
1432
- const { executeTool } = require('./tools');
1433
- return executeTool(toolName, args, context, this);
1434
- }
1435
-
1436
- isReadOnlyToolCall(toolCall) {
1437
- const name = String(toolCall?.function?.name || '');
1438
- const readOnly = new Set([
1439
- 'read_file',
1440
- 'list_directory',
1441
- 'search_files',
1442
- 'code_navigate',
1443
- 'query_structured_data',
1444
- 'memory_recall',
1445
- 'memory_read',
1446
- 'session_search',
1447
- 'web_search',
1448
- 'list_tasks',
1449
- 'list_skills',
1450
- 'list_subagents',
1451
- 'recordings_list',
1452
- 'recordings_get',
1453
- 'recordings_search',
1454
- 'read_health_data',
1455
- ]);
1456
- if (name === 'http_request') {
1457
- try {
1458
- const args = JSON.parse(toolCall.function.arguments || '{}');
1459
- return String(args.method || 'GET').toUpperCase() === 'GET';
1460
- } catch {
1461
- return false;
1462
- }
1463
- }
1464
- return readOnly.has(name);
1465
- }
1466
-
1467
- async executeReadOnlyBatch(toolCalls, context = {}) {
1468
- const {
1469
- userId,
1470
- runId,
1471
- agentId,
1472
- app,
1473
- triggerType,
1474
- triggerSource,
1475
- conversationId,
1476
- startingStepIndex,
1477
- options = {},
1478
- } = context;
1479
- const prepared = [];
1480
- let nextStepIndex = startingStepIndex;
1481
- for (const toolCall of toolCalls) {
1482
- nextStepIndex += 1;
1483
- let toolArgs = {};
1484
- try { toolArgs = JSON.parse(toolCall.function.arguments || '{}'); } catch {}
1485
- const toolName = toolCall.function.name;
1486
- const repetitionGuard = this.getRunMeta(runId)?.repetitionGuard;
1487
- if (repetitionGuard?.shouldBlock(toolName, toolArgs)) {
1488
- const result = {
1489
- status: 'blocked',
1490
- reason: 'The same read-only call already returned an unchanged result twice.',
1491
- };
1492
- prepared.push({
1493
- toolCall,
1494
- toolName,
1495
- toolArgs,
1496
- stepIndex: nextStepIndex,
1497
- blocked: true,
1498
- result,
1499
- error: result.reason,
1500
- });
1501
- this.recordRunEvent(userId, runId, 'repetition_blocked', {
1502
- toolName,
1503
- toolArgs,
1504
- parallel: true,
1505
- }, { agentId });
1506
- continue;
1507
- }
1508
- if (globalHooks.has('before_tool_call')) {
1509
- const hookResult = await globalHooks.run('before_tool_call', {
1510
- toolName,
1511
- toolArgs,
1512
- runId,
1513
- userId,
1514
- agentId,
1515
- iteration: context.iteration,
1516
- });
1517
- if (hookResult.block) {
1518
- prepared.push({
1519
- toolCall,
1520
- toolName,
1521
- toolArgs,
1522
- stepIndex: nextStepIndex,
1523
- blocked: true,
1524
- result: { status: 'blocked', reason: hookResult.reason || 'Blocked by policy.', blocked_by: hookResult.blocked_by || 'policy' },
1525
- });
1526
- continue;
1527
- }
1528
- if (hookResult.toolArgs) toolArgs = hookResult.toolArgs;
1529
- }
1530
- const stepId = uuidv4();
1531
- db.prepare(
1532
- `INSERT INTO agent_steps (
1533
- id, run_id, step_index, type, description, status, tool_name, tool_input, started_at
1534
- ) VALUES (?, ?, ?, ?, ?, 'running', ?, ?, datetime('now'))`
1535
- ).run(
1536
- stepId,
1537
- runId,
1538
- nextStepIndex,
1539
- this.getStepType(toolName),
1540
- `${toolName}: ${JSON.stringify(toolArgs).slice(0, 200)}`,
1541
- toolName,
1542
- JSON.stringify(toolArgs),
1543
- );
1544
- this.emit(userId, 'run:tool_start', {
1545
- runId,
1546
- stepId,
1547
- stepIndex: nextStepIndex,
1548
- toolName,
1549
- toolArgs,
1550
- type: this.getStepType(toolName),
1551
- });
1552
- this.recordRunEvent(userId, runId, 'tool_started', {
1553
- stepIndex: nextStepIndex,
1554
- toolName,
1555
- toolArgs,
1556
- type: this.getStepType(toolName),
1557
- parallel: true,
1558
- }, { agentId, stepId });
1559
- prepared.push({ toolCall, toolName, toolArgs, stepId, stepIndex: nextStepIndex });
1560
- }
1561
- this.recordRunEvent(userId, runId, 'parallel_batch_started', {
1562
- toolNames: prepared.map((item) => item.toolName),
1563
- count: prepared.length,
1564
- }, { agentId });
1565
- const results = await Promise.all(prepared.map(async (item) => {
1566
- if (item.blocked) return item;
1567
- const startedAt = Date.now();
1568
- try {
1569
- const result = await this.executeTool(item.toolName, item.toolArgs, {
1570
- userId,
1571
- runId,
1572
- agentId,
1573
- app,
1574
- triggerType,
1575
- triggerSource,
1576
- conversationId,
1577
- source: options.source || null,
1578
- chatId: options.chatId || null,
1579
- taskId: options.taskId || null,
1580
- widgetId: options.widgetId || null,
1581
- deliveryState: options.deliveryState || null,
1582
- allowMultipleProactiveMessages: options.allowMultipleProactiveMessages === true,
1583
- allowExternalSideEffects: false,
1584
- });
1585
- const error = inferToolFailureMessage(item.toolName, result);
1586
- const status = error ? 'failed' : 'completed';
1587
- db.prepare(
1588
- `UPDATE agent_steps
1589
- SET status = ?, result = ?, error = ?, screenshot_path = ?, completed_at = datetime('now')
1590
- WHERE id = ?`
1591
- ).run(
1592
- status,
1593
- JSON.stringify(result).slice(0, 20000),
1594
- error || null,
1595
- result?.screenshotPath || null,
1596
- item.stepId,
1597
- );
1598
- this.emit(userId, 'run:tool_end', {
1599
- runId,
1600
- stepId: item.stepId,
1601
- toolName: item.toolName,
1602
- result,
1603
- error: error || undefined,
1604
- status,
1605
- });
1606
- this.recordRunEvent(userId, runId, error ? 'tool_failed' : 'tool_completed', {
1607
- toolName: item.toolName,
1608
- status,
1609
- durationMs: Date.now() - startedAt,
1610
- resultPreview: summarizeForLog(result),
1611
- parallel: true,
1612
- }, { agentId, stepId: item.stepId });
1613
- return { ...item, result, error };
1614
- } catch (err) {
1615
- db.prepare(
1616
- `UPDATE agent_steps SET status = 'failed', error = ?, completed_at = datetime('now') WHERE id = ?`
1617
- ).run(err.message, item.stepId);
1618
- this.emit(userId, 'run:tool_end', {
1619
- runId,
1620
- stepId: item.stepId,
1621
- toolName: item.toolName,
1622
- error: err.message,
1623
- status: 'failed',
1624
- });
1625
- this.recordRunEvent(userId, runId, 'tool_failed', {
1626
- toolName: item.toolName,
1627
- status: 'failed',
1628
- error: err.message,
1629
- durationMs: Date.now() - startedAt,
1630
- parallel: true,
1631
- }, { agentId, stepId: item.stepId });
1632
- return { ...item, result: { error: err.message }, error: err.message };
1633
- }
1634
- }));
1635
- this.recordRunEvent(userId, runId, 'parallel_batch_completed', {
1636
- toolNames: results.map((item) => item.toolName),
1637
- failedCount: results.filter((item) => item.error).length,
1638
- }, { agentId });
1639
- return { results, endingStepIndex: nextStepIndex };
1640
- }
1641
-
1642
- async persistRunContext(userId, {
1643
- triggerSource,
1644
- runTitle,
1645
- userMessage,
1646
- lastContent,
1647
- stepIndex,
1648
- skipPersistence = false
1649
- }) {
1650
- if (skipPersistence) {
1651
- return;
1652
- }
1653
- void userId;
1654
- void triggerSource;
1655
- void runTitle;
1656
- void userMessage;
1657
- void lastContent;
1658
- void stepIndex;
1659
- // Run receipts belong in agent_runs/session history, not long-term memory.
1660
- // Long-term memory should only contain durable facts or explicitly saved context.
1661
- return;
1662
- }
1663
-
1664
- getRunMeta(runId) {
1665
- return this.activeRuns.get(runId) || null;
1666
- }
1667
-
1668
- initializeToolRuntime(runId, allTools, initialTools, options = {}) {
1669
- const runMeta = this.getRunMeta(runId);
1670
- if (!runMeta) return;
1671
- runMeta.toolCatalog = Array.isArray(allTools) ? allTools : [];
1672
- runMeta.activeTools = Array.isArray(initialTools) ? initialTools : [];
1673
- runMeta.toolSelectionOptions = {
1674
- widgetId: options.widgetId || null,
1675
- };
1676
- }
1677
-
1678
- getActiveTools(runId) {
1679
- return this.getRunMeta(runId)?.activeTools || [];
1680
- }
1681
-
1682
- activateToolsForRun(runId, names = []) {
1683
- const runMeta = this.getRunMeta(runId);
1684
- if (!runMeta) throw new Error('Run is not active.');
1685
- const result = activateTools(
1686
- runMeta.activeTools,
1687
- runMeta.toolCatalog,
1688
- names,
1689
- runMeta.toolSelectionOptions,
1690
- );
1691
- runMeta.activeTools = result.tools;
1692
- this.recordRunEvent(runMeta.userId, runId, 'tools_activated', {
1693
- activated: result.activated,
1694
- evicted: result.evicted,
1695
- unknown: result.unknown,
1696
- notActivated: result.notActivated,
1697
- activeToolNames: result.tools.map((tool) => tool.name),
1698
- }, { agentId: runMeta.agentId });
1699
- return {
1700
- success: result.unknown.length === 0 && result.notActivated.length === 0,
1701
- activated: result.activated,
1702
- evicted: result.evicted,
1703
- unknown: result.unknown,
1704
- not_activated: result.notActivated,
1705
- active_tools: result.tools.map((tool) => tool.name),
1706
- };
1707
- }
1708
-
1709
- findActiveRunForUser(userId, predicate = null) {
1710
- let candidate = null;
1711
- for (const [runId, runMeta] of this.activeRuns.entries()) {
1712
- if (runMeta.userId !== userId || runMeta.aborted) continue;
1713
- if (typeof predicate === 'function' && !predicate(runMeta, runId)) continue;
1714
- if (!candidate || (runMeta.startedAt || 0) >= (candidate.startedAt || 0)) {
1715
- candidate = { runId, ...runMeta };
1716
- }
1717
- }
1718
- return candidate;
1719
- }
1720
-
1721
- findSteerableRunForUser(userId, triggerSource = 'web') {
1722
- return this.findActiveRunForUser(
1723
- userId,
1724
- (runMeta) => runMeta.triggerSource === triggerSource && runMeta.triggerType === 'user'
1725
- );
1726
- }
1727
-
1728
- enqueueSteering(runId, content, metadata = {}) {
1729
- const runMeta = this.getRunMeta(runId);
1730
- const trimmed = typeof content === 'string' ? content.trim() : '';
1731
- if (!runMeta || runMeta.aborted || !trimmed) return null;
1732
-
1733
- const item = {
1734
- id: uuidv4(),
1735
- content: trimmed,
1736
- metadata,
1737
- createdAt: new Date().toISOString()
1738
- };
1739
-
1740
- runMeta.steeringQueue.push(item);
1741
- this.emit(runMeta.userId, 'run:steer_queued', {
1742
- runId,
1743
- content: item.content,
1744
- pendingCount: runMeta.steeringQueue.length
1745
- });
1746
-
1747
- return {
1748
- runId,
1749
- pendingCount: runMeta.steeringQueue.length,
1750
- item
1751
- };
1752
- }
1753
-
1754
- enqueueSystemSteering(runId, content, metadata = {}) {
1755
- const runMeta = this.getRunMeta(runId);
1756
- const trimmed = typeof content === 'string' ? content.trim() : '';
1757
- if (!runMeta || runMeta.aborted || !trimmed) return null;
1758
- if (!Array.isArray(runMeta.systemSteeringQueue)) {
1759
- runMeta.systemSteeringQueue = [];
1760
- }
1761
- const signature = JSON.stringify({
1762
- content: trimmed,
1763
- reason: metadata.reason || '',
1764
- });
1765
- if (runMeta.systemSteeringQueue.some((item) => item.signature === signature)) {
1766
- return null;
1767
- }
1768
- const item = {
1769
- id: uuidv4(),
1770
- content: trimmed,
1771
- metadata,
1772
- signature,
1773
- createdAt: isoNow(),
1774
- };
1775
- runMeta.systemSteeringQueue.push(item);
1776
- return item;
1777
- }
1778
-
1779
- applyQueuedSystemSteering(runId, messages) {
1780
- const runMeta = this.getRunMeta(runId);
1781
- if (!runMeta?.systemSteeringQueue?.length) {
1782
- return { messages, appliedCount: 0 };
1783
- }
1784
-
1785
- const queued = runMeta.systemSteeringQueue.splice(0, runMeta.systemSteeringQueue.length);
1786
- for (const entry of queued) {
1787
- messages.push({ role: 'system', content: entry.content });
1788
- }
1789
-
1790
- return { messages, appliedCount: queued.length };
1791
- }
1792
-
1793
- applyQueuedSteering(runId, messages, { userId, conversationId }) {
1794
- const runMeta = this.getRunMeta(runId);
1795
- if (!runMeta?.steeringQueue?.length) {
1796
- return { messages, appliedCount: 0 };
1797
- }
1798
-
1799
- const queued = runMeta.steeringQueue.splice(0, runMeta.steeringQueue.length);
1800
- messages.push({
1801
- role: 'system',
1802
- content: [
1803
- 'The user sent follow-up messages while you were already working.',
1804
- 'Treat them as steering or next-up context for the same conversation.',
1805
- 'If a message materially changes the active task, incorporate it now.',
1806
- 'If it is unrelated or better handled after the current task, finish the current work first and then address it.'
1807
- ].join(' ')
1808
- });
1809
-
1810
- for (const entry of queued) {
1811
- messages.push({ role: 'user', content: entry.content });
1812
- if (conversationId) {
1813
- db.prepare('INSERT INTO conversation_messages (conversation_id, role, content) VALUES (?, ?, ?)')
1814
- .run(conversationId, 'user', entry.content);
1815
- }
1816
- }
1817
-
1818
- this.emit(userId, 'run:steer_applied', {
1819
- runId,
1820
- count: queued.length,
1821
- pendingCount: runMeta.steeringQueue.length,
1822
- latestContent: queued[queued.length - 1]?.content || ''
1823
- });
1824
-
1825
- return { messages, appliedCount: queued.length };
1826
- }
1827
-
1828
- buildMessagingHeartbeatText(runMeta, options = {}) {
1829
- const stalled = options.stalled === true;
1830
- const fallbackStartedAtMs = Number.isFinite(runMeta?.startedAt) ? runMeta.startedAt : Date.now();
1831
- const startedAtMs = timestampMs(
1832
- runMeta?.progressLedger?.currentStepStartedAt,
1833
- fallbackStartedAtMs,
1834
- );
1835
- const elapsed = formatElapsedDuration(Date.now() - startedAtMs);
1836
- const currentTool = String(runMeta?.progressLedger?.currentTool || '').trim();
1837
- if (currentTool) {
1838
- return stalled
1839
- ? `Still working on ${currentTool}. This run has not made verified progress for ${elapsed}.`
1840
- : `Still working on ${currentTool}. ${elapsed} elapsed so far.`;
1841
- }
1842
- return stalled
1843
- ? `Still working on this. This run has not made verified progress for ${elapsed}.`
1844
- : `Still working on this. ${elapsed} elapsed so far.`;
1845
- }
1846
-
1847
- async sendRuntimeMessagingHeartbeat(runId, options = {}) {
1848
- const runMeta = this.getRunMeta(runId);
1849
- if (!runMeta || runMeta.aborted) return { sent: false, skipped: true };
1850
- if (runMeta.triggerSource !== 'messaging' || !runMeta.messagingContext?.platform || !runMeta.messagingContext?.chatId) {
1851
- return { sent: false, skipped: true };
1852
- }
1853
- if (!this.messagingManager) {
1854
- return { sent: false, skipped: true };
1855
- }
1856
-
1857
- const createdAt = isoNow();
1858
- const content = this.buildMessagingHeartbeatText(runMeta, options);
1859
- await this.messagingManager.sendMessage(
1860
- runMeta.userId,
1861
- runMeta.messagingContext.platform,
1862
- runMeta.messagingContext.chatId,
1863
- content,
1864
- {
1865
- agentId: runMeta.agentId,
1866
- runId,
1867
- persistConversation: true,
1868
- metadata: {
1869
- interim: true,
1870
- interim_kind: options.stalled === true ? 'blocker' : 'progress',
1871
- runtime_heartbeat: true,
1872
- expects_reply: false,
1873
- },
1874
- deliveryKind: 'interim',
1875
- },
1876
- );
1877
-
1878
- runMeta.lastInterimMessage = content;
1879
- if (!Array.isArray(runMeta.interimMessages)) {
1880
- runMeta.interimMessages = [];
1881
- }
1882
- runMeta.interimMessages.push({
1883
- content,
1884
- kind: options.stalled === true ? 'blocker' : 'progress',
1885
- expectsReply: false,
1886
- deferFollowUp: false,
1887
- createdAt,
1888
- });
1889
- const heartbeatCount = Number(runMeta.progressLedger?.heartbeatCount || 0) + 1;
1890
- this.updateRunProgress(runId, {
1891
- heartbeatCount,
1892
- lastUserVisibleUpdateAt: createdAt,
1893
- });
1894
- this.recordRunEvent(runMeta.userId, runId, 'progress_heartbeat_sent', {
1895
- stalled: options.stalled === true,
1896
- currentTool: runMeta.progressLedger?.currentTool || null,
1897
- currentStep: runMeta.progressLedger?.currentStep || null,
1898
- }, { agentId: runMeta.agentId });
1899
- this.enqueueSystemSteering(
1900
- runId,
1901
- 'A runtime-generated progress update was already sent while the run was blocked. Do not repeat that same status. When control returns, either keep working silently, send a materially new update, or finish with the actual result.',
1902
- { reason: 'runtime_heartbeat' },
1903
- );
1904
- return { sent: true, content };
1905
- }
1906
-
1907
- shouldSendMessagingFinalFallback(runMeta, content, platform = null) {
1908
- const cleanedContent = normalizeOutgoingMessage(content || '', platform, {
1909
- collapseWhitespace: false,
1910
- });
1911
- const lastFinalDeliveryMessage = normalizeOutgoingMessage(
1912
- runMeta?.lastSentMessage
1913
- || (Array.isArray(runMeta?.sentMessages) ? runMeta.sentMessages[runMeta.sentMessages.length - 1] : '')
1914
- || '',
1915
- platform,
1916
- );
1917
- return Boolean(
1918
- cleanedContent
1919
- && !runMeta?.terminalInterim
1920
- && runMeta?.explicitMessageSent !== true
1921
- && runMeta?.finalDeliverySent !== true
1922
- && !lastFinalDeliveryMessage
1923
- );
1924
- }
1925
-
1926
- async deliverMessagingFinalFallback({
1927
- runId,
1928
- userId,
1929
- agentId,
1930
- platform,
1931
- chatId,
1932
- content,
1933
- }) {
1934
- const runMeta = this.getRunMeta(runId);
1935
- if (!runMeta || !this.messagingManager) return { sent: false, skipped: true };
1936
- const cleanedContent = normalizeOutgoingMessage(content || '', platform, {
1937
- collapseWhitespace: false,
1938
- });
1939
- if (!this.shouldSendMessagingFinalFallback(runMeta, cleanedContent, platform)) {
1940
- return { sent: false, skipped: true };
1941
- }
1942
-
1943
- const chunks = splitOutgoingMessageForPlatform(platform, cleanedContent);
1944
- console.info(
1945
- `[Run ${shortenRunId(runId)}] messaging_fallback chunks=${chunks.length} to=${summarizeForLog(chatId, 80)}`
1946
- );
1947
- for (let i = 0; i < chunks.length; i++) {
1948
- if (i > 0) {
1949
- const delay = Math.max(1000, Math.min(chunks[i].length * 30, 4000));
1950
- await this.messagingManager.sendTyping(userId, platform, chatId, true, { agentId }).catch(() => {});
1951
- await new Promise((resolve) => setTimeout(resolve, delay));
1952
- }
1953
- await this.messagingManager.sendMessage(userId, platform, chatId, chunks[i], { runId, agentId }).catch((err) =>
1954
- console.error('[Engine] Auto-reply fallback failed:', err.message)
1955
- );
1956
- }
1957
-
1958
- runMeta.lastSentMessage = chunks[chunks.length - 1] || cleanedContent;
1959
- runMeta.sentMessages = Array.isArray(runMeta.sentMessages)
1960
- ? [...runMeta.sentMessages, ...chunks]
1961
- : chunks.slice();
1962
- this.markRunFinalDelivery(runId, runMeta.lastSentMessage);
1963
- return { sent: true, chunks };
1964
- }
1965
-
1966
- async tickMessagingProgressSupervisor(runId) {
1967
- const runMeta = this.getRunMeta(runId);
1968
- if (!runMeta || runMeta.aborted || runMeta.triggerSource !== 'messaging') {
1969
- return { sent: false, skipped: true };
1970
- }
1971
- if (runMeta.finalDeliverySent === true || runMeta.terminalInterim) {
1972
- return { sent: false, skipped: true };
1973
- }
1974
-
1975
- const now = Date.now();
1976
- const ledger = runMeta.progressLedger || buildInitialProgressLedger({
1977
- startedAt: runMeta.startedAtIso || isoNow(),
1978
- });
1979
- runMeta.progressLedger = ledger;
1980
- const startedAtMs = Number.isFinite(runMeta.startedAt) ? runMeta.startedAt : now;
1981
-
1982
- const lastVerifiedAtMs = timestampMs(ledger.lastVerifiedProgressAt, startedAtMs);
1983
- const lastVisibleAtMs = timestampMs(ledger.lastUserVisibleUpdateAt, 0);
1984
- const heartbeatThresholdMs = lastVisibleAtMs > 0
1985
- ? MESSAGING_PROGRESS_REPEAT_MS
1986
- : MESSAGING_PROGRESS_FIRST_UPDATE_MS;
1987
- const comparisonVisibleAtMs = lastVisibleAtMs > 0 ? lastVisibleAtMs : startedAtMs;
1988
- const stalled = (now - lastVerifiedAtMs) >= MESSAGING_PROGRESS_STALL_MS;
1989
-
1990
- if (stalled && !ledger.stallNotifiedAt) {
1991
- this.updateRunProgress(runId, {
1992
- stallNotifiedAt: isoNow(),
1993
- progressState: 'stalled',
1994
- });
1995
- this.recordRunEvent(runMeta.userId, runId, 'progress_stalled', {
1996
- currentTool: ledger.currentTool || null,
1997
- currentStep: ledger.currentStep || null,
1998
- phase: ledger.currentPhase || 'idle',
1999
- }, { agentId: runMeta.agentId });
2000
- }
2001
-
2002
- if ((now - comparisonVisibleAtMs) < heartbeatThresholdMs) {
2003
- return { sent: false, skipped: true };
2004
- }
2005
-
2006
- if (ledger.currentPhase === 'tool' && ledger.currentStepStartedAt) {
2007
- return this.sendRuntimeMessagingHeartbeat(runId, { stalled });
2008
- }
2009
-
2010
- if (ledger.currentPhase !== 'idle') {
2011
- return { sent: false, skipped: true };
2012
- }
2013
-
2014
- const lastSupervisorNudgeAtMs = timestampMs(runMeta.lastSupervisorNudgeAt, 0);
2015
- if (lastSupervisorNudgeAtMs > 0 && (now - lastSupervisorNudgeAtMs) < heartbeatThresholdMs) {
2016
- return { sent: false, skipped: true };
2017
- }
2018
-
2019
- const nudge = stalled
2020
- ? 'The messaging user has only seen progress updates so far, and the run now appears stalled. Decide explicitly whether to continue, send one concise blocker update, or finish with the final answer. Do not leave the run with only an interim status.'
2021
- : 'The messaging user has not received a final answer yet. Decide explicitly whether to keep working, send one concise progress update, or finish with the final answer. Do not stop with only an interim status.';
2022
- const queued = this.enqueueSystemSteering(runId, nudge, {
2023
- reason: stalled ? 'stalled_progress_check' : 'progress_check',
2024
- });
2025
- if (!queued) {
2026
- return { sent: false, skipped: true };
2027
- }
2028
- runMeta.lastSupervisorNudgeAt = isoNow();
2029
- this.updateRunProgress(runId, {
2030
- lastUserVisibleUpdateAt: ledger.lastUserVisibleUpdateAt || null,
2031
- });
2032
- return { sent: false, queued: true };
2033
- }
2034
-
2035
- startMessagingProgressSupervisor(runId) {
2036
- const runMeta = this.getRunMeta(runId);
2037
- if (!runMeta || runMeta.triggerSource !== 'messaging' || !runMeta.messagingContext?.platform || !runMeta.messagingContext?.chatId) {
2038
- return false;
2039
- }
2040
- if (runMeta.messagingProgressSupervisor?.timer) {
2041
- return true;
2042
- }
2043
- const timer = setInterval(() => {
2044
- this.tickMessagingProgressSupervisor(runId).catch((error) => {
2045
- console.warn('[Engine] Messaging progress supervisor failed:', error?.message || error);
2046
- });
2047
- }, MESSAGING_PROGRESS_TICK_MS);
2048
- timer.unref?.();
2049
- runMeta.messagingProgressSupervisor = { timer };
2050
- return true;
2051
- }
2052
-
2053
- stopMessagingProgressSupervisor(runId) {
2054
- const runMeta = this.getRunMeta(runId);
2055
- const timer = runMeta?.messagingProgressSupervisor?.timer || null;
2056
- if (timer) {
2057
- clearInterval(timer);
2058
- }
2059
- if (runMeta?.messagingProgressSupervisor) {
2060
- runMeta.messagingProgressSupervisor = null;
2061
- }
2062
- }
2063
-
2064
- isRunStopped(runId) {
2065
- return this.getRunMeta(runId)?.aborted === true;
2066
- }
2067
-
2068
- attachProcessToRun(runId, pid) {
2069
- const runMeta = this.getRunMeta(runId);
2070
- if (!runMeta || !pid) return;
2071
- runMeta.toolPids.add(pid);
2072
- if (runMeta.aborted) {
2073
- if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
2074
- void this.runtimeManager.killCommand(runMeta.userId, pid, 'aborted');
2075
- }
2076
- }
2077
- }
2078
-
2079
- detachProcessFromRun(runId, pid) {
2080
- const runMeta = this.getRunMeta(runId);
2081
- if (!runMeta || !pid) return;
2082
- runMeta.toolPids.delete(pid);
2083
- }
2084
-
2085
- // getIterationLimit() removed — use buildLoopPolicy() directly.
2086
- // maxIterations is derived in runWithModel from loopPolicy.maxIterations.
2087
-
2088
- getReasoningEffort(providerName, options = {}) {
2089
- if (providerName === 'google') return undefined;
2090
- if (options.latencyProfile === 'voice') {
2091
- return 'low';
2092
- }
2093
- return options.reasoningEffort || process.env.REASONING_EFFORT || 'low';
2094
- }
2095
-
2096
- shouldFastCompleteVoiceReply({
2097
- options = {},
2098
- toolExecutions = [],
2099
- failedStepCount = 0,
2100
- messagingSent = false,
2101
- lastReply = '',
2102
- }) {
2103
- return options.latencyProfile === 'voice'
2104
- && toolExecutions.length === 0
2105
- && failedStepCount === 0
2106
- && !messagingSent
2107
- && Boolean(String(lastReply || '').trim());
2108
- }
2109
-
2110
- getMessagingRetryLimit(maxIterations) {
2111
- // Cap at 3: more than 3 autonomous messaging retries indicates a structural
2112
- // problem (model unavailable, bad config) that more retries won't solve.
2113
- return Math.min(3, Math.max(1, maxIterations));
2114
- }
2115
-
2116
- buildContextMessages(systemPrompt, summaryMessage, historyMessages, recallMsg) {
2117
- const messages = [];
2118
- if (systemPrompt && typeof systemPrompt === 'object') {
2119
- if (systemPrompt.stable) {
2120
- messages.push({
2121
- role: 'system',
2122
- content: systemPrompt.stable,
2123
- });
2124
- }
2125
- if (systemPrompt.dynamic) {
2126
- messages.push({ role: 'system', content: systemPrompt.dynamic });
2127
- }
2128
- } else {
2129
- messages.push({ role: 'system', content: systemPrompt });
2130
- }
2131
- if (summaryMessage) messages.push(summaryMessage);
2132
- if (Array.isArray(historyMessages)) messages.push(...historyMessages);
2133
- if (recallMsg) messages.push({ role: 'system', content: recallMsg });
2134
- return messages;
2135
- }
2136
-
2137
- buildUserMessage(userMessage, options = {}) {
2138
- if (!options.mediaAttachments || options.mediaAttachments.length === 0) {
2139
- return { role: 'user', content: userMessage };
2140
- }
2141
-
2142
- const contentArr = [{ type: 'text', text: userMessage }];
2143
- for (const att of options.mediaAttachments) {
2144
- if ((att.type === 'image' || att.type === 'video') && att.path) {
2145
- try {
2146
- if (fs.existsSync(att.path)) {
2147
- const b64 = fs.readFileSync(att.path).toString('base64');
2148
- const mime = att.path.endsWith('.png') ? 'image/png' : att.path.endsWith('.gif') ? 'image/gif' : 'image/jpeg';
2149
- contentArr.push({ type: 'image_url', image_url: { url: `data:${mime};base64,${b64}` } });
2150
- }
2151
- } catch (err) {
2152
- console.warn(`[AgentEngine] Failed to read attachment at ${att.path}:`, err?.message);
2153
- }
2154
- }
2155
- }
2156
-
2157
- return { role: 'user', content: contentArr.length > 1 ? contentArr : userMessage };
2158
- }
2159
-
2160
- estimatePromptMetrics(messages, tools) {
2161
- const metrics = {
2162
- systemPromptTokens: 0,
2163
- toolSchemaTokens: estimateTokenValue(tools),
2164
- historyTokens: 0,
2165
- recalledMemoryTokens: 0,
2166
- toolReplayTokens: 0,
2167
- totalEstimatedTokens: 0
2168
- };
2169
-
2170
- messages.forEach((msg, index) => {
2171
- const contentTokens = estimateTokenValue(msg.content);
2172
- const callTokens = estimateTokenValue(msg.tool_calls);
2173
- const total = contentTokens + callTokens;
2174
-
2175
- if (msg.role === 'tool') {
2176
- metrics.toolReplayTokens += total;
2177
- } else if (msg.role === 'system' && index === 0) {
2178
- metrics.systemPromptTokens += total;
2179
- } else if (msg.role === 'system' && /^\[Recalled memory/.test(msg.content || '')) {
2180
- metrics.recalledMemoryTokens += total;
2181
- } else {
2182
- metrics.historyTokens += total;
2183
- }
2184
- });
2185
-
2186
- metrics.totalEstimatedTokens = metrics.systemPromptTokens
2187
- + metrics.toolSchemaTokens
2188
- + metrics.historyTokens
2189
- + metrics.recalledMemoryTokens
2190
- + metrics.toolReplayTokens;
2191
-
2192
- return metrics;
2193
- }
2194
-
2195
- mergePromptMetrics(summary, metrics, iteration, toolCount) {
2196
- return {
2197
- iterationsObserved: Math.max(summary.iterationsObserved || 0, iteration),
2198
- toolCount,
2199
- maxEstimatedTokens: Math.max(summary.maxEstimatedTokens || 0, metrics.totalEstimatedTokens),
2200
- maxSystemPromptTokens: Math.max(summary.maxSystemPromptTokens || 0, metrics.systemPromptTokens),
2201
- maxToolSchemaTokens: Math.max(summary.maxToolSchemaTokens || 0, metrics.toolSchemaTokens),
2202
- maxHistoryTokens: Math.max(summary.maxHistoryTokens || 0, metrics.historyTokens),
2203
- maxRecalledMemoryTokens: Math.max(summary.maxRecalledMemoryTokens || 0, metrics.recalledMemoryTokens),
2204
- maxToolReplayTokens: Math.max(summary.maxToolReplayTokens || 0, metrics.toolReplayTokens),
2205
- lastEstimate: metrics
2206
- };
2207
- }
2208
-
2209
- async persistPromptMetrics(runId, metrics) {
2210
- db.prepare('UPDATE agent_runs SET prompt_metrics = ? WHERE id = ?')
2211
- .run(JSON.stringify(metrics), runId);
2212
- }
2213
-
2214
- async run(userId, userMessage, options = {}) {
2215
- return this.runWithModel(
2216
- userId,
2217
- userMessage,
2218
- options,
2219
- typeof options.model === 'string' && options.model.trim()
2220
- ? options.model.trim()
2221
- : null,
2222
- );
2223
- }
2224
-
2225
- async runWithModel(userId, userMessage, options = {}, _modelOverride = null) {
2226
- const triggerType = options.triggerType || 'user';
2227
- const { resolveAgentId } = require('../agents/manager');
2228
- const agentId = resolveAgentId(userId, options.agentId || options.agent_id || null);
2229
- ensureDefaultAiSettings(userId, agentId);
2230
- const aiSettings = getAiSettings(userId, agentId);
2231
-
2232
- enforceRateLimits(userId);
2233
-
2234
- const runId = options.runId || uuidv4();
2235
- const conversationId = options.conversationId;
2236
- const app = options.app || this.app;
2237
- const triggerSource = options.triggerSource || 'web';
2238
- const historyWindow = Math.max(
2239
- 1,
2240
- Number(options.historyWindow || aiSettings.chat_history_window) || aiSettings.chat_history_window,
2241
- );
2242
- // loopPolicy is built after task analysis so analysisMode can be passed in;
2243
- // we build a provisional policy now (with default mode) and rebuild after
2244
- // analysis when the mode is known. See the post-analysis policy rebuild below.
2245
- let loopPolicy = buildLoopPolicy(aiSettings, triggerType, 'execute', options);
2246
- let maxIterations = loopPolicy.maxIterations;
2247
- const providerStatusConfig = {
2248
- agentId,
2249
- onStatus: (status) => {
2250
- if (!status?.message) return;
2251
- this.emit(userId, 'run:interim', {
2252
- runId,
2253
- message: status.message,
2254
- phase: status.phase
2255
- });
2256
- }
2257
- };
2258
- const selectedProvider = await getProviderForUser(
2259
- userId,
2260
- userMessage,
2261
- triggerType === 'subagent',
2262
- _modelOverride,
2263
- providerStatusConfig
2264
- );
2265
- let provider = selectedProvider.provider;
2266
- let model = selectedProvider.model;
2267
- let providerName = selectedProvider.providerName;
2268
- const switchToFallbackModel = async (failedModel, error, phase) => {
2269
- const fallbackModelId = await getFailureFallbackModelId(userId, agentId, failedModel, aiSettings.fallback_model_id, error);
2270
- if (!fallbackModelId || fallbackModelId === failedModel) return false;
2271
- console.log(`[Engine] ${phase} failed on ${failedModel}; attempting fallback to: ${fallbackModelId}`);
2272
- this.emit(userId, 'run:interim', {
2273
- runId,
2274
- message: `Model service failed on ${failedModel}; retrying with ${fallbackModelId}.`,
2275
- phase: 'model_fallback'
2276
- });
2277
- const fallback = await getProviderForUser(
2278
- userId,
2279
- userMessage,
2280
- triggerType === 'subagent',
2281
- fallbackModelId,
2282
- providerStatusConfig
2283
- );
2284
- provider = fallback.provider;
2285
- model = fallback.model;
2286
- providerName = fallback.providerName;
2287
- return true;
2288
- };
2289
- const runWithModelFallback = async (phase, fn) => {
2290
- try {
2291
- return await fn();
2292
- } catch (err) {
2293
- const failedModel = model;
2294
- const switched = await switchToFallbackModel(failedModel, err, phase);
2295
- if (!switched) throw err;
2296
- return await fn();
2297
- }
2298
- };
2299
-
2300
- const runTitle = generateTitle(userMessage);
2301
- const initialRunMetadata = buildInitialRunMetadata(options);
2302
- db.prepare(`INSERT OR REPLACE INTO agent_runs(
2303
- id, user_id, agent_id, title, status, trigger_type, trigger_source, model, metadata_json
2304
- ) VALUES(?, ?, ?, ?, 'running', ?, ?, ?, ?)`).run(
2305
- runId,
2306
- userId,
2307
- agentId,
2308
- runTitle,
2309
- triggerType,
2310
- triggerSource,
2311
- model,
2312
- Object.keys(initialRunMetadata).length ? JSON.stringify(initialRunMetadata) : null,
2313
- );
2314
-
2315
- const retryMessagingState = options.messagingRetryState || {};
2316
- const carriedFinalMessage = String(retryMessagingState.lastFinalMessage || '').trim();
2317
- const carriedExplicitMessageSent = retryMessagingState.explicitMessageSent === true;
2318
- const carriedInterimHistory = cloneInterimHistory(retryMessagingState.interimHistory);
2319
- const carriedLastInterimMessage = carriedInterimHistory[carriedInterimHistory.length - 1]?.content || '';
2320
- const startedAtIso = isoNow();
2321
- const progressLedger = buildInitialProgressLedger({
2322
- startedAt: startedAtIso,
2323
- retryState: retryMessagingState,
2324
- });
2325
-
2326
- this.activeRuns.set(runId, {
2327
- userId,
2328
- agentId,
2329
- status: 'running',
2330
- aborted: false,
2331
- messagingSent: false,
2332
- noResponse: false,
2333
- explicitMessageSent: carriedExplicitMessageSent,
2334
- finalDeliverySent: carriedExplicitMessageSent,
2335
- lastSentMessage: carriedExplicitMessageSent ? carriedFinalMessage : '',
2336
- sentMessages: [],
2337
- widgetSnapshotSaved: false,
2338
- triggerType,
2339
- triggerSource,
2340
- startedAt: Date.now(),
2341
- startedAtIso,
2342
- lastToolName: null,
2343
- lastToolTarget: null,
2344
- lastInterimMessage: carriedExplicitMessageSent ? '' : carriedLastInterimMessage,
2345
- interimMessages: carriedExplicitMessageSent ? [] : carriedInterimHistory,
2346
- interimSignatures: carriedExplicitMessageSent
2347
- ? new Set()
2348
- : createInterimSignatureSet(carriedInterimHistory, options.source || null),
2349
- terminalInterim: null,
2350
- voiceSessionId: options.voiceSessionId || null,
2351
- steeringQueue: [],
2352
- systemSteeringQueue: [],
2353
- toolPids: new Set(),
2354
- repetitionGuard: new ToolRepetitionGuard(),
2355
- messagingContext: triggerSource === 'messaging'
2356
- ? {
2357
- platform: options.source || null,
2358
- chatId: options.chatId || null,
2359
- }
2360
- : null,
2361
- progressLedger,
2362
- });
2363
- this.persistRunMetadata(runId, {
2364
- progressLedger,
2365
- });
2366
- this.startMessagingProgressSupervisor(runId);
2367
- this.emit(userId, 'run:start', { runId, agentId, title: runTitle, model, triggerType, triggerSource });
2368
- this.recordRunEvent(userId, runId, 'run_started', {
2369
- title: runTitle,
2370
- model,
2371
- triggerType,
2372
- triggerSource,
2373
- }, { agentId });
2374
- console.info(
2375
- `[Run ${shortenRunId(runId)}] started trigger=${triggerSource} type=${triggerType} model=${model} title=${summarizeForLog(runTitle, 120)}`
2376
- );
2377
-
2378
- const systemPrompt = await this.buildSystemPrompt(userId, {
2379
- ...(options.context || {}),
2380
- userMessage,
2381
- agentId,
2382
- triggerSource,
2383
- });
2384
- // Pass short descriptions so the model always knows every available tool.
2385
- // compactToolDefinition caps tool desc at 120 chars, param desc at 70 chars.
2386
- const builtInTools = this.getAvailableTools(app, {
2387
- includeDescriptions: true,
2388
- userId,
2389
- agentId,
2390
- triggerType,
2391
- triggerSource,
2392
- widgetId: options.widgetId || null,
2393
- });
2394
- const mcpManager = app?.locals?.mcpManager || app?.locals?.mcpClient || this.mcpManager;
2395
- const integrationManager = app?.locals?.integrationManager || null;
2396
- const mcpTools = mcpManager ? mcpManager.getAllTools(userId, { agentId }) : [];
2397
- const allTools = selectToolsForTask(userMessage, builtInTools, mcpTools, options);
2398
- let tools = allTools;
2399
- const toolNames = allTools.map((tool) => tool.name).filter(Boolean);
2400
- const coreToolStatus = {
2401
- send_message: toolNames.includes('send_message'),
2402
- create_task: toolNames.includes('create_task'),
2403
- list_tasks: toolNames.includes('list_tasks'),
2404
- update_task: toolNames.includes('update_task'),
2405
- delete_task: toolNames.includes('delete_task'),
2406
- };
2407
- this.recordRunEvent(userId, runId, 'tool_inventory', {
2408
- total: toolNames.length,
2409
- builtInTotal: builtInTools.length,
2410
- mcpTotal: mcpTools.length,
2411
- core: coreToolStatus,
2412
- }, { agentId });
2413
- console.info(
2414
- `[Run ${shortenRunId(runId)}] tools total=${toolNames.length} builtIns=${builtInTools.length} mcp=${mcpTools.length} core=${JSON.stringify(coreToolStatus)}`
2415
- );
2416
- const capabilityHealth = await getCapabilityHealth({ userId, agentId, app, engine: this });
2417
- const capabilitySummary = summarizeCapabilityHealth(capabilityHealth);
2418
- const integrationSummary = integrationManager?.summarizeConnectedProviders?.(userId, agentId) || '';
2419
-
2420
- const { MemoryManager } = require('../memory/manager');
2421
- const memoryManager = this.memoryManager || new MemoryManager();
2422
- const recallQuery = options.context?.rawUserMessage || userMessage;
2423
- const recallMsg = options.skipGlobalRecall === true
2424
- ? null
2425
- : await this.buildMemoryRecall({
2426
- memoryManager,
2427
- userId,
2428
- agentId,
2429
- query: recallQuery,
2430
- provider,
2431
- providerName,
2432
- model,
2433
- runId,
2434
- options,
2435
- });
2436
-
2437
- let summaryMessage = null;
2438
- let historyMessages = [];
2439
-
2440
- if (conversationId && options.skipConversationHistory !== true) {
2441
- const conversationContext = getConversationContext(conversationId, historyWindow);
2442
- summaryMessage = buildSummaryCarrier(conversationContext.summary || options.priorSummary || '');
2443
- historyMessages = conversationContext.recentMessages.length > 0
2444
- ? conversationContext.recentMessages
2445
- : (options.priorMessages || []).slice(-historyWindow).filter((pm) => pm.role && pm.content);
2446
- } else {
2447
- summaryMessage = buildSummaryCarrier(options.priorSummary || '');
2448
- historyMessages = (options.priorMessages || []).slice(-historyWindow).filter((pm) => pm.role && pm.content);
2449
- }
2450
-
2451
- let messages = this.buildContextMessages(systemPrompt, summaryMessage, historyMessages, recallMsg);
2452
- if (capabilitySummary) {
2453
- messages.push({ role: 'system', content: `[Capability health]\n${capabilitySummary}` });
2454
- }
2455
- if (integrationSummary) {
2456
- messages.push({ role: 'system', content: `[Official integrations]\n${integrationSummary}` });
2457
- }
2458
- const threadStateMessage = conversationId ? memoryManager.buildConversationStateMessage(conversationId) : null;
2459
- if (threadStateMessage) {
2460
- messages.push({ role: 'system', content: threadStateMessage });
2461
- }
2462
- this.recordRunEvent(userId, runId, 'memory_injected', {
2463
- hasRecallContext: Boolean(recallMsg),
2464
- hasThreadState: Boolean(threadStateMessage),
2465
- recallPreview: recallMsg ? String(recallMsg).slice(0, 240) : '',
2466
- }, { agentId });
2467
- messages.push(this.buildUserMessage(userMessage, options));
2468
- messages = sanitizeConversationMessages(messages);
2469
-
2470
- if (conversationId) {
2471
- db.prepare('INSERT INTO conversation_messages (conversation_id, role, content) VALUES (?, ?, ?)')
2472
- .run(conversationId, 'user', userMessage);
2473
- }
2474
-
2475
- let iteration = 0;
2476
- let totalTokens = 0;
2477
- let lastContent = '';
2478
- let stepIndex = 0;
2479
- let failedStepCount = 0;
2480
- let modelFailureRecoveries = 0;
2481
- let promptMetrics = {};
2482
- let toolExecutions = [];
2483
- let compactionMetrics = [];
2484
- let analysis = null;
2485
- let plan = null;
2486
- let verification = null;
2487
- let deliverableWorkflow = null;
2488
- let deliverablePlan = null;
2489
- let deliverableArtifacts = [];
2490
- let deliverableValidation = null;
2491
- let directAnswerEligible = false;
2492
- let analysisUsage = 0;
2493
-
2494
- try {
2495
- if (options.skipTaskAnalysis === true) {
2496
- analysis = buildSkipTaskAnalysisResult(options.forceMode);
2497
- } else {
2498
- const analysisResult = await runWithModelFallback('task analysis', () => this.analyzeTask({
2499
- provider,
2500
- providerName,
2501
- model,
2502
- messages,
2503
- tools,
2504
- capabilityHealth,
2505
- forceMode: options.forceMode || null,
2506
- userMessage,
2507
- options: { ...options, triggerSource, runId, userId, agentId },
2508
- }));
2509
- analysisUsage = analysisResult.usage || 0;
2510
- totalTokens += analysisUsage;
2511
- analysis = applyForcedAnalysisMode({ ...analysisResult.analysis }, options.forceMode);
2512
- if (!analysis.goal && userMessage) {
2513
- analysis.goal = String(userMessage).trim().slice(0, 300);
2514
- }
2515
- analysis.mode = promoteAnalysisMode(analysis.mode, {
2516
- verificationNeed: analysis.verification_need,
2517
- freshnessRisk: analysis.freshness_risk,
2518
- draftReply: analysis.draft_reply,
2519
- planningDepth: analysis.planning_depth,
2520
- });
2521
-
2522
- stepIndex += 1;
2523
- const analysisStepId = uuidv4();
2524
- db.prepare(`INSERT INTO agent_steps
2525
- (id, run_id, step_index, type, description, status, result, started_at, completed_at)
2526
- VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`)
2527
- .run(
2528
- analysisStepId,
2529
- runId,
2530
- stepIndex,
2531
- 'analysis',
2532
- 'Task analysis contract',
2533
- 'completed',
2534
- JSON.stringify(analysis).slice(0, 20000)
2535
- );
2536
- this.persistRunMetadata(runId, {
2537
- taskAnalysis: analysis,
2538
- capabilityHealth,
2539
- });
2540
- this.emit(userId, 'run:analysis', {
2541
- runId,
2542
- ...analysis,
2543
- capabilitySummary,
2544
- });
2545
-
2546
- }
2547
-
2548
- tools = selectInitialTools(allTools, analysis.suggested_tools, {
2549
- widgetId: options.widgetId || null,
2550
- });
2551
- this.initializeToolRuntime(runId, allTools, tools, options);
2552
- messages.push({
2553
- role: 'system',
2554
- content: [
2555
- '[Available tool catalog]',
2556
- buildToolCatalog(allTools),
2557
- '',
2558
- `Active tools: ${tools.map((tool) => tool.name).join(', ')}`,
2559
- 'Use activate_tools with exact catalog names when another schema is required.',
2560
- ].join('\n'),
2561
- });
2562
- this.recordRunEvent(userId, runId, 'tool_selection_applied', {
2563
- activeToolNames: tools.map((tool) => tool.name),
2564
- catalogSize: allTools.length,
2565
- }, { agentId });
2566
-
2567
- const activeDefaultModelSetting = triggerType === 'subagent'
2568
- ? aiSettings.default_subagent_model
2569
- : aiSettings.default_chat_model;
2570
- if (!_modelOverride && activeDefaultModelSetting === 'auto' && aiSettings.smarter_model_selector !== false) {
2571
- const requestedPurpose = analysis?.mode === 'plan_execute' || analysis?.complexity === 'complex' || analysis?.autonomy_level === 'high'
2572
- ? 'planning'
2573
- : triggerType === 'subagent'
2574
- ? 'fast'
2575
- : '';
2576
- if (requestedPurpose) {
2577
- const selectedAfterAnalysis = await getProviderForUser(
2578
- userId,
2579
- userMessage,
2580
- triggerType === 'subagent',
2581
- null,
2582
- {
2583
- ...providerStatusConfig,
2584
- selectionHint: {
2585
- purpose: requestedPurpose,
2586
- complexity: analysis?.complexity,
2587
- autonomyLevel: analysis?.autonomy_level,
2588
- requiredConfidence: analysis?.completion_confidence_required,
2589
- costMode: aiSettings.cost_mode,
2590
- },
2591
- }
2592
- );
2593
- if (selectedAfterAnalysis.model !== model) {
2594
- provider = selectedAfterAnalysis.provider;
2595
- model = selectedAfterAnalysis.model;
2596
- providerName = selectedAfterAnalysis.providerName;
2597
- db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
2598
- .run(model, runId);
2599
- this.emit(userId, 'run:interim', {
2600
- runId,
2601
- message: `Switched to ${model} for this run after task analysis.`,
2602
- phase: 'model_selection'
2603
- });
2604
- }
2605
- }
2606
- }
2607
-
2608
- // Rebuild loop policy with the resolved analysis mode. Runs in both the
2609
- // normal path and the skipTaskAnalysis path so that forceMode='plan_execute'
2610
- // (or any mode set by buildSkipTaskAnalysisResult) raises the iteration
2611
- // ceiling correctly.
2612
- loopPolicy = buildLoopPolicy(aiSettings, triggerType, analysis.mode || 'execute', {
2613
- ...options,
2614
- autonomyPolicy: buildAutonomyPolicyFromAnalysis(analysis),
2615
- });
2616
- maxIterations = loopPolicy.maxIterations;
2617
-
2618
- if (options.skipDeliverableWorkflow !== true) {
2619
- const deliverableSelectionResult = await selectDeliverableWorkflow({
2620
- engine: this,
2621
- provider,
2622
- providerName,
2623
- model,
2624
- messages,
2625
- tools,
2626
- options: { ...options, runId, userId, agentId },
2627
- });
2628
- totalTokens += deliverableSelectionResult.usage || 0;
2629
- const selectedWorkflow = getDeliverableWorkflow(deliverableSelectionResult.selection.type);
2630
- if (selectedWorkflow?.canHandle(deliverableSelectionResult.selection)) {
2631
- deliverableWorkflow = {
2632
- workflow: selectedWorkflow,
2633
- selection: deliverableSelectionResult.selection,
2634
- request: selectedWorkflow.normalizeRequest({
2635
- ...deliverableSelectionResult.selection,
2636
- userMessage,
2637
- }),
2638
- };
2639
- deliverablePlan = selectedWorkflow.buildExecutionPlan(deliverableWorkflow.request, {
2640
- analysis,
2641
- tools,
2642
- options,
2643
- });
2644
- await selectedWorkflow.run(deliverablePlan, {
2645
- engine: this,
2646
- userId,
2647
- agentId,
2648
- runId,
2649
- agentId,
2650
- app,
2651
- });
2652
- this.persistRunMetadata(runId, {
2653
- deliverableWorkflow: {
2654
- ...deliverableWorkflow.selection,
2655
- plan: deliverablePlan,
2656
- },
2657
- });
2658
- this.recordRunEvent(userId, runId, 'deliverable_workflow_selected', {
2659
- type: deliverableWorkflow.selection.type,
2660
- confidence: deliverableWorkflow.selection.confidence,
2661
- goal: deliverableWorkflow.selection.goal,
2662
- requestedOutputs: deliverableWorkflow.selection.requestedOutputs,
2663
- }, { agentId });
2664
- }
2665
- }
2666
-
2667
- if (analysis.mode === 'plan_execute') {
2668
- const planResult = await runWithModelFallback('execution planning', () => this.createExecutionPlan({
2669
- provider,
2670
- providerName,
2671
- model,
2672
- messages,
2673
- analysis,
2674
- capabilitySummary,
2675
- options: { ...options, runId, userId, agentId },
2676
- }));
2677
- totalTokens += planResult.usage || 0;
2678
- plan = planResult.plan;
2679
- stepIndex += 1;
2680
- const planStepId = uuidv4();
2681
- db.prepare(`INSERT INTO agent_steps
2682
- (id, run_id, step_index, type, description, status, result, started_at, completed_at)
2683
- VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`)
2684
- .run(
2685
- planStepId,
2686
- runId,
2687
- stepIndex,
2688
- 'planning',
2689
- 'Execution plan',
2690
- 'completed',
2691
- JSON.stringify(plan).slice(0, 20000)
2692
- );
2693
- this.persistRunMetadata(runId, { executionPlan: plan });
2694
- this.emit(userId, 'run:plan', {
2695
- runId,
2696
- steps: plan.steps,
2697
- successCriteria: plan.success_criteria,
2698
- verificationFocus: plan.verification_focus,
2699
- });
2700
- }
2701
-
2702
- messages.push({
2703
- role: 'system',
2704
- content: buildExecutionGuidance({
2705
- analysis,
2706
- plan,
2707
- capabilityHealth: capabilitySummary,
2708
- }),
2709
- });
2710
- if (deliverablePlan) {
2711
- messages.push({
2712
- role: 'system',
2713
- content: buildDeliverableWorkflowGuidance(deliverablePlan),
2714
- });
2715
- this.recordRunEvent(userId, runId, 'deliverable_execution_started', {
2716
- type: deliverableWorkflow?.selection?.type,
2717
- preferredTools: deliverablePlan.preferredTools || [],
2718
- expectedOutputs: deliverablePlan.expectedOutputs || [],
2719
- }, { agentId });
2720
- }
2721
- messages = sanitizeConversationMessages(messages);
2722
-
2723
- directAnswerEligible = isDirectAnswerEligibleAnalysis(analysis)
2724
- && Boolean(normalizeOutgoingMessage(analysis.draft_reply));
2725
-
2726
- if (directAnswerEligible) {
2727
- iteration = 1;
2728
- lastContent = analysis.draft_reply.trim();
2729
- messages.push({ role: 'assistant', content: lastContent });
2730
- if (conversationId) {
2731
- db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
2732
- .run(conversationId, 'assistant', lastContent, analysisUsage);
2733
- }
2734
- }
2735
-
2736
- // BUG FIX: consecutiveToolFailures was previously declared INSIDE the
2737
- // while loop (resetting each iteration). It is now tracked across the
2738
- // full run so the failure guard fires correctly after 5 consecutive failures
2739
- // regardless of which iteration they fall in.
2740
- let consecutiveToolFailures = 0;
2741
-
2742
- while (!directAnswerEligible && iteration < maxIterations) {
2743
- if (this.isRunStopped(runId)) break;
2744
- iteration++;
2745
-
2746
- const systemSteeringAtLoopStart = this.applyQueuedSystemSteering(runId, messages);
2747
- messages = systemSteeringAtLoopStart.messages;
2748
- const steeringAtLoopStart = this.applyQueuedSteering(runId, messages, {
2749
- userId,
2750
- conversationId
2751
- });
2752
- messages = steeringAtLoopStart.messages;
2753
- messages = sanitizeConversationMessages(messages);
2754
- this.updateRunProgress(runId, {
2755
- currentPhase: 'model',
2756
- currentStep: `model:${iteration}`,
2757
- currentTool: null,
2758
- currentStepStartedAt: isoNow(),
2759
- }, {
2760
- verified: true,
2761
- });
2762
-
2763
- let metrics = this.estimatePromptMetrics(messages, tools);
2764
- const contextWindow = provider.getContextWindow(model);
2765
- if (metrics.totalEstimatedTokens > contextWindow * loopPolicy.compactionThreshold) {
2766
- messages = await compact(messages, provider, model, contextWindow);
2767
- messages = sanitizeConversationMessages(messages);
2768
- this.emit(userId, 'run:compaction', { runId, iteration });
2769
- metrics = this.estimatePromptMetrics(messages, tools);
2770
- }
2771
-
2772
- promptMetrics = this.mergePromptMetrics(promptMetrics, metrics, iteration, tools.length);
2773
- this.persistPromptMetrics(runId, promptMetrics).catch(() => { });
2774
- this.emit(userId, 'run:thinking', { runId, iteration });
2775
- this.recordRunEvent(userId, runId, 'model_turn_started', {
2776
- iteration,
2777
- toolCount: tools.length,
2778
- }, { agentId });
2779
-
2780
- let response;
2781
- let responseModel = model;
2782
- let streamContent = '';
2783
-
2784
- const tryModelCall = async (retryForFallback = true) => {
2785
- try {
2786
- const modelCall = await this.requestModelResponse({
2787
- provider,
2788
- providerName,
2789
- model,
2790
- messages,
2791
- tools,
2792
- options: { ...options, userId, agentId, runId, phase: 'model_turn' },
2793
- runId,
2794
- iteration,
2795
- });
2796
- response = modelCall.response;
2797
- responseModel = modelCall.responseModel;
2798
- streamContent = modelCall.streamContent;
2799
- } catch (err) {
2800
- console.error(`[Engine] Model call failed (${model}):`, err.message);
2801
- const fallbackModelId = retryForFallback
2802
- ? await getFailureFallbackModelId(userId, agentId, model, aiSettings.fallback_model_id, err)
2803
- : null;
2804
- if (fallbackModelId) {
2805
- const failedModel = model;
2806
- console.log(`[Engine] Attempting fallback to: ${fallbackModelId}`);
2807
- const fallback = await getProviderForUser(
2808
- userId,
2809
- userMessage,
2810
- triggerType === 'subagent',
2811
- fallbackModelId,
2812
- providerStatusConfig
2813
- );
2814
- provider = fallback.provider;
2815
- model = fallback.model;
2816
- providerName = fallback.providerName;
2817
-
2818
- const retryMessages = sanitizeConversationMessages([
2819
- ...messages,
2820
- {
2821
- role: 'system',
2822
- content: buildModelFailureLoopPrompt({
2823
- failedModel,
2824
- nextModel: model,
2825
- errorMessage: err.message
2826
- })
2827
- }
2828
- ]);
2829
-
2830
- const fallbackCall = await this.requestModelResponse({
2831
- provider,
2832
- providerName,
2833
- model,
2834
- messages: retryMessages,
2835
- tools,
2836
- options: { ...options, userId },
2837
- runId,
2838
- iteration,
2839
- });
2840
- response = fallbackCall.response;
2841
- responseModel = fallbackCall.responseModel;
2842
- streamContent = fallbackCall.streamContent;
2843
- } else {
2844
- throw err;
2845
- }
2846
- }
2847
- };
2848
-
2849
- try {
2850
- await tryModelCall();
2851
- } catch (err) {
2852
- const modelError = String(err?.message || 'Model call failed');
2853
-
2854
- if (modelFailureRecoveries < loopPolicy.maxModelFailureRecoveries) {
2855
- const failedModel = model;
2856
- const switched = await switchToFallbackModel(failedModel, err, 'model turn');
2857
- if (!switched) throw err;
2858
- modelFailureRecoveries += 1;
2859
- failedStepCount += 1;
2860
- messages.push({
2861
- role: 'system',
2862
- content: buildModelFailureLoopPrompt({
2863
- failedModel,
2864
- nextModel: model,
2865
- errorMessage: modelError
2866
- })
2867
- });
2868
- this.emit(userId, 'run:interim', {
2869
- runId,
2870
- message: 'Model call failed; adapting and retrying autonomously.',
2871
- phase: 'recovering'
2872
- });
2873
- continue;
2874
- }
2875
-
2876
- throw err;
2877
- }
2878
-
2879
- if (!response) {
2880
- response = { content: streamContent, toolCalls: [], finishReason: 'stop', usage: null };
2881
- }
2882
-
2883
- if (response.usage) {
2884
- totalTokens += response.usage.totalTokens || 0;
2885
- }
2886
-
2887
- lastContent = sanitizeModelOutput(response.content || streamContent || '', { model: responseModel });
2888
-
2889
- if ((!response.toolCalls || response.toolCalls.length === 0) && lastContent) {
2890
- const salvaged = salvageTextToolCalls(lastContent, tools);
2891
- if (salvaged.toolCalls.length > 0) {
2892
- response.toolCalls = salvaged.toolCalls;
2893
- response.finishReason = 'tool_calls';
2894
- response.content = salvaged.content;
2895
- lastContent = salvaged.content;
2896
- }
2897
- }
2898
-
2899
- this.recordRunEvent(userId, runId, 'model_turn_completed', {
2900
- iteration,
2901
- toolCallCount: response.toolCalls?.length || 0,
2902
- contentPreview: String(lastContent || streamContent || '').slice(0, 240),
2903
- }, { agentId });
2904
-
2905
- const assistantMessage = { role: 'assistant', content: lastContent };
2906
- if (response.toolCalls?.length) assistantMessage.tool_calls = response.toolCalls;
2907
- if (response.providerContentBlocks?.length) assistantMessage.providerContentBlocks = response.providerContentBlocks;
2908
- messages.push(assistantMessage);
2909
-
2910
- if (conversationId) {
2911
- db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tool_calls, tokens) VALUES (?, ?, ?, ?, ?)')
2912
- .run(
2913
- conversationId,
2914
- 'assistant',
2915
- lastContent,
2916
- response.toolCalls?.length ? JSON.stringify(response.toolCalls) : null,
2917
- response.usage?.totalTokens || 0
2918
- );
2919
- }
2920
-
2921
- if (!response.toolCalls || response.toolCalls.length === 0) {
2922
- this.updateRunProgress(runId, {
2923
- currentPhase: 'idle',
2924
- currentStep: null,
2925
- currentTool: null,
2926
- currentStepStartedAt: null,
2927
- }, {
2928
- verified: true,
2929
- });
2930
- const systemSteeringAfterResponse = this.applyQueuedSystemSteering(runId, messages);
2931
- messages = systemSteeringAfterResponse.messages;
2932
- if (systemSteeringAfterResponse.appliedCount > 0) {
2933
- iteration = Math.max(0, iteration - 1);
2934
- lastContent = '';
2935
- continue;
2936
- }
2937
- const steeringAfterResponse = this.applyQueuedSteering(runId, messages, {
2938
- userId,
2939
- conversationId
2940
- });
2941
- messages = steeringAfterResponse.messages;
2942
- if (steeringAfterResponse.appliedCount > 0) {
2943
- iteration = Math.max(0, iteration - 1);
2944
- lastContent = '';
2945
- continue;
2946
- }
2947
- const messagingSent = this.activeRuns.get(runId)?.messagingSent || false;
2948
- if (this.shouldFastCompleteVoiceReply({
2949
- options,
2950
- toolExecutions,
2951
- failedStepCount,
2952
- messagingSent,
2953
- lastReply: lastContent,
2954
- })) {
2955
- break;
2956
- }
2957
- if (iteration < maxIterations) {
2958
- const proactiveRunNeedsDecision = (
2959
- (triggerSource === 'schedule' || triggerSource === 'tasks')
2960
- && this.activeRuns.get(runId)?.noResponse !== true
2961
- && options.deliveryState?.noResponse !== true
2962
- );
2963
- const visibleInterimActivity = hasVisibleInterimActivity(this.activeRuns.get(runId));
2964
- const fallbackStatus = (
2965
- proactiveRunNeedsDecision
2966
- || toolExecutions.length > 0
2967
- || failedStepCount > 0
2968
- || messagingSent
2969
- || visibleInterimActivity
2970
- ) ? 'continue' : 'complete';
2971
- const loopState = await runWithModelFallback('loop decision', () => this.decideLoopState({
2972
- provider,
2973
- providerName,
2974
- model,
2975
- messages,
2976
- tools,
2977
- analysis,
2978
- plan,
2979
- toolExecutions,
2980
- lastReply: lastContent,
2981
- triggerSource,
2982
- messagingSent,
2983
- iteration,
2984
- maxIterations,
2985
- options: { ...options, runId, userId, agentId },
2986
- fallbackStatus,
2987
- }));
2988
- totalTokens += loopState.usage || 0;
2989
- if (loopState.decision.status === 'continue') {
2990
- messages.push({
2991
- role: 'system',
2992
- content: [
2993
- loopState.decision.reason ? `Continue working: ${loopState.decision.reason}.` : 'Continue working autonomously.',
2994
- messagingSent
2995
- ? 'You already sent a user-facing message in this run. Keep working silently unless you have a materially new finished result or a real external blocker.'
2996
- : 'Use send_interim_update sparingly if a short real update or question would help. Otherwise keep working until you have the result or a real blocker.',
2997
- ].join(' ')
2998
- });
2999
- lastContent = '';
3000
- continue;
3001
- }
3002
- }
3003
- break;
3004
- }
3005
-
3006
- const canRunParallelBatch = (
3007
- response.toolCalls.length > 1
3008
- && response.toolCalls.every((toolCall) => this.isReadOnlyToolCall(toolCall))
3009
- );
3010
- if (canRunParallelBatch) {
3011
- const batch = await this.executeReadOnlyBatch(response.toolCalls, {
3012
- userId,
3013
- runId,
3014
- agentId,
3015
- app,
3016
- triggerType,
3017
- triggerSource,
3018
- conversationId,
3019
- startingStepIndex: stepIndex,
3020
- iteration,
3021
- options,
3022
- });
3023
- stepIndex = batch.endingStepIndex;
3024
- for (const item of batch.results) {
3025
- const execution = classifyToolExecution(
3026
- item.toolName,
3027
- item.toolArgs,
3028
- item.result,
3029
- item.error || '',
3030
- );
3031
- execution.input = item.toolArgs;
3032
- execution.artifacts = await extractArtifactsFromResult(item.toolName, item.result);
3033
- toolExecutions.push(execution);
3034
- this.getRunMeta(runId)?.repetitionGuard?.observe(item.toolName, item.toolArgs, item.result);
3035
- if (item.error) failedStepCount += 1;
3036
- const modelPayload = compactPayloadForModel(item.toolName, item.result);
3037
- const toolResultLimits = resolveToolResultLimits(item.toolName, loopPolicy);
3038
- const toolMessage = {
3039
- role: 'tool',
3040
- name: item.toolName,
3041
- tool_call_id: item.toolCall.id,
3042
- content: compactToolResult(item.toolName, item.toolArgs, modelPayload.result, {
3043
- softLimit: toolResultLimits.softLimit,
3044
- hardLimit: toolResultLimits.hardLimit,
3045
- }),
3046
- };
3047
- messages.push(toolMessage);
3048
- if (conversationId) {
3049
- db.prepare(
3050
- `INSERT INTO conversation_messages (
3051
- conversation_id, role, content, tool_call_id, name
3052
- ) VALUES (?, 'tool', ?, ?, ?)`
3053
- ).run(conversationId, toolMessage.content, item.toolCall.id, item.toolName);
3054
- }
3055
- }
3056
- this.persistRunMetadata(runId, {
3057
- evidenceSources: [...new Set(toolExecutions.map((item) => item.evidenceSource).filter(Boolean))],
3058
- subagentState: this.listSubagents(runId),
3059
- deliverableArtifacts,
3060
- compactionMetrics: compactionMetrics.slice(-20),
3061
- });
3062
- continue;
3063
- }
3064
-
3065
- for (const toolCall of response.toolCalls) {
3066
- if (this.isRunStopped(runId)) break;
3067
- stepIndex++;
3068
- const stepId = uuidv4();
3069
- const toolName = toolCall.function.name;
3070
- const stepStartedAt = Date.now();
3071
- let toolArgs;
3072
- try {
3073
- toolArgs = JSON.parse(toolCall.function.arguments || '{}');
3074
- } catch {
3075
- toolArgs = {};
3076
- }
3077
-
3078
- // ── task_complete: AI explicitly signals the task is fully done ──
3079
- // Handle before DB insert / before_tool_call hook — this is not a
3080
- // regular tool execution, it is a loop-exit signal.
3081
- if (toolName === 'task_complete') {
3082
- const finalMessage = String(toolArgs.message || '').trim();
3083
- const confidence = normalizeCompletionConfidence(toolArgs.confidence || 'medium');
3084
- const completionDecision = shouldAcceptTaskComplete({
3085
- confidence,
3086
- requiredConfidence: analysis?.completion_confidence_required || 'medium',
3087
- iteration,
3088
- maxIterations,
3089
- });
3090
- this.recordRunEvent(userId, runId, 'task_complete_signaled', {
3091
- confidence,
3092
- requiredConfidence: analysis?.completion_confidence_required || 'medium',
3093
- accepted: completionDecision.accept,
3094
- iteration,
3095
- messageLength: finalMessage.length,
3096
- }, { agentId });
3097
- console.info(
3098
- `[Run ${shortenRunId(runId)}] task_complete signaled at iteration=${iteration} confidence=${confidence} accepted=${completionDecision.accept}`
3099
- );
3100
- if (!completionDecision.accept) {
3101
- messages.push({
3102
- role: 'tool',
3103
- name: toolName,
3104
- tool_call_id: toolCall.id,
3105
- content: JSON.stringify({
3106
- status: 'continue',
3107
- reason: completionDecision.reason,
3108
- required_confidence: analysis?.completion_confidence_required || 'medium',
3109
- }),
3110
- });
3111
- messages.push({
3112
- role: 'system',
3113
- content: `${completionDecision.reason} Do not ask the user to decide the next step unless external input is truly required.`
3114
- });
3115
- continue;
3116
- }
3117
- if (completionDecision.reason) {
3118
- messages.push({
3119
- role: 'system',
3120
- content: completionDecision.reason,
3121
- });
3122
- }
3123
- lastContent = finalMessage; // empty string is valid; downstream handles it
3124
- directAnswerEligible = true;
3125
- break; // exit the for-loop; the while condition will also exit
3126
- }
3127
-
3128
- const repetitionGuard = this.getRunMeta(runId)?.repetitionGuard;
3129
- if (repetitionGuard?.shouldBlock(toolName, toolArgs)) {
3130
- const blockedResult = {
3131
- tool: toolName,
3132
- status: 'blocked',
3133
- reason: 'The same tool call already returned an unchanged result twice. Change the approach or complete with the available evidence.',
3134
- };
3135
- messages.push({
3136
- role: 'tool',
3137
- name: toolName,
3138
- tool_call_id: toolCall.id,
3139
- content: JSON.stringify(blockedResult),
3140
- });
3141
- this.recordRunEvent(userId, runId, 'repetition_blocked', {
3142
- toolName,
3143
- toolArgs,
3144
- }, { agentId });
3145
- this.emit(userId, 'run:tool_end', {
3146
- runId,
3147
- toolName,
3148
- status: 'blocked',
3149
- result: blockedResult,
3150
- });
3151
- messages.push({
3152
- role: 'system',
3153
- content: 'The repeated call was blocked because it made no progress. Use a different tool, change the arguments, or finish with a truthful result.',
3154
- });
3155
- continue;
3156
- }
3157
-
3158
- // ── before_tool_call hook ──
3159
- // Plugins can block a tool call (e.g. security policy) or mutate args.
3160
- if (globalHooks.has('before_tool_call')) {
3161
- const hookCtx = { toolName, toolArgs, runId, userId, agentId, iteration };
3162
- const hookResult = await globalHooks.run('before_tool_call', hookCtx);
3163
- if (hookResult.block) {
3164
- const blockReason = hookResult.reason || 'Blocked by policy.';
3165
- const blockedBy = hookResult.blocked_by || 'policy';
3166
- console.warn(`[Run ${shortenRunId(runId)}] before_tool_call hook blocked tool=${toolName} reason="${blockReason}"`);
3167
- messages.push({
3168
- role: 'tool',
3169
- name: toolName,
3170
- tool_call_id: toolCall.id,
3171
- content: JSON.stringify({ tool: toolName, status: 'blocked', reason: blockReason, blocked_by: blockedBy }),
3172
- });
3173
- continue;
3174
- }
3175
- if (hookResult.toolArgs) toolArgs = hookResult.toolArgs;
3176
- }
3177
-
3178
- db.prepare('INSERT INTO agent_steps (id, run_id, step_index, type, description, status, tool_name, tool_input, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime(\'now\'))')
3179
- .run(stepId, runId, stepIndex, this.getStepType(toolName), `${toolName}: ${JSON.stringify(toolArgs).slice(0, 200)} `, 'running', toolName, JSON.stringify(toolArgs));
3180
- this.updateRunProgress(runId, {
3181
- currentPhase: 'tool',
3182
- currentStep: stepId,
3183
- currentTool: toolName,
3184
- currentStepStartedAt: isoNow(),
3185
- }, {
3186
- verified: true,
3187
- stepId,
3188
- });
3189
-
3190
- this.emit(userId, 'run:tool_start', {
3191
- runId, stepId, stepIndex, toolName, toolArgs,
3192
- type: this.getStepType(toolName)
3193
- });
3194
- this.recordRunEvent(userId, runId, 'tool_started', {
3195
- stepIndex,
3196
- toolName,
3197
- toolArgs,
3198
- type: this.getStepType(toolName),
3199
- }, { agentId, stepId });
3200
- console.info(
3201
- `[Run ${shortenRunId(runId)}] step=${stepIndex} start tool=${toolName} args=${summarizeForLog(toolArgs)}`
3202
- );
3203
-
3204
- let toolResult;
3205
- let toolErrorMessage = '';
3206
- try {
3207
- toolResult = await this.executeTool(toolName, toolArgs, {
3208
- userId,
3209
- runId,
3210
- agentId,
3211
- app,
3212
- triggerType,
3213
- triggerSource,
3214
- conversationId,
3215
- source: options.source || null,
3216
- chatId: options.chatId || null,
3217
- taskId: options.taskId || null,
3218
- widgetId: options.widgetId || null,
3219
- deliveryState: options.deliveryState || null,
3220
- allowMultipleProactiveMessages: options.allowMultipleProactiveMessages === true,
3221
- allowExternalSideEffects: options.allowExternalSideEffects === true,
3222
- });
3223
- this.detachProcessFromRun(runId, toolResult?.pid);
3224
- toolErrorMessage = inferToolFailureMessage(toolName, toolResult);
3225
- if (toolErrorMessage) {
3226
- failedStepCount++;
3227
- }
3228
- const screenshotPath = toolResult?.screenshotPath || null;
3229
- const stepStatus = this.isRunStopped(runId) ? 'stopped' : (toolErrorMessage ? 'failed' : 'completed');
3230
- db.prepare('UPDATE agent_steps SET status = ?, result = ?, error = ?, screenshot_path = ?, completed_at = datetime(\'now\') WHERE id = ?')
3231
- .run(stepStatus, JSON.stringify(toolResult).slice(0, 20000), toolErrorMessage || null, screenshotPath, stepId);
3232
- if (toolErrorMessage) {
3233
- this.emit(userId, 'run:tool_end', { runId, stepId, toolName, error: toolErrorMessage, result: toolResult, screenshotPath, status: stepStatus });
3234
- this.recordRunEvent(userId, runId, 'tool_failed', {
3235
- toolName,
3236
- status: stepStatus,
3237
- error: toolErrorMessage,
3238
- durationMs: Date.now() - stepStartedAt,
3239
- resultPreview: summarizeForLog(toolResult),
3240
- }, { agentId, stepId });
3241
- console.warn(
3242
- `[Run ${shortenRunId(runId)}] step=${stepIndex} failed tool=${toolName} durationMs=${Date.now() - stepStartedAt} error=${summarizeForLog(toolErrorMessage, 160)}`
3243
- );
3244
- } else {
3245
- this.emit(userId, 'run:tool_end', { runId, stepId, toolName, result: toolResult, screenshotPath, status: stepStatus });
3246
- this.recordRunEvent(userId, runId, 'tool_completed', {
3247
- toolName,
3248
- status: stepStatus,
3249
- durationMs: Date.now() - stepStartedAt,
3250
- resultPreview: summarizeForLog(toolResult),
3251
- }, { agentId, stepId });
3252
- console.info(
3253
- `[Run ${shortenRunId(runId)}] step=${stepIndex} done tool=${toolName} status=${stepStatus} durationMs=${Date.now() - stepStartedAt} result=${summarizeForLog(toolResult)}`
3254
- );
3255
- }
3256
- } catch (err) {
3257
- toolResult = { error: err.message };
3258
- toolErrorMessage = String(err.message || 'Tool execution failed');
3259
- failedStepCount++;
3260
- this.detachProcessFromRun(runId, toolResult?.pid);
3261
- db.prepare('UPDATE agent_steps SET status = ?, error = ?, completed_at = datetime(\'now\') WHERE id = ?')
3262
- .run('failed', err.message, stepId);
3263
- this.emit(userId, 'run:tool_end', { runId, stepId, toolName, error: err.message, status: 'failed' });
3264
- this.recordRunEvent(userId, runId, 'tool_failed', {
3265
- toolName,
3266
- status: 'failed',
3267
- error: err.message,
3268
- durationMs: Date.now() - stepStartedAt,
3269
- }, { agentId, stepId });
3270
- console.warn(
3271
- `[Run ${shortenRunId(runId)}] step=${stepIndex} failed tool=${toolName} durationMs=${Date.now() - stepStartedAt} error=${summarizeForLog(err.message, 160)}`
3272
- );
3273
- }
3274
-
3275
- const execution = classifyToolExecution(toolName, toolArgs, toolResult, toolErrorMessage);
3276
- execution.input = toolArgs;
3277
- repetitionGuard?.observe(toolName, toolArgs, toolResult);
3278
- execution.artifacts = await extractArtifactsFromResult(toolName, toolResult);
3279
- toolExecutions.push(execution);
3280
- if (deliverableWorkflow && Array.isArray(execution.artifacts) && execution.artifacts.length > 0) {
3281
- for (const artifact of execution.artifacts) {
3282
- if (!deliverableArtifacts.some((existing) => (
3283
- (existing.path && artifact.path && existing.path === artifact.path)
3284
- || (existing.uri && artifact.uri && existing.uri === artifact.uri)
3285
- ))) {
3286
- deliverableArtifacts.push(artifact);
3287
- this.recordRunEvent(userId, runId, 'deliverable_artifact_produced', {
3288
- type: deliverableWorkflow.selection.type,
3289
- toolName,
3290
- artifact,
3291
- }, { agentId, stepId });
3292
- }
3293
- }
3294
- }
3295
- this.persistRunMetadata(runId, {
3296
- evidenceSources: [...new Set(toolExecutions.map((item) => item.evidenceSource).filter(Boolean))],
3297
- subagentState: this.listSubagents(runId),
3298
- deliverableArtifacts,
3299
- compactionMetrics: compactionMetrics.slice(-20),
3300
- });
3301
- const modelPayload = compactPayloadForModel(toolName, toolResult);
3302
- if (modelPayload.metrics?.applied) {
3303
- const metric = {
3304
- toolName,
3305
- stepId,
3306
- ...modelPayload.metrics,
3307
- createdAt: new Date().toISOString(),
3308
- };
3309
- compactionMetrics.push(metric);
3310
- this.persistRunMetadata(runId, {
3311
- compactionMetrics: compactionMetrics.slice(-20),
3312
- });
3313
- this.recordRunEvent(userId, runId, 'pre_model_compaction_applied', {
3314
- toolName,
3315
- metrics: modelPayload.metrics,
3316
- }, { agentId, stepId });
3317
- }
3318
-
3319
- const toolResultLimits = resolveToolResultLimits(toolName, loopPolicy);
3320
- const toolMessage = {
3321
- role: 'tool',
3322
- name: toolName,
3323
- tool_call_id: toolCall.id,
3324
- content: compactToolResult(toolName, toolArgs, modelPayload.result, {
3325
- softLimit: toolResultLimits.softLimit,
3326
- hardLimit: toolResultLimits.hardLimit,
3327
- })
3328
- };
3329
- messages.push(toolMessage);
3330
- if (toolName === 'activate_tools' && !toolErrorMessage) {
3331
- tools = this.getActiveTools(runId);
3332
- }
3333
-
3334
- if (toolErrorMessage) {
3335
- consecutiveToolFailures += 1;
3336
- const alternativeTools = summarizeAvailableTools(tools, { exclude: toolName });
3337
- messages.push({
3338
- role: 'system',
3339
- content: [
3340
- `Tool "${toolName}" failed with error: ${summarizeForLog(toolErrorMessage, 240)}.`,
3341
- 'This tool failure is not, by itself, a user-facing blocker.',
3342
- 'Continue autonomously: retry with corrected arguments, try an alternative tool/path, or verify the outcome using other available tools.',
3343
- alternativeTools ? `Other available tools in this run: ${alternativeTools}.` : '',
3344
- 'Only stop and tell the user you are blocked if the remaining issue truly requires an external dependency or user action outside this run.'
3345
- ].filter(Boolean).join(' ')
3346
- });
3347
-
3348
- if (consecutiveToolFailures >= loopPolicy.maxConsecutiveToolFailures) {
3349
- messages.push({
3350
- role: 'system',
3351
- content: `There were ${consecutiveToolFailures} consecutive tool failures. Stop calling tools now and return a clear blocker response that summarizes attempted actions and concrete errors.`
3352
- });
3353
- break;
3354
- }
3355
- } else {
3356
- consecutiveToolFailures = 0;
3357
- }
3358
-
3359
- if (toolName === 'send_interim_update') {
3360
- messages.push({
3361
- role: 'system',
3362
- 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.'
3363
- });
3364
- }
3365
-
3366
- if (toolName === 'execute_command' && (toolResult?.timedOut || toolResult?.killed)) {
3367
- messages.push({
3368
- role: 'system',
3369
- 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.'
3370
- });
3371
- }
3372
-
3373
- if (
3374
- toolName === 'execute_command'
3375
- && toolResult?.exitCode !== undefined
3376
- && toolResult.exitCode !== 0
3377
- ) {
3378
- messages.push({
3379
- role: 'system',
3380
- 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.'
3381
- });
3382
- }
3383
-
3384
- if (conversationId) {
3385
- db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tool_call_id, name) VALUES (?, ?, ?, ?, ?)')
3386
- .run(conversationId, 'tool', toolMessage.content, toolCall.id, toolName);
3387
- }
3388
-
3389
- this.updateRunProgress(runId, {
3390
- currentPhase: 'idle',
3391
- currentStep: null,
3392
- currentTool: null,
3393
- currentStepStartedAt: null,
3394
- }, {
3395
- verified: true,
3396
- stepId,
3397
- });
3398
-
3399
- const runMeta = this.activeRuns.get(runId);
3400
- if (runMeta) {
3401
- runMeta.lastToolName = toolName;
3402
- runMeta.lastToolTarget = toolName === 'send_message' ? toolArgs.to : null;
3403
- if (toolName === 'save_widget_snapshot' && !toolErrorMessage) {
3404
- runMeta.widgetSnapshotSaved = true;
3405
- }
3406
- }
3407
-
3408
- if (toolName === 'save_widget_snapshot' && !toolErrorMessage) {
3409
- lastContent = 'Widget snapshot updated.';
3410
- break;
3411
- }
3412
-
3413
- if (runMeta?.terminalInterim) {
3414
- break;
3415
- }
3416
- }
3417
-
3418
- if (this.isRunStopped(runId)) break;
3419
- if (this.getRunMeta(runId)?.terminalInterim) break;
3420
- if (this.getRunMeta(runId)?.widgetSnapshotSaved) break;
3421
- if (!this.activeRuns.has(runId)) break;
3422
- }
3423
-
3424
- if (this.isRunStopped(runId)) {
3425
- db.prepare('UPDATE agent_runs SET status = ?, updated_at = datetime(\'now\'), completed_at = datetime(\'now\') WHERE id = ?')
3426
- .run('stopped', runId);
3427
- console.warn(
3428
- `[Run ${shortenRunId(runId)}] stopped trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
3429
- );
3430
- this.stopMessagingProgressSupervisor(runId);
3431
- this.activeRuns.delete(runId);
3432
- this.emit(userId, 'run:stopped', { runId, triggerSource });
3433
- this.recordRunEvent(userId, runId, 'run_stopped', {
3434
- triggerSource,
3435
- totalTokens,
3436
- iterations: iteration,
3437
- }, { agentId });
3438
- return { runId, content: '', totalTokens, iterations: iteration, status: 'stopped' };
3439
- }
3440
-
3441
- const runMeta = this.activeRuns.get(runId);
3442
- if (runMeta?.terminalInterim) {
3443
- lastContent = '';
3444
- }
3445
- if (runMeta?.widgetSnapshotSaved && !lastContent) {
3446
- lastContent = 'Widget snapshot updated.';
3447
- }
3448
- const messagingSent = runMeta?.messagingSent || false;
3449
- const lastToolWasMessaging = runMeta?.lastToolName === 'send_message' || runMeta?.lastToolName === 'make_call';
3450
-
3451
- if (triggerSource === 'messaging' && !normalizeOutgoingMessage(lastContent, options?.source || null) && !messagingSent) {
3452
- const recovered = await this.recoverBlankMessagingReply({
3453
- userId,
3454
- runId,
3455
- messages,
3456
- provider,
3457
- model,
3458
- providerName,
3459
- options: { ...options, runId, userId, agentId },
3460
- stepIndex,
3461
- failedStepCount,
3462
- toolExecutions,
3463
- tools
3464
- });
3465
- lastContent = recovered.content;
3466
- totalTokens += recovered.tokens || 0;
3467
- if (normalizeOutgoingMessage(lastContent, options?.source || null)) {
3468
- messages.push({ role: 'assistant', content: lastContent });
3469
- if (conversationId) {
3470
- db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
3471
- .run(conversationId, 'assistant', lastContent, recovered.tokens || 0);
3472
- }
3473
- }
3474
- }
3475
-
3476
- if (
3477
- !normalizeOutgoingMessage(lastContent, options?.source || null)
3478
- && !messagingSent
3479
- && runMeta?.widgetSnapshotSaved !== true
3480
- ) {
3481
- const explicitNoResponse = (
3482
- runMeta?.noResponse === true
3483
- || options.deliveryState?.noResponse === true
3484
- );
3485
- if (
3486
- (triggerSource === 'schedule' || triggerSource === 'tasks')
3487
- && !explicitNoResponse
3488
- ) {
3489
- throw new Error(
3490
- 'Background run ended without producing a result or an explicit no-response decision.',
3491
- );
3492
- }
3493
- if (iteration >= maxIterations) {
3494
- throw new Error(`Iteration limit reached before explicit completion after ${maxIterations} iterations.`);
3495
- }
3496
- if (stepIndex > 0 && !lastToolWasMessaging) {
3497
- throw new Error('Run ended without an explicit completion or blocker reply.');
3498
- }
3499
- }
3500
-
3501
- const sentMessageText = joinSentMessages(runMeta?.sentMessages);
3502
- const normalizedLastContent = normalizeOutgoingMessage(lastContent, options?.source || null);
3503
- let finalResponseText = messagingSent
3504
- ? (sentMessageText || (normalizedLastContent ? lastContent.trim() : ''))
3505
- : (normalizedLastContent ? lastContent.trim() : sentMessageText);
3506
- const lastFinalDeliveryMessage = normalizeOutgoingMessage(
3507
- runMeta?.lastSentMessage
3508
- || (Array.isArray(runMeta?.sentMessages) ? runMeta.sentMessages[runMeta.sentMessages.length - 1] : '')
3509
- || '',
3510
- options?.source || null
3511
- );
3512
-
3513
- if (
3514
- options.skipVerifier !== true
3515
- && shouldRunVerifier({
3516
- analysis,
3517
- toolExecutions,
3518
- finalReply: finalResponseText,
3519
- })) {
3520
- const verificationResult = await runWithModelFallback('final verification', () => this.verifyFinalResponse({
3521
- provider,
3522
- providerName,
3523
- model,
3524
- messages,
3525
- analysis,
3526
- tools,
3527
- toolExecutions,
3528
- finalReply: finalResponseText,
3529
- options: { ...options, runId, userId, agentId },
3530
- }));
3531
- totalTokens += verificationResult.usage || 0;
3532
- verification = verificationResult.verification;
3533
- if (verification.final_reply) {
3534
- finalResponseText = verification.final_reply;
3535
- lastContent = verification.final_reply;
3536
- }
3537
-
3538
- stepIndex += 1;
3539
- const verificationStepId = uuidv4();
3540
- db.prepare(`INSERT INTO agent_steps
3541
- (id, run_id, step_index, type, description, status, result, started_at, completed_at)
3542
- VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`)
3543
- .run(
3544
- verificationStepId,
3545
- runId,
3546
- stepIndex,
3547
- 'verification',
3548
- 'Evidence verification',
3549
- verification.status === 'verified' ? 'completed' : 'failed',
3550
- JSON.stringify(verification).slice(0, 20000)
3551
- );
3552
- this.persistRunMetadata(runId, {
3553
- verification,
3554
- evidenceSources: verificationResult.evidenceSources,
3555
- });
3556
- this.emit(userId, 'run:verification', {
3557
- runId,
3558
- ...verification,
3559
- evidenceSources: verificationResult.evidenceSources,
3560
- });
3561
- }
3562
-
3563
- if (deliverableWorkflow && deliverablePlan) {
3564
- this.recordRunEvent(userId, runId, 'deliverable_validation_started', {
3565
- type: deliverableWorkflow.selection.type,
3566
- artifactCount: deliverableArtifacts.length,
3567
- }, { agentId });
3568
- const validationResult = await validateDeliverableExecution({
3569
- workflow: deliverableWorkflow.workflow,
3570
- request: deliverableWorkflow.request,
3571
- plan: deliverablePlan,
3572
- finalReply: finalResponseText,
3573
- artifacts: deliverableArtifacts,
3574
- toolExecutions,
3575
- runId,
3576
- });
3577
- deliverableValidation = validationResult.validation;
3578
- this.persistRunMetadata(runId, {
3579
- deliverable: validationResult.result,
3580
- });
3581
- if (deliverableValidation.status !== 'passed') {
3582
- this.recordRunEvent(userId, runId, 'deliverable_validation_failed', {
3583
- type: deliverableWorkflow.selection.type,
3584
- errors: deliverableValidation.errors,
3585
- warnings: deliverableValidation.warnings,
3586
- }, { agentId });
3587
- throw new DeliverableValidationError(
3588
- deliverableValidation.summary || `Deliverable validation failed for ${deliverableWorkflow.selection.type}.`,
3589
- {
3590
- validation: deliverableValidation,
3591
- result: validationResult.result,
3592
- },
3593
- );
3594
- }
3595
- await this.persistDeliverableMemory(userId, runId, agentId, validationResult.result);
3596
- this.recordRunEvent(userId, runId, 'deliverable_completed', {
3597
- type: deliverableWorkflow.selection.type,
3598
- artifactCount: validationResult.result.artifacts.length,
3599
- summary: validationResult.result.summary,
3600
- }, { agentId });
3601
- }
3602
-
3603
- db.prepare('UPDATE agent_runs SET status = ?, total_tokens = ?, final_response = ?, updated_at = datetime(\'now\'), completed_at = datetime(\'now\') WHERE id = ?')
3604
- .run('completed', totalTokens, finalResponseText || null, runId);
3605
-
3606
- if (conversationId) {
3607
- db.prepare('UPDATE conversations SET total_tokens = total_tokens + ?, updated_at = datetime(\'now\') WHERE id = ?')
3608
- .run(totalTokens, conversationId);
3609
- if (options.skipConversationMaintenance !== true) {
3610
- refreshConversationSummary(conversationId, provider, model, historyWindow).catch((err) => {
3611
- console.error('[AI] Conversation summary refresh failed:', err.message);
3612
- });
3613
- await this.refreshConversationState({
3614
- conversationId,
3615
- runId,
3616
- provider,
3617
- providerName,
3618
- model,
3619
- finalReply: finalResponseText,
3620
- analysis,
3621
- verification,
3622
- historyWindow,
3623
- options: { ...options, userId, agentId },
3624
- }).catch((err) => {
3625
- console.error('[AI] Conversation working state refresh failed:', err.message);
3626
- });
3627
- }
3628
- }
3629
-
3630
- await this.persistPromptMetrics(runId, {
3631
- ...promptMetrics,
3632
- finalTotalTokens: totalTokens
3633
- });
3634
-
3635
- await this.persistRunContext(userId, {
3636
- triggerSource,
3637
- runTitle,
3638
- userMessage,
3639
- lastContent: finalResponseText,
3640
- stepIndex,
3641
- skipPersistence: options.skipRunContextPersistence === true
3642
- });
3643
-
3644
- // Fallback: if this was a messaging-triggered run and no final delivery
3645
- // was already sent in this run, auto-send the final assistant text.
3646
- // Interim progress updates do not suppress this final delivery.
3647
- if (triggerSource === 'messaging' && options.source && options.chatId) {
3648
- if (this.shouldSendMessagingFinalFallback(runMeta, lastContent || '', options.source) && !lastFinalDeliveryMessage) {
3649
- await this.deliverMessagingFinalFallback({
3650
- runId,
3651
- userId,
3652
- agentId,
3653
- platform: options.source,
3654
- chatId: options.chatId,
3655
- content: lastContent || '',
3656
- });
3657
- }
3658
- }
3659
-
3660
- console.info(
3661
- `[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}`
3662
- );
3663
- this.cleanupSubagentsForRun(runId, { cancelRunning: true });
3664
- this.stopMessagingProgressSupervisor(runId);
3665
- this.activeRuns.delete(runId);
3666
- this.emit(userId, 'run:complete', {
3667
- runId,
3668
- content: lastContent,
3669
- totalTokens,
3670
- iterations: iteration,
3671
- triggerSource,
3672
- executionMode: analysis?.mode || 'execute',
3673
- verificationStatus: verification?.status || 'skipped',
3674
- });
3675
- this.recordRunEvent(userId, runId, 'run_completed', {
3676
- contentPreview: String(finalResponseText || lastContent || '').slice(0, 240),
3677
- totalTokens,
3678
- iterations: iteration,
3679
- triggerSource,
3680
- executionMode: analysis?.mode || 'execute',
3681
- verificationStatus: verification?.status || 'skipped',
3682
- }, { agentId });
3683
- // ── on_loop_end hook ──
3684
- // Fire-and-forget: plugins can use this for self-improvement, memory
3685
- // consolidation, analytics, or other post-run housekeeping.
3686
- if (globalHooks.has('on_loop_end')) {
3687
- globalHooks.run('on_loop_end', {
3688
- userId, runId, agentId, status: 'completed',
3689
- iterations: iteration, totalTokens,
3690
- taskAnalysis: analysis,
3691
- finalContent: finalResponseText,
3692
- }).catch(() => {});
3693
- }
3694
- if (this.learningManager) {
3695
- try {
3696
- const learningSteps = db.prepare(
3697
- `SELECT tool_name, tool_input, result, status
3698
- FROM agent_steps WHERE run_id = ? ORDER BY step_index ASC`
3699
- ).all(runId);
3700
- this.learningManager.maybeCaptureDraft({
3701
- userId,
3702
- agentId,
3703
- runId,
3704
- triggerSource,
3705
- triggerType,
3706
- task: userMessage,
3707
- title: runTitle,
3708
- finalContent: finalResponseText || lastContent,
3709
- steps: learningSteps,
3710
- });
3711
- } catch (learningError) {
3712
- console.warn('[Engine] Skill reflection failed:', learningError.message);
3713
- }
3714
- }
3715
-
3716
- return { runId, content: lastContent, totalTokens, iterations: iteration, status: 'completed' };
3717
- } catch (err) {
3718
- if (this.isRunStopped(runId)) {
3719
- db.prepare('UPDATE agent_runs SET status = ?, updated_at = datetime(\'now\'), completed_at = datetime(\'now\') WHERE id = ?')
3720
- .run('stopped', runId);
3721
- console.warn(
3722
- `[Run ${shortenRunId(runId)}] stopped trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
3723
- );
3724
- this.cleanupSubagentsForRun(runId, { cancelRunning: true });
3725
- this.stopMessagingProgressSupervisor(runId);
3726
- this.activeRuns.delete(runId);
3727
- this.emit(userId, 'run:stopped', { runId, triggerSource });
3728
- this.recordRunEvent(userId, runId, 'run_stopped', {
3729
- triggerSource,
3730
- totalTokens,
3731
- iterations: iteration,
3732
- }, { agentId });
3733
- return { runId, content: '', totalTokens, iterations: iteration, status: 'stopped' };
3734
- }
3735
-
3736
- const runMeta = this.activeRuns.get(runId);
3737
- const retryCount = Number(options.messagingAutonomousRetryCount || 0);
3738
- // Rate-limit errors (429) must not trigger messaging retries: the model
3739
- // won't be available in the milliseconds between retries, so spawning new
3740
- // runs just compounds the rate-limit pressure with no benefit.
3741
- const isRateLimitError = /429|rate.?limit|free-models-per/i.test(String(err?.message || ''));
3742
- const canRetryMessagingRun = (
3743
- triggerSource === 'messaging'
3744
- && options.source
3745
- && options.chatId
3746
- && err?.disableAutonomousRetry !== true
3747
- && !isRateLimitError
3748
- && retryCount < this.getMessagingRetryLimit(maxIterations)
3749
- );
3750
-
3751
- if (canRetryMessagingRun) {
3752
- const recoveryContext = buildAutonomousRecoveryContext({
3753
- err,
3754
- toolExecutions,
3755
- tools,
3756
- userMessage,
3757
- visibleMessageSent: Boolean(
3758
- runMeta?.lastSentMessage
3759
- || runMeta?.lastInterimMessage
3760
- || runMeta?.messagingSent === true
3761
- ),
3762
- });
3763
- db.prepare('UPDATE agent_runs SET status = ?, error = ?, updated_at = datetime(\'now\') WHERE id = ?')
3764
- .run('retrying', err.message, runId);
3765
- console.warn(
3766
- `[Run ${shortenRunId(runId)}] retrying_messaging_attempt=${retryCount + 1} reason=${summarizeForLog(err.message, 140)}`
3767
- );
3768
- this.cleanupSubagentsForRun(runId, { cancelRunning: true });
3769
- this.stopMessagingProgressSupervisor(runId);
3770
- this.activeRuns.delete(runId);
3771
- this.emit(userId, 'run:interim', {
3772
- runId,
3773
- message: 'Retrying internally after a transient failure.',
3774
- phase: 'retrying'
3775
- });
3776
-
3777
- const retryOptions = {
3778
- ...options,
3779
- messagingAutonomousRetryCount: retryCount + 1,
3780
- messagingRetryState: {
3781
- lastFinalMessage: String(runMeta?.lastSentMessage || options?.messagingRetryState?.lastFinalMessage || '').trim(),
3782
- explicitMessageSent: runMeta?.explicitMessageSent === true || options?.messagingRetryState?.explicitMessageSent === true,
3783
- interimHistory: cloneInterimHistory([
3784
- ...(Array.isArray(options?.messagingRetryState?.interimHistory) ? options.messagingRetryState.interimHistory : []),
3785
- ...(Array.isArray(runMeta?.interimMessages) ? runMeta.interimMessages : []),
3786
- ]),
3787
- lastUserVisibleUpdateAt: runMeta?.progressLedger?.lastUserVisibleUpdateAt || options?.messagingRetryState?.lastUserVisibleUpdateAt || null,
3788
- lastFinalDeliveryAt: runMeta?.progressLedger?.lastFinalDeliveryAt || options?.messagingRetryState?.lastFinalDeliveryAt || null,
3789
- heartbeatCount: Number(runMeta?.progressLedger?.heartbeatCount || options?.messagingRetryState?.heartbeatCount || 0),
3790
- progressState: runMeta?.progressLedger?.progressState || options?.messagingRetryState?.progressState || 'active',
3791
- lastVerifiedProgressAt: runMeta?.progressLedger?.lastVerifiedProgressAt || options?.messagingRetryState?.lastVerifiedProgressAt || null,
3792
- },
3793
- context: {
3794
- ...(options.context || {}),
3795
- additionalContext: [
3796
- options.context?.additionalContext || '',
3797
- recoveryContext,
3798
- ].filter(Boolean).join('\n\n')
3799
- }
3800
- };
3801
- delete retryOptions.runId;
3802
-
3803
- return this.runWithModel(userId, userMessage, retryOptions, _modelOverride);
3804
- }
3805
-
3806
- const deliverableFailureResponse = err?.deliverableResult?.summary
3807
- || err?.deliverableValidation?.summary
3808
- || '';
3809
- let messagingFailureContent = '';
3810
- let sendSucceeded = false;
3811
- if (triggerSource === 'messaging' && options.source && options.chatId) {
3812
- if (!runMeta?.messagingSent) {
3813
- const manager = this.messagingManager;
3814
- if (manager) {
3815
- const failureScenario = buildMessagingFailureScenario({
3816
- err,
3817
- failedStepCount,
3818
- stepIndex,
3819
- toolExecutions,
3820
- });
3821
- try {
3822
- const failedMessage = sanitizeConversationMessages([
3823
- ...messages,
3824
- {
3825
- role: 'system',
3826
- 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)}`
3827
- }
3828
- ]);
3829
- const modelReply = await provider.chat(failedMessage, [], {
3830
- model,
3831
- reasoningEffort: this.getReasoningEffort(providerName, options)
3832
- });
3833
- const drafted = sanitizeModelOutput(modelReply.content || '', { model });
3834
- if (normalizeOutgoingMessage(drafted, options?.source || null)) {
3835
- messagingFailureContent = drafted.trim();
3836
- }
3837
- } catch {
3838
- // Fall back to deterministic text below.
3839
- }
3840
-
3841
- if (!messagingFailureContent) {
3842
- messagingFailureContent = buildDeterministicMessagingErrorReply({
3843
- err,
3844
- failedStepCount,
3845
- stepIndex,
3846
- toolExecutions,
3847
- });
3848
- }
3849
-
3850
- try {
3851
- await manager.sendMessage(userId, options.source, options.chatId, messagingFailureContent, { runId, agentId });
3852
- sendSucceeded = true;
3853
- if (runMeta) {
3854
- runMeta.lastSentMessage = messagingFailureContent;
3855
- if (!Array.isArray(runMeta.sentMessages)) runMeta.sentMessages = [];
3856
- runMeta.sentMessages.push(messagingFailureContent);
3857
- }
3858
- this.markRunFinalDelivery(runId, messagingFailureContent);
3859
- } catch (sendErr) {
3860
- console.error('[Engine] Messaging error fallback failed:', sendErr.message);
3861
- messagingFailureContent = '';
3862
- }
3863
- }
3864
- }
3865
- }
3866
-
3867
- db.prepare('UPDATE agent_runs SET status = ?, error = ?, final_response = ?, updated_at = datetime(\'now\') WHERE id = ?')
3868
- .run(
3869
- 'failed',
3870
- err.message,
3871
- sendSucceeded
3872
- ? (messagingFailureContent || null)
3873
- : (deliverableFailureResponse || null),
3874
- runId,
3875
- );
3876
- console.error(
3877
- `[Run ${shortenRunId(runId)}] failed trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens} error=${summarizeForLog(err.message, 180)}`
3878
- );
3879
-
3880
- this.cleanupSubagentsForRun(runId, { cancelRunning: true });
3881
- this.stopMessagingProgressSupervisor(runId);
3882
- this.activeRuns.delete(runId);
3883
- this.emit(userId, 'run:error', { runId, error: err.message });
3884
- this.recordRunEvent(userId, runId, 'run_failed', {
3885
- error: err.message,
3886
- totalTokens,
3887
- iterations: iteration,
3888
- deliverableType: deliverableWorkflow?.selection?.type || null,
3889
- }, { agentId });
3890
-
3891
- if (messagingFailureContent) {
3892
- return {
3893
- runId,
3894
- content: messagingFailureContent,
3895
- totalTokens,
3896
- iterations: iteration,
3897
- status: 'failed'
3898
- };
3899
- }
3900
-
3901
- throw err;
3902
- }
3903
- }
3904
-
3905
- async spawnSubagent(userId, parentRunId, task, options = {}) {
3906
- const handle = uuidv4();
3907
- const childRunId = uuidv4();
3908
- let relevantMemories = [];
3909
- try {
3910
- relevantMemories = this.memoryManager
3911
- ? await this.memoryManager.recallMemory(userId, task, 4, {
3912
- agentId: options.agentId || null,
3913
- })
3914
- : [];
3915
- } catch {}
3916
- const subEngine = new AgentEngine(this.io, {
3917
- app: options.app || this.app,
3918
- browserController: this.browserController,
3919
- androidController: this.androidController,
3920
- runtimeManager: this.runtimeManager,
3921
- workspaceManager: this.workspaceManager,
3922
- messagingManager: this.messagingManager,
3923
- mcpManager: this.mcpManager,
3924
- skillRunner: this.skillRunner,
3925
- taskRuntime: this.taskRuntime,
3926
- memoryManager: this.memoryManager,
3927
- });
3928
-
3929
- const subagentContract = [
3930
- `Goal: ${String(task || '').trim()}`,
3931
- options.context ? `Constraints and relevant context:\n${String(options.context).trim()}` : '',
3932
- relevantMemories.length > 0
3933
- ? `Top relevant memories: ${JSON.stringify(relevantMemories.map((memory) => ({
3934
- content: memory.content,
3935
- confidence: memory.confidence,
3936
- })))}`
3937
- : '',
3938
- Array.isArray(options.requiredArtifacts) && options.requiredArtifacts.length > 0
3939
- ? `Required artifacts: ${JSON.stringify(options.requiredArtifacts)}`
3940
- : '',
3941
- Array.isArray(options.selectedTools) && options.selectedTools.length > 0
3942
- ? `Selected tools: ${JSON.stringify(options.selectedTools.slice(0, 20))}`
3943
- : '',
3944
- 'Return a single JSON object with exactly these top-level fields: status, findings, evidence, artifacts, confidence, remaining_blockers.',
3945
- 'status must be completed, partial, or blocked. findings, evidence, artifacts, and remaining_blockers must be arrays. confidence must be low, medium, or high.',
3946
- ].filter(Boolean).join('\n\n');
3947
- const record = {
3948
- handle,
3949
- parentRunId,
3950
- childRunId,
3951
- userId,
3952
- agentId: options.agentId || null,
3953
- task: subagentContract,
3954
- model: options.model || null,
3955
- status: 'running',
3956
- createdAt: new Date().toISOString(),
3957
- result: null,
3958
- error: null,
3959
- engine: subEngine,
3960
- promise: null,
3961
- };
3962
- this.subagents.set(handle, record);
3963
- this.emit(userId, 'run:subagent', {
3964
- runId: parentRunId,
3965
- handle,
3966
- childRunId,
3967
- status: 'running',
3968
- task: clampRunContext(task, 180),
3969
- });
3970
-
3971
- record.promise = (async () => {
3972
- try {
3973
- const result = await subEngine.runWithModel(
3974
- userId,
3975
- subagentContract,
3976
- {
3977
- app: options.app || this.app,
3978
- triggerType: 'subagent',
3979
- triggerSource: 'agent',
3980
- runId: childRunId,
3981
- agentId: options.agentId || null,
3982
- },
3983
- options.model || null
3984
- );
3985
- record.status = result.status || 'completed';
3986
- let structured = null;
3987
- try {
3988
- const raw = String(result.content || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
3989
- const parsed = JSON.parse(raw);
3990
- if (parsed && typeof parsed === 'object') structured = parsed;
3991
- } catch {}
3992
- record.result = {
3993
- runId: result.runId,
3994
- status: structured?.status || result.status || 'completed',
3995
- findings: Array.isArray(structured?.findings) ? structured.findings : [String(result.content || '').trim()].filter(Boolean),
3996
- evidence: Array.isArray(structured?.evidence) ? structured.evidence : [],
3997
- artifacts: Array.isArray(structured?.artifacts) ? structured.artifacts : [],
3998
- confidence: ['low', 'medium', 'high'].includes(structured?.confidence) ? structured.confidence : 'medium',
3999
- remainingBlockers: Array.isArray(structured?.remaining_blockers) ? structured.remaining_blockers : [],
4000
- totalTokens: result.totalTokens,
4001
- iterations: result.iterations,
4002
- };
4003
- this.emit(userId, 'run:subagent', {
4004
- runId: parentRunId,
4005
- handle,
4006
- childRunId,
4007
- status: record.status,
4008
- result: record.result,
4009
- });
4010
- return record;
4011
- } catch (err) {
4012
- record.status = 'failed';
4013
- record.error = err.message;
4014
- this.emit(userId, 'run:subagent', {
4015
- runId: parentRunId,
4016
- handle,
4017
- childRunId,
4018
- status: 'failed',
4019
- error: err.message,
4020
- });
4021
- throw err;
4022
- }
4023
- })();
4024
-
4025
- return {
4026
- handle,
4027
- status: 'running',
4028
- childRunId,
4029
- task: clampRunContext(task, 180),
4030
- };
4031
- }
4032
-
4033
- async delegateToAgent({
4034
- userId,
4035
- parentAgentId,
4036
- parentRunId,
4037
- target,
4038
- task,
4039
- context = '',
4040
- app = null,
4041
- allowExternalSideEffects = false,
4042
- } = {}) {
4043
- const { agentCanDelegateTo, getAgentById, getAgentBySlug, resolveAgentId } = require('../agents/manager');
4044
- const targetText = String(target || '').trim();
4045
- const taskText = String(task || '').trim();
4046
- if (!targetText || !taskText) {
4047
- throw new Error('Target agent and task are required.');
4048
- }
4049
-
4050
- let targetAgent = getAgentById(userId, targetText) || getAgentBySlug(userId, targetText);
4051
- if (!targetAgent) {
4052
- targetAgent = db.prepare(
4053
- "SELECT * FROM agents WHERE user_id = ? AND status = 'active' AND lower(display_name) = lower(?)"
4054
- ).get(userId, targetText);
4055
- }
4056
- if (!targetAgent || targetAgent.status !== 'active') {
4057
- throw new Error(`No active specialist agent matches "${targetText}".`);
4058
- }
4059
-
4060
- const scopedParentAgentId = resolveAgentId(userId, parentAgentId);
4061
- const parentAgent = getAgentById(userId, scopedParentAgentId);
4062
- if (targetAgent.id === scopedParentAgentId) {
4063
- throw new Error('An agent cannot delegate to itself.');
4064
- }
4065
- if (!agentCanDelegateTo(parentAgent, targetAgent)) {
4066
- throw new Error(`${parentAgent?.display_name || 'This agent'} is not allowed to delegate tasks to ${targetAgent.display_name}.`);
4067
- }
4068
-
4069
- const delegationId = uuidv4();
4070
- const childRunId = uuidv4();
4071
- db.prepare(
4072
- `INSERT INTO agent_delegations (
4073
- id, user_id, parent_agent_id, target_agent_id, parent_run_id, child_run_id, task, context, status
4074
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running')`
4075
- ).run(
4076
- delegationId,
4077
- userId,
4078
- scopedParentAgentId,
4079
- targetAgent.id,
4080
- parentRunId || null,
4081
- childRunId,
4082
- taskText,
4083
- context || null,
4084
- );
4085
-
4086
- const delegatedPrompt = [
4087
- '[SYSTEM: Delegated specialist-agent task]',
4088
- `You are running as ${targetAgent.display_name} (${targetAgent.slug}).`,
4089
- 'Complete this delegated task using only your own agent memory, settings, credentials, and available tools.',
4090
- allowExternalSideEffects
4091
- ? 'External side effects are allowed only when they directly satisfy the delegated task.'
4092
- : 'Do not send external messages, make calls, or change external shared systems. Return findings and recommendations to the parent agent instead.',
4093
- '',
4094
- `Task:\n${taskText}`,
4095
- context ? `\nContext from parent agent:\n${context}` : '',
4096
- ].filter(Boolean).join('\n');
4097
-
4098
- try {
4099
- const result = await this.runWithModel(
4100
- userId,
4101
- delegatedPrompt,
4102
- {
4103
- app: app || this.app,
4104
- runId: childRunId,
4105
- agentId: targetAgent.id,
4106
- triggerType: 'agent_delegation',
4107
- triggerSource: 'agent_delegation',
4108
- skipConversationHistory: true,
4109
- skipConversationMaintenance: true,
4110
- context: { additionalContext: `Parent run: ${parentRunId || 'unknown'}` },
4111
- allowExternalSideEffects,
4112
- },
4113
- null,
4114
- );
4115
- const summary = String(result?.content || '').trim();
4116
- db.prepare(
4117
- `UPDATE agent_delegations
4118
- SET status = ?, result_summary = ?, updated_at = datetime('now'), completed_at = datetime('now')
4119
- WHERE id = ?`
4120
- ).run(result?.status || 'completed', summary.slice(0, 20000), delegationId);
4121
- return {
4122
- delegationId,
4123
- targetAgent: {
4124
- id: targetAgent.id,
4125
- slug: targetAgent.slug,
4126
- name: targetAgent.display_name,
4127
- },
4128
- childRunId: result?.runId || childRunId,
4129
- status: result?.status || 'completed',
4130
- summary,
4131
- totalTokens: result?.totalTokens || 0,
4132
- };
4133
- } catch (err) {
4134
- db.prepare(
4135
- `UPDATE agent_delegations
4136
- SET status = 'failed', error = ?, updated_at = datetime('now'), completed_at = datetime('now')
4137
- WHERE id = ?`
4138
- ).run(String(err?.message || err).slice(0, 20000), delegationId);
4139
- throw err;
4140
- }
4141
- }
4142
-
4143
- listSubagents(parentRunId = null) {
4144
- return Array.from(this.subagents.values())
4145
- .filter((record) => !parentRunId || record.parentRunId === parentRunId)
4146
- .map((record) => ({
4147
- handle: record.handle,
4148
- parentRunId: record.parentRunId,
4149
- childRunId: record.childRunId,
4150
- status: record.status,
4151
- task: clampRunContext(record.task, 180),
4152
- result: record.result,
4153
- error: record.error,
4154
- createdAt: record.createdAt,
4155
- }));
4156
- }
4157
-
4158
- cleanupSubagentsForRun(parentRunId, options = {}) {
4159
- if (!parentRunId) return;
4160
- const cancelRunning = options.cancelRunning !== false;
4161
- for (const [handle, record] of this.subagents.entries()) {
4162
- if (record.parentRunId !== parentRunId) continue;
4163
- if (cancelRunning && record.status === 'running') {
4164
- try {
4165
- record.engine?.abort(record.childRunId);
4166
- record.status = 'cancelled';
4167
- } catch (err) {
4168
- console.warn(`[AgentEngine] Failed to abort subagent ${handle}:`, err?.message);
4169
- }
4170
- }
4171
- this.subagents.delete(handle);
4172
- }
4173
- }
4174
-
4175
- async waitForSubagent(handle, options = {}) {
4176
- const record = this.subagents.get(handle);
4177
- if (!record) {
4178
- return { error: `Unknown sub-agent handle: ${handle}` };
4179
- }
4180
- if (options.parentRunId && record.parentRunId !== options.parentRunId) {
4181
- return { error: 'That sub-agent does not belong to the current parent run.' };
4182
- }
4183
-
4184
- if (record.status !== 'running' || !record.promise) {
4185
- return {
4186
- handle,
4187
- status: record.status,
4188
- result: record.result,
4189
- error: record.error,
4190
- };
4191
- }
4192
-
4193
- const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 30000);
4194
- const timeout = new Promise((resolve) => {
4195
- setTimeout(() => resolve(null), timeoutMs);
4196
- });
4197
- const settled = await Promise.race([
4198
- record.promise.then(() => record).catch(() => record),
4199
- timeout,
4200
- ]);
4201
-
4202
- if (!settled) {
4203
- return {
4204
- handle,
4205
- status: 'running',
4206
- timedOut: true,
4207
- };
4208
- }
4209
-
4210
- return {
4211
- handle,
4212
- status: record.status,
4213
- result: record.result,
4214
- error: record.error,
4215
- };
4216
- }
4217
-
4218
- async cancelSubagent(handle, options = {}) {
4219
- const record = this.subagents.get(handle);
4220
- if (!record) {
4221
- return { error: `Unknown sub-agent handle: ${handle}` };
4222
- }
4223
- if (options.parentRunId && record.parentRunId !== options.parentRunId) {
4224
- return { error: 'That sub-agent does not belong to the current parent run.' };
4225
- }
4226
- if (record.status !== 'running') {
4227
- return {
4228
- handle,
4229
- status: record.status,
4230
- result: record.result,
4231
- error: record.error,
4232
- };
4233
- }
4234
-
4235
- record.engine?.abort(record.childRunId);
4236
- record.status = 'cancelled';
4237
- this.emit(record.userId, 'run:subagent', {
4238
- runId: record.parentRunId,
4239
- handle,
4240
- childRunId: record.childRunId,
4241
- status: 'cancelled',
4242
- });
4243
-
4244
- return { handle, status: 'cancelled' };
4245
- }
4246
-
4247
- stopRun(runId) {
4248
- const runMeta = this.activeRuns.get(runId);
4249
- const delegatedChildren = db.prepare(
4250
- "SELECT child_run_id FROM agent_delegations WHERE parent_run_id = ? AND status = 'running'"
4251
- ).all(runId);
4252
- if (runMeta) {
4253
- runMeta.status = 'stopped';
4254
- runMeta.aborted = true;
4255
- this.emit(runMeta.userId, 'run:stopping', { runId });
4256
- for (const pid of runMeta.toolPids) {
4257
- if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
4258
- void this.runtimeManager.killCommand(runMeta.userId, pid, 'aborted');
4259
- }
4260
- }
4261
- runMeta.toolPids.clear();
4262
- }
4263
- for (const child of delegatedChildren) {
4264
- if (child.child_run_id && child.child_run_id !== runId) {
4265
- this.stopRun(child.child_run_id);
4266
- }
4267
- }
4268
- db.prepare(
4269
- "UPDATE agent_delegations SET status = 'stopped', updated_at = datetime('now'), completed_at = datetime('now') WHERE parent_run_id = ? AND status = 'running'"
4270
- ).run(runId);
4271
- db.prepare("UPDATE agent_runs SET status = 'stopped', updated_at = datetime('now') WHERE id = ?").run(runId);
4272
- }
4273
-
4274
- abort(runId, { userId } = {}) {
4275
- if (!runId) return false;
4276
- if (userId != null) {
4277
- // Ownership gate: never let one user abort another user's active run.
4278
- const runMeta = this.activeRuns.get(runId);
4279
- if (runMeta && Number(runMeta.userId) !== Number(userId)) return false;
4280
- }
4281
- this.stopRun(runId);
4282
- return true;
4283
- }
4284
-
4285
- abortAll(userId) {
4286
- for (const [runId, run] of this.activeRuns) {
4287
- if (run.userId === userId) this.stopRun(runId);
4288
- }
4289
- }
4290
-
4291
- getStepType(toolName) {
4292
- if (toolName.startsWith('browser_')) return 'browser';
4293
- if (toolName.startsWith('android_')) return 'android';
4294
- if (toolName === 'execute_command') return 'cli';
4295
- if (toolName.startsWith('memory_')) return 'memory';
4296
- if (toolName === 'send_interim_update') return 'note';
4297
- if (toolName === 'send_message') return 'messaging';
4298
- if (toolName === 'make_call') return 'messaging';
4299
- if (toolName.startsWith('mcp_') || toolName.includes('mcp')) return 'mcp';
4300
- if (toolName === 'create_task' || toolName === 'update_task' || toolName === 'delete_task' || toolName === 'list_tasks' || toolName.includes('widget')) return 'tasks';
4301
- if (toolName.includes('subagent')) return 'subagent';
4302
- if (toolName === 'think') return 'thinking';
4303
- return 'tool';
4304
- }
4305
-
4306
- emit(userId, event, data) {
4307
- if (this.io) {
4308
- this.io.to(`user:${userId}`).emit(event, data);
4309
- }
4310
- }
4311
- }
4312
-
4313
- module.exports = { AgentEngine, buildInitialRunMetadata, getProviderForUser };
3
+ module.exports = require('./loop/agent_engine_core');