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

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