neoagent 2.4.1-beta.36 → 2.4.1-beta.37

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.
@@ -30,7 +30,6 @@ const {
30
30
  const { getCapabilityHealth, summarizeCapabilityHealth } = require('./capabilityHealth');
31
31
  const {
32
32
  buildPlatformFormattingGuide,
33
- normalizeOutgoingMessageForPlatform,
34
33
  splitOutgoingMessageForPlatform,
35
34
  } = require('../messaging/formatting_guides');
36
35
  const {
@@ -49,6 +48,27 @@ const {
49
48
  } = require('./deliverables');
50
49
  const { buildLoopPolicy, resolveToolResultLimits } = require('./loopPolicy');
51
50
  const { globalHooks } = require('./hooks');
51
+ const { withProviderRetry, isTransientError } = require('./providerRetry');
52
+ const { normalizeCompletionConfidence, shouldAcceptTaskComplete } = require('./completion');
53
+ const { shortenRunId, summarizeForLog, parseMaybeJson } = require('./logFormat');
54
+ const {
55
+ normalizeOutgoingMessage,
56
+ clampRunContext,
57
+ joinSentMessages,
58
+ normalizeInterimText,
59
+ buildBlankMessagingReplyPrompt,
60
+ buildDeterministicMessagingFallback,
61
+ buildMessagingFailureScenario,
62
+ buildDeterministicMessagingErrorReply,
63
+ buildModelFailureLoopPrompt,
64
+ } = require('./messagingFallback');
65
+ const {
66
+ classifyToolExecution,
67
+ summarizeToolExecutions,
68
+ summarizeAvailableTools,
69
+ inferToolFailureMessage,
70
+ buildAutonomousRecoveryContext,
71
+ } = require('./toolEvidence');
52
72
 
53
73
  function generateTitle(task) {
54
74
  if (!task || typeof task !== 'string') return 'Untitled';
@@ -250,468 +270,9 @@ function estimateTokenValue(value) {
250
270
  return Math.ceil(JSON.stringify(value).length / 4);
251
271
  }
252
272
 
253
- function normalizeOutgoingMessage(content, platform = null, options = {}) {
254
- const normalized = normalizeOutgoingMessageForPlatform(platform, content);
255
- if (options.collapseWhitespace === false) {
256
- return normalized;
257
- }
258
- return normalized.replace(/\s+/g, ' ').trim();
259
- }
260
-
261
- function joinSentMessages(messages = []) {
262
- if (!Array.isArray(messages)) return '';
263
- return messages
264
- .map((message) => String(message || '').trim())
265
- .filter(Boolean)
266
- .join('\n\n');
267
- }
268
-
269
- function normalizeInterimText(content, platform = null) {
270
- return normalizeOutgoingMessageForPlatform(platform, content, {
271
- stripNoResponseMarker: false,
272
- }).trim();
273
- }
274
-
275
- function buildBlankMessagingReplyPrompt(attempt, platform = null) {
276
- const formattingGuide = buildPlatformFormattingGuide(platform);
277
- if (attempt <= 1) {
278
- return `You must send one non-empty reply for the external messaging user right now. Do not call tools. Give either: (a) the concrete outcome, or (b) a clear blocker. If tool work already happened, summarize what you actually tried and where it got blocked. Do not ask the user to repeat the original request. Do not promise future work unless that work already happened in this run or will happen automatically before this reply is sent.\n\n${formattingGuide}`;
279
- }
280
-
281
- return `Your previous reply was empty. Return one non-empty message now. Do not call tools. If needed, apologize briefly and explain the blocker in one sentence. Use the run evidence already in the conversation instead of asking the user to restate the task. Do not promise future work unless that work already happened in this run or will happen automatically before this reply is sent.\n\n${formattingGuide}`;
282
- }
283
-
284
- function parseToolExecutionSummary(item) {
285
- if (!item?.summary) return null;
286
- try {
287
- const parsed = JSON.parse(item.summary);
288
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
289
- } catch {
290
- return null;
291
- }
292
- }
293
-
294
- function toolWorkDescription(toolName) {
295
- const name = String(toolName || '');
296
- if (name === 'execute_command') return 'ran shell commands';
297
- if (name === 'read_file' || name === 'search_files' || name === 'list_directory') return 'checked files';
298
- if (name === 'web_search' || name === 'http_request') return 'looked up supporting information';
299
- if (name.startsWith('browser_')) return 'checked the browser state';
300
- if (name.startsWith('android_')) return 'checked the Android state';
301
- if (name === 'read_health_data' || name.startsWith('recordings_')) return 'checked stored data';
302
- return '';
303
- }
304
-
305
- function summarizeRecentWork(toolExecutions = []) {
306
- const descriptions = [];
307
- for (const item of toolExecutions.slice(-6)) {
308
- const description = toolWorkDescription(item?.toolName);
309
- if (!description || descriptions.includes(description)) continue;
310
- descriptions.push(description);
311
- if (descriptions.length >= 2) break;
312
- }
313
-
314
- if (descriptions.length === 0) return '';
315
- if (descriptions.length === 1) return `I ${descriptions[0]}`;
316
- return `I ${descriptions[0]} and ${descriptions[1]}`;
317
- }
318
-
319
- function hasFailureSignal(text) {
320
- const normalized = normalizeOutgoingMessage(text);
321
- if (!normalized) return false;
322
- return /\b(error|failed|failure|traceback|exception|timed out|timeout|not found|no such file|permission denied|unable to|cannot|could not|module not found)\b/i.test(normalized);
323
- }
324
-
325
- function extractToolFailureMessage(item) {
326
- const directError = normalizeOutgoingMessage(item?.error || '');
327
- if (directError) return directError;
328
-
329
- const summary = parseToolExecutionSummary(item);
330
- if (!summary) return '';
331
-
332
- const candidates = [
333
- summary.message,
334
- summary.note,
335
- summary.stderr,
336
- summary.stdout,
337
- summary.content,
338
- summary.excerpt,
339
- summary.result,
340
- summary.summary,
341
- ];
342
-
343
- if (summary.status === 'error') {
344
- for (const candidate of candidates) {
345
- const normalized = normalizeOutgoingMessage(candidate || '');
346
- if (normalized) return normalized;
347
- }
348
- if (summary.exitCode != null) {
349
- return `The last shell command exited with code ${summary.exitCode}`;
350
- }
351
- }
352
-
353
- for (const candidate of candidates) {
354
- const normalized = normalizeOutgoingMessage(candidate || '');
355
- if (hasFailureSignal(normalized)) return normalized;
356
- }
357
-
358
- return '';
359
- }
360
-
361
- function buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions = [] }) {
362
- const workSummary = summarizeRecentWork(toolExecutions);
363
- const blocker = [...toolExecutions].reverse()
364
- .map((item) => extractToolFailureMessage(item))
365
- .find(Boolean);
366
-
367
- if (workSummary && blocker) {
368
- return `${workSummary}, but I got blocked: ${blocker}. I do not have a confirmed finished result yet.`;
369
- }
370
- if (blocker) {
371
- return `I got blocked while working on this: ${blocker}. I do not have a confirmed finished result yet.`;
372
- }
373
- if (workSummary && stepIndex > 0) {
374
- return `${workSummary}, but I do not have a confirmed finished result yet.`;
375
- }
376
- if (failedStepCount > 0) {
377
- return 'I ran into a tool problem while working on your request, so I do not have a confirmed finished result yet.';
378
- }
379
- if (stepIndex > 0) {
380
- return 'I completed part of the work, but I do not have a confirmed finished result yet.';
381
- }
382
- return 'I could not produce a reliable final reply just now.';
383
- }
384
-
385
- function buildMessagingFailureScenario({ err, failedStepCount, stepIndex, toolExecutions = [] }) {
386
- const parts = [];
387
- const runtimeError = normalizeOutgoingMessage(err?.message || '');
388
- const workSummary = summarizeRecentWork(toolExecutions);
389
- const blocker = [...toolExecutions].reverse()
390
- .map((item) => extractToolFailureMessage(item))
391
- .find(Boolean);
392
-
393
- if (runtimeError) {
394
- parts.push(`Runtime error: ${summarizeForLog(runtimeError, 260)}.`);
395
- }
396
- if (workSummary) {
397
- parts.push(`Observed work before failure: ${workSummary}.`);
398
- }
399
- if (blocker) {
400
- parts.push(`Most specific blocker from run evidence: ${summarizeForLog(blocker, 260)}.`);
401
- }
402
- if (stepIndex > 0) {
403
- parts.push(`Completed steps before failure: ${stepIndex}.`);
404
- }
405
- if (failedStepCount > 0) {
406
- parts.push(`Failed tool steps: ${failedStepCount}.`);
407
- }
408
-
409
- return parts.join(' ');
410
- }
411
-
412
- function buildDeterministicMessagingErrorReply({ err, failedStepCount, stepIndex, toolExecutions = [] }) {
413
- const message = normalizeOutgoingMessage(err?.message || '');
414
- if (/no ai providers? are currently available/i.test(message)) {
415
- return 'I cannot continue right now because no AI provider is available for this account. Please check the provider settings.';
416
- }
417
-
418
- if (/(timeout|timed out)/i.test(message)) {
419
- return 'I hit a timeout while processing your request and could not finish it reliably.';
420
- }
421
-
422
- const blocker = [...toolExecutions].reverse()
423
- .map((item) => extractToolFailureMessage(item))
424
- .find(Boolean);
425
- if (blocker) {
426
- return `I got blocked while checking this: ${blocker}.`;
427
- }
428
-
429
- if (message) {
430
- return `I got blocked while working on this: ${message}.`;
431
- }
432
-
433
- return buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
434
- }
435
-
436
- function buildModelFailureLoopPrompt({ failedModel, nextModel, errorMessage }) {
437
- return [
438
- `The previous model call on "${failedModel}" failed with: ${summarizeForLog(errorMessage, 220)}.`,
439
- `Continue on "${nextModel}" and recover autonomously.`,
440
- 'If a previous plan depended on that failed call, adjust your approach and proceed end-to-end.',
441
- 'Only ask the user for help if no safe path remains.'
442
- ].join(' ');
443
- }
444
-
445
- function normalizeCompletionConfidence(value) {
446
- const normalized = String(value || '').trim().toLowerCase();
447
- if (normalized === 'high' || normalized === 'medium' || normalized === 'low') return normalized;
448
- return 'medium';
449
- }
450
-
451
- function completionConfidenceRank(value) {
452
- const normalized = normalizeCompletionConfidence(value);
453
- if (normalized === 'high') return 3;
454
- if (normalized === 'medium') return 2;
455
- return 1;
456
- }
457
-
458
- function shouldAcceptTaskComplete({ confidence, requiredConfidence, iteration, maxIterations }) {
459
- const required = normalizeCompletionConfidence(requiredConfidence || 'medium');
460
- const actual = normalizeCompletionConfidence(confidence || 'medium');
461
- if (completionConfidenceRank(actual) >= completionConfidenceRank(required)) {
462
- return { accept: true, reason: '' };
463
- }
464
-
465
- const iterationsRemaining = Math.max(0, Number(maxIterations || 0) - Number(iteration || 0));
466
- if (iterationsRemaining <= 1) {
467
- return {
468
- accept: true,
469
- reason: `Accepted ${actual}-confidence completion at the iteration limit; final verifier will calibrate the answer.`,
470
- };
471
- }
472
-
473
- return {
474
- accept: false,
475
- reason: `Completion confidence "${actual}" is below required "${required}". Continue with verification, recovery, or a narrower truthful result before completing.`,
476
- };
477
- }
478
-
479
- function clampRunContext(text, maxChars) {
480
- const value = normalizeOutgoingMessage(text);
481
- if (!value) return '';
482
- if (value.length <= maxChars) return value;
483
- return `${value.slice(0, maxChars)}...`;
484
- }
485
-
486
- function shortenRunId(runId) {
487
- const value = String(runId || '').trim();
488
- if (!value) return 'unknown';
489
- return value.length <= 8 ? value : value.slice(0, 8);
490
- }
491
-
492
- function summarizeForLog(value, maxChars = 220) {
493
- if (value == null) return '';
494
-
495
- let text = '';
496
- if (typeof value === 'string') {
497
- text = value;
498
- } else {
499
- try {
500
- text = JSON.stringify(value);
501
- } catch {
502
- text = String(value);
503
- }
504
- }
505
-
506
- const normalized = text.replace(/\s+/g, ' ').trim();
507
- if (normalized.length <= maxChars) return normalized;
508
- return `${normalized.slice(0, maxChars)}...`;
509
- }
510
-
511
- function parseMaybeJson(value, fallback = null) {
512
- if (!value) return fallback;
513
- if (typeof value === 'object') return value;
514
- try {
515
- return JSON.parse(value);
516
- } catch {
517
- return fallback;
518
- }
519
- }
520
-
521
- function classifyToolExecution(toolName, toolArgs = {}, result, errorMessage = '') {
522
- const name = String(toolName || '');
523
- const evidenceRelevantPrefixes = ['browser_', 'android_'];
524
- const evidenceRelevantExact = new Set([
525
- 'web_search',
526
- 'http_request',
527
- 'read_file',
528
- 'search_files',
529
- 'list_directory',
530
- 'session_search',
531
- 'memory_recall',
532
- 'analyze_image',
533
- 'read_health_data',
534
- 'recordings_list',
535
- 'recordings_get',
536
- 'recordings_search',
537
- 'list_tasks',
538
- 'wait_subagent',
539
- ]);
540
- const stateChangingExact = new Set([
541
- 'execute_command',
542
- 'write_file',
543
- 'edit_file',
544
- 'send_interim_update',
545
- 'send_message',
546
- 'make_call',
547
- 'create_skill',
548
- 'update_skill',
549
- 'delete_skill',
550
- 'create_task',
551
- 'update_task',
552
- 'delete_task',
553
- 'create_ai_widget',
554
- 'update_ai_widget',
555
- 'delete_ai_widget',
556
- 'save_widget_snapshot',
557
- 'mcp_add_server',
558
- 'mcp_remove_server',
559
- 'spawn_subagent',
560
- 'cancel_subagent',
561
- ]);
562
-
563
- const evidenceSource = name.startsWith('browser_')
564
- ? 'browser'
565
- : name.startsWith('android_')
566
- ? 'android'
567
- : name.startsWith('mcp_')
568
- ? 'mcp'
569
- : name.startsWith('memory_') || name === 'session_search'
570
- ? 'memory'
571
- : name === 'web_search'
572
- ? 'search'
573
- : name === 'http_request'
574
- ? 'http'
575
- : ['read_file', 'search_files', 'list_directory', 'write_file', 'edit_file'].includes(name)
576
- ? 'files'
577
- : name === 'execute_command'
578
- ? 'command'
579
- : name.includes('skill')
580
- ? 'skills'
581
- : (name === 'create_task' || name === 'update_task' || name === 'delete_task' || name === 'list_tasks' || name.includes('widget'))
582
- ? 'tasks'
583
- : name === 'send_message' || name === 'make_call'
584
- ? 'messaging'
585
- : name.startsWith('recordings_') || name === 'read_health_data'
586
- ? 'data'
587
- : name === 'analyze_image'
588
- ? 'vision'
589
- : name.includes('subagent')
590
- ? 'subagent'
591
- : 'tool';
592
-
593
- const evidenceRelevant = evidenceRelevantExact.has(name)
594
- || evidenceRelevantPrefixes.some((prefix) => name.startsWith(prefix));
595
- const stateChanged = stateChangingExact.has(name)
596
- || name.startsWith('android_')
597
- || ['browser_click', 'browser_type', 'browser_evaluate'].includes(name);
598
-
599
- let normalizedError = String(errorMessage || result?.error || '').trim();
600
- if (!normalizedError && name === 'execute_command' && result && typeof result === 'object') {
601
- if (result.timedOut) {
602
- normalizedError = `Command timed out after ${result.durationMs || 'unknown'} ms`;
603
- } else if (result.killed || result.signal) {
604
- normalizedError = 'Command was killed before it finished';
605
- } else if (typeof result.exitCode === 'number' && result.exitCode !== 0) {
606
- normalizedError = summarizeForLog(result.stderr || result.stdout || `Command exited with code ${result.exitCode}`, 220);
607
- }
608
- }
609
-
610
- if (!normalizedError && result && typeof result === 'object') {
611
- const nestedResult = result.result && typeof result.result === 'object' && !Array.isArray(result.result)
612
- ? result.result
613
- : null;
614
- const detail = normalizeOutgoingMessage(
615
- result.reason
616
- || result.message
617
- || nestedResult?.reason
618
- || nestedResult?.message
619
- || ''
620
- );
621
-
622
- if (result.skipped === true || nestedResult?.skipped === true) {
623
- normalizedError = detail || 'Tool reported skipped outcome.';
624
- } else if (result.success === false || nestedResult?.success === false) {
625
- normalizedError = detail || 'Tool reported success=false.';
626
- } else if (result.sent === false || nestedResult?.sent === false) {
627
- normalizedError = detail || 'Tool reported sent=false.';
628
- }
629
- }
630
-
631
- return {
632
- toolName: name,
633
- ok: !normalizedError,
634
- error: normalizedError,
635
- evidenceSource,
636
- evidenceRelevant,
637
- stateChanged,
638
- dependsOnOutput: true,
639
- summary: compactToolResult(name, toolArgs, result || { error: errorMessage || 'Tool failed' }, {
640
- softLimit: 500,
641
- hardLimit: 900,
642
- }),
643
- };
644
- }
645
-
646
- function summarizeToolExecutions(toolExecutions = [], maxItems = 10) {
647
- return toolExecutions.slice(-maxItems).map((item, index) => {
648
- const status = item.ok ? 'ok' : `error=${item.error}`;
649
- return `${index + 1}. ${item.toolName} [${item.evidenceSource}] ${status} :: ${clampRunContext(item.summary || '', 220)}`;
650
- }).join('\n');
651
- }
652
-
653
- function summarizeAvailableTools(tools = [], { exclude = [] } = {}) {
654
- const excluded = new Set((Array.isArray(exclude) ? exclude : [exclude]).filter(Boolean));
655
- return tools
656
- .map((tool) => String(tool?.name || '').trim())
657
- .filter((name) => name && !excluded.has(name))
658
- .slice(0, 24)
659
- .join(', ');
660
- }
661
-
662
- function inferToolFailureMessage(toolName, result) {
663
- const explicitError = normalizeOutgoingMessage(result?.error || '');
664
- if (explicitError) return explicitError;
665
-
666
- if (!result || typeof result !== 'object') return '';
667
-
668
- if (toolName === 'execute_command') {
669
- if (result.timedOut) {
670
- return `Command timed out after ${result.durationMs || 'unknown'} ms`;
671
- }
672
- if (result.killed || result.signal) {
673
- return 'Command was killed before it finished';
674
- }
675
- if (typeof result.exitCode === 'number' && result.exitCode !== 0) {
676
- return summarizeForLog(result.stderr || result.stdout || `Command exited with code ${result.exitCode}`, 220);
677
- }
678
- }
679
-
680
- if (toolName === 'http_request' && typeof result.status === 'number' && result.status >= 400) {
681
- const bodySnippet = normalizeOutgoingMessage(result.body || '');
682
- return summarizeForLog(
683
- bodySnippet
684
- ? `HTTP request returned status ${result.status}: ${bodySnippet}`
685
- : `HTTP request returned status ${result.status}`,
686
- 240
687
- );
688
- }
689
-
690
- return '';
691
- }
692
-
693
- function buildAutonomousRecoveryContext({ err, toolExecutions = [], tools = [], userMessage, visibleMessageSent = false }) {
694
- const lastFailure = [...toolExecutions].reverse().find((item) => !item.ok);
695
- const alternativeTools = summarizeAvailableTools(tools, { exclude: lastFailure?.toolName || null });
696
- const parts = [
697
- 'This is an internal recovery retry for the same user task. Continue the task instead of stopping.',
698
- userMessage ? `Original task: ${clampRunContext(userMessage, 260)}` : '',
699
- lastFailure?.toolName ? `Previous attempt failed on tool: ${lastFailure.toolName}.` : '',
700
- lastFailure?.error ? `Concrete failure: ${summarizeForLog(lastFailure.error, 260)}.` : '',
701
- err?.message ? `Run-level error after that failure: ${summarizeForLog(err.message, 220)}.` : '',
702
- 'Do not send a blocker message just because one tool path failed.',
703
- 'Use a different safe approach if available: alternate tool, different query, browser path, HTTP fetch, file/code inspection, or command verification.',
704
- visibleMessageSent ? 'A user-facing message was already sent in a previous internal attempt. Continue silently unless you have a materially new finished result or a real external blocker.' : '',
705
- alternativeTools ? `Other available tools in this run: ${alternativeTools}.` : '',
706
- 'Only stop if the remaining problem truly requires an external dependency or user action outside this run.'
707
- ];
708
- return parts.filter(Boolean).join(' ');
709
- }
710
-
711
273
  class AgentEngine {
712
274
  constructor(io, services = {}) {
713
275
  this.io = io;
714
- this.maxIterations = 12;
715
276
  this.activeRuns = new Map();
716
277
  this.subagents = new Map();
717
278
  this.app = services.app || null;
@@ -943,17 +504,20 @@ class AgentEngine {
943
504
  fallback = {},
944
505
  reasoningEffort,
945
506
  }) {
946
- const response = await provider.chat(
947
- sanitizeConversationMessages([
948
- ...messages,
949
- { role: 'system', content: prompt },
950
- ]),
951
- [],
952
- {
953
- model,
954
- maxTokens,
955
- reasoningEffort: reasoningEffort || this.getReasoningEffort(providerName, {}),
956
- }
507
+ const response = await withProviderRetry(
508
+ () => provider.chat(
509
+ sanitizeConversationMessages([
510
+ ...messages,
511
+ { role: 'system', content: prompt },
512
+ ]),
513
+ [],
514
+ {
515
+ model,
516
+ maxTokens,
517
+ reasoningEffort: reasoningEffort || this.getReasoningEffort(providerName, {}),
518
+ }
519
+ ),
520
+ { label: `Engine ${model} (structured)` }
957
521
  );
958
522
 
959
523
  const parsed = parseJsonObject(response.content || '');
@@ -979,36 +543,63 @@ class AgentEngine {
979
543
  model,
980
544
  reasoningEffort: this.getReasoningEffort(providerName, options),
981
545
  };
982
- let response = null;
983
- let streamContent = '';
984
-
985
- if (options.stream !== false) {
986
- const stream = provider.stream(requestMessages, tools, callOptions);
987
- for await (const chunk of stream) {
988
- if (chunk.type === 'content') {
989
- streamContent += chunk.content;
990
- this.emit(options.userId, 'run:stream', {
991
- runId,
992
- content: sanitizeModelOutput(streamContent, { model }),
993
- iteration,
994
- });
995
- }
996
- if (chunk.type === 'done') {
997
- response = chunk;
998
- }
999
- if (chunk.type === 'tool_calls') {
1000
- response = {
1001
- content: chunk.content || streamContent,
1002
- toolCalls: chunk.toolCalls,
1003
- providerContentBlocks: chunk.providerContentBlocks || null,
1004
- finishReason: 'tool_calls',
1005
- usage: chunk.usage || null,
1006
- };
546
+
547
+ const attemptModelCall = async () => {
548
+ let response = null;
549
+ let streamContent = '';
550
+
551
+ if (options.stream !== false) {
552
+ let emittedContent = false;
553
+ const stream = provider.stream(requestMessages, tools, callOptions);
554
+ try {
555
+ for await (const chunk of stream) {
556
+ if (chunk.type === 'content') {
557
+ emittedContent = true;
558
+ streamContent += chunk.content;
559
+ this.emit(options.userId, 'run:stream', {
560
+ runId,
561
+ content: sanitizeModelOutput(streamContent, { model }),
562
+ iteration,
563
+ });
564
+ }
565
+ if (chunk.type === 'done') {
566
+ response = chunk;
567
+ }
568
+ if (chunk.type === 'tool_calls') {
569
+ response = {
570
+ content: chunk.content || streamContent,
571
+ toolCalls: chunk.toolCalls,
572
+ providerContentBlocks: chunk.providerContentBlocks || null,
573
+ finishReason: 'tool_calls',
574
+ usage: chunk.usage || null,
575
+ };
576
+ }
577
+ }
578
+ } catch (err) {
579
+ // Once tokens have streamed to the client a retry would duplicate
580
+ // output, so only the pre-stream window is safe to replay.
581
+ if (emittedContent) err.__providerRetryUnsafe = true;
582
+ throw err;
1007
583
  }
584
+ } else {
585
+ response = await provider.chat(requestMessages, tools, callOptions);
1008
586
  }
1009
- } else {
1010
- response = await provider.chat(requestMessages, tools, callOptions);
1011
- }
587
+
588
+ return { response, streamContent };
589
+ };
590
+
591
+ const { response, streamContent } = await withProviderRetry(attemptModelCall, {
592
+ ...(options.retry || {}),
593
+ label: `Engine ${model}`,
594
+ isRetryable: (err) => !err?.__providerRetryUnsafe && isTransientError(err),
595
+ onRetry: ({ attempt, delayMs }) => {
596
+ this.emit(options.userId, 'run:interim', {
597
+ runId,
598
+ message: `Model service busy; retrying (attempt ${attempt}) in ${Math.max(1, Math.round(delayMs / 1000))}s.`,
599
+ phase: 'recovering',
600
+ });
601
+ },
602
+ });
1012
603
 
1013
604
  const resolvedResponse = response || {
1014
605
  content: streamContent,
@@ -3400,8 +2991,15 @@ class AgentEngine {
3400
2991
  db.prepare("UPDATE agent_runs SET status = 'stopped', updated_at = datetime('now') WHERE id = ?").run(runId);
3401
2992
  }
3402
2993
 
3403
- abort(runId) {
3404
- if (runId) this.stopRun(runId);
2994
+ abort(runId, { userId } = {}) {
2995
+ if (!runId) return false;
2996
+ if (userId != null) {
2997
+ // Ownership gate: never let one user abort another user's active run.
2998
+ const runMeta = this.activeRuns.get(runId);
2999
+ if (runMeta && Number(runMeta.userId) !== Number(userId)) return false;
3000
+ }
3001
+ this.stopRun(runId);
3002
+ return true;
3405
3003
  }
3406
3004
 
3407
3005
  abortAll(userId) {
@@ -5,6 +5,7 @@ const path = require('path');
5
5
 
6
6
  const { getProviderForUser } = require('./engine');
7
7
  const { createProviderInstance, getProviderCatalog } = require('./models');
8
+ const { withProviderRetry } = require('./providerRetry');
8
9
 
9
10
  function resolveImageMimeType(imagePath, overrideMimeType = null) {
10
11
  const normalized = String(overrideMimeType || '').trim().toLowerCase();
@@ -76,11 +77,14 @@ async function analyzeImageForUser({
76
77
  }
77
78
 
78
79
  try {
79
- const response = await candidate.provider.analyzeImage({
80
- imagePath,
81
- mimeType: resolveImageMimeType(imagePath, mimeType),
82
- question,
83
- });
80
+ const response = await withProviderRetry(
81
+ () => candidate.provider.analyzeImage({
82
+ imagePath,
83
+ mimeType: resolveImageMimeType(imagePath, mimeType),
84
+ question,
85
+ }),
86
+ { label: `ImageAnalysis ${candidate.providerName}` },
87
+ );
84
88
  return {
85
89
  description: String(response.content || '').trim(),
86
90
  model: response.model || null,