neoagent 2.5.2-beta.9 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +5 -1
  2. package/docs/integrations.md +11 -7
  3. package/package.json +2 -2
  4. package/server/public/.last_build_id +1 -1
  5. package/server/public/flutter_bootstrap.js +1 -1
  6. package/server/public/main.dart.js +4 -4
  7. package/server/services/ai/deliverables/artifact_helpers.js +92 -9
  8. package/server/services/ai/deliverables/selector.js +17 -0
  9. package/server/services/ai/loop/blank_recovery.js +37 -0
  10. package/server/services/ai/loop/conversation_loop.js +345 -242
  11. package/server/services/ai/loop/error_recovery.js +38 -0
  12. package/server/services/ai/loop/messaging_delivery.js +52 -6
  13. package/server/services/ai/loop/progress_classification.js +178 -0
  14. package/server/services/ai/loop/progress_monitor.js +6 -5
  15. package/server/services/ai/loop/tool_dispatch.js +1 -0
  16. package/server/services/ai/loopPolicy.js +15 -6
  17. package/server/services/ai/messagingFallback.js +23 -1
  18. package/server/services/ai/preModelCompaction.js +31 -0
  19. package/server/services/ai/repetitionGuard.js +47 -2
  20. package/server/services/ai/systemPrompt.js +6 -0
  21. package/server/services/ai/taskAnalysis.js +10 -0
  22. package/server/services/ai/toolEvidence.js +15 -3
  23. package/server/services/ai/toolResult.js +29 -0
  24. package/server/services/ai/toolSelector.js +20 -3
  25. package/server/services/ai/tools.js +196 -26
  26. package/server/services/integrations/github/common.js +2 -2
  27. package/server/services/integrations/github/repos.js +123 -55
  28. package/server/services/runtime/docker-vm-manager.js +21 -1
  29. 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,
@@ -132,7 +144,6 @@ const {
132
144
  summarizeToolExecutions,
133
145
  summarizeAvailableTools,
134
146
  inferToolFailureMessage,
135
- buildAutonomousRecoveryContext,
136
147
  } = require('../toolEvidence');
137
148
  const {
138
149
  buildMemoryConsolidationInstructions,
@@ -195,7 +206,7 @@ function buildErrorPatternGuidance(key, count) {
195
206
  // Immediate guidance on first occurrence for high-signal patterns that waste
196
207
  // multiple iterations before self-correcting.
197
208
  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.',
209
+ 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
210
  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
211
  };
201
212
  if (immediateGuides[key]) {
@@ -205,9 +216,9 @@ function buildErrorPatternGuidance(key, count) {
205
216
 
206
217
  if (count < 3) return null;
207
218
  const guides = {
208
- outside_workspace: 'read_file cannot access /tmp paths. Use execute_command with `cat <path>` instead.',
219
+ 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
220
  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.',
221
+ 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
222
  not_found: 'This path or resource was not found. Try listing the parent directory or checking with a broader search first.',
212
223
  };
213
224
  const guide = guides[key];
@@ -217,8 +228,17 @@ function buildErrorPatternGuidance(key, count) {
217
228
 
218
229
  const OUTPUT_FINGERPRINT_TOOLS = /^(list_|search_|read_|get_|find_|github_list|github_get|github_search)/;
219
230
 
220
- function fingerprintOutput(toolName, result) {
221
- if (!toolName || !OUTPUT_FINGERPRINT_TOOLS.test(toolName)) return null;
231
+ function fingerprintOutput(toolName, result, toolArgs = {}) {
232
+ const name = String(toolName || '');
233
+ if (
234
+ !name
235
+ || (
236
+ !OUTPUT_FINGERPRINT_TOOLS.test(name)
237
+ && !(name === 'execute_command' && !isProgressToolCall(name, toolArgs))
238
+ )
239
+ ) {
240
+ return null;
241
+ }
222
242
  const raw = typeof result === 'string' ? result : JSON.stringify(result ?? '');
223
243
  if (raw.length < 200) return null;
224
244
  // djb2 hash over first 3000 chars — fast, collision-unlikely for our sizes
@@ -228,16 +248,37 @@ function fingerprintOutput(toolName, result) {
228
248
  return h >>> 0;
229
249
  }
230
250
 
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;
251
+ // Concise list of files/targets the run has already read or searched, so the
252
+ // analysis-paralysis nudge can name them and tell the model not to re-read them.
253
+ function summarizeReadTargets(toolExecutions = []) {
254
+ const targets = [];
255
+ const seen = new Set();
256
+ for (const item of toolExecutions) {
257
+ if (!item || item.stateChanged) continue; // only read-only steps
258
+ const input = item.input || {};
259
+ let target = '';
260
+ if (Array.isArray(input.files) && input.files.length) {
261
+ target = input.files
262
+ .map((file) => (typeof file === 'string' ? file : file?.path || file?.file_path || ''))
263
+ .filter(Boolean)
264
+ .slice(0, 3)
265
+ .join(', ');
266
+ } else if (typeof input.path === 'string' && input.path.trim()) {
267
+ target = input.path.trim();
268
+ } else if (typeof input.command === 'string') {
269
+ const files = input.command.match(/[\w./-]+\.(?:js|ts|tsx|jsx|py|dart|json|md|kt|c|h|ya?ml|sql|txt|sh)\b/g);
270
+ if (files && files.length) target = [...new Set(files)].slice(0, 2).join(', ');
271
+ } else if (typeof input.query === 'string' && input.query.trim()) {
272
+ target = `search:${input.query.trim().slice(0, 30)}`;
273
+ }
274
+ if (!target) continue;
275
+ const key = target.toLowerCase();
276
+ if (seen.has(key)) continue;
277
+ seen.add(key);
278
+ targets.push(target);
279
+ if (targets.length >= 8) break;
280
+ }
281
+ return targets.join('; ');
241
282
  }
242
283
 
243
284
  function cloneInterimHistory(history = []) {
@@ -556,9 +597,16 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
556
597
 
557
598
  const runTitle = generateTitle(userMessage);
558
599
  const initialRunMetadata = buildInitialRunMetadata(options);
559
- db.prepare(`INSERT OR REPLACE INTO agent_runs(
600
+ db.prepare(`INSERT INTO agent_runs(
560
601
  id, user_id, agent_id, title, status, trigger_type, trigger_source, model, metadata_json
561
- ) VALUES(?, ?, ?, ?, 'running', ?, ?, ?, ?)`).run(
602
+ ) VALUES(?, ?, ?, ?, 'running', ?, ?, ?, ?)
603
+ ON CONFLICT(id) DO UPDATE SET
604
+ status = 'running',
605
+ model = excluded.model,
606
+ updated_at = datetime('now'),
607
+ completed_at = NULL,
608
+ error = NULL,
609
+ metadata_json = COALESCE(agent_runs.metadata_json, excluded.metadata_json)`).run(
562
610
  runId,
563
611
  userId,
564
612
  agentId,
@@ -686,7 +734,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
686
734
  console.info(
687
735
  `[Run ${shortenRunId(runId)}] tools total=${toolNames.length} builtIns=${builtInTools.length} mcp=${mcpTools.length} core=${JSON.stringify(coreToolStatus)}`
688
736
  );
689
- const capabilityHealth = await getCapabilityHealth({ userId, agentId, app, engine: this });
737
+ const capabilityHealth = await getCapabilityHealth({ userId, agentId, app, engine });
690
738
  const capabilitySummary = summarizeCapabilityHealth(capabilityHealth);
691
739
  const integrationSummary = integrationManager?.summarizeConnectedProviders?.(userId, agentId) || '';
692
740
 
@@ -762,6 +810,56 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
762
810
  let compactionMetrics = [];
763
811
  let analysis = null;
764
812
  let plan = null;
813
+
814
+ // Model-authored progress updates: the messaging supervisor calls this to get a
815
+ // dynamic "what I'm doing right now" line instead of a hard-coded string. It runs
816
+ // a tiny tool-less model call over the current run activity. `provider`/`model` are
817
+ // `let` (closure tracks fallbacks) and `toolExecutions` is push-only.
818
+ if (triggerSource === 'messaging') {
819
+ const runMetaForCompose = engine.getRunMeta(runId);
820
+ if (runMetaForCompose) {
821
+ runMetaForCompose.composeProgressUpdate = async ({ stalled = false } = {}) => {
822
+ try {
823
+ const rm = engine.getRunMeta(runId);
824
+ const ledger = rm?.progressLedger || {};
825
+ // Real, specific evidence (actual commands + their output) so the update is
826
+ // grounded in what happened, not invented. Bare tool names make a weak model
827
+ // confabulate generic activity ("running the build", "training").
828
+ const recent = summarizeToolExecutions(toolExecutions, 5) || '(no tool activity yet)';
829
+ const priorUpdate = String(rm?.lastInterimMessage || '').trim();
830
+ const contextBlock = [
831
+ buildProgressUpdatePrompt(),
832
+ stalled ? 'This step has been running a while with no new progress; reassure the user you are still on it.' : '',
833
+ '',
834
+ `Original request: ${summarizeForLog(userMessage, 320)}`,
835
+ `Doing now: ${ledger.currentTool ? `using ${ledger.currentTool}` : (ledger.currentPhase || 'thinking')}`,
836
+ `Actual recent tool activity (newest last) — describe ONLY this, do not extrapolate:\n${recent}`,
837
+ priorUpdate ? `Your previous update (say something different): ${summarizeForLog(priorUpdate, 160)}` : '',
838
+ ].filter(Boolean).join('\n');
839
+ // Reuse the run's real system prompt so the update follows the same voice and
840
+ // formatting guidelines as every other message (single source of truth).
841
+ const sysContent = [systemPrompt?.stable, systemPrompt?.dynamic].filter(Boolean).join('\n\n')
842
+ || 'You are a helpful assistant.';
843
+ const resp = await withModelCallTimeout(
844
+ provider.chat(
845
+ [
846
+ { role: 'system', content: sysContent },
847
+ { role: 'user', content: contextBlock },
848
+ ],
849
+ [],
850
+ { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
851
+ ),
852
+ options,
853
+ 'Progress update compose',
854
+ );
855
+ return sanitizeModelOutput(resp.content || '', { model });
856
+ } catch (composeErr) {
857
+ console.warn(`[Run ${shortenRunId(runId)}] progress_update_compose failed: ${summarizeForLog(composeErr?.message || composeErr, 140)}`);
858
+ return '';
859
+ }
860
+ };
861
+ }
862
+ }
765
863
  let verification = null;
766
864
  let deliverableWorkflow = null;
767
865
  let deliverablePlan = null;
@@ -827,6 +925,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
827
925
 
828
926
  tools = selectInitialTools(allTools, analysis.suggested_tools, {
829
927
  widgetId: options.widgetId || null,
928
+ includeCoreFileTools: analysis.mode === 'execute' || analysis.mode === 'plan_execute',
830
929
  });
831
930
  engine.initializeToolRuntime(runId, allTools, tools, options);
832
931
  messages.push({
@@ -836,6 +935,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
836
935
  buildToolCatalog(allTools),
837
936
  '',
838
937
  `Active tools: ${tools.map((tool) => tool.name).join(', ')}`,
938
+ '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
939
  'Use activate_tools with exact catalog names when another schema is required.',
840
940
  ].join('\n'),
841
941
  });
@@ -896,54 +996,63 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
896
996
  maxIterations = loopPolicy.maxIterations;
897
997
 
898
998
  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,
999
+ try {
1000
+ const deliverableSelectionResult = await selectDeliverableWorkflow({
1001
+ engine,
1002
+ provider,
1003
+ providerName,
1004
+ model,
1005
+ messages,
921
1006
  tools,
922
- options,
1007
+ options: { ...options, runId, userId, agentId },
923
1008
  });
924
- await selectedWorkflow.run(deliverablePlan, {
925
- engine: this,
926
- userId,
927
- agentId,
928
- runId,
929
- agentId,
930
- app,
931
- });
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,
1009
+ totalTokens += deliverableSelectionResult.usage || 0;
1010
+ const selectedWorkflow = getDeliverableWorkflow(deliverableSelectionResult.selection.type);
1011
+ if (selectedWorkflow?.canHandle(deliverableSelectionResult.selection)) {
1012
+ deliverableWorkflow = {
1013
+ workflow: selectedWorkflow,
1014
+ selection: deliverableSelectionResult.selection,
1015
+ request: selectedWorkflow.normalizeRequest({
1016
+ ...deliverableSelectionResult.selection,
1017
+ userMessage,
1018
+ }),
1019
+ };
1020
+ deliverablePlan = selectedWorkflow.buildExecutionPlan(deliverableWorkflow.request, {
1021
+ analysis,
1022
+ tools,
1023
+ options,
1024
+ });
1025
+ await selectedWorkflow.run(deliverablePlan, {
1026
+ engine,
1027
+ userId,
1028
+ agentId,
1029
+ runId,
1030
+ app,
1031
+ });
1032
+ engine.persistRunMetadata(runId, {
1033
+ deliverableWorkflow: {
1034
+ ...deliverableWorkflow.selection,
1035
+ plan: deliverablePlan,
1036
+ },
1037
+ });
1038
+ engine.updateRunGoalContract(runId, {
1039
+ goal: deliverableWorkflow.selection.goal,
1040
+ });
1041
+ engine.recordRunEvent(userId, runId, 'deliverable_workflow_selected', {
1042
+ type: deliverableWorkflow.selection.type,
1043
+ confidence: deliverableWorkflow.selection.confidence,
1044
+ goal: deliverableWorkflow.selection.goal,
1045
+ requestedOutputs: deliverableWorkflow.selection.requestedOutputs,
1046
+ }, { agentId });
1047
+ }
1048
+ } catch (error) {
1049
+ engine.recordRunEvent(userId, runId, 'deliverable_workflow_skipped', {
1050
+ reason: summarizeForLog(error?.message || error, 240),
946
1051
  }, { agentId });
1052
+ messages.push({
1053
+ role: 'system',
1054
+ 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.',
1055
+ });
947
1056
  }
948
1057
  }
949
1058
 
@@ -1051,9 +1160,18 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1051
1160
  const readOnlyCount = engine.getRunMeta(runId)?.consecutiveReadOnlyIterations || 0;
1052
1161
  if (readOnlyCount >= 3) {
1053
1162
  const urgency = readOnlyCount >= 6 ? 'CRITICAL' : 'ACTION REQUIRED';
1163
+ const alreadyRead = summarizeReadTargets(toolExecutions);
1054
1164
  messages.push({
1055
1165
  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.`,
1166
+ content: [
1167
+ `${urgency} — ${readOnlyCount} consecutive read-only turns with no concrete action.`,
1168
+ alreadyRead
1169
+ ? `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.`
1170
+ : 'Do not re-read or re-search anything already in this conversation.',
1171
+ 'If you are repeatedly extracting evidence through low-level commands, switch to the available file/search/edit tools over the shared workspace.',
1172
+ 'Act on what you already know now: edit files, run a state-changing command, create the branch/PR, or send the result.',
1173
+ '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.',
1174
+ ].join(' '),
1057
1175
  });
1058
1176
  }
1059
1177
  }
@@ -1268,6 +1386,23 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1268
1386
  })) {
1269
1387
  break;
1270
1388
  }
1389
+ if (shouldContinueAfterBlankToolFailure({
1390
+ lastContent,
1391
+ failedStepCount,
1392
+ remainingIterations: iterationBudget.remaining,
1393
+ toolExecutions,
1394
+ })) {
1395
+ engine.recordRunEvent(userId, runId, 'blank_after_tool_failure_continued', {
1396
+ iteration,
1397
+ remainingIterations: iterationBudget.remaining,
1398
+ }, { agentId });
1399
+ messages.push({
1400
+ role: 'system',
1401
+ content: buildBlankAfterToolFailureGuidance(toolExecutions),
1402
+ });
1403
+ lastContent = '';
1404
+ continue;
1405
+ }
1271
1406
  const loopDecision = await engine.decideLoopState({
1272
1407
  provider,
1273
1408
  providerName,
@@ -1378,9 +1513,16 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1378
1513
  }, {
1379
1514
  verified: true,
1380
1515
  });
1516
+ if (analysis.mode === 'execute' || analysis.mode === 'plan_execute') {
1517
+ const iterMeta = engine.getRunMeta(runId);
1518
+ if (iterMeta) {
1519
+ iterMeta.consecutiveReadOnlyIterations = (iterMeta.consecutiveReadOnlyIterations || 0) + 1;
1520
+ }
1521
+ }
1381
1522
  continue;
1382
1523
  }
1383
1524
 
1525
+ let iterationConcreteProgress = false;
1384
1526
  for (const toolCall of response.toolCalls) {
1385
1527
  if (engine.isRunStopped(runId)) break;
1386
1528
  stepIndex++;
@@ -1595,6 +1737,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1595
1737
 
1596
1738
  const execution = classifyToolExecution(toolName, toolArgs, toolResult, toolErrorMessage);
1597
1739
  execution.input = toolArgs;
1740
+ if (execution.stateChanged && isProgressToolCall(toolName, toolArgs)) {
1741
+ iterationConcreteProgress = true;
1742
+ }
1598
1743
  repetitionGuard?.observe(toolName, toolArgs, toolResult);
1599
1744
  execution.artifacts = await extractArtifactsFromResult(toolName, toolResult);
1600
1745
  toolExecutions.push(execution);
@@ -1684,7 +1829,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1684
1829
  // Output fingerprint guard: steer away from re-fetching data already seen.
1685
1830
  if (!toolErrorMessage) {
1686
1831
  const currentRunMeta = engine.getRunMeta(runId);
1687
- const fp = fingerprintOutput(toolName, toolResult);
1832
+ const fp = fingerprintOutput(toolName, toolResult, toolArgs);
1688
1833
  if (fp !== null && currentRunMeta?.seenOutputHashes) {
1689
1834
  const prior = currentRunMeta.seenOutputHashes.get(fp);
1690
1835
  if (prior) {
@@ -1694,24 +1839,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1694
1839
  });
1695
1840
  } else {
1696
1841
  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
1842
  }
1716
1843
  }
1717
1844
  }
@@ -1782,8 +1909,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1782
1909
  && (analysis.mode === 'execute' || analysis.mode === 'plan_execute')) {
1783
1910
  const iterMeta = engine.getRunMeta(runId);
1784
1911
  if (iterMeta) {
1785
- const calledProgress = response.toolCalls.some((tc) => isProgressTool(tc.function?.name || ''));
1786
- iterMeta.consecutiveReadOnlyIterations = calledProgress
1912
+ iterMeta.consecutiveReadOnlyIterations = iterationConcreteProgress
1787
1913
  ? 0
1788
1914
  : (iterMeta.consecutiveReadOnlyIterations || 0) + 1;
1789
1915
  }
@@ -1822,6 +1948,54 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1822
1948
  const messagingSent = runMeta?.messagingSent || false;
1823
1949
  const lastToolWasMessaging = runMeta?.lastToolName === 'send_message' || runMeta?.lastToolName === 'make_call';
1824
1950
 
1951
+ // Hermes _handle_max_iterations: if the run exhausted its step budget without a
1952
+ // judged completion, the model's last text is usually a mid-thought fragment
1953
+ // ("let me inline everything:"). Do one tool-less wrap-up call so the user gets a
1954
+ // real final answer instead of that fragment.
1955
+ const budgetExhaustedWithoutCompletion = triggerSource === 'messaging'
1956
+ && !directAnswerEligible
1957
+ && !messagingSent
1958
+ && !runMeta?.terminalInterim
1959
+ && iteration >= maxIterations;
1960
+ if (budgetExhaustedWithoutCompletion) {
1961
+ console.warn(`[Run ${shortenRunId(runId)}] max_iteration_wrapup model=${model} iteration=${iteration}/${maxIterations}`);
1962
+ try {
1963
+ const wrapResponse = await withModelCallTimeout(
1964
+ provider.chat(
1965
+ sanitizeConversationMessages([
1966
+ ...messages,
1967
+ { role: 'system', content: buildMaxIterationWrapupPrompt(options?.source || null) },
1968
+ ]),
1969
+ [],
1970
+ { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
1971
+ ),
1972
+ options,
1973
+ 'Max-iteration wrap-up',
1974
+ );
1975
+ totalTokens += wrapResponse.usage?.totalTokens || 0;
1976
+ const wrapText = sanitizeModelOutput(wrapResponse.content || '', { model });
1977
+ // On budget exhaustion the model's last text is an untrustworthy mid-thought
1978
+ // fragment. Replace it with the wrap-up answer, or a clean deterministic
1979
+ // fallback if the wrap-up came back empty — never deliver the fragment.
1980
+ const usableWrap = normalizeOutgoingMessage(wrapText, options?.source || null);
1981
+ lastContent = usableWrap
1982
+ ? wrapText
1983
+ : buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
1984
+ messages.push({ role: 'assistant', content: lastContent });
1985
+ if (conversationId) {
1986
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
1987
+ .run(conversationId, 'assistant', lastContent, usableWrap ? (wrapResponse.usage?.totalTokens || 0) : 0);
1988
+ }
1989
+ engine.recordRunEvent(userId, runId, 'max_iteration_wrapup_delivered', {
1990
+ iteration, maxIterations, stepIndex, source: usableWrap ? 'model' : 'deterministic',
1991
+ }, { agentId });
1992
+ } catch (wrapErr) {
1993
+ console.warn(`[Run ${shortenRunId(runId)}] max_iteration_wrapup failed: ${summarizeForLog(wrapErr?.message || wrapErr, 180)}`);
1994
+ lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
1995
+ messages.push({ role: 'assistant', content: lastContent });
1996
+ }
1997
+ }
1998
+
1825
1999
  if (triggerSource === 'messaging' && !normalizeOutgoingMessage(lastContent, options?.source || null) && !messagingSent) {
1826
2000
  // Simplified blank reply recovery: one model call with direct instruction,
1827
2001
  // then fall back to a deterministic message. No multi-attempt LLM loop.
@@ -1852,37 +2026,24 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1852
2026
  console.warn(`[Run ${shortenRunId(runId)}] blank_reply_recovery failed: ${summarizeForLog(recoverErr?.message || recoverErr, 180)}`);
1853
2027
  }
1854
2028
  totalTokens += recoveredTokens;
1855
- if (!normalizeOutgoingMessage(lastContent, options?.source || null)) {
2029
+ // The loop has already exited, so we cannot keep working: deliver the model's
2030
+ // own wrap-up (it summarizes what it tried / where it got blocked from the run
2031
+ // evidence) instead of second-guessing it into a generic blob. Only fall back to
2032
+ // a deterministic message when the model returned nothing usable.
2033
+ const recoveredVisible = Boolean(normalizeOutgoingMessage(lastContent, options?.source || null));
2034
+ if (!recoveredVisible) {
1856
2035
  lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
1857
2036
  }
1858
2037
  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,
2038
+ engine.recordRunEvent(userId, runId, 'blank_reply_recovery_delivered', {
2039
+ source: recoveredVisible ? 'model' : 'deterministic',
2040
+ stepIndex,
2041
+ failedStepCount,
1878
2042
  }, { agentId });
1879
- if (recoveryDecision.decision.status === 'continue') {
1880
- throw new Error('Messaging run ended without a judged terminal reply.');
1881
- }
1882
2043
  messages.push({ role: 'assistant', content: lastContent });
1883
2044
  if (conversationId) {
1884
2045
  db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
1885
- .run(conversationId, 'assistant', lastContent, recoveredTokens);
2046
+ .run(conversationId, 'assistant', lastContent, recoveredVisible ? recoveredTokens : 0);
1886
2047
  }
1887
2048
  }
1888
2049
  }
@@ -1910,10 +2071,28 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1910
2071
  stepIndex,
1911
2072
  failedStepCount,
1912
2073
  }, { agentId });
1913
- throw new Error(`Iteration budget exhausted before judged completion after ${maxIterations} iterations.`);
2074
+ if (triggerSource === 'messaging') {
2075
+ lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
2076
+ messages.push({ role: 'assistant', content: lastContent });
2077
+ if (conversationId) {
2078
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
2079
+ .run(conversationId, 'assistant', lastContent, 0);
2080
+ }
2081
+ } else {
2082
+ throw new Error(`Iteration budget exhausted before judged completion after ${maxIterations} iterations.`);
2083
+ }
1914
2084
  }
1915
2085
  if (stepIndex > 0 && !lastToolWasMessaging && iteration < maxIterations) {
1916
- throw new Error('Run ended without an explicit completion or blocker reply.');
2086
+ if (triggerSource === 'messaging') {
2087
+ lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
2088
+ messages.push({ role: 'assistant', content: lastContent });
2089
+ if (conversationId) {
2090
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
2091
+ .run(conversationId, 'assistant', lastContent, 0);
2092
+ }
2093
+ } else {
2094
+ throw new Error('Run ended without an explicit completion or blocker reply.');
2095
+ }
1917
2096
  }
1918
2097
  }
1919
2098
 
@@ -2156,149 +2335,73 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2156
2335
  }
2157
2336
 
2158
2337
  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
2338
 
2234
2339
  const deliverableFailureResponse = err?.deliverableResult?.summary
2235
2340
  || err?.deliverableValidation?.summary
2236
2341
  || '';
2237
2342
  let messagingFailureContent = '';
2238
2343
  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({
2344
+ if (shouldSendMessagingErrorFallback({ triggerSource, options, runMeta })) {
2345
+ const manager = engine.messagingManager;
2346
+ if (manager) {
2347
+ const failureScenario = buildMessagingFailureScenario({
2348
+ err,
2349
+ failedStepCount,
2350
+ stepIndex,
2351
+ toolExecutions,
2352
+ });
2353
+ try {
2354
+ const failedMessage = sanitizeConversationMessages([
2355
+ ...messages,
2356
+ {
2357
+ role: 'system',
2358
+ 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)}`
2359
+ }
2360
+ ]);
2361
+ const modelReply = await withModelCallTimeout(
2362
+ provider.chat(failedMessage, [], {
2363
+ model,
2364
+ reasoningEffort: engine.getReasoningEffort(providerName, options)
2365
+ }),
2366
+ options,
2367
+ 'Messaging failure reply',
2368
+ );
2369
+ const drafted = sanitizeModelOutput(modelReply.content || '', { model });
2370
+ if (normalizeOutgoingMessage(drafted, options?.source || null)) {
2371
+ messagingFailureContent = drafted.trim();
2372
+ }
2373
+ } catch {
2374
+ // Fall back to deterministic text below.
2375
+ }
2376
+
2377
+ if (!messagingFailureContent) {
2378
+ messagingFailureContent = buildDeterministicMessagingErrorReply({
2244
2379
  err,
2245
2380
  failedStepCount,
2246
2381
  stepIndex,
2247
2382
  toolExecutions,
2248
2383
  });
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
- }
2384
+ }
2281
2385
 
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 = '';
2386
+ try {
2387
+ const deliveryResult = await manager.sendMessage(
2388
+ userId,
2389
+ options.source,
2390
+ options.chatId,
2391
+ messagingFailureContent,
2392
+ { runId, agentId },
2393
+ );
2394
+ requireSuccessfulMessagingDelivery(deliveryResult, 'Messaging failure delivery');
2395
+ sendSucceeded = true;
2396
+ if (runMeta) {
2397
+ runMeta.lastSentMessage = messagingFailureContent;
2398
+ if (!Array.isArray(runMeta.sentMessages)) runMeta.sentMessages = [];
2399
+ runMeta.sentMessages.push(messagingFailureContent);
2301
2400
  }
2401
+ engine.markRunFinalDelivery(runId, messagingFailureContent);
2402
+ } catch (sendErr) {
2403
+ console.error('[Engine] Messaging error fallback failed:', sendErr.message);
2404
+ messagingFailureContent = '';
2302
2405
  }
2303
2406
  }
2304
2407
  }