neoagent 2.5.2-beta.0 → 2.5.2-beta.2

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.
@@ -112,6 +112,80 @@ function buildInitialRunMetadata(options = {}) {
112
112
  return metadata;
113
113
  }
114
114
 
115
+ const MESSAGING_PROGRESS_FIRST_UPDATE_MS = 60 * 1000;
116
+ const MESSAGING_PROGRESS_REPEAT_MS = 90 * 1000;
117
+ const MESSAGING_PROGRESS_STALL_MS = 240 * 1000;
118
+ const MESSAGING_PROGRESS_TICK_MS = 15 * 1000;
119
+
120
+ function isoNow() {
121
+ return new Date().toISOString();
122
+ }
123
+
124
+ function timestampMs(value, fallback = 0) {
125
+ const resolved = value ? Date.parse(value) : NaN;
126
+ return Number.isFinite(resolved) ? resolved : fallback;
127
+ }
128
+
129
+ function formatElapsedDuration(durationMs) {
130
+ const totalSeconds = Math.max(1, Math.floor(Number(durationMs || 0) / 1000));
131
+ if (totalSeconds < 60) return `${totalSeconds}s`;
132
+ const minutes = Math.floor(totalSeconds / 60);
133
+ const seconds = totalSeconds % 60;
134
+ if (seconds === 0) return `${minutes}m`;
135
+ return `${minutes}m ${seconds}s`;
136
+ }
137
+
138
+ function cloneInterimHistory(history = []) {
139
+ if (!Array.isArray(history)) return [];
140
+ return history.map((item) => ({
141
+ content: String(item?.content || '').trim(),
142
+ kind: normalizeInterimKind(item?.kind),
143
+ expectsReply: item?.expectsReply === true,
144
+ deferFollowUp: item?.deferFollowUp === true,
145
+ createdAt: item?.createdAt || isoNow(),
146
+ })).filter((item) => item.content);
147
+ }
148
+
149
+ function createInterimSignatureSet(history = [], platform = null) {
150
+ const signatures = new Set();
151
+ for (const item of cloneInterimHistory(history)) {
152
+ signatures.add(buildInterimSignature({
153
+ content: item.content,
154
+ kind: item.kind,
155
+ expectsReply: item.expectsReply === true,
156
+ platform,
157
+ }));
158
+ }
159
+ return signatures;
160
+ }
161
+
162
+ function buildInitialProgressLedger({ startedAt, retryState = {} } = {}) {
163
+ const startedAtIso = startedAt || isoNow();
164
+ const interimHistory = cloneInterimHistory(retryState.interimHistory);
165
+ const lastInterimMessage = interimHistory[interimHistory.length - 1]?.content || '';
166
+ const lastVisibleAt = retryState.lastUserVisibleUpdateAt || (lastInterimMessage ? startedAtIso : null);
167
+ return {
168
+ currentStep: retryState.currentStep || null,
169
+ currentTool: retryState.currentTool || null,
170
+ currentStepStartedAt: retryState.currentStepStartedAt || null,
171
+ lastVerifiedProgressAt: retryState.lastVerifiedProgressAt || startedAtIso,
172
+ lastUserVisibleUpdateAt: lastVisibleAt,
173
+ lastFinalDeliveryAt: retryState.lastFinalDeliveryAt || null,
174
+ heartbeatCount: Number(retryState.heartbeatCount || 0),
175
+ stallNotifiedAt: retryState.stallNotifiedAt || null,
176
+ progressState: retryState.progressState || 'active',
177
+ currentPhase: retryState.currentPhase || 'idle',
178
+ };
179
+ }
180
+
181
+ function hasVisibleInterimActivity(runMeta) {
182
+ return Boolean(
183
+ runMeta?.lastInterimMessage
184
+ || (Array.isArray(runMeta?.interimMessages) && runMeta.interimMessages.length > 0)
185
+ || Number(runMeta?.progressLedger?.heartbeatCount || 0) > 0
186
+ );
187
+ }
188
+
115
189
  function planningDepthForForceMode(forceMode) {
116
190
  return forceMode === 'plan_execute' ? 'deep' : 'light';
117
191
  }
@@ -555,6 +629,97 @@ class AgentEngine {
555
629
  .run(JSON.stringify(next), runId);
556
630
  }
557
631
 
632
+ buildProgressLedgerSnapshot(runMeta) {
633
+ if (!runMeta?.progressLedger) return null;
634
+ return {
635
+ currentStep: runMeta.progressLedger.currentStep || null,
636
+ currentTool: runMeta.progressLedger.currentTool || null,
637
+ currentStepStartedAt: runMeta.progressLedger.currentStepStartedAt || null,
638
+ lastVerifiedProgressAt: runMeta.progressLedger.lastVerifiedProgressAt || null,
639
+ lastUserVisibleUpdateAt: runMeta.progressLedger.lastUserVisibleUpdateAt || null,
640
+ lastFinalDeliveryAt: runMeta.progressLedger.lastFinalDeliveryAt || null,
641
+ heartbeatCount: Number(runMeta.progressLedger.heartbeatCount || 0),
642
+ stallNotifiedAt: runMeta.progressLedger.stallNotifiedAt || null,
643
+ progressState: runMeta.progressLedger.progressState || 'active',
644
+ currentPhase: runMeta.progressLedger.currentPhase || 'idle',
645
+ };
646
+ }
647
+
648
+ persistProgressLedger(runId) {
649
+ const runMeta = this.getRunMeta(runId);
650
+ if (!runMeta?.progressLedger) return;
651
+ this.persistRunMetadata(runId, {
652
+ progressLedger: this.buildProgressLedgerSnapshot(runMeta),
653
+ });
654
+ }
655
+
656
+ updateRunProgress(runId, patch = {}, options = {}) {
657
+ const runMeta = this.getRunMeta(runId);
658
+ if (!runMeta) return null;
659
+ if (!runMeta.progressLedger) {
660
+ runMeta.progressLedger = buildInitialProgressLedger({
661
+ startedAt: runMeta.startedAtIso || isoNow(),
662
+ });
663
+ }
664
+
665
+ const previousState = runMeta.progressLedger.progressState || 'active';
666
+ runMeta.progressLedger = {
667
+ ...runMeta.progressLedger,
668
+ ...patch,
669
+ };
670
+
671
+ if (options.verified === true) {
672
+ runMeta.progressLedger.lastVerifiedProgressAt = options.timestamp || isoNow();
673
+ runMeta.progressLedger.progressState = 'active';
674
+ runMeta.progressLedger.stallNotifiedAt = null;
675
+ this.recordRunEvent(runMeta.userId, runId, 'progress_verified', {
676
+ phase: runMeta.progressLedger.currentPhase || 'idle',
677
+ currentStep: runMeta.progressLedger.currentStep || null,
678
+ currentTool: runMeta.progressLedger.currentTool || null,
679
+ }, { agentId: runMeta.agentId, stepId: options.stepId || null });
680
+ if (previousState === 'stalled') {
681
+ this.recordRunEvent(runMeta.userId, runId, 'progress_resumed', {
682
+ phase: runMeta.progressLedger.currentPhase || 'idle',
683
+ currentStep: runMeta.progressLedger.currentStep || null,
684
+ currentTool: runMeta.progressLedger.currentTool || null,
685
+ }, { agentId: runMeta.agentId, stepId: options.stepId || null });
686
+ }
687
+ }
688
+
689
+ if (options.persist !== false) {
690
+ this.persistProgressLedger(runId);
691
+ }
692
+ return runMeta.progressLedger;
693
+ }
694
+
695
+ markRunVisibleProgress(runId, timestamp = isoNow()) {
696
+ const runMeta = this.getRunMeta(runId);
697
+ if (!runMeta) return null;
698
+ const ledger = this.updateRunProgress(runId, {
699
+ lastUserVisibleUpdateAt: timestamp,
700
+ }, {
701
+ persist: false,
702
+ });
703
+ this.persistProgressLedger(runId);
704
+ return ledger;
705
+ }
706
+
707
+ markRunFinalDelivery(runId, content = '', timestamp = isoNow()) {
708
+ const runMeta = this.getRunMeta(runId);
709
+ if (!runMeta) return null;
710
+ runMeta.finalDeliverySent = true;
711
+ runMeta.lastSentMessage = String(content || '').trim() || runMeta.lastSentMessage || '';
712
+ const ledger = this.updateRunProgress(runId, {
713
+ lastUserVisibleUpdateAt: timestamp,
714
+ lastFinalDeliveryAt: timestamp,
715
+ progressState: 'complete',
716
+ }, {
717
+ persist: false,
718
+ });
719
+ this.persistProgressLedger(runId);
720
+ return ledger;
721
+ }
722
+
558
723
  recordRunEvent(userId, runId, eventType, payload = {}, options = {}) {
559
724
  try {
560
725
  return recordRunEvent({
@@ -695,6 +860,7 @@ class AgentEngine {
695
860
  createdAt,
696
861
  });
697
862
  runMeta.lastInterimMessage = normalizedContent;
863
+ this.markRunVisibleProgress(runId, createdAt);
698
864
 
699
865
  this.emit(userId, 'run:assistant_interim', {
700
866
  runId,
@@ -722,6 +888,7 @@ class AgentEngine {
722
888
  content: normalizedContent,
723
889
  createdAt,
724
890
  },
891
+ progressLedger: this.buildProgressLedgerSnapshot(runMeta),
725
892
  terminalInterim: terminalInterim
726
893
  ? { kind: normalizedKind, content: normalizedContent, createdAt }
727
894
  : null,
@@ -1584,6 +1751,45 @@ class AgentEngine {
1584
1751
  };
1585
1752
  }
1586
1753
 
1754
+ enqueueSystemSteering(runId, content, metadata = {}) {
1755
+ const runMeta = this.getRunMeta(runId);
1756
+ const trimmed = typeof content === 'string' ? content.trim() : '';
1757
+ if (!runMeta || runMeta.aborted || !trimmed) return null;
1758
+ if (!Array.isArray(runMeta.systemSteeringQueue)) {
1759
+ runMeta.systemSteeringQueue = [];
1760
+ }
1761
+ const signature = JSON.stringify({
1762
+ content: trimmed,
1763
+ reason: metadata.reason || '',
1764
+ });
1765
+ if (runMeta.systemSteeringQueue.some((item) => item.signature === signature)) {
1766
+ return null;
1767
+ }
1768
+ const item = {
1769
+ id: uuidv4(),
1770
+ content: trimmed,
1771
+ metadata,
1772
+ signature,
1773
+ createdAt: isoNow(),
1774
+ };
1775
+ runMeta.systemSteeringQueue.push(item);
1776
+ return item;
1777
+ }
1778
+
1779
+ applyQueuedSystemSteering(runId, messages) {
1780
+ const runMeta = this.getRunMeta(runId);
1781
+ if (!runMeta?.systemSteeringQueue?.length) {
1782
+ return { messages, appliedCount: 0 };
1783
+ }
1784
+
1785
+ const queued = runMeta.systemSteeringQueue.splice(0, runMeta.systemSteeringQueue.length);
1786
+ for (const entry of queued) {
1787
+ messages.push({ role: 'system', content: entry.content });
1788
+ }
1789
+
1790
+ return { messages, appliedCount: queued.length };
1791
+ }
1792
+
1587
1793
  applyQueuedSteering(runId, messages, { userId, conversationId }) {
1588
1794
  const runMeta = this.getRunMeta(runId);
1589
1795
  if (!runMeta?.steeringQueue?.length) {
@@ -1619,6 +1825,242 @@ class AgentEngine {
1619
1825
  return { messages, appliedCount: queued.length };
1620
1826
  }
1621
1827
 
1828
+ buildMessagingHeartbeatText(runMeta, options = {}) {
1829
+ const stalled = options.stalled === true;
1830
+ const fallbackStartedAtMs = Number.isFinite(runMeta?.startedAt) ? runMeta.startedAt : Date.now();
1831
+ const startedAtMs = timestampMs(
1832
+ runMeta?.progressLedger?.currentStepStartedAt,
1833
+ fallbackStartedAtMs,
1834
+ );
1835
+ const elapsed = formatElapsedDuration(Date.now() - startedAtMs);
1836
+ const currentTool = String(runMeta?.progressLedger?.currentTool || '').trim();
1837
+ if (currentTool) {
1838
+ return stalled
1839
+ ? `Still working on ${currentTool}. This run has not made verified progress for ${elapsed}.`
1840
+ : `Still working on ${currentTool}. ${elapsed} elapsed so far.`;
1841
+ }
1842
+ return stalled
1843
+ ? `Still working on this. This run has not made verified progress for ${elapsed}.`
1844
+ : `Still working on this. ${elapsed} elapsed so far.`;
1845
+ }
1846
+
1847
+ async sendRuntimeMessagingHeartbeat(runId, options = {}) {
1848
+ const runMeta = this.getRunMeta(runId);
1849
+ if (!runMeta || runMeta.aborted) return { sent: false, skipped: true };
1850
+ if (runMeta.triggerSource !== 'messaging' || !runMeta.messagingContext?.platform || !runMeta.messagingContext?.chatId) {
1851
+ return { sent: false, skipped: true };
1852
+ }
1853
+ if (!this.messagingManager) {
1854
+ return { sent: false, skipped: true };
1855
+ }
1856
+
1857
+ const createdAt = isoNow();
1858
+ const content = this.buildMessagingHeartbeatText(runMeta, options);
1859
+ await this.messagingManager.sendMessage(
1860
+ runMeta.userId,
1861
+ runMeta.messagingContext.platform,
1862
+ runMeta.messagingContext.chatId,
1863
+ content,
1864
+ {
1865
+ agentId: runMeta.agentId,
1866
+ runId,
1867
+ persistConversation: true,
1868
+ metadata: {
1869
+ interim: true,
1870
+ interim_kind: options.stalled === true ? 'blocker' : 'progress',
1871
+ runtime_heartbeat: true,
1872
+ expects_reply: false,
1873
+ },
1874
+ deliveryKind: 'interim',
1875
+ },
1876
+ );
1877
+
1878
+ runMeta.lastInterimMessage = content;
1879
+ if (!Array.isArray(runMeta.interimMessages)) {
1880
+ runMeta.interimMessages = [];
1881
+ }
1882
+ runMeta.interimMessages.push({
1883
+ content,
1884
+ kind: options.stalled === true ? 'blocker' : 'progress',
1885
+ expectsReply: false,
1886
+ deferFollowUp: false,
1887
+ createdAt,
1888
+ });
1889
+ const heartbeatCount = Number(runMeta.progressLedger?.heartbeatCount || 0) + 1;
1890
+ this.updateRunProgress(runId, {
1891
+ heartbeatCount,
1892
+ lastUserVisibleUpdateAt: createdAt,
1893
+ });
1894
+ this.recordRunEvent(runMeta.userId, runId, 'progress_heartbeat_sent', {
1895
+ stalled: options.stalled === true,
1896
+ currentTool: runMeta.progressLedger?.currentTool || null,
1897
+ currentStep: runMeta.progressLedger?.currentStep || null,
1898
+ }, { agentId: runMeta.agentId });
1899
+ this.enqueueSystemSteering(
1900
+ runId,
1901
+ 'A runtime-generated progress update was already sent while the run was blocked. Do not repeat that same status. When control returns, either keep working silently, send a materially new update, or finish with the actual result.',
1902
+ { reason: 'runtime_heartbeat' },
1903
+ );
1904
+ return { sent: true, content };
1905
+ }
1906
+
1907
+ shouldSendMessagingFinalFallback(runMeta, content, platform = null) {
1908
+ const cleanedContent = normalizeOutgoingMessage(content || '', platform, {
1909
+ collapseWhitespace: false,
1910
+ });
1911
+ const lastFinalDeliveryMessage = normalizeOutgoingMessage(
1912
+ runMeta?.lastSentMessage
1913
+ || (Array.isArray(runMeta?.sentMessages) ? runMeta.sentMessages[runMeta.sentMessages.length - 1] : '')
1914
+ || '',
1915
+ platform,
1916
+ );
1917
+ return Boolean(
1918
+ cleanedContent
1919
+ && !runMeta?.terminalInterim
1920
+ && runMeta?.explicitMessageSent !== true
1921
+ && runMeta?.finalDeliverySent !== true
1922
+ && !lastFinalDeliveryMessage
1923
+ );
1924
+ }
1925
+
1926
+ async deliverMessagingFinalFallback({
1927
+ runId,
1928
+ userId,
1929
+ agentId,
1930
+ platform,
1931
+ chatId,
1932
+ content,
1933
+ }) {
1934
+ const runMeta = this.getRunMeta(runId);
1935
+ if (!runMeta || !this.messagingManager) return { sent: false, skipped: true };
1936
+ const cleanedContent = normalizeOutgoingMessage(content || '', platform, {
1937
+ collapseWhitespace: false,
1938
+ });
1939
+ if (!this.shouldSendMessagingFinalFallback(runMeta, cleanedContent, platform)) {
1940
+ return { sent: false, skipped: true };
1941
+ }
1942
+
1943
+ const chunks = splitOutgoingMessageForPlatform(platform, cleanedContent);
1944
+ console.info(
1945
+ `[Run ${shortenRunId(runId)}] messaging_fallback chunks=${chunks.length} to=${summarizeForLog(chatId, 80)}`
1946
+ );
1947
+ for (let i = 0; i < chunks.length; i++) {
1948
+ if (i > 0) {
1949
+ const delay = Math.max(1000, Math.min(chunks[i].length * 30, 4000));
1950
+ await this.messagingManager.sendTyping(userId, platform, chatId, true, { agentId }).catch(() => {});
1951
+ await new Promise((resolve) => setTimeout(resolve, delay));
1952
+ }
1953
+ await this.messagingManager.sendMessage(userId, platform, chatId, chunks[i], { runId, agentId }).catch((err) =>
1954
+ console.error('[Engine] Auto-reply fallback failed:', err.message)
1955
+ );
1956
+ }
1957
+
1958
+ runMeta.lastSentMessage = chunks[chunks.length - 1] || cleanedContent;
1959
+ runMeta.sentMessages = Array.isArray(runMeta.sentMessages)
1960
+ ? [...runMeta.sentMessages, ...chunks]
1961
+ : chunks.slice();
1962
+ this.markRunFinalDelivery(runId, runMeta.lastSentMessage);
1963
+ return { sent: true, chunks };
1964
+ }
1965
+
1966
+ async tickMessagingProgressSupervisor(runId) {
1967
+ const runMeta = this.getRunMeta(runId);
1968
+ if (!runMeta || runMeta.aborted || runMeta.triggerSource !== 'messaging') {
1969
+ return { sent: false, skipped: true };
1970
+ }
1971
+ if (runMeta.finalDeliverySent === true || runMeta.terminalInterim) {
1972
+ return { sent: false, skipped: true };
1973
+ }
1974
+
1975
+ const now = Date.now();
1976
+ const ledger = runMeta.progressLedger || buildInitialProgressLedger({
1977
+ startedAt: runMeta.startedAtIso || isoNow(),
1978
+ });
1979
+ runMeta.progressLedger = ledger;
1980
+ const startedAtMs = Number.isFinite(runMeta.startedAt) ? runMeta.startedAt : now;
1981
+
1982
+ const lastVerifiedAtMs = timestampMs(ledger.lastVerifiedProgressAt, startedAtMs);
1983
+ const lastVisibleAtMs = timestampMs(ledger.lastUserVisibleUpdateAt, 0);
1984
+ const heartbeatThresholdMs = lastVisibleAtMs > 0
1985
+ ? MESSAGING_PROGRESS_REPEAT_MS
1986
+ : MESSAGING_PROGRESS_FIRST_UPDATE_MS;
1987
+ const comparisonVisibleAtMs = lastVisibleAtMs > 0 ? lastVisibleAtMs : startedAtMs;
1988
+ const stalled = (now - lastVerifiedAtMs) >= MESSAGING_PROGRESS_STALL_MS;
1989
+
1990
+ if (stalled && !ledger.stallNotifiedAt) {
1991
+ this.updateRunProgress(runId, {
1992
+ stallNotifiedAt: isoNow(),
1993
+ progressState: 'stalled',
1994
+ });
1995
+ this.recordRunEvent(runMeta.userId, runId, 'progress_stalled', {
1996
+ currentTool: ledger.currentTool || null,
1997
+ currentStep: ledger.currentStep || null,
1998
+ phase: ledger.currentPhase || 'idle',
1999
+ }, { agentId: runMeta.agentId });
2000
+ }
2001
+
2002
+ if ((now - comparisonVisibleAtMs) < heartbeatThresholdMs) {
2003
+ return { sent: false, skipped: true };
2004
+ }
2005
+
2006
+ if (ledger.currentPhase === 'tool' && ledger.currentStepStartedAt) {
2007
+ return this.sendRuntimeMessagingHeartbeat(runId, { stalled });
2008
+ }
2009
+
2010
+ if (ledger.currentPhase !== 'idle') {
2011
+ return { sent: false, skipped: true };
2012
+ }
2013
+
2014
+ const lastSupervisorNudgeAtMs = timestampMs(runMeta.lastSupervisorNudgeAt, 0);
2015
+ if (lastSupervisorNudgeAtMs > 0 && (now - lastSupervisorNudgeAtMs) < heartbeatThresholdMs) {
2016
+ return { sent: false, skipped: true };
2017
+ }
2018
+
2019
+ const nudge = stalled
2020
+ ? 'The messaging user has only seen progress updates so far, and the run now appears stalled. Decide explicitly whether to continue, send one concise blocker update, or finish with the final answer. Do not leave the run with only an interim status.'
2021
+ : 'The messaging user has not received a final answer yet. Decide explicitly whether to keep working, send one concise progress update, or finish with the final answer. Do not stop with only an interim status.';
2022
+ const queued = this.enqueueSystemSteering(runId, nudge, {
2023
+ reason: stalled ? 'stalled_progress_check' : 'progress_check',
2024
+ });
2025
+ if (!queued) {
2026
+ return { sent: false, skipped: true };
2027
+ }
2028
+ runMeta.lastSupervisorNudgeAt = isoNow();
2029
+ this.updateRunProgress(runId, {
2030
+ lastUserVisibleUpdateAt: ledger.lastUserVisibleUpdateAt || null,
2031
+ });
2032
+ return { sent: false, queued: true };
2033
+ }
2034
+
2035
+ startMessagingProgressSupervisor(runId) {
2036
+ const runMeta = this.getRunMeta(runId);
2037
+ if (!runMeta || runMeta.triggerSource !== 'messaging' || !runMeta.messagingContext?.platform || !runMeta.messagingContext?.chatId) {
2038
+ return false;
2039
+ }
2040
+ if (runMeta.messagingProgressSupervisor?.timer) {
2041
+ return true;
2042
+ }
2043
+ const timer = setInterval(() => {
2044
+ this.tickMessagingProgressSupervisor(runId).catch((error) => {
2045
+ console.warn('[Engine] Messaging progress supervisor failed:', error?.message || error);
2046
+ });
2047
+ }, MESSAGING_PROGRESS_TICK_MS);
2048
+ timer.unref?.();
2049
+ runMeta.messagingProgressSupervisor = { timer };
2050
+ return true;
2051
+ }
2052
+
2053
+ stopMessagingProgressSupervisor(runId) {
2054
+ const runMeta = this.getRunMeta(runId);
2055
+ const timer = runMeta?.messagingProgressSupervisor?.timer || null;
2056
+ if (timer) {
2057
+ clearInterval(timer);
2058
+ }
2059
+ if (runMeta?.messagingProgressSupervisor) {
2060
+ runMeta.messagingProgressSupervisor = null;
2061
+ }
2062
+ }
2063
+
1622
2064
  isRunStopped(runId) {
1623
2065
  return this.getRunMeta(runId)?.aborted === true;
1624
2066
  }
@@ -1871,8 +2313,15 @@ class AgentEngine {
1871
2313
  );
1872
2314
 
1873
2315
  const retryMessagingState = options.messagingRetryState || {};
1874
- const carriedVisibleMessage = String(retryMessagingState.lastVisibleMessage || '').trim();
2316
+ const carriedFinalMessage = String(retryMessagingState.lastFinalMessage || '').trim();
1875
2317
  const carriedExplicitMessageSent = retryMessagingState.explicitMessageSent === true;
2318
+ const carriedInterimHistory = cloneInterimHistory(retryMessagingState.interimHistory);
2319
+ const carriedLastInterimMessage = carriedInterimHistory[carriedInterimHistory.length - 1]?.content || '';
2320
+ const startedAtIso = isoNow();
2321
+ const progressLedger = buildInitialProgressLedger({
2322
+ startedAt: startedAtIso,
2323
+ retryState: retryMessagingState,
2324
+ });
1876
2325
 
1877
2326
  this.activeRuns.set(runId, {
1878
2327
  userId,
@@ -1882,23 +2331,39 @@ class AgentEngine {
1882
2331
  messagingSent: false,
1883
2332
  noResponse: false,
1884
2333
  explicitMessageSent: carriedExplicitMessageSent,
1885
- lastSentMessage: carriedExplicitMessageSent ? carriedVisibleMessage : '',
2334
+ finalDeliverySent: carriedExplicitMessageSent,
2335
+ lastSentMessage: carriedExplicitMessageSent ? carriedFinalMessage : '',
1886
2336
  sentMessages: [],
1887
2337
  widgetSnapshotSaved: false,
1888
2338
  triggerType,
1889
2339
  triggerSource,
1890
2340
  startedAt: Date.now(),
2341
+ startedAtIso,
1891
2342
  lastToolName: null,
1892
2343
  lastToolTarget: null,
1893
- lastInterimMessage: carriedExplicitMessageSent ? '' : carriedVisibleMessage,
1894
- interimMessages: [],
1895
- interimSignatures: new Set(),
2344
+ lastInterimMessage: carriedExplicitMessageSent ? '' : carriedLastInterimMessage,
2345
+ interimMessages: carriedExplicitMessageSent ? [] : carriedInterimHistory,
2346
+ interimSignatures: carriedExplicitMessageSent
2347
+ ? new Set()
2348
+ : createInterimSignatureSet(carriedInterimHistory, options.source || null),
1896
2349
  terminalInterim: null,
1897
2350
  voiceSessionId: options.voiceSessionId || null,
1898
2351
  steeringQueue: [],
2352
+ systemSteeringQueue: [],
1899
2353
  toolPids: new Set(),
1900
2354
  repetitionGuard: new ToolRepetitionGuard(),
2355
+ messagingContext: triggerSource === 'messaging'
2356
+ ? {
2357
+ platform: options.source || null,
2358
+ chatId: options.chatId || null,
2359
+ }
2360
+ : null,
2361
+ progressLedger,
1901
2362
  });
2363
+ this.persistRunMetadata(runId, {
2364
+ progressLedger,
2365
+ });
2366
+ this.startMessagingProgressSupervisor(runId);
1902
2367
  this.emit(userId, 'run:start', { runId, agentId, title: runTitle, model, triggerType, triggerSource });
1903
2368
  this.recordRunEvent(userId, runId, 'run_started', {
1904
2369
  title: runTitle,
@@ -2278,12 +2743,22 @@ class AgentEngine {
2278
2743
  if (this.isRunStopped(runId)) break;
2279
2744
  iteration++;
2280
2745
 
2746
+ const systemSteeringAtLoopStart = this.applyQueuedSystemSteering(runId, messages);
2747
+ messages = systemSteeringAtLoopStart.messages;
2281
2748
  const steeringAtLoopStart = this.applyQueuedSteering(runId, messages, {
2282
2749
  userId,
2283
2750
  conversationId
2284
2751
  });
2285
2752
  messages = steeringAtLoopStart.messages;
2286
2753
  messages = sanitizeConversationMessages(messages);
2754
+ this.updateRunProgress(runId, {
2755
+ currentPhase: 'model',
2756
+ currentStep: `model:${iteration}`,
2757
+ currentTool: null,
2758
+ currentStepStartedAt: isoNow(),
2759
+ }, {
2760
+ verified: true,
2761
+ });
2287
2762
 
2288
2763
  let metrics = this.estimatePromptMetrics(messages, tools);
2289
2764
  const contextWindow = provider.getContextWindow(model);
@@ -2444,6 +2919,21 @@ class AgentEngine {
2444
2919
  }
2445
2920
 
2446
2921
  if (!response.toolCalls || response.toolCalls.length === 0) {
2922
+ this.updateRunProgress(runId, {
2923
+ currentPhase: 'idle',
2924
+ currentStep: null,
2925
+ currentTool: null,
2926
+ currentStepStartedAt: null,
2927
+ }, {
2928
+ verified: true,
2929
+ });
2930
+ const systemSteeringAfterResponse = this.applyQueuedSystemSteering(runId, messages);
2931
+ messages = systemSteeringAfterResponse.messages;
2932
+ if (systemSteeringAfterResponse.appliedCount > 0) {
2933
+ iteration = Math.max(0, iteration - 1);
2934
+ lastContent = '';
2935
+ continue;
2936
+ }
2447
2937
  const steeringAfterResponse = this.applyQueuedSteering(runId, messages, {
2448
2938
  userId,
2449
2939
  conversationId
@@ -2470,11 +2960,13 @@ class AgentEngine {
2470
2960
  && this.activeRuns.get(runId)?.noResponse !== true
2471
2961
  && options.deliveryState?.noResponse !== true
2472
2962
  );
2963
+ const visibleInterimActivity = hasVisibleInterimActivity(this.activeRuns.get(runId));
2473
2964
  const fallbackStatus = (
2474
2965
  proactiveRunNeedsDecision
2475
2966
  || toolExecutions.length > 0
2476
2967
  || failedStepCount > 0
2477
2968
  || messagingSent
2969
+ || visibleInterimActivity
2478
2970
  ) ? 'continue' : 'complete';
2479
2971
  const loopState = await runWithModelFallback('loop decision', () => this.decideLoopState({
2480
2972
  provider,
@@ -2685,6 +3177,15 @@ class AgentEngine {
2685
3177
 
2686
3178
  db.prepare('INSERT INTO agent_steps (id, run_id, step_index, type, description, status, tool_name, tool_input, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime(\'now\'))')
2687
3179
  .run(stepId, runId, stepIndex, this.getStepType(toolName), `${toolName}: ${JSON.stringify(toolArgs).slice(0, 200)} `, 'running', toolName, JSON.stringify(toolArgs));
3180
+ this.updateRunProgress(runId, {
3181
+ currentPhase: 'tool',
3182
+ currentStep: stepId,
3183
+ currentTool: toolName,
3184
+ currentStepStartedAt: isoNow(),
3185
+ }, {
3186
+ verified: true,
3187
+ stepId,
3188
+ });
2688
3189
 
2689
3190
  this.emit(userId, 'run:tool_start', {
2690
3191
  runId, stepId, stepIndex, toolName, toolArgs,
@@ -2885,6 +3386,16 @@ class AgentEngine {
2885
3386
  .run(conversationId, 'tool', toolMessage.content, toolCall.id, toolName);
2886
3387
  }
2887
3388
 
3389
+ this.updateRunProgress(runId, {
3390
+ currentPhase: 'idle',
3391
+ currentStep: null,
3392
+ currentTool: null,
3393
+ currentStepStartedAt: null,
3394
+ }, {
3395
+ verified: true,
3396
+ stepId,
3397
+ });
3398
+
2888
3399
  const runMeta = this.activeRuns.get(runId);
2889
3400
  if (runMeta) {
2890
3401
  runMeta.lastToolName = toolName;
@@ -2916,6 +3427,7 @@ class AgentEngine {
2916
3427
  console.warn(
2917
3428
  `[Run ${shortenRunId(runId)}] stopped trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
2918
3429
  );
3430
+ this.stopMessagingProgressSupervisor(runId);
2919
3431
  this.activeRuns.delete(runId);
2920
3432
  this.emit(userId, 'run:stopped', { runId, triggerSource });
2921
3433
  this.recordRunEvent(userId, runId, 'run_stopped', {
@@ -2991,9 +3503,8 @@ class AgentEngine {
2991
3503
  let finalResponseText = messagingSent
2992
3504
  ? (sentMessageText || (normalizedLastContent ? lastContent.trim() : ''))
2993
3505
  : (normalizedLastContent ? lastContent.trim() : sentMessageText);
2994
- const lastVisibleMessage = normalizeOutgoingMessage(
3506
+ const lastFinalDeliveryMessage = normalizeOutgoingMessage(
2995
3507
  runMeta?.lastSentMessage
2996
- || runMeta?.lastInterimMessage
2997
3508
  || (Array.isArray(runMeta?.sentMessages) ? runMeta.sentMessages[runMeta.sentMessages.length - 1] : '')
2998
3509
  || '',
2999
3510
  options?.source || null
@@ -3130,39 +3641,19 @@ class AgentEngine {
3130
3641
  skipPersistence: options.skipRunContextPersistence === true
3131
3642
  });
3132
3643
 
3133
- // Fallback: if this was a messaging-triggered run and no user-visible
3134
- // message was already sent in this run, auto-send the final assistant text.
3135
- // After any visible reply already went out, later user-facing messages
3136
- // must be sent explicitly via send_message.
3644
+ // Fallback: if this was a messaging-triggered run and no final delivery
3645
+ // was already sent in this run, auto-send the final assistant text.
3646
+ // Interim progress updates do not suppress this final delivery.
3137
3647
  if (triggerSource === 'messaging' && options.source && options.chatId) {
3138
- // Strip [NO RESPONSE] markers the AI may have embedded anywhere in the text,
3139
- // then only send if real content remains.
3140
- const cleanedContent = normalizeOutgoingMessage(lastContent || '', options.source, {
3141
- collapseWhitespace: false
3142
- });
3143
- const shouldSendFallback = (
3144
- cleanedContent
3145
- && runMeta?.explicitMessageSent !== true
3146
- && !lastVisibleMessage
3147
- );
3148
- if (shouldSendFallback) {
3149
- const manager = this.messagingManager;
3150
- if (manager) {
3151
- const chunks = splitOutgoingMessageForPlatform(options.source, cleanedContent);
3152
- console.info(
3153
- `[Run ${shortenRunId(runId)}] messaging_fallback chunks=${chunks.length} to=${summarizeForLog(options.chatId, 80)}`
3154
- );
3155
- for (let i = 0; i < chunks.length; i++) {
3156
- if (i > 0) {
3157
- const delay = Math.max(1000, Math.min(chunks[i].length * 30, 4000));
3158
- await manager.sendTyping(userId, options.source, options.chatId, true, { agentId }).catch(() => { });
3159
- await new Promise((resolve) => setTimeout(resolve, delay));
3160
- }
3161
- await manager.sendMessage(userId, options.source, options.chatId, chunks[i], { runId, agentId }).catch((err) =>
3162
- console.error('[Engine] Auto-reply fallback failed:', err.message)
3163
- );
3164
- }
3165
- }
3648
+ if (this.shouldSendMessagingFinalFallback(runMeta, lastContent || '', options.source) && !lastFinalDeliveryMessage) {
3649
+ await this.deliverMessagingFinalFallback({
3650
+ runId,
3651
+ userId,
3652
+ agentId,
3653
+ platform: options.source,
3654
+ chatId: options.chatId,
3655
+ content: lastContent || '',
3656
+ });
3166
3657
  }
3167
3658
  }
3168
3659
 
@@ -3170,6 +3661,7 @@ class AgentEngine {
3170
3661
  `[Run ${shortenRunId(runId)}] completed trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens} durationMs=${runMeta?.startedAt ? Date.now() - runMeta.startedAt : 0} finalResponse=${finalResponseText ? 'yes' : 'no'} sentMessages=${runMeta?.sentMessages?.length || 0}`
3171
3662
  );
3172
3663
  this.cleanupSubagentsForRun(runId, { cancelRunning: true });
3664
+ this.stopMessagingProgressSupervisor(runId);
3173
3665
  this.activeRuns.delete(runId);
3174
3666
  this.emit(userId, 'run:complete', {
3175
3667
  runId,
@@ -3230,6 +3722,7 @@ class AgentEngine {
3230
3722
  `[Run ${shortenRunId(runId)}] stopped trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
3231
3723
  );
3232
3724
  this.cleanupSubagentsForRun(runId, { cancelRunning: true });
3725
+ this.stopMessagingProgressSupervisor(runId);
3233
3726
  this.activeRuns.delete(runId);
3234
3727
  this.emit(userId, 'run:stopped', { runId, triggerSource });
3235
3728
  this.recordRunEvent(userId, runId, 'run_stopped', {
@@ -3267,18 +3760,13 @@ class AgentEngine {
3267
3760
  || runMeta?.messagingSent === true
3268
3761
  ),
3269
3762
  });
3270
- const lastVisibleMessage = normalizeOutgoingMessage(
3271
- runMeta?.lastSentMessage
3272
- || runMeta?.lastInterimMessage
3273
- || '',
3274
- options?.source || null
3275
- );
3276
3763
  db.prepare('UPDATE agent_runs SET status = ?, error = ?, updated_at = datetime(\'now\') WHERE id = ?')
3277
3764
  .run('retrying', err.message, runId);
3278
3765
  console.warn(
3279
3766
  `[Run ${shortenRunId(runId)}] retrying_messaging_attempt=${retryCount + 1} reason=${summarizeForLog(err.message, 140)}`
3280
3767
  );
3281
3768
  this.cleanupSubagentsForRun(runId, { cancelRunning: true });
3769
+ this.stopMessagingProgressSupervisor(runId);
3282
3770
  this.activeRuns.delete(runId);
3283
3771
  this.emit(userId, 'run:interim', {
3284
3772
  runId,
@@ -3290,8 +3778,17 @@ class AgentEngine {
3290
3778
  ...options,
3291
3779
  messagingAutonomousRetryCount: retryCount + 1,
3292
3780
  messagingRetryState: {
3293
- lastVisibleMessage: lastVisibleMessage || String(options?.messagingRetryState?.lastVisibleMessage || '').trim(),
3781
+ lastFinalMessage: String(runMeta?.lastSentMessage || options?.messagingRetryState?.lastFinalMessage || '').trim(),
3294
3782
  explicitMessageSent: runMeta?.explicitMessageSent === true || options?.messagingRetryState?.explicitMessageSent === true,
3783
+ interimHistory: cloneInterimHistory([
3784
+ ...(Array.isArray(options?.messagingRetryState?.interimHistory) ? options.messagingRetryState.interimHistory : []),
3785
+ ...(Array.isArray(runMeta?.interimMessages) ? runMeta.interimMessages : []),
3786
+ ]),
3787
+ lastUserVisibleUpdateAt: runMeta?.progressLedger?.lastUserVisibleUpdateAt || options?.messagingRetryState?.lastUserVisibleUpdateAt || null,
3788
+ lastFinalDeliveryAt: runMeta?.progressLedger?.lastFinalDeliveryAt || options?.messagingRetryState?.lastFinalDeliveryAt || null,
3789
+ heartbeatCount: Number(runMeta?.progressLedger?.heartbeatCount || options?.messagingRetryState?.heartbeatCount || 0),
3790
+ progressState: runMeta?.progressLedger?.progressState || options?.messagingRetryState?.progressState || 'active',
3791
+ lastVerifiedProgressAt: runMeta?.progressLedger?.lastVerifiedProgressAt || options?.messagingRetryState?.lastVerifiedProgressAt || null,
3295
3792
  },
3296
3793
  context: {
3297
3794
  ...(options.context || {}),
@@ -3358,6 +3855,7 @@ class AgentEngine {
3358
3855
  if (!Array.isArray(runMeta.sentMessages)) runMeta.sentMessages = [];
3359
3856
  runMeta.sentMessages.push(messagingFailureContent);
3360
3857
  }
3858
+ this.markRunFinalDelivery(runId, messagingFailureContent);
3361
3859
  } catch (sendErr) {
3362
3860
  console.error('[Engine] Messaging error fallback failed:', sendErr.message);
3363
3861
  messagingFailureContent = '';
@@ -3380,6 +3878,7 @@ class AgentEngine {
3380
3878
  );
3381
3879
 
3382
3880
  this.cleanupSubagentsForRun(runId, { cancelRunning: true });
3881
+ this.stopMessagingProgressSupervisor(runId);
3383
3882
  this.activeRuns.delete(runId);
3384
3883
  this.emit(userId, 'run:error', { runId, error: err.message });
3385
3884
  this.recordRunEvent(userId, runId, 'run_failed', {