neoagent 2.5.2-beta.8 → 3.0.0

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