neoagent 2.5.2-beta.9 → 3.0.1-beta.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 (52) hide show
  1. package/README.md +5 -1
  2. package/docs/integrations.md +11 -7
  3. package/flutter_app/lib/main_controller.dart +34 -0
  4. package/flutter_app/lib/main_security.dart +19 -0
  5. package/lib/schema_migrations.js +224 -0
  6. package/package.json +2 -2
  7. package/server/admin/admin.css +29 -0
  8. package/server/admin/admin.js +81 -1
  9. package/server/admin/index.html +97 -9
  10. package/server/db/database.js +57 -1
  11. package/server/public/.last_build_id +1 -1
  12. package/server/public/flutter_bootstrap.js +1 -1
  13. package/server/public/main.dart.js +32788 -32752
  14. package/server/routes/security.js +19 -0
  15. package/server/routes/settings.js +1 -0
  16. package/server/routes/skills.js +9 -5
  17. package/server/services/ai/deliverables/artifact_helpers.js +92 -9
  18. package/server/services/ai/deliverables/selector.js +17 -0
  19. package/server/services/ai/hooks.js +3 -2
  20. package/server/services/ai/learning.js +8 -3
  21. package/server/services/ai/loop/agent_engine_core.js +67 -0
  22. package/server/services/ai/loop/blank_recovery.js +37 -0
  23. package/server/services/ai/loop/conversation_loop.js +391 -252
  24. package/server/services/ai/loop/error_recovery.js +38 -0
  25. package/server/services/ai/loop/messaging_delivery.js +52 -6
  26. package/server/services/ai/loop/progress_classification.js +178 -0
  27. package/server/services/ai/loop/progress_monitor.js +6 -5
  28. package/server/services/ai/loop/tool_dispatch.js +1 -0
  29. package/server/services/ai/loopPolicy.js +15 -6
  30. package/server/services/ai/messagingFallback.js +23 -1
  31. package/server/services/ai/preModelCompaction.js +31 -0
  32. package/server/services/ai/repetitionGuard.js +47 -2
  33. package/server/services/ai/settings.js +8 -0
  34. package/server/services/ai/systemPrompt.js +24 -0
  35. package/server/services/ai/taskAnalysis.js +10 -0
  36. package/server/services/ai/toolEvidence.js +39 -3
  37. package/server/services/ai/toolResult.js +29 -0
  38. package/server/services/ai/toolRunner.js +262 -55
  39. package/server/services/ai/toolSelector.js +20 -3
  40. package/server/services/ai/tools.js +201 -31
  41. package/server/services/cli/shell_worker_pool.js +87 -5
  42. package/server/services/integrations/github/common.js +2 -2
  43. package/server/services/integrations/github/repos.js +123 -55
  44. package/server/services/manager.js +16 -0
  45. package/server/services/memory/manager.js +39 -24
  46. package/server/services/runtime/docker-vm-manager.js +21 -1
  47. package/server/services/runtime/manager.js +6 -2
  48. package/server/services/security/approval_gate_service.js +108 -5
  49. package/server/services/security/tool_categories.js +113 -4
  50. package/server/services/security/tool_security_hook.js +7 -2
  51. package/server/services/skills/runtime.js +2 -0
  52. package/server/services/workspace/manager.js +56 -0
@@ -56,6 +56,13 @@ const { enforceRateLimits } = require('../rate_limits');
56
56
  const { ToolRepetitionGuard } = require('../repetitionGuard');
57
57
  const { shortenRunId, summarizeForLog } = require('../logFormat');
58
58
  const { IterationBudget } = require('./iteration_budget');
59
+ const {
60
+ buildBlankAfterToolFailureGuidance,
61
+ shouldContinueAfterBlankToolFailure,
62
+ } = require('./blank_recovery');
63
+ const {
64
+ shouldSendMessagingErrorFallback,
65
+ } = require('./error_recovery');
59
66
  const {
60
67
  buildCompletionDecisionPrompt,
61
68
  buildGoalContractPrompt,
@@ -117,11 +124,16 @@ const {
117
124
  getAvailableTools: getAvailableToolsImpl,
118
125
  isReadOnlyToolCall: isReadOnlyToolCallImpl,
119
126
  } = require('./tool_dispatch');
127
+ const {
128
+ isProgressToolCall,
129
+ } = require('./progress_classification');
120
130
  const {
121
131
  normalizeOutgoingMessage,
122
132
  clampRunContext,
123
133
  joinSentMessages,
124
134
  buildBlankMessagingReplyPrompt,
135
+ buildMaxIterationWrapupPrompt,
136
+ buildProgressUpdatePrompt,
125
137
  buildDeterministicMessagingFallback,
126
138
  buildMessagingFailureScenario,
127
139
  buildDeterministicMessagingErrorReply,
@@ -129,10 +141,11 @@ const {
129
141
  } = require('../messagingFallback');
130
142
  const {
131
143
  classifyToolExecution,
144
+ isSubstantiveProgressToolName,
145
+ summarizeProgressToolExecutions,
132
146
  summarizeToolExecutions,
133
147
  summarizeAvailableTools,
134
148
  inferToolFailureMessage,
135
- buildAutonomousRecoveryContext,
136
149
  } = require('../toolEvidence');
137
150
  const {
138
151
  buildMemoryConsolidationInstructions,
@@ -195,7 +208,7 @@ function buildErrorPatternGuidance(key, count) {
195
208
  // Immediate guidance on first occurrence for high-signal patterns that waste
196
209
  // multiple iterations before self-correcting.
197
210
  const immediateGuides = {
198
- 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.',
211
+ eisdir: 'That path is a directory or outside the workspace file-tool boundary. Use list_directory for workspace directories. Keep source files in the shared workspace before reading them with file tools.',
199
212
  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.',
200
213
  };
201
214
  if (immediateGuides[key]) {
@@ -205,9 +218,9 @@ function buildErrorPatternGuidance(key, count) {
205
218
 
206
219
  if (count < 3) return null;
207
220
  const guides = {
208
- outside_workspace: 'read_file cannot access /tmp paths. Use execute_command with `cat <path>` instead.',
221
+ outside_workspace: 'read_file/read_files only access the shared workspace. Put the relevant files there, then use read_files/search_files/edit_file instead of repeatedly extracting snippets through shell commands.',
209
222
  enoent: 'That path does not exist. Use execute_command with `find . -name "..."` to locate the correct path first.',
210
- bad_cwd: 'The VM home directory is not ~/. Use absolute paths starting from /tmp or discover the workspace root first.',
223
+ bad_cwd: 'The VM home directory is not ~/. Discover the workspace root with pwd/list_directory and keep working files there so file tools can inspect them.',
211
224
  not_found: 'This path or resource was not found. Try listing the parent directory or checking with a broader search first.',
212
225
  };
213
226
  const guide = guides[key];
@@ -217,8 +230,17 @@ function buildErrorPatternGuidance(key, count) {
217
230
 
218
231
  const OUTPUT_FINGERPRINT_TOOLS = /^(list_|search_|read_|get_|find_|github_list|github_get|github_search)/;
219
232
 
220
- function fingerprintOutput(toolName, result) {
221
- if (!toolName || !OUTPUT_FINGERPRINT_TOOLS.test(toolName)) return null;
233
+ function fingerprintOutput(toolName, result, toolArgs = {}) {
234
+ const name = String(toolName || '');
235
+ if (
236
+ !name
237
+ || (
238
+ !OUTPUT_FINGERPRINT_TOOLS.test(name)
239
+ && !(name === 'execute_command' && !isProgressToolCall(name, toolArgs))
240
+ )
241
+ ) {
242
+ return null;
243
+ }
222
244
  const raw = typeof result === 'string' ? result : JSON.stringify(result ?? '');
223
245
  if (raw.length < 200) return null;
224
246
  // djb2 hash over first 3000 chars — fast, collision-unlikely for our sizes
@@ -228,16 +250,37 @@ function fingerprintOutput(toolName, result) {
228
250
  return h >>> 0;
229
251
  }
230
252
 
231
- // Tools that represent concrete forward progress (write, create, send, update, run).
232
- // Anything NOT in this set is considered read-only for the analysis-paralysis gate.
233
- // execute_command counts as progress — it can do anything, including modify state.
234
- function isProgressTool(toolName) {
235
- if (!toolName) return false;
236
- // Neutral / bookkeeping don't count either way
237
- if (toolName === 'activate_tools' || toolName === 'save_widget_snapshot') return false;
238
- // Explicitly read-only patterns
239
- if (/^(list_|search_|read_file|get_file|find_files?|github_list|github_get|github_search|browser_get|browser_read)/.test(toolName)) return false;
240
- return true;
253
+ // Concise list of files/targets the run has already read or searched, so the
254
+ // analysis-paralysis nudge can name them and tell the model not to re-read them.
255
+ function summarizeReadTargets(toolExecutions = []) {
256
+ const targets = [];
257
+ const seen = new Set();
258
+ for (const item of toolExecutions) {
259
+ if (!item || item.stateChanged) continue; // only read-only steps
260
+ const input = item.input || {};
261
+ let target = '';
262
+ if (Array.isArray(input.files) && input.files.length) {
263
+ target = input.files
264
+ .map((file) => (typeof file === 'string' ? file : file?.path || file?.file_path || ''))
265
+ .filter(Boolean)
266
+ .slice(0, 3)
267
+ .join(', ');
268
+ } else if (typeof input.path === 'string' && input.path.trim()) {
269
+ target = input.path.trim();
270
+ } else if (typeof input.command === 'string') {
271
+ const files = input.command.match(/[\w./-]+\.(?:js|ts|tsx|jsx|py|dart|json|md|kt|c|h|ya?ml|sql|txt|sh)\b/g);
272
+ if (files && files.length) target = [...new Set(files)].slice(0, 2).join(', ');
273
+ } else if (typeof input.query === 'string' && input.query.trim()) {
274
+ target = `search:${input.query.trim().slice(0, 30)}`;
275
+ }
276
+ if (!target) continue;
277
+ const key = target.toLowerCase();
278
+ if (seen.has(key)) continue;
279
+ seen.add(key);
280
+ targets.push(target);
281
+ if (targets.length >= 8) break;
282
+ }
283
+ return targets.join('; ');
241
284
  }
242
285
 
243
286
  function cloneInterimHistory(history = []) {
@@ -556,9 +599,16 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
556
599
 
557
600
  const runTitle = generateTitle(userMessage);
558
601
  const initialRunMetadata = buildInitialRunMetadata(options);
559
- db.prepare(`INSERT OR REPLACE INTO agent_runs(
602
+ db.prepare(`INSERT INTO agent_runs(
560
603
  id, user_id, agent_id, title, status, trigger_type, trigger_source, model, metadata_json
561
- ) VALUES(?, ?, ?, ?, 'running', ?, ?, ?, ?)`).run(
604
+ ) VALUES(?, ?, ?, ?, 'running', ?, ?, ?, ?)
605
+ ON CONFLICT(id) DO UPDATE SET
606
+ status = 'running',
607
+ model = excluded.model,
608
+ updated_at = datetime('now'),
609
+ completed_at = NULL,
610
+ error = NULL,
611
+ metadata_json = COALESCE(agent_runs.metadata_json, excluded.metadata_json)`).run(
562
612
  runId,
563
613
  userId,
564
614
  agentId,
@@ -615,6 +665,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
615
665
  steeringQueue: [],
616
666
  systemSteeringQueue: [],
617
667
  toolPids: new Set(),
668
+ subagentDepth: Math.max(0, Number(options.subagentDepth) || 0),
618
669
  repetitionGuard: new ToolRepetitionGuard(),
619
670
  seenOutputHashes: new Map(),
620
671
  consecutiveReadOnlyIterations: 0,
@@ -667,7 +718,13 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
667
718
  const mcpManager = app?.locals?.mcpManager || app?.locals?.mcpClient || engine.mcpManager;
668
719
  const integrationManager = app?.locals?.integrationManager || null;
669
720
  const mcpTools = mcpManager ? mcpManager.getAllTools(userId, { agentId }) : [];
670
- const allTools = selectToolsForTask(userMessage, builtInTools, mcpTools, options);
721
+ const disallowedToolNames = new Set(
722
+ (Array.isArray(options.disallowedToolNames) ? options.disallowedToolNames : [])
723
+ .map((name) => String(name || '').trim())
724
+ .filter(Boolean),
725
+ );
726
+ const allTools = selectToolsForTask(userMessage, builtInTools, mcpTools, options)
727
+ .filter((tool) => !disallowedToolNames.has(tool?.name));
671
728
  let tools = allTools;
672
729
  const toolNames = allTools.map((tool) => tool.name).filter(Boolean);
673
730
  const coreToolStatus = {
@@ -686,7 +743,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
686
743
  console.info(
687
744
  `[Run ${shortenRunId(runId)}] tools total=${toolNames.length} builtIns=${builtInTools.length} mcp=${mcpTools.length} core=${JSON.stringify(coreToolStatus)}`
688
745
  );
689
- const capabilityHealth = await getCapabilityHealth({ userId, agentId, app, engine: this });
746
+ const capabilityHealth = await getCapabilityHealth({ userId, agentId, app, engine });
690
747
  const capabilitySummary = summarizeCapabilityHealth(capabilityHealth);
691
748
  const integrationSummary = integrationManager?.summarizeConnectedProviders?.(userId, agentId) || '';
692
749
 
@@ -762,6 +819,62 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
762
819
  let compactionMetrics = [];
763
820
  let analysis = null;
764
821
  let plan = null;
822
+
823
+ // Model-authored progress updates: the messaging supervisor calls this to get a
824
+ // dynamic "what I'm doing right now" line instead of a hard-coded string. It runs
825
+ // a tiny tool-less model call over the current run activity. `provider`/`model` are
826
+ // `let` (closure tracks fallbacks) and `toolExecutions` is push-only.
827
+ if (triggerSource === 'messaging') {
828
+ const runMetaForCompose = engine.getRunMeta(runId);
829
+ if (runMetaForCompose) {
830
+ runMetaForCompose.composeProgressUpdate = async ({ stalled = false } = {}) => {
831
+ try {
832
+ const rm = engine.getRunMeta(runId);
833
+ const ledger = rm?.progressLedger || {};
834
+ // Real, specific evidence (actual commands + their output) so the update is
835
+ // grounded in what happened, not invented. Bare tool names make a weak model
836
+ // confabulate generic activity ("running the build", "training").
837
+ const recent = summarizeProgressToolExecutions(toolExecutions, 5);
838
+ const currentTool = isSubstantiveProgressToolName(ledger.currentTool)
839
+ ? ledger.currentTool
840
+ : '';
841
+ if (!recent && !currentTool) {
842
+ return '';
843
+ }
844
+ const priorUpdate = String(rm?.lastInterimMessage || '').trim();
845
+ const contextBlock = [
846
+ buildProgressUpdatePrompt(),
847
+ stalled ? 'This step has been running a while with no new progress; reassure the user you are still on it.' : '',
848
+ '',
849
+ `Original request: ${summarizeForLog(userMessage, 320)}`,
850
+ currentTool ? `Doing now: using ${currentTool}` : '',
851
+ recent ? `Actual recent tool activity (newest last) — describe ONLY this, do not extrapolate:\n${recent}` : '',
852
+ priorUpdate ? `Your previous update (say something different): ${summarizeForLog(priorUpdate, 160)}` : '',
853
+ ].filter(Boolean).join('\n');
854
+ // Reuse the run's real system prompt so the update follows the same voice and
855
+ // formatting guidelines as every other message (single source of truth).
856
+ const sysContent = [systemPrompt?.stable, systemPrompt?.dynamic].filter(Boolean).join('\n\n')
857
+ || 'You are a helpful assistant.';
858
+ const resp = await withModelCallTimeout(
859
+ provider.chat(
860
+ [
861
+ { role: 'system', content: sysContent },
862
+ { role: 'user', content: contextBlock },
863
+ ],
864
+ [],
865
+ { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
866
+ ),
867
+ options,
868
+ 'Progress update compose',
869
+ );
870
+ return sanitizeModelOutput(resp.content || '', { model });
871
+ } catch (composeErr) {
872
+ console.warn(`[Run ${shortenRunId(runId)}] progress_update_compose failed: ${summarizeForLog(composeErr?.message || composeErr, 140)}`);
873
+ return '';
874
+ }
875
+ };
876
+ }
877
+ }
765
878
  let verification = null;
766
879
  let deliverableWorkflow = null;
767
880
  let deliverablePlan = null;
@@ -827,6 +940,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
827
940
 
828
941
  tools = selectInitialTools(allTools, analysis.suggested_tools, {
829
942
  widgetId: options.widgetId || null,
943
+ includeCoreFileTools: analysis.mode === 'execute' || analysis.mode === 'plan_execute',
830
944
  });
831
945
  engine.initializeToolRuntime(runId, allTools, tools, options);
832
946
  messages.push({
@@ -836,6 +950,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
836
950
  buildToolCatalog(allTools),
837
951
  '',
838
952
  `Active tools: ${tools.map((tool) => tool.name).join(', ')}`,
953
+ 'For workspace file inspection/editing, prefer read_files, read_file, search_files, list_directory, edit_file, replace_file_range, and write_file over shell cat/sed/python snippets. Use execute_command for git, tests, package managers, builds, and other shell-native actions.',
839
954
  'Use activate_tools with exact catalog names when another schema is required.',
840
955
  ].join('\n'),
841
956
  });
@@ -896,54 +1011,63 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
896
1011
  maxIterations = loopPolicy.maxIterations;
897
1012
 
898
1013
  if (options.skipDeliverableWorkflow !== true) {
899
- const deliverableSelectionResult = await selectDeliverableWorkflow({
900
- engine: this,
901
- provider,
902
- providerName,
903
- model,
904
- messages,
905
- tools,
906
- options: { ...options, runId, userId, agentId },
907
- });
908
- totalTokens += deliverableSelectionResult.usage || 0;
909
- const selectedWorkflow = getDeliverableWorkflow(deliverableSelectionResult.selection.type);
910
- if (selectedWorkflow?.canHandle(deliverableSelectionResult.selection)) {
911
- deliverableWorkflow = {
912
- workflow: selectedWorkflow,
913
- selection: deliverableSelectionResult.selection,
914
- request: selectedWorkflow.normalizeRequest({
915
- ...deliverableSelectionResult.selection,
916
- userMessage,
917
- }),
918
- };
919
- deliverablePlan = selectedWorkflow.buildExecutionPlan(deliverableWorkflow.request, {
920
- analysis,
1014
+ try {
1015
+ const deliverableSelectionResult = await selectDeliverableWorkflow({
1016
+ engine,
1017
+ provider,
1018
+ providerName,
1019
+ model,
1020
+ messages,
921
1021
  tools,
922
- options,
923
- });
924
- await selectedWorkflow.run(deliverablePlan, {
925
- engine: this,
926
- userId,
927
- agentId,
928
- runId,
929
- agentId,
930
- app,
1022
+ options: { ...options, runId, userId, agentId },
931
1023
  });
932
- engine.persistRunMetadata(runId, {
933
- deliverableWorkflow: {
934
- ...deliverableWorkflow.selection,
935
- plan: deliverablePlan,
936
- },
937
- });
938
- engine.updateRunGoalContract(runId, {
939
- goal: deliverableWorkflow.selection.goal,
940
- });
941
- engine.recordRunEvent(userId, runId, 'deliverable_workflow_selected', {
942
- type: deliverableWorkflow.selection.type,
943
- confidence: deliverableWorkflow.selection.confidence,
944
- goal: deliverableWorkflow.selection.goal,
945
- requestedOutputs: deliverableWorkflow.selection.requestedOutputs,
1024
+ totalTokens += deliverableSelectionResult.usage || 0;
1025
+ const selectedWorkflow = getDeliverableWorkflow(deliverableSelectionResult.selection.type);
1026
+ if (selectedWorkflow?.canHandle(deliverableSelectionResult.selection)) {
1027
+ deliverableWorkflow = {
1028
+ workflow: selectedWorkflow,
1029
+ selection: deliverableSelectionResult.selection,
1030
+ request: selectedWorkflow.normalizeRequest({
1031
+ ...deliverableSelectionResult.selection,
1032
+ userMessage,
1033
+ }),
1034
+ };
1035
+ deliverablePlan = selectedWorkflow.buildExecutionPlan(deliverableWorkflow.request, {
1036
+ analysis,
1037
+ tools,
1038
+ options,
1039
+ });
1040
+ await selectedWorkflow.run(deliverablePlan, {
1041
+ engine,
1042
+ userId,
1043
+ agentId,
1044
+ runId,
1045
+ app,
1046
+ });
1047
+ engine.persistRunMetadata(runId, {
1048
+ deliverableWorkflow: {
1049
+ ...deliverableWorkflow.selection,
1050
+ plan: deliverablePlan,
1051
+ },
1052
+ });
1053
+ engine.updateRunGoalContract(runId, {
1054
+ goal: deliverableWorkflow.selection.goal,
1055
+ });
1056
+ engine.recordRunEvent(userId, runId, 'deliverable_workflow_selected', {
1057
+ type: deliverableWorkflow.selection.type,
1058
+ confidence: deliverableWorkflow.selection.confidence,
1059
+ goal: deliverableWorkflow.selection.goal,
1060
+ requestedOutputs: deliverableWorkflow.selection.requestedOutputs,
1061
+ }, { agentId });
1062
+ }
1063
+ } catch (error) {
1064
+ engine.recordRunEvent(userId, runId, 'deliverable_workflow_skipped', {
1065
+ reason: summarizeForLog(error?.message || error, 240),
946
1066
  }, { agentId });
1067
+ messages.push({
1068
+ role: 'system',
1069
+ content: 'The optional deliverable workflow classifier failed. Continue with the normal agent loop; do not stop or retry the whole run just because this classifier failed.',
1070
+ });
947
1071
  }
948
1072
  }
949
1073
 
@@ -1051,9 +1175,18 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1051
1175
  const readOnlyCount = engine.getRunMeta(runId)?.consecutiveReadOnlyIterations || 0;
1052
1176
  if (readOnlyCount >= 3) {
1053
1177
  const urgency = readOnlyCount >= 6 ? 'CRITICAL' : 'ACTION REQUIRED';
1178
+ const alreadyRead = summarizeReadTargets(toolExecutions);
1054
1179
  messages.push({
1055
1180
  role: 'system',
1056
- 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.`,
1181
+ content: [
1182
+ `${urgency} — ${readOnlyCount} consecutive read-only turns with no concrete action.`,
1183
+ alreadyRead
1184
+ ? `You have ALREADY read/searched: ${alreadyRead}. Their output is in this conversation above — do not read or search them again; re-reading what you already have is the main way runs stall.`
1185
+ : 'Do not re-read or re-search anything already in this conversation.',
1186
+ 'If you are repeatedly extracting evidence through low-level commands, switch to the available file/search/edit tools over the shared workspace.',
1187
+ 'Act on what you already know now: edit files, run a state-changing command, create the branch/PR, or send the result.',
1188
+ 'If you genuinely cannot proceed, call task_complete with your best answer or a concrete blocker. Deciding to finish is a valid action; continuing to gather is not.',
1189
+ ].join(' '),
1057
1190
  });
1058
1191
  }
1059
1192
  }
@@ -1268,6 +1401,23 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1268
1401
  })) {
1269
1402
  break;
1270
1403
  }
1404
+ if (shouldContinueAfterBlankToolFailure({
1405
+ lastContent,
1406
+ failedStepCount,
1407
+ remainingIterations: iterationBudget.remaining,
1408
+ toolExecutions,
1409
+ })) {
1410
+ engine.recordRunEvent(userId, runId, 'blank_after_tool_failure_continued', {
1411
+ iteration,
1412
+ remainingIterations: iterationBudget.remaining,
1413
+ }, { agentId });
1414
+ messages.push({
1415
+ role: 'system',
1416
+ content: buildBlankAfterToolFailureGuidance(toolExecutions),
1417
+ });
1418
+ lastContent = '';
1419
+ continue;
1420
+ }
1271
1421
  const loopDecision = await engine.decideLoopState({
1272
1422
  provider,
1273
1423
  providerName,
@@ -1378,9 +1528,16 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1378
1528
  }, {
1379
1529
  verified: true,
1380
1530
  });
1531
+ if (analysis.mode === 'execute' || analysis.mode === 'plan_execute') {
1532
+ const iterMeta = engine.getRunMeta(runId);
1533
+ if (iterMeta) {
1534
+ iterMeta.consecutiveReadOnlyIterations = (iterMeta.consecutiveReadOnlyIterations || 0) + 1;
1535
+ }
1536
+ }
1381
1537
  continue;
1382
1538
  }
1383
1539
 
1540
+ let iterationConcreteProgress = false;
1384
1541
  for (const toolCall of response.toolCalls) {
1385
1542
  if (engine.isRunStopped(runId)) break;
1386
1543
  stepIndex++;
@@ -1595,6 +1752,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1595
1752
 
1596
1753
  const execution = classifyToolExecution(toolName, toolArgs, toolResult, toolErrorMessage);
1597
1754
  execution.input = toolArgs;
1755
+ if (execution.stateChanged && isProgressToolCall(toolName, toolArgs)) {
1756
+ iterationConcreteProgress = true;
1757
+ }
1598
1758
  repetitionGuard?.observe(toolName, toolArgs, toolResult);
1599
1759
  execution.artifacts = await extractArtifactsFromResult(toolName, toolResult);
1600
1760
  toolExecutions.push(execution);
@@ -1684,7 +1844,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1684
1844
  // Output fingerprint guard: steer away from re-fetching data already seen.
1685
1845
  if (!toolErrorMessage) {
1686
1846
  const currentRunMeta = engine.getRunMeta(runId);
1687
- const fp = fingerprintOutput(toolName, toolResult);
1847
+ const fp = fingerprintOutput(toolName, toolResult, toolArgs);
1688
1848
  if (fp !== null && currentRunMeta?.seenOutputHashes) {
1689
1849
  const prior = currentRunMeta.seenOutputHashes.get(fp);
1690
1850
  if (prior) {
@@ -1694,24 +1854,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1694
1854
  });
1695
1855
  } else {
1696
1856
  currentRunMeta.seenOutputHashes.set(fp, { toolName, iteration });
1697
- // External state: persist large read results to disk so the
1698
- // model can reference them after context compaction without
1699
- // re-fetching. Only for significant payloads.
1700
- const persistRaw = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult ?? '');
1701
- if (persistRaw.length >= 1000 && runId) {
1702
- const persistPath = `/tmp/run-${runId.slice(0, 8)}-${toolName}.json`;
1703
- try {
1704
- require('fs').writeFileSync(persistPath, persistRaw.slice(0, 40000));
1705
- if (!currentRunMeta.persistedDataPaths) currentRunMeta.persistedDataPaths = [];
1706
- if (!currentRunMeta.persistedDataPaths.includes(persistPath)) {
1707
- currentRunMeta.persistedDataPaths.push(persistPath);
1708
- messages.push({
1709
- role: 'system',
1710
- 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.`,
1711
- });
1712
- }
1713
- } catch { /* non-fatal — disk full or permissions */ }
1714
- }
1715
1857
  }
1716
1858
  }
1717
1859
  }
@@ -1782,8 +1924,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1782
1924
  && (analysis.mode === 'execute' || analysis.mode === 'plan_execute')) {
1783
1925
  const iterMeta = engine.getRunMeta(runId);
1784
1926
  if (iterMeta) {
1785
- const calledProgress = response.toolCalls.some((tc) => isProgressTool(tc.function?.name || ''));
1786
- iterMeta.consecutiveReadOnlyIterations = calledProgress
1927
+ iterMeta.consecutiveReadOnlyIterations = iterationConcreteProgress
1787
1928
  ? 0
1788
1929
  : (iterMeta.consecutiveReadOnlyIterations || 0) + 1;
1789
1930
  }
@@ -1796,20 +1937,41 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1796
1937
  }
1797
1938
 
1798
1939
  if (engine.isRunStopped(runId)) {
1799
- db.prepare('UPDATE agent_runs SET status = ?, updated_at = datetime(\'now\'), completed_at = datetime(\'now\') WHERE id = ?')
1800
- .run('stopped', runId);
1940
+ const stoppedRunMeta = engine.getRunMeta(runId);
1941
+ const terminalStatus = stoppedRunMeta?.status === 'interrupted' ? 'interrupted' : 'stopped';
1942
+ const stopReason = stoppedRunMeta?.stopReason || null;
1943
+ db.prepare(
1944
+ `UPDATE agent_runs
1945
+ SET status = ?,
1946
+ error = COALESCE(?, error),
1947
+ updated_at = datetime('now'),
1948
+ completed_at = datetime('now')
1949
+ WHERE id = ?`
1950
+ ).run(terminalStatus, stopReason, runId);
1801
1951
  console.warn(
1802
- `[Run ${shortenRunId(runId)}] stopped trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
1952
+ `[Run ${shortenRunId(runId)}] ${terminalStatus} trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
1803
1953
  );
1954
+ engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
1804
1955
  engine.stopMessagingProgressSupervisor(runId);
1805
1956
  engine.activeRuns.delete(runId);
1806
- engine.emit(userId, 'run:stopped', { runId, triggerSource });
1807
- engine.recordRunEvent(userId, runId, 'run_stopped', {
1957
+ engine.emit(userId, terminalStatus === 'interrupted' ? 'run:interrupted' : 'run:stopped', {
1958
+ runId,
1808
1959
  triggerSource,
1809
- totalTokens,
1810
- iterations: iteration,
1811
- }, { agentId });
1812
- return { runId, content: '', totalTokens, iterations: iteration, status: 'stopped' };
1960
+ reason: stopReason,
1961
+ });
1962
+ engine.recordRunEvent(
1963
+ userId,
1964
+ runId,
1965
+ terminalStatus === 'interrupted' ? 'run_interrupted' : 'run_stopped',
1966
+ {
1967
+ triggerSource,
1968
+ totalTokens,
1969
+ iterations: iteration,
1970
+ reason: stopReason,
1971
+ },
1972
+ { agentId },
1973
+ );
1974
+ return { runId, content: '', totalTokens, iterations: iteration, status: terminalStatus };
1813
1975
  }
1814
1976
 
1815
1977
  const runMeta = engine.activeRuns.get(runId);
@@ -1822,6 +1984,54 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1822
1984
  const messagingSent = runMeta?.messagingSent || false;
1823
1985
  const lastToolWasMessaging = runMeta?.lastToolName === 'send_message' || runMeta?.lastToolName === 'make_call';
1824
1986
 
1987
+ // Hermes _handle_max_iterations: if the run exhausted its step budget without a
1988
+ // judged completion, the model's last text is usually a mid-thought fragment
1989
+ // ("let me inline everything:"). Do one tool-less wrap-up call so the user gets a
1990
+ // real final answer instead of that fragment.
1991
+ const budgetExhaustedWithoutCompletion = triggerSource === 'messaging'
1992
+ && !directAnswerEligible
1993
+ && !messagingSent
1994
+ && !runMeta?.terminalInterim
1995
+ && iteration >= maxIterations;
1996
+ if (budgetExhaustedWithoutCompletion) {
1997
+ console.warn(`[Run ${shortenRunId(runId)}] max_iteration_wrapup model=${model} iteration=${iteration}/${maxIterations}`);
1998
+ try {
1999
+ const wrapResponse = await withModelCallTimeout(
2000
+ provider.chat(
2001
+ sanitizeConversationMessages([
2002
+ ...messages,
2003
+ { role: 'system', content: buildMaxIterationWrapupPrompt(options?.source || null) },
2004
+ ]),
2005
+ [],
2006
+ { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
2007
+ ),
2008
+ options,
2009
+ 'Max-iteration wrap-up',
2010
+ );
2011
+ totalTokens += wrapResponse.usage?.totalTokens || 0;
2012
+ const wrapText = sanitizeModelOutput(wrapResponse.content || '', { model });
2013
+ // On budget exhaustion the model's last text is an untrustworthy mid-thought
2014
+ // fragment. Replace it with the wrap-up answer, or a clean deterministic
2015
+ // fallback if the wrap-up came back empty — never deliver the fragment.
2016
+ const usableWrap = normalizeOutgoingMessage(wrapText, options?.source || null);
2017
+ lastContent = usableWrap
2018
+ ? wrapText
2019
+ : buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
2020
+ messages.push({ role: 'assistant', content: lastContent });
2021
+ if (conversationId) {
2022
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
2023
+ .run(conversationId, 'assistant', lastContent, usableWrap ? (wrapResponse.usage?.totalTokens || 0) : 0);
2024
+ }
2025
+ engine.recordRunEvent(userId, runId, 'max_iteration_wrapup_delivered', {
2026
+ iteration, maxIterations, stepIndex, source: usableWrap ? 'model' : 'deterministic',
2027
+ }, { agentId });
2028
+ } catch (wrapErr) {
2029
+ console.warn(`[Run ${shortenRunId(runId)}] max_iteration_wrapup failed: ${summarizeForLog(wrapErr?.message || wrapErr, 180)}`);
2030
+ lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
2031
+ messages.push({ role: 'assistant', content: lastContent });
2032
+ }
2033
+ }
2034
+
1825
2035
  if (triggerSource === 'messaging' && !normalizeOutgoingMessage(lastContent, options?.source || null) && !messagingSent) {
1826
2036
  // Simplified blank reply recovery: one model call with direct instruction,
1827
2037
  // then fall back to a deterministic message. No multi-attempt LLM loop.
@@ -1852,37 +2062,24 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1852
2062
  console.warn(`[Run ${shortenRunId(runId)}] blank_reply_recovery failed: ${summarizeForLog(recoverErr?.message || recoverErr, 180)}`);
1853
2063
  }
1854
2064
  totalTokens += recoveredTokens;
1855
- if (!normalizeOutgoingMessage(lastContent, options?.source || null)) {
2065
+ // The loop has already exited, so we cannot keep working: deliver the model's
2066
+ // own wrap-up (it summarizes what it tried / where it got blocked from the run
2067
+ // evidence) instead of second-guessing it into a generic blob. Only fall back to
2068
+ // a deterministic message when the model returned nothing usable.
2069
+ const recoveredVisible = Boolean(normalizeOutgoingMessage(lastContent, options?.source || null));
2070
+ if (!recoveredVisible) {
1856
2071
  lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
1857
2072
  }
1858
2073
  if (normalizeOutgoingMessage(lastContent, options?.source || null)) {
1859
- const recoveryDecision = await engine.decideLoopState({
1860
- provider,
1861
- providerName,
1862
- model,
1863
- messages,
1864
- analysis,
1865
- plan,
1866
- tools,
1867
- toolExecutions,
1868
- lastReply: lastContent,
1869
- iteration,
1870
- maxIterations,
1871
- options: { ...options, triggerSource, runId, userId, agentId },
1872
- messagingSent: false,
1873
- });
1874
- totalTokens += recoveryDecision.usage || 0;
1875
- engine.recordRunEvent(userId, runId, 'blank_reply_recovery_checked', {
1876
- status: recoveryDecision.decision.status,
1877
- reason: recoveryDecision.decision.reason,
2074
+ engine.recordRunEvent(userId, runId, 'blank_reply_recovery_delivered', {
2075
+ source: recoveredVisible ? 'model' : 'deterministic',
2076
+ stepIndex,
2077
+ failedStepCount,
1878
2078
  }, { agentId });
1879
- if (recoveryDecision.decision.status === 'continue') {
1880
- throw new Error('Messaging run ended without a judged terminal reply.');
1881
- }
1882
2079
  messages.push({ role: 'assistant', content: lastContent });
1883
2080
  if (conversationId) {
1884
2081
  db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
1885
- .run(conversationId, 'assistant', lastContent, recoveredTokens);
2082
+ .run(conversationId, 'assistant', lastContent, recoveredVisible ? recoveredTokens : 0);
1886
2083
  }
1887
2084
  }
1888
2085
  }
@@ -1910,10 +2107,28 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1910
2107
  stepIndex,
1911
2108
  failedStepCount,
1912
2109
  }, { agentId });
1913
- throw new Error(`Iteration budget exhausted before judged completion after ${maxIterations} iterations.`);
2110
+ if (triggerSource === 'messaging') {
2111
+ lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
2112
+ messages.push({ role: 'assistant', content: lastContent });
2113
+ if (conversationId) {
2114
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
2115
+ .run(conversationId, 'assistant', lastContent, 0);
2116
+ }
2117
+ } else {
2118
+ throw new Error(`Iteration budget exhausted before judged completion after ${maxIterations} iterations.`);
2119
+ }
1914
2120
  }
1915
2121
  if (stepIndex > 0 && !lastToolWasMessaging && iteration < maxIterations) {
1916
- throw new Error('Run ended without an explicit completion or blocker reply.');
2122
+ if (triggerSource === 'messaging') {
2123
+ lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
2124
+ messages.push({ role: 'assistant', content: lastContent });
2125
+ if (conversationId) {
2126
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
2127
+ .run(conversationId, 'assistant', lastContent, 0);
2128
+ }
2129
+ } else {
2130
+ throw new Error('Run ended without an explicit completion or blocker reply.');
2131
+ }
1917
2132
  }
1918
2133
  }
1919
2134
 
@@ -2156,149 +2371,73 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2156
2371
  }
2157
2372
 
2158
2373
  const runMeta = engine.activeRuns.get(runId);
2159
- const retryCount = Number(options.messagingAutonomousRetryCount || 0);
2160
- // Rate-limit errors (429) must not trigger messaging retries: the model
2161
- // won't be available in the milliseconds between retries, so spawning new
2162
- // runs just compounds the rate-limit pressure with no benefit.
2163
- const isRateLimitError = /429|rate.?limit|free-models-per/i.test(String(err?.message || ''));
2164
- const canRetryMessagingRun = (
2165
- triggerSource === 'messaging'
2166
- && options.source
2167
- && options.chatId
2168
- && runMeta?.finalDeliverySent !== true
2169
- && runMeta?.messagingSent !== true
2170
- && err?.disableAutonomousRetry !== true
2171
- && !isRateLimitError
2172
- && retryCount < engine.getMessagingRetryLimit(maxIterations)
2173
- );
2174
-
2175
- if (canRetryMessagingRun) {
2176
- const recoveryContext = buildAutonomousRecoveryContext({
2177
- err,
2178
- toolExecutions,
2179
- tools,
2180
- userMessage,
2181
- visibleMessageSent: Boolean(
2182
- runMeta?.lastSentMessage
2183
- || runMeta?.lastInterimMessage
2184
- || runMeta?.messagingSent === true
2185
- ),
2186
- });
2187
- db.prepare('UPDATE agent_runs SET status = ?, error = ?, updated_at = datetime(\'now\') WHERE id = ?')
2188
- .run('retrying', err.message, runId);
2189
- console.warn(
2190
- `[Run ${shortenRunId(runId)}] retrying_messaging_attempt=${retryCount + 1} reason=${summarizeForLog(err.message, 140)}`
2191
- );
2192
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2193
- engine.stopMessagingProgressSupervisor(runId);
2194
- engine.activeRuns.delete(runId);
2195
- engine.emit(userId, 'run:interim', {
2196
- runId,
2197
- message: 'Retrying internally after a transient failure.',
2198
- phase: 'retrying'
2199
- });
2200
-
2201
- const retryOptions = {
2202
- ...options,
2203
- messagingAutonomousRetryCount: retryCount + 1,
2204
- messagingRetryState: {
2205
- lastFinalMessage: String(runMeta?.lastSentMessage || options?.messagingRetryState?.lastFinalMessage || '').trim(),
2206
- explicitMessageSent: runMeta?.explicitMessageSent === true || options?.messagingRetryState?.explicitMessageSent === true,
2207
- interimHistory: cloneInterimHistory([
2208
- ...(Array.isArray(options?.messagingRetryState?.interimHistory) ? options.messagingRetryState.interimHistory : []),
2209
- ...(Array.isArray(runMeta?.interimMessages) ? runMeta.interimMessages : []),
2210
- ]),
2211
- goalContract: mergeGoalContracts(
2212
- options?.messagingRetryState?.goalContract || null,
2213
- runMeta?.goalContract || null,
2214
- ),
2215
- lastUserVisibleUpdateAt: runMeta?.progressLedger?.lastUserVisibleUpdateAt || options?.messagingRetryState?.lastUserVisibleUpdateAt || null,
2216
- lastFinalDeliveryAt: runMeta?.progressLedger?.lastFinalDeliveryAt || options?.messagingRetryState?.lastFinalDeliveryAt || null,
2217
- heartbeatCount: Number(runMeta?.progressLedger?.heartbeatCount || options?.messagingRetryState?.heartbeatCount || 0),
2218
- progressState: runMeta?.progressLedger?.progressState || options?.messagingRetryState?.progressState || 'active',
2219
- lastVerifiedProgressAt: runMeta?.progressLedger?.lastVerifiedProgressAt || options?.messagingRetryState?.lastVerifiedProgressAt || null,
2220
- },
2221
- context: {
2222
- ...(options.context || {}),
2223
- additionalContext: [
2224
- options.context?.additionalContext || '',
2225
- recoveryContext,
2226
- ].filter(Boolean).join('\n\n')
2227
- }
2228
- };
2229
- delete retryOptions.runId;
2230
-
2231
- return engine.runWithModel(userId, userMessage, retryOptions, _modelOverride);
2232
- }
2233
2374
 
2234
2375
  const deliverableFailureResponse = err?.deliverableResult?.summary
2235
2376
  || err?.deliverableValidation?.summary
2236
2377
  || '';
2237
2378
  let messagingFailureContent = '';
2238
2379
  let sendSucceeded = false;
2239
- if (triggerSource === 'messaging' && options.source && options.chatId) {
2240
- if (!runMeta?.finalDeliverySent && !runMeta?.messagingSent) {
2241
- const manager = engine.messagingManager;
2242
- if (manager) {
2243
- const failureScenario = buildMessagingFailureScenario({
2380
+ if (shouldSendMessagingErrorFallback({ triggerSource, options, runMeta })) {
2381
+ const manager = engine.messagingManager;
2382
+ if (manager) {
2383
+ const failureScenario = buildMessagingFailureScenario({
2384
+ err,
2385
+ failedStepCount,
2386
+ stepIndex,
2387
+ toolExecutions,
2388
+ });
2389
+ try {
2390
+ const failedMessage = sanitizeConversationMessages([
2391
+ ...messages,
2392
+ {
2393
+ role: 'system',
2394
+ 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)}`
2395
+ }
2396
+ ]);
2397
+ const modelReply = await withModelCallTimeout(
2398
+ provider.chat(failedMessage, [], {
2399
+ model,
2400
+ reasoningEffort: engine.getReasoningEffort(providerName, options)
2401
+ }),
2402
+ options,
2403
+ 'Messaging failure reply',
2404
+ );
2405
+ const drafted = sanitizeModelOutput(modelReply.content || '', { model });
2406
+ if (normalizeOutgoingMessage(drafted, options?.source || null)) {
2407
+ messagingFailureContent = drafted.trim();
2408
+ }
2409
+ } catch {
2410
+ // Fall back to deterministic text below.
2411
+ }
2412
+
2413
+ if (!messagingFailureContent) {
2414
+ messagingFailureContent = buildDeterministicMessagingErrorReply({
2244
2415
  err,
2245
2416
  failedStepCount,
2246
2417
  stepIndex,
2247
2418
  toolExecutions,
2248
2419
  });
2249
- try {
2250
- const failedMessage = sanitizeConversationMessages([
2251
- ...messages,
2252
- {
2253
- role: 'system',
2254
- content: `The run encountered a runtime error and cannot continue reliably. Use the actual run scenario below to explain the blocker naturally.\n\nScenario:\n${failureScenario || 'No additional scenario details were captured.'}\n\nDo not call tools. Write exactly one short user message. Do not ask the user to resend or restate the same task. Only ask the user for something if a specific external input, permission, or configuration change is actually required. Do not promise future work unless it will happen automatically before this reply is sent.\n\n${buildPlatformFormattingGuide(options?.source || null)}`
2255
- }
2256
- ]);
2257
- const modelReply = await withModelCallTimeout(
2258
- provider.chat(failedMessage, [], {
2259
- model,
2260
- reasoningEffort: engine.getReasoningEffort(providerName, options)
2261
- }),
2262
- options,
2263
- 'Messaging failure reply',
2264
- );
2265
- const drafted = sanitizeModelOutput(modelReply.content || '', { model });
2266
- if (normalizeOutgoingMessage(drafted, options?.source || null)) {
2267
- messagingFailureContent = drafted.trim();
2268
- }
2269
- } catch {
2270
- // Fall back to deterministic text below.
2271
- }
2272
-
2273
- if (!messagingFailureContent) {
2274
- messagingFailureContent = buildDeterministicMessagingErrorReply({
2275
- err,
2276
- failedStepCount,
2277
- stepIndex,
2278
- toolExecutions,
2279
- });
2280
- }
2420
+ }
2281
2421
 
2282
- try {
2283
- const deliveryResult = await manager.sendMessage(
2284
- userId,
2285
- options.source,
2286
- options.chatId,
2287
- messagingFailureContent,
2288
- { runId, agentId },
2289
- );
2290
- requireSuccessfulMessagingDelivery(deliveryResult, 'Messaging failure delivery');
2291
- sendSucceeded = true;
2292
- if (runMeta) {
2293
- runMeta.lastSentMessage = messagingFailureContent;
2294
- if (!Array.isArray(runMeta.sentMessages)) runMeta.sentMessages = [];
2295
- runMeta.sentMessages.push(messagingFailureContent);
2296
- }
2297
- engine.markRunFinalDelivery(runId, messagingFailureContent);
2298
- } catch (sendErr) {
2299
- console.error('[Engine] Messaging error fallback failed:', sendErr.message);
2300
- messagingFailureContent = '';
2422
+ try {
2423
+ const deliveryResult = await manager.sendMessage(
2424
+ userId,
2425
+ options.source,
2426
+ options.chatId,
2427
+ messagingFailureContent,
2428
+ { runId, agentId },
2429
+ );
2430
+ requireSuccessfulMessagingDelivery(deliveryResult, 'Messaging failure delivery');
2431
+ sendSucceeded = true;
2432
+ if (runMeta) {
2433
+ runMeta.lastSentMessage = messagingFailureContent;
2434
+ if (!Array.isArray(runMeta.sentMessages)) runMeta.sentMessages = [];
2435
+ runMeta.sentMessages.push(messagingFailureContent);
2301
2436
  }
2437
+ engine.markRunFinalDelivery(runId, messagingFailureContent);
2438
+ } catch (sendErr) {
2439
+ console.error('[Engine] Messaging error fallback failed:', sendErr.message);
2440
+ messagingFailureContent = '';
2302
2441
  }
2303
2442
  }
2304
2443
  }