neoagent 2.5.2-beta.3 → 2.5.2-beta.5

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.
@@ -117,6 +117,7 @@ const MESSAGING_PROGRESS_REPEAT_MS = 90 * 1000;
117
117
  const MESSAGING_PROGRESS_STALL_MS = 240 * 1000;
118
118
  const MESSAGING_PROGRESS_TICK_MS = 15 * 1000;
119
119
  const GOAL_CONTRACT_SUCCESS_CRITERIA_LIMIT = 12;
120
+ const MODEL_CALL_TIMEOUT_MS = 5 * 60 * 1000;
120
121
 
121
122
  function isoNow() {
122
123
  return new Date().toISOString();
@@ -136,6 +137,62 @@ function formatElapsedDuration(durationMs) {
136
137
  return `${minutes}m ${seconds}s`;
137
138
  }
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
+ return msg.slice(0, 60);
148
+ }
149
+
150
+ function trackErrorPattern(errorMsg, runMeta) {
151
+ if (!errorMsg) return;
152
+ const key = normalizeErrorKey(errorMsg);
153
+ if (!runMeta.errorPatterns) runMeta.errorPatterns = new Map();
154
+ runMeta.errorPatterns.set(key, (runMeta.errorPatterns.get(key) || 0) + 1);
155
+ }
156
+
157
+ function buildErrorPatternGuidance(key, count) {
158
+ if (count < 3) return null;
159
+ const guides = {
160
+ outside_workspace: 'read_file cannot access /tmp paths. Use execute_command with `cat <path>` instead.',
161
+ eisdir: 'That path is a directory, not a file. Use list_directory or execute_command with `ls` to inspect it.',
162
+ enoent: 'That path does not exist. Use execute_command with `find . -name "..."` to locate the correct path first.',
163
+ bad_cwd: 'The VM home directory is not ~/. Use absolute paths starting from /tmp or discover the workspace root first.',
164
+ not_found: 'This path or resource was not found. Try listing the parent directory or checking with a broader search first.',
165
+ };
166
+ const guide = guides[key];
167
+ if (!guide) return null;
168
+ return `REPEATED ERROR (${count}×): ${guide}`;
169
+ }
170
+
171
+ function resolveModelCallTimeoutMs(options = {}) {
172
+ const requested = Number(options?.modelCallTimeoutMs);
173
+ if (Number.isFinite(requested) && requested > 0) {
174
+ return Math.max(10, requested);
175
+ }
176
+ return MODEL_CALL_TIMEOUT_MS;
177
+ }
178
+
179
+ async function withModelCallTimeout(promise, options = {}, label = 'Model call') {
180
+ const timeoutMs = resolveModelCallTimeoutMs(options);
181
+ let timer = null;
182
+ const timeout = new Promise((_, reject) => {
183
+ timer = setTimeout(() => {
184
+ const error = new Error(`${label} timed out after ${formatElapsedDuration(timeoutMs)}.`);
185
+ error.code = 'MODEL_CALL_TIMEOUT';
186
+ reject(error);
187
+ }, timeoutMs);
188
+ });
189
+ try {
190
+ return await Promise.race([Promise.resolve(promise), timeout]);
191
+ } finally {
192
+ if (timer) clearTimeout(timer);
193
+ }
194
+ }
195
+
139
196
  function cloneInterimHistory(history = []) {
140
197
  if (!Array.isArray(history)) return [];
141
198
  return history.map((item) => ({
@@ -187,6 +244,23 @@ function hasVisibleInterimActivity(runMeta) {
187
244
  );
188
245
  }
189
246
 
247
+ function requireSuccessfulMessagingDelivery(result, label = 'Messaging delivery') {
248
+ if (result?.success === true && result?.suppressed !== true) {
249
+ return result;
250
+ }
251
+ const reason = String(
252
+ result?.error
253
+ || result?.reason
254
+ || result?.result?.error
255
+ || result?.result?.reason
256
+ || 'the platform did not confirm delivery',
257
+ ).trim();
258
+ const error = new Error(`${label} failed: ${reason}`);
259
+ error.code = 'MESSAGING_DELIVERY_FAILED';
260
+ error.deliveryResult = result || null;
261
+ throw error;
262
+ }
263
+
190
264
  function normalizeGoalCriteria(value) {
191
265
  if (!Array.isArray(value)) return [];
192
266
  const seen = new Set();
@@ -257,7 +331,7 @@ function mergeGoalContracts(existing = null, patch = null) {
257
331
  const nextPatch = normalizeGoalContract(patch) || null;
258
332
  if (!current && !nextPatch) return null;
259
333
 
260
- const goal = String(nextPatch?.goal || current?.goal || '').trim();
334
+ const goal = String(current?.goal || nextPatch?.goal || '').trim();
261
335
  const successCriteria = normalizeGoalCriteria([
262
336
  ...(current?.successCriteria || []),
263
337
  ...(nextPatch?.successCriteria || []),
@@ -363,7 +437,6 @@ function resolveRunGoalContext(runMeta, analysis = null, plan = null) {
363
437
  }
364
438
 
365
439
  function buildCompletionDecisionPrompt({
366
- mode,
367
440
  triggerSource,
368
441
  messagingSent = false,
369
442
  goalContext,
@@ -373,52 +446,28 @@ function buildCompletionDecisionPrompt({
373
446
  lastReply,
374
447
  iteration,
375
448
  maxIterations,
376
- progressSummary = '',
377
- platform = null,
378
449
  }) {
379
- const draftReply = mode === 'messaging'
380
- ? (normalizeOutgoingMessage(lastReply || '', platform, { collapseWhitespace: false })
381
- ? String(lastReply || '').trim()
382
- : '')
383
- : normalizeOutgoingMessage(lastReply) || '';
450
+ const draftReply = normalizeOutgoingMessage(lastReply) || '';
384
451
  const lines = [
385
452
  'Return JSON only.',
453
+ 'Decide whether this run should continue autonomously or stop now.',
454
+ 'Schema: {"status":"continue|complete|blocked","reason":"short concrete reason"}',
455
+ 'Rules:',
456
+ '- Use "continue" whenever any safe next step remains in this same run.',
457
+ '- Use "complete" only when the requested outcome is actually achieved and the latest draft is the finished user-facing answer.',
458
+ '- 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.',
459
+ '- 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.',
460
+ '- A progress note, next-step note, apology, plan, or promise to investigate is "continue", not "complete".',
461
+ '- A single failed tool attempt is not blocked if another safe retry, verification step, or alternative path remains.',
462
+ '- 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.',
463
+ `- 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.`,
464
+ triggerSource === 'messaging' && messagingSent
465
+ ? '- A final reply was already delivered via send_message. Use "complete" unless concrete task work remains.'
466
+ : triggerSource === 'messaging'
467
+ ? '- For messaging, do not stop on a partial status message. Continue unless the task is actually complete or externally blocked.'
468
+ : '- Do not stop just because you wrote a status update. Continue unless the task is actually complete or externally blocked.',
386
469
  ];
387
470
 
388
- if (mode === 'messaging') {
389
- lines.push(
390
- 'A messaging run is about to stop after sending user-visible progress, but no final delivery has happened yet.',
391
- 'Decide whether the run should keep working, finish with the completed result now, or stop with one blocker reply now.',
392
- 'Schema: {"status":"continue|complete|blocked","reason":"short concrete reason","final_reply":"string"}',
393
- 'Rules:',
394
- '- Use "continue" whenever any safe next step remains in this same run.',
395
- '- Use "complete" only when the requested outcome is actually achieved and final_reply is the finished user-facing answer to send now.',
396
- '- Use "blocked" only when a specific external dependency, missing user input, or permission outside this run is required and final_reply is the concise blocker reply to send now.',
397
- '- A progress note, next-step note, apology, plan, or "I will investigate" draft is "continue", not "complete" and not "blocked".',
398
- '- If user-visible progress was already sent and no final delivery exists yet, do not stop silently and do not stop on a status-only draft.',
399
- '- final_reply must be empty when status is "continue".',
400
- );
401
- } else {
402
- lines.push(
403
- 'Decide whether this run should continue autonomously or stop now.',
404
- 'Schema: {"status":"continue|complete|blocked","reason":"short concrete reason"}',
405
- 'Rules:',
406
- '- Use "continue" whenever any safe next step remains in this same run.',
407
- '- Use "complete" only when the requested outcome is actually achieved or a truthful final user reply is already ready now.',
408
- '- Use "blocked" only when a specific external dependency outside this run is required.',
409
- '- 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.',
410
- '- A progress update is not complete.',
411
- '- A single failed tool attempt is not blocked if another safe retry, verification step, or alternative path remains.',
412
- '- 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.',
413
- `- 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.`,
414
- triggerSource === 'messaging' && messagingSent
415
- ? '- A reply was already delivered to the user via send_message. Use "complete" unless there is concrete remaining work (e.g., a tool call you still need to make) before the task is truly done. Do not send follow-up elaborations or re-introductions.'
416
- : triggerSource === 'messaging'
417
- ? '- For messaging, do not stop on a partial status message. Continue unless the task is actually complete or externally blocked. If you already asked for missing user input, choose "blocked" and wait.'
418
- : '- Do not stop just because you wrote a status update. Continue unless the task is actually complete or externally blocked.',
419
- );
420
- }
421
-
422
471
  lines.push(
423
472
  goalContext.effectiveGoal ? `Goal: ${goalContext.effectiveGoal}` : '',
424
473
  goalContext.persistedGoalPrompt,
@@ -428,44 +477,14 @@ function buildCompletionDecisionPrompt({
428
477
  : '',
429
478
  `Current iteration: ${iteration} of ${maxIterations}.`,
430
479
  `Available tools in this run: ${summarizeAvailableTools(tools) || 'none'}`,
431
- mode === 'messaging' && progressSummary ? `Progress ledger: ${progressSummary}` : '',
432
480
  `Recent tool evidence:\n${summarizeToolExecutions(toolExecutions, 8) || 'none'}`,
433
481
  `Latest draft reply:\n${draftReply || '(empty)'}`,
434
- mode === 'messaging' ? buildPlatformFormattingGuide(platform) : '',
435
482
  );
436
483
  return lines.filter(Boolean).join('\n');
437
484
  }
438
485
 
439
- function normalizeCompletionDecision(raw, {
440
- mode,
441
- fallbackStatus = 'continue',
442
- platform = null,
443
- draftReply = '',
444
- }) {
486
+ function normalizeCompletionDecision(raw, fallbackStatus = 'continue') {
445
487
  const allowed = new Set(['continue', 'complete', 'blocked']);
446
- if (mode === 'messaging') {
447
- let status = allowed.has(String(raw.status || '').trim().toLowerCase())
448
- ? String(raw.status || '').trim().toLowerCase()
449
- : 'continue';
450
- let finalReply = normalizeOutgoingMessage(raw.final_reply || '', platform, {
451
- collapseWhitespace: false,
452
- })
453
- ? String(raw.final_reply || '').trim()
454
- : '';
455
- if (status === 'continue') {
456
- finalReply = '';
457
- } else if (!finalReply && draftReply) {
458
- finalReply = draftReply;
459
- } else if (!finalReply) {
460
- status = 'continue';
461
- }
462
- return {
463
- status,
464
- reason: String(raw.reason || '').trim().slice(0, 400),
465
- final_reply: finalReply,
466
- };
467
- }
468
-
469
488
  const requestedStatus = String(raw.status || '').trim().toLowerCase();
470
489
  return {
471
490
  status: allowed.has(requestedStatus) ? requestedStatus : fallbackStatus,
@@ -473,16 +492,6 @@ function normalizeCompletionDecision(raw, {
473
492
  };
474
493
  }
475
494
 
476
- function shouldRequireMessagingFinalityCheck(runMeta) {
477
- return Boolean(
478
- runMeta
479
- && runMeta.triggerSource === 'messaging'
480
- && runMeta.finalDeliverySent !== true
481
- && !runMeta.terminalInterim
482
- && hasVisibleInterimActivity(runMeta)
483
- );
484
- }
485
-
486
495
  function planningDepthForForceMode(forceMode) {
487
496
  return forceMode === 'plan_execute' ? 'deep' : 'light';
488
497
  }
@@ -706,6 +715,7 @@ class AgentEngine {
706
715
  this.taskRuntime = services.taskRuntime || null;
707
716
  this.memoryManager = services.memoryManager || null;
708
717
  this.voiceRuntimeManager = services.voiceRuntimeManager || null;
718
+ this.messagingDeliveryRetry = services.messagingDeliveryRetry || {};
709
719
  }
710
720
 
711
721
  async buildSystemPrompt(userId, context = {}) {
@@ -926,21 +936,6 @@ class AgentEngine {
926
936
  .run(JSON.stringify(next), runId);
927
937
  }
928
938
 
929
- replaceLatestConversationAssistantMessage(conversationId, content) {
930
- if (!conversationId) return false;
931
- const messageId = db.prepare(
932
- `SELECT id
933
- FROM conversation_messages
934
- WHERE conversation_id = ? AND role = 'assistant'
935
- ORDER BY id DESC
936
- LIMIT 1`
937
- ).get(conversationId)?.id;
938
- if (!messageId) return false;
939
- db.prepare('UPDATE conversation_messages SET content = ? WHERE id = ?')
940
- .run(content, messageId);
941
- return true;
942
- }
943
-
944
939
  updateRunGoalContract(runId, patch = {}, options = {}) {
945
940
  const runMeta = this.getRunMeta(runId);
946
941
  if (!runMeta) return null;
@@ -1031,6 +1026,7 @@ class AgentEngine {
1031
1026
  markRunFinalDelivery(runId, content = '', timestamp = isoNow()) {
1032
1027
  const runMeta = this.getRunMeta(runId);
1033
1028
  if (!runMeta) return null;
1029
+ runMeta.messagingSent = true;
1034
1030
  runMeta.finalDeliverySent = true;
1035
1031
  runMeta.lastSentMessage = String(content || '').trim() || runMeta.lastSentMessage || '';
1036
1032
  const ledger = this.updateRunProgress(runId, {
@@ -1142,13 +1138,14 @@ class AgentEngine {
1142
1138
  if (!platform || !chatId || !this.messagingManager) {
1143
1139
  return { sent: false, skipped: true, reason: 'Messaging context is not available.' };
1144
1140
  }
1145
- await this.messagingManager.sendMessage(userId, platform, chatId, normalizedContent, {
1141
+ const deliveryResult = await this.messagingManager.sendMessage(userId, platform, chatId, normalizedContent, {
1146
1142
  agentId,
1147
1143
  runId,
1148
1144
  persistConversation: true,
1149
1145
  metadata,
1150
1146
  deliveryKind: 'interim',
1151
1147
  });
1148
+ requireSuccessfulMessagingDelivery(deliveryResult, 'Interim messaging delivery');
1152
1149
  } else if (triggerSource === 'voice_live') {
1153
1150
  const voiceSessionId = runMeta.voiceSessionId || null;
1154
1151
  const manager = this.voiceRuntimeManager || this.app?.locals?.voiceRuntimeManager || null;
@@ -1242,42 +1239,72 @@ class AgentEngine {
1242
1239
  phase = 'structured',
1243
1240
  }) {
1244
1241
  const startedAt = Date.now();
1245
- const response = await withProviderRetry(
1246
- () => provider.chat(
1247
- sanitizeConversationMessages([
1248
- ...messages,
1249
- { role: 'system', content: prompt },
1250
- ]),
1251
- [],
1252
- {
1253
- model,
1254
- maxTokens,
1255
- reasoningEffort: reasoningEffort || this.getReasoningEffort(providerName, {}),
1256
- }
1257
- ),
1258
- { label: `Engine ${model} (structured)` }
1259
- );
1260
- if (telemetry?.runId && telemetry?.userId) {
1261
- recordModelUsage({
1262
- runId: telemetry.runId,
1263
- stepId: telemetry.stepId || null,
1264
- userId: telemetry.userId,
1265
- agentId: telemetry.agentId || null,
1266
- provider: providerName,
1267
- model,
1268
- phase,
1269
- usage: response.usage,
1270
- latencyMs: Date.now() - startedAt,
1242
+ const structuredStep = `model:${phase}`;
1243
+ if (telemetry?.runId) {
1244
+ this.updateRunProgress(telemetry.runId, {
1245
+ currentPhase: 'model',
1246
+ currentStep: structuredStep,
1247
+ currentTool: null,
1248
+ currentStepStartedAt: isoNow(),
1271
1249
  });
1272
1250
  }
1273
1251
 
1274
- const parsed = parseJsonObject(response.content || '');
1275
- const normalizedUsage = normalizeUsage(response.usage);
1276
- return {
1277
- value: normalize(parsed || {}, fallback),
1278
- raw: response.content || '',
1279
- usage: normalizedUsage?.totalTokens || 0,
1280
- };
1252
+ let completed = false;
1253
+ try {
1254
+ const response = await withProviderRetry(
1255
+ () => withModelCallTimeout(
1256
+ provider.chat(
1257
+ sanitizeConversationMessages([
1258
+ ...messages,
1259
+ { role: 'system', content: prompt },
1260
+ ]),
1261
+ [],
1262
+ {
1263
+ model,
1264
+ maxTokens,
1265
+ reasoningEffort: reasoningEffort || this.getReasoningEffort(providerName, {}),
1266
+ }
1267
+ ),
1268
+ telemetry || {},
1269
+ `${phase} model call`,
1270
+ ),
1271
+ { label: `Engine ${model} (structured)` }
1272
+ );
1273
+ completed = true;
1274
+ if (telemetry?.runId && telemetry?.userId) {
1275
+ recordModelUsage({
1276
+ runId: telemetry.runId,
1277
+ stepId: telemetry.stepId || null,
1278
+ userId: telemetry.userId,
1279
+ agentId: telemetry.agentId || null,
1280
+ provider: providerName,
1281
+ model,
1282
+ phase,
1283
+ usage: response.usage,
1284
+ latencyMs: Date.now() - startedAt,
1285
+ });
1286
+ }
1287
+
1288
+ const parsed = parseJsonObject(response.content || '');
1289
+ const normalizedUsage = normalizeUsage(response.usage);
1290
+ return {
1291
+ value: normalize(parsed || {}, fallback),
1292
+ raw: response.content || '',
1293
+ usage: normalizedUsage?.totalTokens || 0,
1294
+ };
1295
+ } finally {
1296
+ const runMeta = telemetry?.runId ? this.getRunMeta(telemetry.runId) : null;
1297
+ if (runMeta?.progressLedger?.currentStep === structuredStep) {
1298
+ this.updateRunProgress(telemetry.runId, {
1299
+ currentPhase: 'idle',
1300
+ currentStep: null,
1301
+ currentTool: null,
1302
+ currentStepStartedAt: null,
1303
+ }, {
1304
+ verified: completed,
1305
+ });
1306
+ }
1307
+ }
1281
1308
  }
1282
1309
 
1283
1310
  async requestModelResponse({
@@ -1304,8 +1331,16 @@ class AgentEngine {
1304
1331
  if (options.stream !== false) {
1305
1332
  let emittedContent = false;
1306
1333
  const stream = provider.stream(requestMessages, tools, callOptions);
1334
+ const iterator = stream[Symbol.asyncIterator]();
1307
1335
  try {
1308
- for await (const chunk of stream) {
1336
+ while (true) {
1337
+ const next = await withModelCallTimeout(
1338
+ iterator.next(),
1339
+ options,
1340
+ `Model stream iteration ${iteration}`,
1341
+ );
1342
+ if (next.done) break;
1343
+ const chunk = next.value;
1309
1344
  if (chunk.type === 'content') {
1310
1345
  emittedContent = true;
1311
1346
  streamContent += chunk.content;
@@ -1329,13 +1364,18 @@ class AgentEngine {
1329
1364
  }
1330
1365
  }
1331
1366
  } catch (err) {
1367
+ Promise.resolve(iterator.return?.()).catch(() => {});
1332
1368
  // Once tokens have streamed to the client a retry would duplicate
1333
1369
  // output, so only the pre-stream window is safe to replay.
1334
1370
  if (emittedContent) err.__providerRetryUnsafe = true;
1335
1371
  throw err;
1336
1372
  }
1337
1373
  } else {
1338
- response = await provider.chat(requestMessages, tools, callOptions);
1374
+ response = await withModelCallTimeout(
1375
+ provider.chat(requestMessages, tools, callOptions),
1376
+ options,
1377
+ `Model iteration ${iteration}`,
1378
+ );
1339
1379
  }
1340
1380
 
1341
1381
  return { response, streamContent };
@@ -1459,60 +1499,6 @@ class AgentEngine {
1459
1499
  };
1460
1500
  }
1461
1501
 
1462
- async decideLoopState({
1463
- provider,
1464
- providerName,
1465
- model,
1466
- messages,
1467
- tools,
1468
- analysis,
1469
- plan,
1470
- toolExecutions,
1471
- lastReply,
1472
- triggerSource,
1473
- messagingSent,
1474
- iteration,
1475
- maxIterations,
1476
- options,
1477
- fallbackStatus,
1478
- }) {
1479
- const runMeta = options?.runId ? this.getRunMeta(options.runId) : null;
1480
- const goalContext = resolveRunGoalContext(runMeta, analysis, plan);
1481
-
1482
- const response = await this.requestStructuredJson({
1483
- provider,
1484
- providerName,
1485
- model,
1486
- messages,
1487
- prompt: buildCompletionDecisionPrompt({
1488
- mode: 'loop',
1489
- triggerSource,
1490
- messagingSent,
1491
- goalContext,
1492
- parallelWork: analysis?.parallel_work === true,
1493
- tools,
1494
- toolExecutions,
1495
- lastReply,
1496
- iteration,
1497
- maxIterations,
1498
- }),
1499
- maxTokens: 320,
1500
- normalize: (raw) => normalizeCompletionDecision(raw, {
1501
- mode: 'loop',
1502
- fallbackStatus,
1503
- }),
1504
- fallback: { status: fallbackStatus },
1505
- reasoningEffort: this.getReasoningEffort(providerName, options),
1506
- telemetry: options,
1507
- phase: 'loop_decision',
1508
- });
1509
-
1510
- return {
1511
- decision: response.value,
1512
- usage: response.usage,
1513
- };
1514
- }
1515
-
1516
1502
  async verifyFinalResponse({
1517
1503
  provider,
1518
1504
  providerName,
@@ -1623,11 +1609,15 @@ class AgentEngine {
1623
1609
  }
1624
1610
  ];
1625
1611
 
1626
- const response = await provider.chat(promptMessages, [], {
1627
- model,
1628
- maxTokens: 800,
1629
- reasoningEffort: this.getReasoningEffort(providerName, options),
1630
- });
1612
+ const response = await withModelCallTimeout(
1613
+ provider.chat(promptMessages, [], {
1614
+ model,
1615
+ maxTokens: 800,
1616
+ reasoningEffort: this.getReasoningEffort(providerName, options),
1617
+ }),
1618
+ options,
1619
+ 'Conversation state refresh',
1620
+ );
1631
1621
  const parsed = parseJsonObject(response.content || '') || {};
1632
1622
  const nextState = {
1633
1623
  summary: String(parsed.summary || existingState?.summary || '').trim(),
@@ -1662,69 +1652,6 @@ class AgentEngine {
1662
1652
  return nextState;
1663
1653
  }
1664
1654
 
1665
- async recoverBlankMessagingReply({
1666
- userId,
1667
- runId,
1668
- messages,
1669
- provider,
1670
- model,
1671
- providerName,
1672
- options,
1673
- stepIndex,
1674
- failedStepCount,
1675
- toolExecutions = [],
1676
- tools = []
1677
- }) {
1678
- const attempts = 3;
1679
- let recoveredContent = '';
1680
- let totalTokens = 0;
1681
-
1682
- for (let attempt = 1; attempt <= attempts; attempt++) {
1683
- console.warn(
1684
- `[Run ${shortenRunId(runId)}] blank_reply_recovery attempt=${attempt} model=${model}`
1685
- );
1686
- try {
1687
- const response = await provider.chat(
1688
- sanitizeConversationMessages([
1689
- ...messages,
1690
- {
1691
- role: 'system',
1692
- content: buildBlankMessagingReplyPrompt(attempt, options?.source || null)
1693
- }
1694
- ]),
1695
- [],
1696
- {
1697
- model,
1698
- reasoningEffort: this.getReasoningEffort(providerName, options)
1699
- }
1700
- );
1701
- totalTokens += response.usage?.totalTokens || 0;
1702
- recoveredContent = sanitizeModelOutput(response.content || '', { model });
1703
- if (normalizeOutgoingMessage(recoveredContent)) {
1704
- console.info(
1705
- `[Run ${shortenRunId(runId)}] blank_reply_recovery succeeded attempt=${attempt}`
1706
- );
1707
- return { content: recoveredContent, tokens: totalTokens, recovered: true };
1708
- }
1709
- } catch (recoverErr) {
1710
- console.warn(
1711
- `[Run ${shortenRunId(runId)}] blank_reply_recovery attempt=${attempt} failed: ${summarizeForLog(recoverErr?.message || recoverErr, 180)}`
1712
- );
1713
- }
1714
- }
1715
-
1716
- const error = new Error(
1717
- buildDeterministicMessagingFallback({
1718
- failedStepCount,
1719
- stepIndex,
1720
- toolExecutions,
1721
- })
1722
- );
1723
- error.code = 'BLANK_MESSAGING_REPLY';
1724
- error.recoveryTokens = totalTokens;
1725
- throw error;
1726
- }
1727
-
1728
1655
  getAvailableTools(app, options = {}) {
1729
1656
  const { getAvailableTools } = require('./tools');
1730
1657
  return getAvailableTools(app, options);
@@ -2127,169 +2054,6 @@ class AgentEngine {
2127
2054
  return { messages, appliedCount: queued.length };
2128
2055
  }
2129
2056
 
2130
- async decideMessagingCompletionState({
2131
- provider,
2132
- providerName,
2133
- model,
2134
- messages,
2135
- analysis,
2136
- plan,
2137
- tools,
2138
- toolExecutions,
2139
- lastReply,
2140
- iteration,
2141
- maxIterations,
2142
- runId,
2143
- options,
2144
- }) {
2145
- const runMeta = this.getRunMeta(runId);
2146
- const goalContext = resolveRunGoalContext(runMeta, analysis, plan);
2147
- const platform = options?.source || null;
2148
- const normalizedDraft = normalizeOutgoingMessage(lastReply || '', platform, {
2149
- collapseWhitespace: false,
2150
- });
2151
- const draftReply = normalizedDraft ? String(lastReply || '').trim() : '';
2152
- const ledger = runMeta?.progressLedger || null;
2153
- const progressSummary = [
2154
- `progress_state=${ledger?.progressState || 'active'}`,
2155
- `current_phase=${ledger?.currentPhase || 'idle'}`,
2156
- `current_tool=${ledger?.currentTool || 'none'}`,
2157
- `heartbeat_count=${Number(ledger?.heartbeatCount || 0)}`,
2158
- `last_visible_update=${ledger?.lastUserVisibleUpdateAt || 'none'}`,
2159
- `last_verified_progress=${ledger?.lastVerifiedProgressAt || 'none'}`,
2160
- `last_final_delivery=${ledger?.lastFinalDeliveryAt || 'none'}`,
2161
- ].join('; ');
2162
-
2163
- const response = await this.requestStructuredJson({
2164
- provider,
2165
- providerName,
2166
- model,
2167
- messages,
2168
- prompt: buildCompletionDecisionPrompt({
2169
- mode: 'messaging',
2170
- goalContext,
2171
- parallelWork: analysis?.parallel_work === true,
2172
- tools,
2173
- toolExecutions,
2174
- lastReply: draftReply,
2175
- iteration,
2176
- maxIterations,
2177
- progressSummary,
2178
- platform,
2179
- }),
2180
- maxTokens: 480,
2181
- normalize: (raw) => normalizeCompletionDecision(raw, {
2182
- mode: 'messaging',
2183
- platform,
2184
- draftReply,
2185
- }),
2186
- fallback: {
2187
- status: 'continue',
2188
- reason: '',
2189
- final_reply: '',
2190
- },
2191
- reasoningEffort: this.getReasoningEffort(providerName, options),
2192
- telemetry: options,
2193
- phase: 'messaging_completion',
2194
- });
2195
-
2196
- return {
2197
- decision: response.value,
2198
- usage: response.usage,
2199
- };
2200
- }
2201
-
2202
- async resolveMessagingCompletionDecision({
2203
- provider,
2204
- providerName,
2205
- model,
2206
- messages,
2207
- analysis,
2208
- plan,
2209
- tools,
2210
- toolExecutions,
2211
- lastReply,
2212
- iteration,
2213
- maxIterations,
2214
- runId,
2215
- conversationId,
2216
- options,
2217
- }) {
2218
- const runMeta = this.getRunMeta(runId);
2219
- if (!shouldRequireMessagingFinalityCheck(runMeta)) {
2220
- return {
2221
- action: 'none',
2222
- content: lastReply,
2223
- reason: '',
2224
- usage: 0,
2225
- };
2226
- }
2227
-
2228
- let completionDecision;
2229
- try {
2230
- completionDecision = await this.decideMessagingCompletionState({
2231
- provider,
2232
- providerName,
2233
- model,
2234
- messages,
2235
- analysis,
2236
- plan,
2237
- tools,
2238
- toolExecutions,
2239
- lastReply,
2240
- iteration,
2241
- maxIterations,
2242
- runId,
2243
- options,
2244
- });
2245
- } catch (error) {
2246
- if (iteration >= maxIterations) {
2247
- const wrapped = new Error(
2248
- `Messaging completion check failed after visible progress: ${error?.message || error}`,
2249
- );
2250
- wrapped.disableAutonomousRetry = error?.disableAutonomousRetry === true;
2251
- throw wrapped;
2252
- }
2253
- return {
2254
- action: 'continue',
2255
- content: '',
2256
- reason: 'The run still needs an explicit final result or blocker decision.',
2257
- usage: 0,
2258
- };
2259
- }
2260
-
2261
- const decision = completionDecision.decision || { status: 'continue', reason: '' };
2262
- if (decision.status === 'continue') {
2263
- if (iteration >= maxIterations) {
2264
- throw new Error(
2265
- 'Messaging run reached the iteration limit before producing a final answer or blocker after visible progress.',
2266
- );
2267
- }
2268
- return {
2269
- action: 'continue',
2270
- content: '',
2271
- reason: decision.reason || 'The current draft is still only progress.',
2272
- usage: completionDecision.usage || 0,
2273
- };
2274
- }
2275
-
2276
- const finalContent = String(decision.final_reply || lastReply || '').trim();
2277
- if (finalContent && messages[messages.length - 1]?.role === 'assistant') {
2278
- messages[messages.length - 1] = {
2279
- ...messages[messages.length - 1],
2280
- content: finalContent,
2281
- };
2282
- this.replaceLatestConversationAssistantMessage(conversationId, finalContent);
2283
- }
2284
-
2285
- return {
2286
- action: decision.status === 'blocked' ? 'blocked' : 'complete',
2287
- content: finalContent,
2288
- reason: decision.reason || '',
2289
- usage: completionDecision.usage || 0,
2290
- };
2291
- }
2292
-
2293
2057
  buildMessagingHeartbeatText(runMeta, options = {}) {
2294
2058
  const stalled = options.stalled === true;
2295
2059
  const now = Date.now();
@@ -2305,14 +2069,16 @@ class AgentEngine {
2305
2069
  runStartedAtMs,
2306
2070
  ));
2307
2071
  const currentTool = String(runMeta?.progressLedger?.currentTool || '').trim();
2072
+ const runTitle = String(runMeta?.title || '').trim().slice(0, 60);
2073
+ const titlePrefix = runTitle ? `[${runTitle}] ` : '';
2308
2074
  if (currentTool) {
2309
2075
  return stalled
2310
- ? `Still working on ${currentTool}. Run active ${runElapsed}; no verified progress for ${unverifiedElapsed}.`
2311
- : `Still working on ${currentTool}. Run active ${runElapsed}; current step ${stepElapsed} so far.`;
2076
+ ? `${titlePrefix}Still working on ${currentTool}. Run active ${runElapsed}; no verified progress for ${unverifiedElapsed}.`
2077
+ : `${titlePrefix}Still working on ${currentTool}. Run active ${runElapsed}; current step ${stepElapsed} so far.`;
2312
2078
  }
2313
2079
  return stalled
2314
- ? `Still working on this. Run active ${runElapsed}; no verified progress for ${unverifiedElapsed}.`
2315
- : `Still working on this. Run active ${runElapsed}.`;
2080
+ ? `${titlePrefix}Still working on this. Run active ${runElapsed}; no verified progress for ${unverifiedElapsed}.`
2081
+ : `${titlePrefix}Still working on this. Run active ${runElapsed}.`;
2316
2082
  }
2317
2083
 
2318
2084
  async sendRuntimeMessagingHeartbeat(runId, options = {}) {
@@ -2327,7 +2093,7 @@ class AgentEngine {
2327
2093
 
2328
2094
  const createdAt = isoNow();
2329
2095
  const content = this.buildMessagingHeartbeatText(runMeta, options);
2330
- await this.messagingManager.sendMessage(
2096
+ const deliveryResult = await this.messagingManager.sendMessage(
2331
2097
  runMeta.userId,
2332
2098
  runMeta.messagingContext.platform,
2333
2099
  runMeta.messagingContext.chatId,
@@ -2345,6 +2111,7 @@ class AgentEngine {
2345
2111
  deliveryKind: 'interim',
2346
2112
  },
2347
2113
  );
2114
+ requireSuccessfulMessagingDelivery(deliveryResult, 'Messaging heartbeat delivery');
2348
2115
 
2349
2116
  runMeta.lastInterimMessage = content;
2350
2117
  if (!Array.isArray(runMeta.interimMessages)) {
@@ -2369,8 +2136,8 @@ class AgentEngine {
2369
2136
  }, { agentId: runMeta.agentId });
2370
2137
  this.enqueueSystemSteering(
2371
2138
  runId,
2372
- 'A runtime-generated progress update was already sent while the run was blocked. Do not repeat that same status. When control returns, either keep working silently, send a materially new update, or finish with the actual result.',
2373
- { reason: 'runtime_heartbeat' },
2139
+ '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.',
2140
+ { reason: 'heartbeat_ai_followup' },
2374
2141
  );
2375
2142
  return { sent: true, content };
2376
2143
  }
@@ -2421,9 +2188,31 @@ class AgentEngine {
2421
2188
  await this.messagingManager.sendTyping(userId, platform, chatId, true, { agentId }).catch(() => {});
2422
2189
  await new Promise((resolve) => setTimeout(resolve, delay));
2423
2190
  }
2424
- await this.messagingManager.sendMessage(userId, platform, chatId, chunks[i], { runId, agentId }).catch((err) =>
2425
- console.error('[Engine] Auto-reply fallback failed:', err.message)
2426
- );
2191
+ try {
2192
+ await withProviderRetry(async () => {
2193
+ const deliveryResult = await this.messagingManager.sendMessage(
2194
+ userId,
2195
+ platform,
2196
+ chatId,
2197
+ chunks[i],
2198
+ { runId, agentId },
2199
+ );
2200
+ return requireSuccessfulMessagingDelivery(deliveryResult, 'Final messaging delivery');
2201
+ }, {
2202
+ ...this.messagingDeliveryRetry,
2203
+ label: `MessagingDelivery ${platform}`,
2204
+ isRetryable: (error) => (
2205
+ error?.retryable !== false
2206
+ && (
2207
+ error?.code === 'MESSAGING_DELIVERY_FAILED'
2208
+ || isTransientError(error)
2209
+ )
2210
+ ),
2211
+ });
2212
+ } catch (error) {
2213
+ error.disableAutonomousRetry = true;
2214
+ throw error;
2215
+ }
2427
2216
  }
2428
2217
 
2429
2218
  runMeta.lastSentMessage = chunks[chunks.length - 1] || cleanedContent;
@@ -2474,7 +2263,10 @@ class AgentEngine {
2474
2263
  return { sent: false, skipped: true };
2475
2264
  }
2476
2265
 
2477
- if (ledger.currentPhase === 'tool' && ledger.currentStepStartedAt) {
2266
+ if (
2267
+ (ledger.currentPhase === 'tool' || ledger.currentPhase === 'model')
2268
+ && ledger.currentStepStartedAt
2269
+ ) {
2478
2270
  return this.sendRuntimeMessagingHeartbeat(runId, { stalled });
2479
2271
  }
2480
2272
 
@@ -2487,9 +2279,10 @@ class AgentEngine {
2487
2279
  return { sent: false, skipped: true };
2488
2280
  }
2489
2281
 
2282
+ const elapsed = formatElapsedDuration(now - startedAtMs);
2490
2283
  const nudge = stalled
2491
- ? 'The messaging user has only seen progress updates so far, and the run now appears stalled. Decide explicitly whether to continue, send one concise blocker update, or finish with the final answer. Do not leave the run with only an interim status.'
2492
- : 'The messaging user has not received a final answer yet. Decide explicitly whether to keep working, send one concise progress update, or finish with the final answer. Do not stop with only an interim status.';
2284
+ ? `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.`
2285
+ : `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.`;
2493
2286
  const queued = this.enqueueSystemSteering(runId, nudge, {
2494
2287
  reason: stalled ? 'stalled_progress_check' : 'progress_check',
2495
2288
  });
@@ -2788,7 +2581,12 @@ class AgentEngine {
2788
2581
  const carriedExplicitMessageSent = retryMessagingState.explicitMessageSent === true;
2789
2582
  const carriedInterimHistory = cloneInterimHistory(retryMessagingState.interimHistory);
2790
2583
  const carriedLastInterimMessage = carriedInterimHistory[carriedInterimHistory.length - 1]?.content || '';
2791
- const carriedGoalContract = normalizeGoalContract(retryMessagingState.goalContract);
2584
+ const carriedGoalContract = mergeGoalContracts(
2585
+ normalizeGoalContract({
2586
+ goal: clampRunContext(userMessage, 1200),
2587
+ }),
2588
+ retryMessagingState.goalContract,
2589
+ );
2792
2590
  const startedAtIso = isoNow();
2793
2591
  const progressLedger = buildInitialProgressLedger({
2794
2592
  startedAt: startedAtIso,
@@ -3248,14 +3046,16 @@ class AgentEngine {
3248
3046
  currentStep: `model:${iteration}`,
3249
3047
  currentTool: null,
3250
3048
  currentStepStartedAt: isoNow(),
3251
- }, {
3252
- verified: true,
3253
3049
  });
3254
3050
 
3255
3051
  let metrics = this.estimatePromptMetrics(messages, tools);
3256
3052
  const contextWindow = provider.getContextWindow(model);
3257
3053
  if (metrics.totalEstimatedTokens > contextWindow * loopPolicy.compactionThreshold) {
3258
- messages = await compact(messages, provider, model, contextWindow);
3054
+ messages = await withModelCallTimeout(
3055
+ compact(messages, provider, model, contextWindow),
3056
+ options,
3057
+ `Context compaction before iteration ${iteration}`,
3058
+ );
3259
3059
  messages = sanitizeConversationMessages(messages);
3260
3060
  this.emit(userId, 'run:compaction', { runId, iteration });
3261
3061
  metrics = this.estimatePromptMetrics(messages, tools);
@@ -3393,6 +3193,9 @@ class AgentEngine {
3393
3193
  toolCallCount: response.toolCalls?.length || 0,
3394
3194
  contentPreview: String(lastContent || streamContent || '').slice(0, 240),
3395
3195
  }, { agentId });
3196
+ this.updateRunProgress(runId, {}, {
3197
+ verified: true,
3198
+ });
3396
3199
 
3397
3200
  const assistantMessage = { role: 'assistant', content: lastContent };
3398
3201
  if (response.toolCalls?.length) assistantMessage.tool_calls = response.toolCalls;
@@ -3416,9 +3219,10 @@ class AgentEngine {
3416
3219
  currentStep: null,
3417
3220
  currentTool: null,
3418
3221
  currentStepStartedAt: null,
3419
- }, {
3420
- verified: true,
3421
3222
  });
3223
+ // Check for queued steering first — if something was injected while the
3224
+ // model was responding (e.g. a heartbeat nudge), give the model a chance
3225
+ // to act on it before we treat this as a final answer.
3422
3226
  const systemSteeringAfterResponse = this.applyQueuedSystemSteering(runId, messages);
3423
3227
  messages = systemSteeringAfterResponse.messages;
3424
3228
  if (systemSteeringAfterResponse.appliedCount > 0) {
@@ -3436,99 +3240,17 @@ class AgentEngine {
3436
3240
  lastContent = '';
3437
3241
  continue;
3438
3242
  }
3439
- const messagingSent = this.activeRuns.get(runId)?.messagingSent || false;
3440
3243
  if (this.shouldFastCompleteVoiceReply({
3441
3244
  options,
3442
3245
  toolExecutions,
3443
3246
  failedStepCount,
3444
- messagingSent,
3247
+ messagingSent: this.activeRuns.get(runId)?.messagingSent || false,
3445
3248
  lastReply: lastContent,
3446
3249
  })) {
3447
3250
  break;
3448
3251
  }
3449
- const runMetaAfterResponse = this.getRunMeta(runId);
3450
- if (shouldRequireMessagingFinalityCheck(runMetaAfterResponse)) {
3451
- const messagingCompletion = await this.resolveMessagingCompletionDecision({
3452
- provider,
3453
- providerName,
3454
- model,
3455
- messages,
3456
- analysis,
3457
- plan,
3458
- tools,
3459
- toolExecutions,
3460
- lastReply: lastContent,
3461
- iteration,
3462
- maxIterations,
3463
- runId,
3464
- conversationId,
3465
- options: { ...options, runId, userId, agentId },
3466
- });
3467
- totalTokens += messagingCompletion.usage || 0;
3468
- if (messagingCompletion.action === 'continue') {
3469
- messages.push({
3470
- role: 'system',
3471
- content: [
3472
- messagingCompletion.reason
3473
- ? `Continue working: ${messagingCompletion.reason}.`
3474
- : 'Continue working autonomously.',
3475
- 'The messaging user has already seen progress. Do not stop until you either have the finished answer now or a concrete blocker reply now.',
3476
- ].join(' ')
3477
- });
3478
- lastContent = '';
3479
- continue;
3480
- }
3481
- if (typeof messagingCompletion.content === 'string') {
3482
- lastContent = messagingCompletion.content;
3483
- }
3484
- break;
3485
- }
3486
- if (iteration < maxIterations) {
3487
- const proactiveRunNeedsDecision = (
3488
- (triggerSource === 'schedule' || triggerSource === 'tasks')
3489
- && this.activeRuns.get(runId)?.noResponse !== true
3490
- && options.deliveryState?.noResponse !== true
3491
- );
3492
- const visibleInterimActivity = hasVisibleInterimActivity(this.activeRuns.get(runId));
3493
- const fallbackStatus = (
3494
- proactiveRunNeedsDecision
3495
- || toolExecutions.length > 0
3496
- || failedStepCount > 0
3497
- || messagingSent
3498
- || visibleInterimActivity
3499
- ) ? 'continue' : 'complete';
3500
- const loopState = await runWithModelFallback('loop decision', () => this.decideLoopState({
3501
- provider,
3502
- providerName,
3503
- model,
3504
- messages,
3505
- tools,
3506
- analysis,
3507
- plan,
3508
- toolExecutions,
3509
- lastReply: lastContent,
3510
- triggerSource,
3511
- messagingSent,
3512
- iteration,
3513
- maxIterations,
3514
- options: { ...options, runId, userId, agentId },
3515
- fallbackStatus,
3516
- }));
3517
- totalTokens += loopState.usage || 0;
3518
- if (loopState.decision.status === 'continue') {
3519
- messages.push({
3520
- role: 'system',
3521
- content: [
3522
- loopState.decision.reason ? `Continue working: ${loopState.decision.reason}.` : 'Continue working autonomously.',
3523
- messagingSent
3524
- ? 'You already sent a user-facing message in this run. Keep working silently unless you have a materially new finished result or a real external blocker.'
3525
- : 'Use send_interim_update sparingly if a short real update or question would help. Otherwise keep working until you have the result or a real blocker.',
3526
- ].join(' ')
3527
- });
3528
- lastContent = '';
3529
- continue;
3530
- }
3531
- }
3252
+ // AI returned text with no tool calls → trust it as the final answer.
3253
+ directAnswerEligible = true;
3532
3254
  break;
3533
3255
  }
3534
3256
 
@@ -3537,6 +3259,15 @@ class AgentEngine {
3537
3259
  && response.toolCalls.every((toolCall) => this.isReadOnlyToolCall(toolCall))
3538
3260
  );
3539
3261
  if (canRunParallelBatch) {
3262
+ const parallelToolNames = response.toolCalls
3263
+ .map((toolCall) => toolCall.function?.name)
3264
+ .filter(Boolean);
3265
+ this.updateRunProgress(runId, {
3266
+ currentPhase: 'tool',
3267
+ currentStep: `parallel:${iteration}`,
3268
+ currentTool: parallelToolNames.join(', ') || 'parallel tools',
3269
+ currentStepStartedAt: isoNow(),
3270
+ });
3540
3271
  const batch = await this.executeReadOnlyBatch(response.toolCalls, {
3541
3272
  userId,
3542
3273
  runId,
@@ -3588,6 +3319,14 @@ class AgentEngine {
3588
3319
  deliverableArtifacts,
3589
3320
  compactionMetrics: compactionMetrics.slice(-20),
3590
3321
  });
3322
+ this.updateRunProgress(runId, {
3323
+ currentPhase: 'idle',
3324
+ currentStep: null,
3325
+ currentTool: null,
3326
+ currentStepStartedAt: null,
3327
+ }, {
3328
+ verified: true,
3329
+ });
3591
3330
  continue;
3592
3331
  }
3593
3332
 
@@ -3605,53 +3344,20 @@ class AgentEngine {
3605
3344
  }
3606
3345
 
3607
3346
  // ── task_complete: AI explicitly signals the task is fully done ──
3608
- // Handle before DB insert / before_tool_call hook this is not a
3609
- // regular tool execution, it is a loop-exit signal.
3347
+ // Trust the model no separate judge LLM call needed.
3610
3348
  if (toolName === 'task_complete') {
3611
3349
  const finalMessage = String(toolArgs.message || '').trim();
3612
- const confidence = normalizeCompletionConfidence(toolArgs.confidence || 'medium');
3613
- const completionDecision = shouldAcceptTaskComplete({
3614
- confidence,
3615
- requiredConfidence: analysis?.completion_confidence_required || 'medium',
3616
- iteration,
3617
- maxIterations,
3618
- });
3619
3350
  this.recordRunEvent(userId, runId, 'task_complete_signaled', {
3620
- confidence,
3621
- requiredConfidence: analysis?.completion_confidence_required || 'medium',
3622
- accepted: completionDecision.accept,
3351
+ accepted: true,
3623
3352
  iteration,
3624
3353
  messageLength: finalMessage.length,
3625
3354
  }, { agentId });
3626
3355
  console.info(
3627
- `[Run ${shortenRunId(runId)}] task_complete signaled at iteration=${iteration} confidence=${confidence} accepted=${completionDecision.accept}`
3356
+ `[Run ${shortenRunId(runId)}] task_complete accepted at iteration=${iteration}`
3628
3357
  );
3629
- if (!completionDecision.accept) {
3630
- messages.push({
3631
- role: 'tool',
3632
- name: toolName,
3633
- tool_call_id: toolCall.id,
3634
- content: JSON.stringify({
3635
- status: 'continue',
3636
- reason: completionDecision.reason,
3637
- required_confidence: analysis?.completion_confidence_required || 'medium',
3638
- }),
3639
- });
3640
- messages.push({
3641
- role: 'system',
3642
- content: `${completionDecision.reason} Do not ask the user to decide the next step unless external input is truly required.`
3643
- });
3644
- continue;
3645
- }
3646
- if (completionDecision.reason) {
3647
- messages.push({
3648
- role: 'system',
3649
- content: completionDecision.reason,
3650
- });
3651
- }
3652
- lastContent = finalMessage; // empty string is valid; downstream handles it
3358
+ lastContent = finalMessage;
3653
3359
  directAnswerEligible = true;
3654
- break; // exit the for-loop; the while condition will also exit
3360
+ break;
3655
3361
  }
3656
3362
 
3657
3363
  const repetitionGuard = this.getRunMeta(runId)?.repetitionGuard;
@@ -3712,7 +3418,6 @@ class AgentEngine {
3712
3418
  currentTool: toolName,
3713
3419
  currentStepStartedAt: isoNow(),
3714
3420
  }, {
3715
- verified: true,
3716
3421
  stepId,
3717
3422
  });
3718
3423
 
@@ -3862,6 +3567,11 @@ class AgentEngine {
3862
3567
 
3863
3568
  if (toolErrorMessage) {
3864
3569
  consecutiveToolFailures += 1;
3570
+ const currentRunMeta = this.getRunMeta(runId);
3571
+ trackErrorPattern(toolErrorMessage, currentRunMeta);
3572
+ const errorKey = normalizeErrorKey(toolErrorMessage);
3573
+ const errorCount = currentRunMeta?.errorPatterns?.get(errorKey) || 0;
3574
+ const patternGuide = buildErrorPatternGuidance(errorKey, errorCount);
3865
3575
  const alternativeTools = summarizeAvailableTools(tools, { exclude: toolName });
3866
3576
  messages.push({
3867
3577
  role: 'system',
@@ -3870,6 +3580,7 @@ class AgentEngine {
3870
3580
  'This tool failure is not, by itself, a user-facing blocker.',
3871
3581
  'Continue autonomously: retry with corrected arguments, try an alternative tool/path, or verify the outcome using other available tools.',
3872
3582
  alternativeTools ? `Other available tools in this run: ${alternativeTools}.` : '',
3583
+ patternGuide || '',
3873
3584
  'Only stop and tell the user you are blocked if the remaining issue truly requires an external dependency or user action outside this run.'
3874
3585
  ].filter(Boolean).join(' ')
3875
3586
  });
@@ -3978,26 +3689,43 @@ class AgentEngine {
3978
3689
  const lastToolWasMessaging = runMeta?.lastToolName === 'send_message' || runMeta?.lastToolName === 'make_call';
3979
3690
 
3980
3691
  if (triggerSource === 'messaging' && !normalizeOutgoingMessage(lastContent, options?.source || null) && !messagingSent) {
3981
- const recovered = await this.recoverBlankMessagingReply({
3982
- userId,
3983
- runId,
3984
- messages,
3985
- provider,
3986
- model,
3987
- providerName,
3988
- options: { ...options, runId, userId, agentId },
3989
- stepIndex,
3990
- failedStepCount,
3991
- toolExecutions,
3992
- tools
3993
- });
3994
- lastContent = recovered.content;
3995
- totalTokens += recovered.tokens || 0;
3692
+ // Simplified blank reply recovery: one model call with direct instruction,
3693
+ // then fall back to a deterministic message. No multi-attempt LLM loop.
3694
+ console.warn(`[Run ${shortenRunId(runId)}] blank_reply_recovery model=${model}`);
3695
+ let recoveredTokens = 0;
3696
+ try {
3697
+ const recoveryResponse = await withModelCallTimeout(
3698
+ provider.chat(
3699
+ sanitizeConversationMessages([
3700
+ ...messages,
3701
+ {
3702
+ role: 'system',
3703
+ content: buildBlankMessagingReplyPrompt(1, options?.source || null)
3704
+ }
3705
+ ]),
3706
+ [],
3707
+ {
3708
+ model,
3709
+ reasoningEffort: this.getReasoningEffort(providerName, options)
3710
+ }
3711
+ ),
3712
+ options,
3713
+ 'Blank messaging reply recovery',
3714
+ );
3715
+ recoveredTokens = recoveryResponse.usage?.totalTokens || 0;
3716
+ lastContent = sanitizeModelOutput(recoveryResponse.content || '', { model });
3717
+ } catch (recoverErr) {
3718
+ console.warn(`[Run ${shortenRunId(runId)}] blank_reply_recovery failed: ${summarizeForLog(recoverErr?.message || recoverErr, 180)}`);
3719
+ }
3720
+ totalTokens += recoveredTokens;
3721
+ if (!normalizeOutgoingMessage(lastContent, options?.source || null)) {
3722
+ lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
3723
+ }
3996
3724
  if (normalizeOutgoingMessage(lastContent, options?.source || null)) {
3997
3725
  messages.push({ role: 'assistant', content: lastContent });
3998
3726
  if (conversationId) {
3999
3727
  db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
4000
- .run(conversationId, 'assistant', lastContent, recovered.tokens || 0);
3728
+ .run(conversationId, 'assistant', lastContent, recoveredTokens);
4001
3729
  }
4002
3730
  }
4003
3731
  }
@@ -4139,20 +3867,6 @@ class AgentEngine {
4139
3867
  refreshConversationSummary(conversationId, provider, model, historyWindow).catch((err) => {
4140
3868
  console.error('[AI] Conversation summary refresh failed:', err.message);
4141
3869
  });
4142
- await this.refreshConversationState({
4143
- conversationId,
4144
- runId,
4145
- provider,
4146
- providerName,
4147
- model,
4148
- finalReply: finalResponseText,
4149
- analysis,
4150
- verification,
4151
- historyWindow,
4152
- options: { ...options, userId, agentId },
4153
- }).catch((err) => {
4154
- console.error('[AI] Conversation working state refresh failed:', err.message);
4155
- });
4156
3870
  }
4157
3871
  }
4158
3872
 
@@ -4186,6 +3900,23 @@ class AgentEngine {
4186
3900
  }
4187
3901
  }
4188
3902
 
3903
+ if (conversationId && options.skipConversationMaintenance !== true) {
3904
+ await this.refreshConversationState({
3905
+ conversationId,
3906
+ runId,
3907
+ provider,
3908
+ providerName,
3909
+ model,
3910
+ finalReply: finalResponseText,
3911
+ analysis,
3912
+ verification,
3913
+ historyWindow,
3914
+ options: { ...options, userId, agentId },
3915
+ }).catch((err) => {
3916
+ console.error('[AI] Conversation working state refresh failed:', err.message);
3917
+ });
3918
+ }
3919
+
4189
3920
  console.info(
4190
3921
  `[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}`
4191
3922
  );
@@ -4272,6 +4003,8 @@ class AgentEngine {
4272
4003
  triggerSource === 'messaging'
4273
4004
  && options.source
4274
4005
  && options.chatId
4006
+ && runMeta?.finalDeliverySent !== true
4007
+ && runMeta?.messagingSent !== true
4275
4008
  && err?.disableAutonomousRetry !== true
4276
4009
  && !isRateLimitError
4277
4010
  && retryCount < this.getMessagingRetryLimit(maxIterations)
@@ -4342,7 +4075,7 @@ class AgentEngine {
4342
4075
  let messagingFailureContent = '';
4343
4076
  let sendSucceeded = false;
4344
4077
  if (triggerSource === 'messaging' && options.source && options.chatId) {
4345
- if (!runMeta?.messagingSent) {
4078
+ if (!runMeta?.finalDeliverySent && !runMeta?.messagingSent) {
4346
4079
  const manager = this.messagingManager;
4347
4080
  if (manager) {
4348
4081
  const failureScenario = buildMessagingFailureScenario({
@@ -4359,10 +4092,14 @@ class AgentEngine {
4359
4092
  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)}`
4360
4093
  }
4361
4094
  ]);
4362
- const modelReply = await provider.chat(failedMessage, [], {
4363
- model,
4364
- reasoningEffort: this.getReasoningEffort(providerName, options)
4365
- });
4095
+ const modelReply = await withModelCallTimeout(
4096
+ provider.chat(failedMessage, [], {
4097
+ model,
4098
+ reasoningEffort: this.getReasoningEffort(providerName, options)
4099
+ }),
4100
+ options,
4101
+ 'Messaging failure reply',
4102
+ );
4366
4103
  const drafted = sanitizeModelOutput(modelReply.content || '', { model });
4367
4104
  if (normalizeOutgoingMessage(drafted, options?.source || null)) {
4368
4105
  messagingFailureContent = drafted.trim();
@@ -4381,7 +4118,14 @@ class AgentEngine {
4381
4118
  }
4382
4119
 
4383
4120
  try {
4384
- await manager.sendMessage(userId, options.source, options.chatId, messagingFailureContent, { runId, agentId });
4121
+ const deliveryResult = await manager.sendMessage(
4122
+ userId,
4123
+ options.source,
4124
+ options.chatId,
4125
+ messagingFailureContent,
4126
+ { runId, agentId },
4127
+ );
4128
+ requireSuccessfulMessagingDelivery(deliveryResult, 'Messaging failure delivery');
4385
4129
  sendSucceeded = true;
4386
4130
  if (runMeta) {
4387
4131
  runMeta.lastSentMessage = messagingFailureContent;