@swarmclawai/swarmclaw 1.2.0 → 1.2.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.
Files changed (241) hide show
  1. package/README.md +19 -0
  2. package/package.json +5 -2
  3. package/skills/coding-agent/SKILL.md +111 -0
  4. package/skills/github/SKILL.md +140 -0
  5. package/skills/nano-banana-pro/SKILL.md +62 -0
  6. package/skills/nano-banana-pro/scripts/generate_image.py +235 -0
  7. package/skills/nano-pdf/SKILL.md +53 -0
  8. package/skills/openai-image-gen/SKILL.md +78 -0
  9. package/skills/openai-image-gen/scripts/gen.py +328 -0
  10. package/skills/resourceful-problem-solving/SKILL.md +49 -0
  11. package/skills/skill-creator/SKILL.md +147 -0
  12. package/skills/skill-creator/scripts/init_skill.py +378 -0
  13. package/skills/skill-creator/scripts/quick_validate.py +159 -0
  14. package/skills/summarize/SKILL.md +77 -0
  15. package/src/app/api/auth/route.ts +20 -5
  16. package/src/app/api/chats/[id]/deploy/route.ts +11 -6
  17. package/src/app/api/chats/[id]/devserver/route.ts +17 -20
  18. package/src/app/api/chats/[id]/messages/route.ts +15 -11
  19. package/src/app/api/chats/[id]/route.ts +9 -10
  20. package/src/app/api/chats/[id]/stop/route.ts +5 -7
  21. package/src/app/api/chats/messages-route.test.ts +8 -6
  22. package/src/app/api/chats/route.ts +9 -10
  23. package/src/app/api/credentials/[id]/route.ts +4 -1
  24. package/src/app/api/extensions/marketplace/route.ts +5 -2
  25. package/src/app/api/ip/route.ts +2 -2
  26. package/src/app/api/memory/maintenance/route.ts +5 -2
  27. package/src/app/api/preview-server/route.ts +15 -12
  28. package/src/app/api/projects/[id]/route.ts +7 -46
  29. package/src/app/api/system/status/route.ts +11 -0
  30. package/src/app/api/upload/route.ts +4 -1
  31. package/src/cli/index.js +7 -0
  32. package/src/cli/spec.js +1 -0
  33. package/src/components/agents/agent-files-editor.tsx +44 -32
  34. package/src/components/agents/personality-builder.tsx +13 -7
  35. package/src/components/agents/trash-list.tsx +1 -1
  36. package/src/components/chat/chat-area.tsx +45 -23
  37. package/src/components/chat/message-bubble.test.ts +35 -0
  38. package/src/components/chat/message-bubble.tsx +20 -9
  39. package/src/components/chat/message-list.tsx +62 -42
  40. package/src/components/chat/swarm-status-card.tsx +10 -3
  41. package/src/components/input/chat-input.tsx +34 -14
  42. package/src/components/layout/daemon-indicator.tsx +7 -8
  43. package/src/components/layout/update-banner.tsx +8 -13
  44. package/src/components/logs/log-list.tsx +1 -1
  45. package/src/components/memory/memory-card.tsx +3 -1
  46. package/src/components/org-chart/org-chart-view.tsx +4 -0
  47. package/src/components/projects/project-list.tsx +4 -2
  48. package/src/components/projects/tabs/overview-tab.tsx +3 -2
  49. package/src/components/secrets/secret-sheet.tsx +1 -1
  50. package/src/components/secrets/secrets-list.tsx +1 -1
  51. package/src/components/shared/agent-switch-dialog.tsx +12 -6
  52. package/src/components/shared/dir-browser.tsx +22 -18
  53. package/src/components/skills/skill-sheet.tsx +2 -3
  54. package/src/components/tasks/task-list.tsx +1 -1
  55. package/src/components/tasks/task-sheet.tsx +1 -1
  56. package/src/hooks/use-openclaw-gateway.ts +46 -27
  57. package/src/instrumentation.ts +10 -7
  58. package/src/lib/chat/assistant-render-id.ts +3 -0
  59. package/src/lib/chat/chat-streaming-state.test.ts +42 -3
  60. package/src/lib/chat/chat-streaming-state.ts +20 -8
  61. package/src/lib/chat/chat.ts +18 -2
  62. package/src/lib/chat/queued-message-queue.test.ts +23 -1
  63. package/src/lib/chat/queued-message-queue.ts +11 -2
  64. package/src/lib/providers/anthropic.ts +6 -3
  65. package/src/lib/providers/claude-cli.ts +9 -3
  66. package/src/lib/providers/cli-utils.test.ts +124 -0
  67. package/src/lib/providers/cli-utils.ts +15 -0
  68. package/src/lib/providers/codex-cli.ts +9 -3
  69. package/src/lib/providers/gemini-cli.ts +6 -2
  70. package/src/lib/providers/index.ts +4 -1
  71. package/src/lib/providers/ollama.ts +5 -2
  72. package/src/lib/providers/openai.ts +8 -5
  73. package/src/lib/providers/opencode-cli.ts +6 -2
  74. package/src/lib/server/activity/activity-log.ts +21 -0
  75. package/src/lib/server/agents/agent-availability.test.ts +10 -5
  76. package/src/lib/server/agents/agent-cascade.ts +79 -59
  77. package/src/lib/server/agents/agent-registry.ts +23 -4
  78. package/src/lib/server/agents/agent-repository.ts +90 -0
  79. package/src/lib/server/agents/delegation-job-repository.ts +53 -0
  80. package/src/lib/server/agents/delegation-jobs.ts +11 -4
  81. package/src/lib/server/agents/guardian-checkpoint-repository.ts +35 -0
  82. package/src/lib/server/agents/guardian.ts +2 -2
  83. package/src/lib/server/agents/main-agent-loop.ts +14 -6
  84. package/src/lib/server/agents/main-loop-state-repository.ts +38 -0
  85. package/src/lib/server/agents/subagent-runtime.ts +9 -6
  86. package/src/lib/server/agents/subagent-swarm.ts +3 -2
  87. package/src/lib/server/agents/task-session.ts +3 -4
  88. package/src/lib/server/approvals/approval-repository.ts +30 -0
  89. package/src/lib/server/autonomy/supervisor-incident-repository.ts +42 -0
  90. package/src/lib/server/autonomy/supervisor-reflection.ts +14 -1
  91. package/src/lib/server/chat-execution/chat-execution-types.ts +38 -0
  92. package/src/lib/server/chat-execution/chat-execution-utils.ts +1 -1
  93. package/src/lib/server/chat-execution/chat-execution.ts +84 -1914
  94. package/src/lib/server/chat-execution/chat-turn-finalization.ts +620 -0
  95. package/src/lib/server/chat-execution/chat-turn-partial-persistence.ts +221 -0
  96. package/src/lib/server/chat-execution/chat-turn-preflight.ts +133 -0
  97. package/src/lib/server/chat-execution/chat-turn-preparation.ts +817 -0
  98. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +296 -0
  99. package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +5 -5
  100. package/src/lib/server/chat-execution/continuation-evaluator.ts +4 -3
  101. package/src/lib/server/chat-execution/continuation-limits.ts +6 -3
  102. package/src/lib/server/chat-execution/message-classifier.test.ts +329 -0
  103. package/src/lib/server/chat-execution/message-classifier.ts +5 -2
  104. package/src/lib/server/chat-execution/post-stream-finalization.ts +5 -2
  105. package/src/lib/server/chat-execution/prompt-builder.ts +22 -1
  106. package/src/lib/server/chat-execution/prompt-sections.ts +55 -13
  107. package/src/lib/server/chat-execution/response-completeness.ts +5 -2
  108. package/src/lib/server/chat-execution/situational-awareness.ts +12 -7
  109. package/src/lib/server/chat-execution/stream-agent-chat.ts +58 -25
  110. package/src/lib/server/chatrooms/chatroom-memory-bridge.ts +6 -3
  111. package/src/lib/server/chatrooms/chatroom-repository.ts +32 -0
  112. package/src/lib/server/connectors/bluebubbles.ts +7 -4
  113. package/src/lib/server/connectors/connector-inbound.ts +16 -13
  114. package/src/lib/server/connectors/connector-lifecycle.ts +11 -8
  115. package/src/lib/server/connectors/connector-outbound.ts +6 -3
  116. package/src/lib/server/connectors/connector-repository.ts +58 -0
  117. package/src/lib/server/connectors/discord.ts +10 -7
  118. package/src/lib/server/connectors/email.ts +17 -14
  119. package/src/lib/server/connectors/googlechat.ts +7 -4
  120. package/src/lib/server/connectors/inbound-audio-transcription.ts +5 -2
  121. package/src/lib/server/connectors/matrix.ts +6 -3
  122. package/src/lib/server/connectors/openclaw.ts +20 -17
  123. package/src/lib/server/connectors/outbox.ts +4 -1
  124. package/src/lib/server/connectors/runtime-state.test.ts +117 -0
  125. package/src/lib/server/connectors/runtime-state.ts +19 -0
  126. package/src/lib/server/connectors/session-consolidation.ts +5 -2
  127. package/src/lib/server/connectors/signal.ts +9 -6
  128. package/src/lib/server/connectors/slack.ts +13 -10
  129. package/src/lib/server/connectors/teams.ts +8 -5
  130. package/src/lib/server/connectors/telegram.ts +15 -12
  131. package/src/lib/server/connectors/whatsapp.ts +32 -29
  132. package/src/lib/server/credentials/credential-repository.ts +7 -0
  133. package/src/lib/server/embeddings.ts +4 -1
  134. package/src/lib/server/gateways/gateway-profile-repository.ts +4 -0
  135. package/src/lib/server/link-understanding.ts +4 -1
  136. package/src/lib/server/memory/memory-abstract.test.ts +59 -0
  137. package/src/lib/server/memory/memory-abstract.ts +59 -0
  138. package/src/lib/server/memory/memory-db.ts +40 -14
  139. package/src/lib/server/missions/mission-repository.ts +74 -0
  140. package/src/lib/server/missions/mission-service/actions.ts +6 -0
  141. package/src/lib/server/missions/mission-service/bindings.ts +9 -0
  142. package/src/lib/server/missions/mission-service/context.ts +4 -0
  143. package/src/lib/server/missions/mission-service/core.ts +2269 -0
  144. package/src/lib/server/missions/mission-service/queries.ts +12 -0
  145. package/src/lib/server/missions/mission-service/recovery.ts +5 -0
  146. package/src/lib/server/missions/mission-service/ticks.ts +9 -0
  147. package/src/lib/server/missions/mission-service.test.ts +9 -2
  148. package/src/lib/server/missions/mission-service.ts +6 -2263
  149. package/src/lib/server/openclaw/gateway.ts +8 -5
  150. package/src/lib/server/persistence/repository-utils.ts +154 -0
  151. package/src/lib/server/persistence/storage-context.ts +51 -0
  152. package/src/lib/server/persistence/transaction.ts +1 -0
  153. package/src/lib/server/project-utils.ts +13 -0
  154. package/src/lib/server/projects/project-repository.ts +36 -0
  155. package/src/lib/server/projects/project-service.ts +79 -0
  156. package/src/lib/server/protocols/protocol-agent-turn.ts +5 -2
  157. package/src/lib/server/protocols/protocol-normalization.test.ts +6 -4
  158. package/src/lib/server/protocols/protocol-run-lifecycle.ts +5 -2
  159. package/src/lib/server/protocols/protocol-step-helpers.ts +4 -1
  160. package/src/lib/server/provider-health.ts +18 -0
  161. package/src/lib/server/query-expansion.ts +4 -1
  162. package/src/lib/server/runtime/alert-dispatch.ts +8 -7
  163. package/src/lib/server/runtime/daemon-policy.ts +1 -1
  164. package/src/lib/server/runtime/daemon-state/core.ts +1570 -0
  165. package/src/lib/server/runtime/daemon-state/health.ts +6 -0
  166. package/src/lib/server/runtime/daemon-state/policy.ts +7 -0
  167. package/src/lib/server/runtime/daemon-state/supervisor.ts +6 -0
  168. package/src/lib/server/runtime/daemon-state.test.ts +48 -0
  169. package/src/lib/server/runtime/daemon-state.ts +3 -1331
  170. package/src/lib/server/runtime/estop-repository.ts +4 -0
  171. package/src/lib/server/runtime/estop.ts +3 -1
  172. package/src/lib/server/runtime/heartbeat-service.test.ts +2 -2
  173. package/src/lib/server/runtime/heartbeat-service.ts +78 -34
  174. package/src/lib/server/runtime/heartbeat-wake.ts +6 -4
  175. package/src/lib/server/runtime/idle-window.ts +6 -3
  176. package/src/lib/server/runtime/network.ts +11 -0
  177. package/src/lib/server/runtime/orchestrator-events.ts +2 -2
  178. package/src/lib/server/runtime/perf.ts +4 -1
  179. package/src/lib/server/runtime/process-manager.ts +7 -4
  180. package/src/lib/server/runtime/queue/claims.ts +4 -0
  181. package/src/lib/server/runtime/queue/core.ts +2079 -0
  182. package/src/lib/server/runtime/queue/execution.ts +7 -0
  183. package/src/lib/server/runtime/queue/followups.ts +4 -0
  184. package/src/lib/server/runtime/queue/queries.ts +12 -0
  185. package/src/lib/server/runtime/queue/recovery.ts +7 -0
  186. package/src/lib/server/runtime/queue-recovery.test.ts +48 -13
  187. package/src/lib/server/runtime/queue-repository.ts +17 -0
  188. package/src/lib/server/runtime/queue.ts +5 -2058
  189. package/src/lib/server/runtime/run-ledger.ts +6 -5
  190. package/src/lib/server/runtime/run-repository.ts +73 -0
  191. package/src/lib/server/runtime/runtime-lock-repository.ts +8 -0
  192. package/src/lib/server/runtime/runtime-settings.ts +1 -1
  193. package/src/lib/server/runtime/runtime-state.ts +99 -0
  194. package/src/lib/server/runtime/scheduler.ts +13 -8
  195. package/src/lib/server/runtime/session-run-manager/cancellation.ts +157 -0
  196. package/src/lib/server/runtime/session-run-manager/drain.ts +246 -0
  197. package/src/lib/server/runtime/session-run-manager/enqueue.ts +287 -0
  198. package/src/lib/server/runtime/session-run-manager/queries.ts +117 -0
  199. package/src/lib/server/runtime/session-run-manager/recovery.ts +238 -0
  200. package/src/lib/server/runtime/session-run-manager/state.ts +441 -0
  201. package/src/lib/server/runtime/session-run-manager/types.ts +74 -0
  202. package/src/lib/server/runtime/session-run-manager.ts +72 -1374
  203. package/src/lib/server/runtime/watch-job-repository.ts +35 -0
  204. package/src/lib/server/runtime/watch-jobs.ts +3 -1
  205. package/src/lib/server/sandbox/bridge-auth-registry.ts +6 -0
  206. package/src/lib/server/sandbox/novnc-auth.ts +10 -0
  207. package/src/lib/server/schedules/schedule-repository.ts +42 -0
  208. package/src/lib/server/session-tools/context.ts +14 -0
  209. package/src/lib/server/session-tools/discovery.ts +9 -6
  210. package/src/lib/server/session-tools/index.ts +3 -1
  211. package/src/lib/server/session-tools/platform.ts +1 -1
  212. package/src/lib/server/session-tools/subagent.ts +23 -2
  213. package/src/lib/server/session-tools/wallet.ts +4 -1
  214. package/src/lib/server/sessions/session-repository.ts +85 -0
  215. package/src/lib/server/settings/settings-repository.ts +25 -0
  216. package/src/lib/server/skills/clawhub-client.ts +4 -1
  217. package/src/lib/server/skills/runtime-skill-resolver.ts +8 -2
  218. package/src/lib/server/skills/skill-discovery.test.ts +2 -2
  219. package/src/lib/server/skills/skill-discovery.ts +2 -2
  220. package/src/lib/server/skills/skill-eligibility.ts +6 -0
  221. package/src/lib/server/skills/skill-repository.ts +14 -0
  222. package/src/lib/server/solana.ts +6 -0
  223. package/src/lib/server/storage-auth.ts +5 -5
  224. package/src/lib/server/storage-normalization.ts +4 -0
  225. package/src/lib/server/storage.ts +32 -32
  226. package/src/lib/server/tasks/task-followups.ts +4 -1
  227. package/src/lib/server/tasks/task-repository.ts +54 -0
  228. package/src/lib/server/tool-loop-detection.ts +8 -3
  229. package/src/lib/server/tool-planning.ts +226 -0
  230. package/src/lib/server/tool-retry.ts +4 -3
  231. package/src/lib/server/usage/usage-repository.ts +30 -0
  232. package/src/lib/server/wallet/wallet-portfolio.ts +29 -0
  233. package/src/lib/server/webhooks/webhook-repository.ts +10 -0
  234. package/src/lib/server/ws-hub.ts +5 -2
  235. package/src/lib/strip-internal-metadata.test.ts +78 -37
  236. package/src/lib/strip-internal-metadata.ts +20 -6
  237. package/src/stores/use-approval-store.ts +7 -1
  238. package/src/stores/use-chat-store.test.ts +54 -0
  239. package/src/stores/use-chat-store.ts +26 -6
  240. package/src/types/index.ts +6 -0
  241. /package/{bundled-skills → skills}/google-workspace/SKILL.md +0 -0
@@ -1,113 +1,16 @@
1
- import fs from 'fs'
2
- import os from 'os'
1
+ import type { ExecuteChatTurnInput, ExecuteChatTurnResult } from './chat-execution-types'
3
2
  import { perf } from '@/lib/server/runtime/perf'
3
+ import { markProviderSuccess } from '@/lib/server/provider-health'
4
+ import { executePreparedChatTurn } from '@/lib/server/chat-execution/chat-turn-stream-execution'
5
+ import { finalizeChatTurn } from '@/lib/server/chat-execution/chat-turn-finalization'
6
+ import { prepareChatTurn } from '@/lib/server/chat-execution/chat-turn-preparation'
4
7
  import {
5
- loadSessions,
6
- saveSessions,
7
- loadCredentials,
8
- decryptKey,
9
- getSessionMessages,
10
- loadAgents,
11
- loadSkills,
12
- loadSettings,
13
- appendUsage,
14
- active,
15
- } from '@/lib/server/storage'
16
- import { getProvider } from '@/lib/providers'
17
- import { CONTEXT_OVERFLOW_RE } from '@/lib/providers/error-classification'
18
- import { estimateCost, checkAgentBudgetLimits } from '@/lib/server/cost'
19
- import { log } from '@/lib/server/logger'
20
- import { logExecution } from '@/lib/server/execution-log'
21
- import { streamAgentChat } from '@/lib/server/chat-execution/stream-agent-chat'
22
- import { buildToolSection, joinPromptSegments } from '@/lib/server/chat-execution/prompt-builder'
23
- import { pruneIncompleteToolEvents } from '@/lib/server/chat-execution/chat-streaming-utils'
24
- import { runLinkUnderstanding } from '@/lib/server/link-understanding'
25
- import type { Session } from '@/types'
26
- import { stripMainLoopMetaForPersistence } from '@/lib/server/agents/main-agent-loop'
27
- import { isLocalOpenClawEndpoint, normalizeProviderEndpoint } from '@/lib/openclaw/openclaw-endpoint'
28
- import { notify } from '@/lib/server/ws-hub'
29
- import { applyResolvedRoute, resolvePrimaryAgentRoute } from '@/lib/server/agents/agent-runtime-config'
30
- import { resolveSessionToolPolicy } from '@/lib/server/tool-capability-policy'
31
- import { buildCurrentDateTimePromptContext } from '@/lib/server/prompt-runtime-context'
32
- import { buildWorkspaceContext } from '@/lib/server/workspace-context'
33
- import { buildRuntimeSkillPromptBlocks, resolveRuntimeSkills } from '@/lib/server/skills/runtime-skill-resolver'
34
- import { resolveImagePath } from '@/lib/server/resolve-image'
8
+ createPartialAssistantPersistence,
9
+ } from '@/lib/server/chat-execution/chat-turn-partial-persistence'
35
10
  import {
36
- applyContextClearBoundary,
37
- filterRuntimeCapabilityIds,
38
- shouldApplySessionFreshnessReset,
39
- shouldAutoRouteHeartbeatAlerts,
40
- shouldPersistInboundUserMessage,
41
- translateRequestedToolInvocation,
42
- normalizeAssistantArtifactLinks,
43
- extractHeartbeatStatus,
44
- shouldReplaceRecentAssistantMessage,
45
- hasPersistableAssistantPayload,
46
- getPersistedAssistantText,
47
- getToolEventsSnapshotKey,
48
- requestedToolNamesFromMessage,
49
- shouldReplaceRecentConnectorFollowupMessage,
50
- shouldSuppressRedundantConnectorDeliveryFollowup,
51
- hasDirectLocalCodingTools,
52
- parseUsdLimit,
53
- getTodaySpendUsd,
54
- classifyHeartbeatResponse,
55
- estimateConversationTone,
56
- pruneOldHeartbeatMessages,
57
- } from '@/lib/server/chat-execution/chat-execution-utils'
58
- import { reconcileConnectorDeliveryText } from '@/lib/server/chat-execution/chat-execution-connector-delivery'
59
- import {
60
- resolveRequestedToolPreflightResponse,
61
- runExclusiveDirectMemoryPreflight,
62
- runPostLlmToolRouting,
63
- } from '@/lib/server/chat-execution/chat-turn-tool-routing'
64
- import {
65
- applyExactOutputContract,
66
- classifyExactOutputContract,
67
- type ExactOutputContract,
68
- } from '@/lib/server/chat-execution/exact-output-contract'
69
- import {
70
- getCachedLlmResponse,
71
- resolveLlmResponseCacheConfig,
72
- setCachedLlmResponse,
73
- type LlmResponseCacheKeyInput,
74
- } from '@/lib/server/llm-response-cache'
75
- import type { Message, MessageToolEvent, SSEEvent, UsageRecord } from '@/types'
76
- import { markProviderFailure, markProviderSuccess } from '@/lib/server/provider-health'
77
- import { isHeartbeatSource, isInternalHeartbeatRun } from '@/lib/server/runtime/heartbeat-source'
78
- import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
79
- import { buildIdentityContinuityContext, refreshSessionIdentityState } from '@/lib/server/identity-continuity'
80
- import { resolveEffectiveSessionMemoryScopeMode } from '@/lib/server/memory/session-memory-scope'
81
- import { syncSessionArchiveMemory } from '@/lib/server/memory/session-archive-memory'
82
- import { evaluateSessionFreshness, resetSessionRuntime, resolveSessionResetPolicy } from '@/lib/server/session-reset-policy'
83
- import { pruneStreamingAssistantArtifacts, upsertStreamingAssistantArtifact } from '@/lib/chat/chat-streaming-state'
84
- import { shouldSuppressHiddenControlText, stripHiddenControlTokens } from '@/lib/server/agents/assistant-control'
85
- import { buildAgentDisabledMessage, isAgentDisabled } from '@/lib/server/agents/agent-availability'
86
- import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
87
- import { errorMessage as toErrorMessage } from '@/lib/shared-utils'
88
- import { listUniversalToolAccessExtensionIds } from '@/lib/server/universal-tool-access'
89
- import { bridgeHumanReplyFromChat } from '@/lib/server/chatrooms/session-mailbox'
90
- import {
91
- collectCapabilityDescriptions,
92
- collectCapabilityOperatingGuidance,
93
- runCapabilityBeforeMessageWrite,
94
- runCapabilityBeforeModelResolve,
95
- runCapabilityHook,
96
- runCapabilityToolResultPersist,
97
- transformCapabilityText,
98
- } from '@/lib/server/native-capabilities'
99
- import {
100
- getEnabledCapabilityIds,
101
- getEnabledCapabilitySelection,
102
- splitCapabilityIds,
103
- } from '@/lib/capability-selection'
104
- import { guardUntrustedText, guardUntrustedToolEvents, getUntrustedContentGuardMode } from '@/lib/server/untrusted-content'
105
- import { loadEstopState } from '@/lib/server/runtime/estop'
106
- import {
107
- applyMissionOutcomeForTurn,
108
- buildMissionContextBlock,
109
- resolveMissionForTurn,
110
- } from '@/lib/server/missions/mission-service'
11
+ completeBlockedChatTurn,
12
+ runChatTurnPreflight,
13
+ } from '@/lib/server/chat-execution/chat-turn-preflight'
111
14
 
112
15
  export {
113
16
  shouldApplySessionFreshnessReset,
@@ -117,1841 +20,108 @@ export {
117
20
  requestedToolNamesFromMessage,
118
21
  filterRuntimeCapabilityIds,
119
22
  hasDirectLocalCodingTools,
120
- reconcileConnectorDeliveryText,
121
- }
122
-
123
- export function buildAgentRuntimeCapabilities(enabledExtensions: string[]): string[] {
124
- const capabilities = ['heartbeats', 'autonomous_loop', 'multi_agent_chat']
125
- if (enabledExtensions.length > 0) capabilities.unshift('tools')
126
- return capabilities
127
- }
128
-
129
- export function buildNoToolsGuidance(): string[] {
130
- return [
131
- '## Tool Availability',
132
- 'No runtime tools are available in this chat after policy filtering.',
133
- 'Do not imply that a normal read-only action is waiting on user permission when the real blocker is missing tool access.',
134
- 'If browsing, web fetches, file edits, or other actions are unavailable, state that the capability is blocked by runtime policy in this session.',
135
- 'Only mention confirmation or approval when a real runtime tool explicitly returned that boundary for a concrete action.',
136
- ]
137
- }
138
-
139
- export function buildEnabledToolsAutonomyGuidance(): string[] {
140
- return [
141
- '## Tool Autonomy',
142
- 'Runtime tools are already available for normal use in this chat.',
143
- 'Do not request that a tool be enabled or switched on before using it.',
144
- 'Do not ask the user for permission before using enabled tools for ordinary read-only work, routine diagnostics, or reversible execution steps that are clearly part of the request.',
145
- 'If the user asks you to use an enabled tool or to perform a task that clearly maps to an enabled tool, attempt that tool path before asking the user to do the work manually.',
146
- 'If the task depends on current or external information and web tools are enabled, use them instead of answering from stale memory.',
147
- 'If the task asks for a file, report, dashboard, JSON, or other workspace artifact to be saved, use file-writing or shell tools to actually create it and mention the resulting path.',
148
- 'If the task asks you to inspect the local repository, runtime, or filesystem state, use shell or file tools instead of guessing.',
149
- 'Treat capability policy blocks and explicit platform feature gates as the real boundaries. Do not invent an approval queue when none exists.',
150
- ]
151
- }
152
-
153
- function resolveHeartbeatLastConnectorTarget(session: Session | null | undefined): {
154
- connectorId?: string
155
- channelId: string
156
- } | null {
157
- if (!isDirectConnectorSession(session)) return null
158
- const connectorId = typeof session?.connectorContext?.connectorId === 'string'
159
- ? session.connectorContext.connectorId.trim()
160
- : ''
161
- const channelId = typeof session?.connectorContext?.channelId === 'string'
162
- ? session.connectorContext.channelId.trim()
163
- : ''
164
- if (!channelId) return null
165
- return {
166
- connectorId: connectorId || undefined,
167
- channelId,
168
- }
169
- }
170
-
171
- type PersistPhase = 'user' | 'system' | 'assistant_partial' | 'assistant_final' | 'heartbeat'
172
-
173
- async function applyMessageLifecycleHooks(params: {
174
- session: Session
175
- message: Message
176
- enabledIds: string[]
177
- phase: PersistPhase
178
- runId?: string
179
- isSynthetic?: boolean
180
- }): Promise<Message | null> {
181
- let currentMessage = params.message
182
- const guardMode = getUntrustedContentGuardMode(loadSettings())
183
- if (Array.isArray(currentMessage.toolEvents) && currentMessage.toolEvents.length > 0) {
184
- currentMessage = {
185
- ...currentMessage,
186
- toolEvents: guardUntrustedToolEvents({
187
- toolEvents: currentMessage.toolEvents,
188
- mode: guardMode,
189
- }),
190
- }
191
- }
192
- const toolEvents = Array.isArray(currentMessage.toolEvents)
193
- ? currentMessage.toolEvents.filter((event) => typeof event.output === 'string' || event.error === true)
194
- : []
195
-
196
- for (const event of toolEvents) {
197
- currentMessage = await runCapabilityToolResultPersist(
198
- {
199
- session: params.session,
200
- message: currentMessage,
201
- toolName: event.name,
202
- toolCallId: event.toolCallId,
203
- isSynthetic: params.isSynthetic,
204
- },
205
- { enabledIds: params.enabledIds },
206
- )
207
- }
208
-
209
- const writeResult = await runCapabilityBeforeMessageWrite(
210
- {
211
- session: params.session,
212
- message: currentMessage,
213
- phase: params.phase,
214
- runId: params.runId,
215
- },
216
- { enabledIds: params.enabledIds },
217
- )
218
-
219
- if (writeResult.block) return null
220
- return writeResult.message
221
- }
222
-
223
- interface SessionWithCredentials {
224
- credentialId?: string | null
225
- }
226
-
227
- interface ProviderApiKeyConfig {
228
- requiresApiKey?: boolean
229
- optionalApiKey?: boolean
230
- }
231
-
232
- export interface ExecuteChatTurnInput {
233
- sessionId: string
234
- message: string
235
- missionId?: string | null
236
- imagePath?: string
237
- imageUrl?: string
238
- attachedFiles?: string[]
239
- internal?: boolean
240
- source?: string
241
- runId?: string
242
- signal?: AbortSignal
243
- onEvent?: (event: SSEEvent) => void
244
- modelOverride?: string
245
- heartbeatConfig?: {
246
- ackMaxChars: number
247
- showOk: boolean
248
- showAlerts: boolean
249
- target: string | null
250
- lightContext?: boolean
251
- deliveryMode?: 'default' | 'tool_only' | 'silent'
252
- }
253
- replyToId?: string
254
- }
255
-
256
- export interface ExecuteChatTurnResult {
257
- runId?: string
258
- sessionId: string
259
- missionId?: string | null
260
- text: string
261
- persisted: boolean
262
- toolEvents: MessageToolEvent[]
263
- error?: string
264
- inputTokens?: number
265
- outputTokens?: number
266
- estimatedCost?: number
267
- }
268
-
269
- const EXACT_OUTPUT_CONTRACT_TIMEOUT_MS = 5_000
270
-
271
- async function resolveExactOutputContractWithTimeout(params: {
272
- sessionId: string
273
- agentId?: string | null
274
- userMessage: string
275
- currentResponse: string
276
- toolEvents: MessageToolEvent[]
277
- internal: boolean
278
- source: string
279
- }): Promise<ExactOutputContract | null> {
280
- if (params.internal || params.source !== 'chat') return null
281
- if (params.toolEvents.length === 0) return null
282
- // Skip expensive LLM classifier when no explicit exact-output markers appear
283
- const { extractExplicitExactLiteral } = await import('@/lib/server/chat-execution/exact-output-contract')
284
- if (!extractExplicitExactLiteral(params.userMessage)) return null
285
-
286
- let timer: NodeJS.Timeout | null = null
287
- try {
288
- return await Promise.race<ExactOutputContract | null>([
289
- classifyExactOutputContract({
290
- sessionId: params.sessionId,
291
- agentId: params.agentId || null,
292
- userMessage: params.userMessage,
293
- currentResponse: params.currentResponse,
294
- toolEvents: params.toolEvents,
295
- }).catch(() => null),
296
- new Promise<null>((resolve) => {
297
- timer = setTimeout(() => resolve(null), EXACT_OUTPUT_CONTRACT_TIMEOUT_MS)
298
- }),
299
- ])
300
- } finally {
301
- if (timer) clearTimeout(timer)
302
- }
303
- }
304
-
305
- function extractEventJson(line: string): SSEEvent | null {
306
- if (!line.startsWith('data: ')) return null
307
- try {
308
- return JSON.parse(line.slice(6).trim()) as SSEEvent
309
- } catch {
310
- return null
311
- }
312
- }
313
-
314
- function joinSystemPromptBlocks(...blocks: Array<string | null | undefined>): string | undefined {
315
- const joined = joinPromptSegments(...blocks)
316
- return joined || undefined
317
- }
318
-
319
- export function collectToolEvent(ev: SSEEvent, bag: MessageToolEvent[]) {
320
- if (ev.t === 'tool_call') {
321
- const previous = bag[bag.length - 1]
322
- if (
323
- previous
324
- && previous.name === (ev.toolName || 'unknown')
325
- && previous.input === (ev.toolInput || '')
326
- && previous.toolCallId === (ev.toolCallId || previous.toolCallId)
327
- && !previous.output
328
- ) {
329
- return
330
- }
331
- bag.push({
332
- name: ev.toolName || 'unknown',
333
- input: ev.toolInput || '',
334
- toolCallId: ev.toolCallId,
335
- })
336
- return
337
- }
338
- if (ev.t === 'tool_result') {
339
- const idx = ev.toolCallId
340
- ? bag.findLastIndex((e) => e.toolCallId === ev.toolCallId && !e.output)
341
- : bag.findLastIndex((e) => e.name === (ev.toolName || 'unknown') && !e.output)
342
- if (idx === -1) return
343
- const output = ev.toolOutput || ''
344
- bag[idx] = {
345
- ...bag[idx],
346
- output,
347
- error: isLikelyToolErrorOutput(output) || undefined,
348
- }
349
- }
350
- }
351
-
352
- export function dedupeConsecutiveToolEvents(events: MessageToolEvent[]): MessageToolEvent[] {
353
- const sameEvent = (left: MessageToolEvent, right: MessageToolEvent): boolean => (
354
- left.name === right.name
355
- && left.input === right.input
356
- && (left.output || '') === (right.output || '')
357
- && (left.error === true) === (right.error === true)
358
- )
359
- const sameBlock = (startA: number, startB: number, size: number): boolean => {
360
- for (let offset = 0; offset < size; offset += 1) {
361
- if (!sameEvent(events[startA + offset], events[startB + offset])) return false
362
- }
363
- return true
364
- }
365
-
366
- const deduped: MessageToolEvent[] = []
367
- for (let index = 0; index < events.length;) {
368
- const remaining = events.length - index
369
- let collapsed = false
370
- for (let blockSize = Math.floor(remaining / 2); blockSize >= 1; blockSize -= 1) {
371
- if (!sameBlock(index, index + blockSize, blockSize)) continue
372
- for (let offset = 0; offset < blockSize; offset += 1) deduped.push(events[index + offset])
373
- const blockStart = index
374
- index += blockSize
375
- while (index + blockSize <= events.length && sameBlock(blockStart, index, blockSize)) {
376
- index += blockSize
377
- }
378
- collapsed = true
379
- break
380
- }
381
- if (collapsed) continue
382
- deduped.push(events[index])
383
- index += 1
384
- }
385
- return deduped
386
- }
387
-
388
- export function deriveTerminalRunError(params: {
389
- errorMessage?: string
390
- fullResponse: string
391
- streamErrors: string[]
392
- toolEvents: MessageToolEvent[]
393
- internal: boolean
394
- }): string | undefined {
395
- if (params.errorMessage) return params.errorMessage
396
-
397
- if (params.streamErrors.length > 0 && !params.fullResponse.trim()) {
398
- return params.streamErrors[params.streamErrors.length - 1]
399
- }
400
-
401
- if (!params.internal && !params.fullResponse.trim() && params.toolEvents.length === 0) {
402
- return 'Run completed without any response text, tool calls, or explicit error details. Check the provider configuration and try again.'
403
- }
404
-
405
- return undefined
406
- }
407
-
408
- export function shouldAppendMissedRequestedToolNotice(params: {
409
- missedRequestedTools: string[]
410
- fullResponse: string
411
- errorMessage?: string
412
- calledToolCount?: number
413
- }): boolean {
414
- if (!Array.isArray(params.missedRequestedTools) || params.missedRequestedTools.length === 0) return false
415
- if (params.errorMessage) return false
416
- if (params.fullResponse.includes('Tool execution notice:')) return false
417
- if (!params.fullResponse.trim() && (params.calledToolCount || 0) === 0) return false
418
- return true
419
- }
420
-
421
- function shouldAutoDraftSkillSuggestion(params: {
422
- assistantPersisted: boolean
423
- internal: boolean
424
- isHeartbeatRun: boolean
425
- agentAutoDraftSetting: boolean
426
- toolEventCount: number
427
- messageCount: number
428
- }): boolean {
429
- if (!params.assistantPersisted) return false
430
- if (params.internal || params.isHeartbeatRun) return false
431
- if (!params.agentAutoDraftSetting) return false
432
- if (params.toolEventCount === 0) return false
433
- return params.messageCount >= 4
434
- }
435
-
436
- export function isLikelyToolErrorOutput(output: string): boolean {
437
- const trimmed = String(output || '').trim()
438
- if (!trimmed) return false
439
- if (/^(Error(?::|\s*\(exit\b[^)]*\):?)|error:)/i.test(trimmed)) return true
440
- if (/\b(MCP error|ECONNREFUSED|ETIMEDOUT|ERR_CONNECTION_REFUSED|ENOENT|EACCES)\b/i.test(trimmed)) return true
441
- if (/\binvalid_type\b/i.test(trimmed) && /\b(issue|issues|expected|required|received|zod)\b/i.test(trimmed)) return true
442
- try {
443
- const parsed = JSON.parse(trimmed) as Record<string, unknown>
444
- const status = typeof parsed.status === 'string' ? parsed.status.trim().toLowerCase() : ''
445
- if (status === 'error' || status === 'failed') return true
446
- if (typeof parsed.error === 'string' && parsed.error.trim()) return true
447
- } catch {
448
- // Ignore non-JSON tool output.
449
- }
450
- return false
451
- }
452
-
453
- export function pruneSuppressedHeartbeatStreamMessage(messages: Message[]): boolean {
454
- return pruneStreamingAssistantArtifacts(messages)
455
- }
456
-
457
- function syncSessionFromAgent(sessionId: string): void {
458
- const sessions = loadSessions()
459
- const session = sessions[sessionId]
460
- if (!session?.agentId) return
461
- const agents = loadAgents()
462
- const agent = agents[session.agentId]
463
- if (!agent) return
464
-
465
- let changed = false
466
- const route = resolvePrimaryAgentRoute(agent, undefined, {
467
- preferredGatewayTags: session.routePreferredGatewayTags || [],
468
- preferredGatewayUseCase: session.routePreferredGatewayUseCase || null,
469
- })
470
- if (!session.provider && agent.provider) { session.provider = agent.provider; changed = true }
471
- if ((session.model === undefined || session.model === null || session.model === '') && agent.model !== undefined) {
472
- session.model = agent.model
473
- changed = true
474
- }
475
- if (route) {
476
- const resolved = applyResolvedRoute({ ...session }, route)
477
- if (session.provider !== resolved.provider) { session.provider = resolved.provider; changed = true }
478
- if (session.model !== resolved.model) { session.model = resolved.model; changed = true }
479
- if ((session.credentialId || null) !== (resolved.credentialId || null)) {
480
- session.credentialId = resolved.credentialId ?? null
481
- changed = true
482
- }
483
- if (JSON.stringify(session.fallbackCredentialIds || []) !== JSON.stringify(resolved.fallbackCredentialIds || [])) {
484
- session.fallbackCredentialIds = [...(resolved.fallbackCredentialIds || [])]
485
- changed = true
486
- }
487
- if ((session.apiEndpoint || null) !== (resolved.apiEndpoint || null)) {
488
- session.apiEndpoint = resolved.apiEndpoint ?? null
489
- changed = true
490
- }
491
- if ((session.gatewayProfileId || null) !== (resolved.gatewayProfileId || null)) {
492
- session.gatewayProfileId = resolved.gatewayProfileId ?? null
493
- changed = true
494
- }
495
- } else {
496
- if (session.credentialId === undefined && agent.credentialId !== undefined) {
497
- session.credentialId = agent.credentialId ?? null
498
- changed = true
499
- }
500
- if ((session.apiEndpoint === undefined || session.apiEndpoint === null) && agent.apiEndpoint !== undefined) {
501
- const normalized = normalizeProviderEndpoint(agent.provider, agent.apiEndpoint ?? null)
502
- if (normalized !== session.apiEndpoint) { session.apiEndpoint = normalized; changed = true }
503
- }
504
- }
505
- const agentSelection = getEnabledCapabilitySelection(agent)
506
- // Subagent sessions have capabilities computed at spawn time (agent + parent merge).
507
- // Don't overwrite them with just the agent's capabilities.
508
- if (!session.parentSessionId) {
509
- const currentSelection = getEnabledCapabilitySelection(session)
510
- if (
511
- JSON.stringify(currentSelection.tools) !== JSON.stringify(agentSelection.tools)
512
- || JSON.stringify(currentSelection.extensions) !== JSON.stringify(agentSelection.extensions)
513
- ) {
514
- session.tools = agentSelection.tools
515
- session.extensions = agentSelection.extensions
516
- changed = true
517
- }
518
- }
519
- const desiredMemoryScopeMode = resolveEffectiveSessionMemoryScopeMode(session, agent.memoryScopeMode ?? null)
520
- if ((((session as unknown as Record<string, unknown>).memoryScopeMode as string | null | undefined) ?? null) !== desiredMemoryScopeMode) {
521
- ;(session as unknown as Record<string, unknown>).memoryScopeMode = desiredMemoryScopeMode
522
- changed = true
523
- }
524
- const isShortcutChat = session.shortcutForAgentId === agent.id || agent.threadSessionId === sessionId
525
- if (isShortcutChat) {
526
- const desiredSelection = agentSelection
527
- const currentShortcutSelection = getEnabledCapabilitySelection(session)
528
- if (
529
- JSON.stringify(currentShortcutSelection.tools) !== JSON.stringify(desiredSelection.tools)
530
- || JSON.stringify(currentShortcutSelection.extensions) !== JSON.stringify(desiredSelection.extensions)
531
- ) {
532
- session.tools = desiredSelection.tools
533
- session.extensions = desiredSelection.extensions
534
- changed = true
535
- }
536
- if (session.shortcutForAgentId !== agent.id) { session.shortcutForAgentId = agent.id; changed = true }
537
- if (session.name !== agent.name) { session.name = agent.name; changed = true }
538
- const desiredHeartbeatEnabled = agent.heartbeatEnabled ?? false
539
- if ((session.heartbeatEnabled ?? false) !== desiredHeartbeatEnabled) {
540
- session.heartbeatEnabled = desiredHeartbeatEnabled
541
- changed = true
542
- }
543
- const desiredHeartbeatIntervalSec = agent.heartbeatIntervalSec ?? null
544
- if ((session.heartbeatIntervalSec ?? null) !== desiredHeartbeatIntervalSec) {
545
- session.heartbeatIntervalSec = desiredHeartbeatIntervalSec
546
- changed = true
547
- }
548
- const desiredMemoryTierPreference = agent.memoryTierPreference ?? null
549
- if ((((session as unknown as Record<string, unknown>).memoryTierPreference as string | null | undefined) ?? null) !== desiredMemoryTierPreference) {
550
- ;(session as unknown as Record<string, unknown>).memoryTierPreference = desiredMemoryTierPreference
551
- changed = true
552
- }
553
- const desiredProjectId = agent.projectId ?? null
554
- if ((session.projectId ?? null) !== desiredProjectId) {
555
- session.projectId = desiredProjectId
556
- changed = true
557
- }
558
- const desiredOpenClawAgentId = agent.openclawAgentId ?? null
559
- if ((session.openclawAgentId ?? null) !== desiredOpenClawAgentId) {
560
- session.openclawAgentId = desiredOpenClawAgentId
561
- changed = true
562
- }
563
- if (session.connectorContext) {
564
- session.connectorContext = undefined
565
- changed = true
566
- }
567
- }
568
-
569
- if (changed) {
570
- sessions[sessionId] = session
571
- saveSessions(sessions)
572
- }
573
- }
574
-
575
- /**
576
- * Build a minimal system prompt for lightweight heartbeat context.
577
- * Strips conversation history, skills, tool discipline, and workspace context.
578
- * Keeps identity, datetime, and heartbeat guidance for correct routing.
579
- */
580
- function buildLightHeartbeatSystemPrompt(session: Session): string | undefined {
581
- if (!session.agentId) return undefined
582
- const agents = loadAgents()
583
- const agent = agents[session.agentId]
584
- if (!agent) return undefined
585
-
586
- const parts: string[] = []
587
- parts.push(`## Identity\nName: ${agent.name}`)
588
- if (agent.description) parts.push(`Description: ${agent.description}`)
589
- parts.push(buildCurrentDateTimePromptContext())
590
- if (agent.soul) parts.push(`## Soul\n${agent.soul.slice(0, 300)}`)
591
- parts.push([
592
- '## Heartbeats',
593
- 'You run on an autonomous heartbeat. If you receive a heartbeat poll and nothing needs attention, reply exactly: HEARTBEAT_OK',
594
- ].join('\n'))
595
- return parts.join('\n\n')
596
- }
597
-
598
- function buildAgentSystemPrompt(session: Session): string | undefined {
599
- if (!session.agentId) return undefined
600
- const agents = loadAgents()
601
- const agent = agents[session.agentId]
602
- if (!agent) return undefined
603
-
604
- const settings = loadSettings()
605
- const allowSilentReplies = isDirectConnectorSession(session)
606
- const parts: string[] = []
607
- const enabledExtensions = listUniversalToolAccessExtensionIds(
608
- getEnabledCapabilityIds(session).length > 0 ? getEnabledCapabilityIds(session) : getEnabledCapabilityIds(agent),
609
- )
610
-
611
- // 1. Identity & Persona
612
- const identityLines = [`## My Identity`]
613
- identityLines.push(`Name: ${agent.name}`)
614
- if (agent.emoji) identityLines.push(`Emoji: ${agent.emoji}`)
615
- if (agent.creature) identityLines.push(`Creature: ${agent.creature}`)
616
- if (agent.vibe) identityLines.push(`Vibe: ${agent.vibe}`)
617
- if (agent.theme) identityLines.push(`Theme: ${agent.theme}`)
618
- if (agent.description) identityLines.push(`Description: ${agent.description}`)
619
- identityLines.push('I should always embody this identity. I am not just an "Assistant" — I am a persistent companion with my own soul and presence.')
620
- parts.push(identityLines.join('\n'))
621
- const continuityBlock = buildIdentityContinuityContext(session, agent)
622
- if (continuityBlock) parts.push(continuityBlock)
623
-
624
- // 2. Runtime & Capabilities
625
- const runtimeLines = [
626
- '## Runtime',
627
- `os=${process.platform} | host=${os.hostname()} | agent=${agent.id} | provider=${session.provider} | model=${session.model}`,
628
- `capabilities=${buildAgentRuntimeCapabilities(enabledExtensions).join(',')}`,
629
- 'tool_access=universal',
630
- ]
631
- parts.push(runtimeLines.join('\n'))
632
-
633
- // 3. User & DateTime Context
634
- if (typeof settings.userPrompt === 'string' && settings.userPrompt.trim()) parts.push(`## User Instructions\n${settings.userPrompt}`)
635
- parts.push(buildCurrentDateTimePromptContext())
636
-
637
- // 4. Soul & Core Instructions
638
- if (agent.soul) parts.push(`## Soul\n${agent.soul}`)
639
- if (agent.systemPrompt) parts.push(`## System Prompt\n${agent.systemPrompt}`)
640
-
641
- // 5. Skills (SwarmClaw Core)
642
- try {
643
- const runtimeSkills = resolveRuntimeSkills({
644
- cwd: session.cwd,
645
- enabledExtensions,
646
- agentId: agent.id,
647
- sessionId: session.id,
648
- userId: session.user,
649
- agentSkillIds: agent.skillIds || [],
650
- storedSkills: loadSkills(),
651
- selectedSkillId: session.skillRuntimeState?.selectedSkillId || null,
652
- })
653
- parts.push(...buildRuntimeSkillPromptBlocks(runtimeSkills))
654
- } catch { /* non-critical */ }
655
-
656
- // 5b. Workspace context files (HEARTBEAT.md, IDENTITY.md, AGENTS.md, etc.)
657
- try {
658
- const wsCtx = buildWorkspaceContext({ cwd: session.cwd })
659
- if (wsCtx.block) parts.push(wsCtx.block)
660
- } catch {
661
- // Workspace context is non-critical
662
- }
663
-
664
- // 6. Thinking & Output Format
665
- const thinkingHint = [
666
- '## Output Format',
667
- 'If your model supports internal reasoning/thinking, put all internal analysis inside <think>...</think> tags.',
668
- 'Your final response to the user should be clear and concise.',
669
- allowSilentReplies
670
- ? 'When you truly have nothing to say, respond with ONLY: NO_MESSAGE'
671
- : 'For direct user chats, always send a visible reply. Never answer with NO_MESSAGE or HEARTBEAT_OK unless this is an explicit heartbeat poll.',
672
- ]
673
- parts.push(thinkingHint.join('\n'))
674
-
675
- if (enabledExtensions.length === 0) {
676
- parts.push(buildNoToolsGuidance().join('\n'))
677
- } else {
678
- parts.push(buildEnabledToolsAutonomyGuidance().join('\n'))
679
- }
680
- const toolSectionLines = buildToolSection(enabledExtensions)
681
- if (toolSectionLines.length > 0) parts.push(['## Tool Discipline', ...toolSectionLines].join('\n'))
682
- const operatingGuidance = collectCapabilityOperatingGuidance(enabledExtensions)
683
- if (operatingGuidance.length > 0) parts.push(['## Tool Guidance', ...operatingGuidance].join('\n'))
684
- const capabilityLines = collectCapabilityDescriptions(enabledExtensions)
685
- if (capabilityLines.length > 0) parts.push(['## Tool Capabilities', ...capabilityLines].join('\n'))
23
+ } from '@/lib/server/chat-execution/chat-execution-utils'
686
24
 
687
- // 7. Heartbeat Guidance
688
- parts.push([
689
- '## Heartbeats',
690
- 'You run on an autonomous heartbeat. If you receive a heartbeat poll and nothing needs attention, reply exactly: HEARTBEAT_OK',
691
- ].join('\n'))
25
+ export {
26
+ reconcileConnectorDeliveryText,
27
+ } from '@/lib/server/chat-execution/chat-execution-connector-delivery'
692
28
 
693
- return parts.join('\n\n')
694
- }
29
+ export {
30
+ buildAgentRuntimeCapabilities,
31
+ buildEnabledToolsAutonomyGuidance,
32
+ buildNoToolsGuidance,
33
+ } from '@/lib/server/chat-execution/chat-turn-preparation'
695
34
 
696
- function resolveApiKeyForSession(session: SessionWithCredentials, provider: ProviderApiKeyConfig): string | null {
697
- if (provider.requiresApiKey) {
698
- if (!session.credentialId) throw new Error('No API key configured for this session')
699
- const creds = loadCredentials()
700
- const cred = creds[session.credentialId]
701
- if (!cred) throw new Error('API key not found. Please add one in Settings.')
702
- return decryptKey(cred.encryptedKey)
703
- }
704
- if (provider.optionalApiKey && session.credentialId) {
705
- const creds = loadCredentials()
706
- const cred = creds[session.credentialId]
707
- if (cred) {
708
- try { return decryptKey(cred.encryptedKey) } catch { return null }
709
- }
710
- }
711
- return null
712
- }
35
+ export {
36
+ collectToolEvent,
37
+ dedupeConsecutiveToolEvents,
38
+ deriveTerminalRunError,
39
+ isLikelyToolErrorOutput,
40
+ } from '@/lib/server/chat-execution/chat-execution-tool-events'
41
+ export {
42
+ pruneSuppressedHeartbeatStreamMessage,
43
+ shouldAppendMissedRequestedToolNotice,
44
+ } from '@/lib/server/chat-execution/chat-turn-finalization'
713
45
 
46
+ export type { ExecuteChatTurnInput, ExecuteChatTurnResult } from './chat-execution-types'
714
47
 
715
48
  export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promise<ExecuteChatTurnResult> {
716
- const estop = loadEstopState()
717
- if (estop.level === 'all') {
718
- throw new Error(estop.reason
719
- ? `Execution is blocked because all estop is engaged: ${estop.reason}`
720
- : 'Execution is blocked because all estop is engaged.')
721
- }
722
- const { message } = input
723
49
  const {
724
50
  sessionId,
725
- imagePath,
726
- imageUrl,
727
- attachedFiles,
728
- missionId: explicitMissionId,
729
- internal = false,
730
- runId,
731
51
  source = 'chat',
732
- onEvent,
733
- signal,
734
52
  } = input
735
-
736
- // Resolve image path early: if the filesystem path is gone, fall back to
737
- // the upload URL which resolveImagePath maps back to the uploads directory.
738
- const resolvedImagePath = resolveImagePath(imagePath, imageUrl) ?? undefined
739
-
740
53
  const endTurnPerf = perf.start('chat-execution', 'executeSessionChatTurn', { sessionId, source })
741
-
742
- syncSessionFromAgent(sessionId)
743
-
744
- const sessions = loadSessions()
745
- const session = sessions[sessionId]
746
- if (!session) throw new Error(`Session not found: ${sessionId}`)
747
- session.messages = Array.isArray(session.messages) ? session.messages : []
748
- const runStartedAt = Date.now()
749
- const runMessageStartIndex = session.messages.length
750
-
751
- const appSettings = loadSettings()
752
- const lifecycleRunId = runId || `${sessionId}:${runStartedAt}`
753
- const agentForSession = session.agentId ? loadAgents()[session.agentId] : null
754
- if (isAgentDisabled(agentForSession)) {
755
- const disabledError = buildAgentDisabledMessage(agentForSession, 'run chats')
756
- onEvent?.({ t: 'err', text: disabledError })
757
-
758
- let persisted = false
759
- if (!internal) {
760
- const disabledMessage = await applyMessageLifecycleHooks({
761
- session,
762
- message: {
763
- role: 'assistant',
764
- text: disabledError,
765
- time: Date.now(),
766
- },
767
- enabledIds: getEnabledCapabilityIds(session),
768
- phase: 'assistant_final',
769
- runId: lifecycleRunId,
770
- isSynthetic: true,
771
- })
772
- if (disabledMessage) {
773
- session.messages.push(disabledMessage)
774
- session.lastActiveAt = Date.now()
775
- saveSessions(sessions)
776
- persisted = true
777
- }
778
- }
779
-
780
- return {
781
- runId,
782
- sessionId,
783
- text: disabledError,
784
- persisted,
785
- toolEvents: [],
786
- error: disabledError,
787
- }
788
- }
789
- const runtimeCapabilityIds = filterRuntimeCapabilityIds(getEnabledCapabilityIds(session), {
790
- delegationEnabled: agentForSession?.delegationEnabled === true,
791
- })
792
- const toolPolicy = resolveSessionToolPolicy(listUniversalToolAccessExtensionIds(runtimeCapabilityIds), appSettings)
793
- const isHeartbeatRun = isInternalHeartbeatRun(internal, source)
794
- const isAutonomousInternalRun = internal && source !== 'chat'
795
- const heartbeatLightContext = isHeartbeatRun && !!input.heartbeatConfig?.lightContext
796
- const isAutoRunNoHistory = isHeartbeatRun
797
- const heartbeatStatusOnly = false
798
- if (shouldApplySessionFreshnessReset(source)) {
799
- const freshness = evaluateSessionFreshness({
800
- session,
801
- policy: resolveSessionResetPolicy({
802
- session,
803
- agent: agentForSession,
804
- settings: appSettings,
805
- }),
806
- })
807
- if (!freshness.fresh) {
808
- try { syncSessionArchiveMemory(session, { agent: agentForSession }) } catch { /* archive sync is best-effort */ }
809
- await runCapabilityHook(
810
- 'sessionEnd',
811
- {
812
- sessionId: session.id,
813
- session,
814
- messageCount: Array.isArray(session.messages) ? session.messages.length : 0,
815
- durationMs: Date.now() - (session.createdAt || runStartedAt),
816
- reason: freshness.reason || 'session_reset',
817
- },
818
- {
819
- enabledIds: runtimeCapabilityIds,
820
- },
821
- )
822
- resetSessionRuntime(session, freshness.reason || 'session_reset')
823
- onEvent?.({ t: 'status', text: JSON.stringify({ sessionReset: freshness.reason || 'session_reset' }) })
824
- sessions[sessionId] = session
825
- saveSessions(sessions)
826
- }
827
- }
828
- if (isAutonomousInternalRun) {
829
- try { syncSessionArchiveMemory(session, { agent: agentForSession }) } catch { /* archive sync is best-effort */ }
830
- }
831
- const mission = await resolveMissionForTurn({
832
- session,
833
- message,
834
- source,
835
- internal,
836
- runId: lifecycleRunId,
837
- explicitMissionId: explicitMissionId || null,
838
- })
839
- if (mission?.id) {
840
- session.missionId = mission.id
841
- }
842
- const extensionsForRun = heartbeatStatusOnly ? [] : toolPolicy.enabledExtensions
843
- if (runMessageStartIndex === 0) {
844
- await runCapabilityHook(
845
- 'sessionStart',
846
- {
847
- session,
848
- resumedFrom: session.parentSessionId || null,
849
- },
850
- { enabledIds: extensionsForRun },
851
- )
852
- }
853
- const sessionEnabledIds = runtimeCapabilityIds
854
- const sessionForRunSelection = splitCapabilityIds(extensionsForRun)
855
- let sessionForRun = JSON.stringify(sessionEnabledIds) === JSON.stringify(extensionsForRun)
856
- ? session
857
- : { ...session, tools: sessionForRunSelection.tools, extensions: sessionForRunSelection.extensions }
858
- if (mission?.id) {
859
- sessionForRun = {
860
- ...sessionForRun,
861
- missionId: mission.id,
862
- }
863
- }
864
- if (agentForSession) {
865
- const preferredRoute = resolvePrimaryAgentRoute(agentForSession, undefined, {
866
- preferredGatewayTags: session.routePreferredGatewayTags || [],
867
- preferredGatewayUseCase: session.routePreferredGatewayUseCase || null,
54
+ const preparedTurn = await prepareChatTurn(input)
55
+ if (preparedTurn.kind === 'blocked') {
56
+ const result = await completeBlockedChatTurn(preparedTurn)
57
+ endTurnPerf({
58
+ durationMs: 0,
59
+ toolEventCount: result.toolEvents.length,
60
+ inputTokens: result.inputTokens || 0,
61
+ outputTokens: result.outputTokens || 0,
62
+ error: !!result.error,
868
63
  })
869
- if (preferredRoute) {
870
- sessionForRun = applyResolvedRoute({ ...sessionForRun }, preferredRoute)
871
- }
872
- }
873
- let effectiveMessage = message
874
-
875
- if (extensionsForRun.length > 0) {
876
- try {
877
- effectiveMessage = await transformCapabilityText(
878
- 'transformInboundMessage',
879
- { session: sessionForRun, text: message },
880
- { enabledIds: extensionsForRun },
881
- )
882
- } catch {
883
- effectiveMessage = message
884
- }
885
- }
886
-
887
- // Apply model override for heartbeat runs (cheaper model)
888
- if (isHeartbeatRun && input.modelOverride) {
889
- sessionForRun = { ...sessionForRun, model: input.modelOverride }
890
- }
891
- const missionContextBlock = buildMissionContextBlock(mission)
892
-
893
- if (extensionsForRun.length > 0) {
894
- const modelResolvePrompt = heartbeatLightContext
895
- ? (joinSystemPromptBlocks(buildLightHeartbeatSystemPrompt(sessionForRun), missionContextBlock) || '')
896
- : (joinSystemPromptBlocks(buildAgentSystemPrompt(sessionForRun), missionContextBlock) || '')
897
- const modelResolve = await runCapabilityBeforeModelResolve(
898
- {
899
- session: sessionForRun,
900
- prompt: modelResolvePrompt,
901
- message: effectiveMessage,
902
- provider: sessionForRun.provider,
903
- model: sessionForRun.model,
904
- apiEndpoint: sessionForRun.apiEndpoint || null,
905
- },
906
- { enabledIds: extensionsForRun },
907
- )
908
- if (modelResolve) {
909
- sessionForRun = {
910
- ...sessionForRun,
911
- provider: modelResolve.providerOverride ?? sessionForRun.provider,
912
- model: modelResolve.modelOverride ?? sessionForRun.model,
913
- ...(modelResolve.apiEndpointOverride !== undefined ? { apiEndpoint: modelResolve.apiEndpointOverride } : {}),
914
- }
915
- }
64
+ return result
916
65
  }
917
66
 
918
- if (!heartbeatStatusOnly && toolPolicy.blockedExtensions.length > 0) {
919
- const blockedSummary = toolPolicy.blockedExtensions
920
- .map((entry) => `${entry.tool} (${entry.reason})`)
921
- .join(', ')
922
- onEvent?.({ t: 'err', text: `Capability policy blocked extensions for this run: ${blockedSummary}` })
923
- }
924
-
925
- // --- Agent spend-limit enforcement (hourly/daily/monthly) ---
926
- if (session.agentId) {
927
- const agentsMap = loadAgents()
928
- const agent = agentsMap[session.agentId]
929
- if (agent) {
930
- const budgetCheck = checkAgentBudgetLimits(agent)
931
- const action = agent.budgetAction || 'warn'
932
-
933
- if (budgetCheck.exceeded.length > 0) {
934
- const budgetError = budgetCheck.exceeded.map((entry) => entry.message).join(' ')
935
- if (action === 'block') {
936
- onEvent?.({ t: 'err', text: budgetError })
937
-
938
- let persisted = false
939
- if (!internal) {
940
- const budgetMessage = await applyMessageLifecycleHooks({
941
- session,
942
- message: {
943
- role: 'assistant',
944
- text: budgetError,
945
- time: Date.now(),
946
- },
947
- enabledIds: getEnabledCapabilityIds(session),
948
- phase: 'assistant_final',
949
- runId: lifecycleRunId,
950
- isSynthetic: true,
951
- })
952
- if (budgetMessage) {
953
- session.messages.push(budgetMessage)
954
- session.lastActiveAt = Date.now()
955
- saveSessions(sessions)
956
- persisted = true
957
- }
958
- }
959
-
960
- return {
961
- runId,
962
- sessionId,
963
- text: budgetError,
964
- persisted,
965
- toolEvents: [],
966
- error: budgetError,
967
- }
968
- }
969
- // budgetAction === 'warn': emit a warning but continue
970
- onEvent?.({ t: 'status', text: JSON.stringify({ budgetWarning: budgetError }) })
971
- } else if (budgetCheck.warnings.length > 0) {
972
- const warningText = budgetCheck.warnings.map((entry) => entry.message).join(' ')
973
- onEvent?.({ t: 'status', text: JSON.stringify({ budgetWarning: warningText }) })
974
- }
975
- }
976
- }
977
-
978
- const dailySpendLimitUsd = parseUsdLimit(appSettings.safetyMaxDailySpendUsd)
979
- if (dailySpendLimitUsd !== null) {
980
- const todaySpendUsd = getTodaySpendUsd()
981
- if (todaySpendUsd >= dailySpendLimitUsd) {
982
- const spendError = `Safety budget reached: today's spend is $${todaySpendUsd.toFixed(4)} (limit $${dailySpendLimitUsd.toFixed(4)}). Increase safetyMaxDailySpendUsd to continue autonomous runs.`
983
- onEvent?.({ t: 'err', text: spendError })
984
-
985
- let persisted = false
986
- if (!internal) {
987
- const spendMessage = await applyMessageLifecycleHooks({
988
- session,
989
- message: {
990
- role: 'assistant',
991
- text: spendError,
992
- time: Date.now(),
993
- },
994
- enabledIds: getEnabledCapabilityIds(session),
995
- phase: 'assistant_final',
996
- runId: lifecycleRunId,
997
- isSynthetic: true,
998
- })
999
- if (spendMessage) {
1000
- session.messages.push(spendMessage)
1001
- session.lastActiveAt = Date.now()
1002
- saveSessions(sessions)
1003
- persisted = true
1004
- }
1005
- }
1006
-
1007
- return {
1008
- runId,
1009
- sessionId,
1010
- text: spendError,
1011
- persisted,
1012
- toolEvents: [],
1013
- error: spendError,
1014
- }
1015
- }
1016
- }
1017
-
1018
- // Log the trigger
1019
- logExecution(sessionId, 'trigger', `${source} message received`, {
1020
- runId,
1021
- agentId: session.agentId,
1022
- detail: {
1023
- source,
1024
- internal,
1025
- provider: sessionForRun.provider,
1026
- model: sessionForRun.model,
1027
- messagePreview: effectiveMessage.slice(0, 200),
1028
- hasImage: !!(imagePath || imageUrl),
1029
- },
67
+ const partialPersistence = createPartialAssistantPersistence({
68
+ prepared: preparedTurn,
69
+ onEvent: input.onEvent,
1030
70
  })
1031
71
 
1032
- const providerType = sessionForRun.provider || 'claude-cli'
1033
- const provider = getProvider(providerType)
1034
- if (!provider) throw new Error(`Unknown provider: ${providerType}`)
1035
-
1036
- if (providerType === 'claude-cli' && !fs.existsSync(session.cwd)) {
1037
- throw new Error(`Directory not found: ${session.cwd}`)
1038
- }
1039
-
1040
- const apiKey = resolveApiKeyForSession(sessionForRun, provider)
1041
- const hideAssistantTranscript = internal && source === 'main-loop-followup'
1042
-
1043
- const shouldPersistUserMessage = shouldPersistInboundUserMessage(internal, source)
1044
- if (shouldPersistUserMessage) {
1045
- const linkAnalysis = !internal ? await runLinkUnderstanding(message) : []
1046
- const guardedUserText = guardUntrustedText({
1047
- text: message,
1048
- source,
1049
- mode: getUntrustedContentGuardMode(appSettings),
1050
- trusted: (source === 'chat' && !internal) || internal,
1051
- }).text
1052
- const nextUserMessage = await applyMessageLifecycleHooks({
1053
- session,
1054
- message: {
1055
- role: 'user',
1056
- text: guardedUserText,
1057
- time: Date.now(),
1058
- imagePath: imagePath || undefined,
1059
- imageUrl: imageUrl || undefined,
1060
- attachedFiles: attachedFiles?.length ? attachedFiles : undefined,
1061
- replyToId: input.replyToId || undefined,
1062
- },
1063
- enabledIds: extensionsForRun,
1064
- phase: 'user',
1065
- runId: lifecycleRunId,
1066
- })
1067
- if (nextUserMessage) {
1068
- session.messages.push(nextUserMessage)
1069
- if (linkAnalysis.length > 0) {
1070
- const linkAnalysisMessage = await applyMessageLifecycleHooks({
1071
- session,
1072
- message: {
1073
- role: 'assistant',
1074
- kind: 'system',
1075
- text: `[Automated Link Analysis]\n${linkAnalysis.join('\n\n')}`,
1076
- time: Date.now(),
1077
- },
1078
- enabledIds: extensionsForRun,
1079
- phase: 'system',
1080
- runId: lifecycleRunId,
1081
- isSynthetic: true,
1082
- })
1083
- if (linkAnalysisMessage) {
1084
- session.messages.push(linkAnalysisMessage)
1085
- }
1086
- }
1087
- session.lastActiveAt = Date.now()
1088
- saveSessions(sessions)
1089
- if (!internal && source === 'chat') {
1090
- try {
1091
- bridgeHumanReplyFromChat({
1092
- sessionId,
1093
- payload: nextUserMessage.text,
1094
- })
1095
- } catch {
1096
- // Best-effort bridge only — normal chat persistence must not fail on mailbox cleanup.
1097
- }
1098
- }
1099
- if (!internal) {
1100
- try {
1101
- await runCapabilityHook('onMessage', { session, message: nextUserMessage }, { enabledIds: extensionsForRun })
1102
- } catch { /* onMessage hooks are non-critical */ }
1103
- }
1104
- }
1105
- }
1106
-
1107
- // Determine extension/LangGraph path early so we can skip the redundant system prompt.
1108
- // Dependencies: providerType (line 750), sessionForRun (line 625), isLocalOpenClawEndpoint (import).
1109
- const useLocalOpenClawNativeRuntime = providerType === 'openclaw' && isLocalOpenClawEndpoint(sessionForRun.apiEndpoint)
1110
- const enabledSessionExtensions = getEnabledCapabilityIds(sessionForRun)
1111
- const hasExtensions = enabledSessionExtensions.length > 0
1112
- && !NON_LANGGRAPH_PROVIDER_IDS.has(providerType)
1113
- && !useLocalOpenClawNativeRuntime
1114
-
1115
- // When using LangGraph (hasExtensions), streamAgentChatCore builds the full prompt
1116
- // including identity, soul, skills, tool discipline, and execution policy.
1117
- // Only build the standalone system prompt for the direct-provider (no LangGraph) path
1118
- // to avoid duplicating tool discipline, operating guidance, and capability sections.
1119
- // lightContext mode uses a minimal prompt for both paths to reduce token cost.
1120
- const systemPrompt = heartbeatLightContext
1121
- ? joinSystemPromptBlocks(buildLightHeartbeatSystemPrompt(sessionForRun), missionContextBlock)
1122
- : (hasExtensions ? undefined : joinSystemPromptBlocks(buildAgentSystemPrompt(sessionForRun), missionContextBlock))
1123
- const toolEvents: MessageToolEvent[] = []
1124
- const streamErrors: string[] = []
1125
- const accumulatedUsage = { inputTokens: 0, outputTokens: 0, estimatedCost: 0 }
1126
-
1127
- let thinkingText = ''
1128
- let streamingPartialText = ''
1129
- let lastPartialSaveAt = 0
1130
- let lastPartialSnapshotKey = ''
1131
- let partialSaveTimeout: ReturnType<typeof setTimeout> | null = null
1132
- let partialPersistenceClosed = false
1133
- let partialPersistChain: Promise<void> = Promise.resolve()
1134
-
1135
- const stopPartialAssistantPersistence = () => {
1136
- partialPersistenceClosed = true
1137
- if (partialSaveTimeout) {
1138
- clearTimeout(partialSaveTimeout)
1139
- partialSaveTimeout = null
1140
- }
1141
- }
1142
-
1143
- const persistStreamingAssistantArtifact = async () => {
1144
- if (hideAssistantTranscript) return
1145
- partialSaveTimeout = null
1146
- if (partialPersistenceClosed) return
1147
- const persistedToolEvents = toolEvents.length
1148
- ? dedupeConsecutiveToolEvents(pruneIncompleteToolEvents([...toolEvents]))
1149
- : []
1150
- if (!hasPersistableAssistantPayload(streamingPartialText, thinkingText, persistedToolEvents)) return
1151
-
1152
- try {
1153
- const fresh = loadSessions()
1154
- const current = fresh[sessionId]
1155
- if (!current) return
1156
- current.messages = Array.isArray(current.messages) ? current.messages : []
1157
- const partialMsg = await applyMessageLifecycleHooks({
1158
- session: current,
1159
- message: {
1160
- role: 'assistant',
1161
- text: streamingPartialText,
1162
- time: Date.now(),
1163
- streaming: true,
1164
- runId: lifecycleRunId,
1165
- thinking: thinkingText || undefined,
1166
- toolEvents: persistedToolEvents.length ? persistedToolEvents : undefined,
1167
- },
1168
- enabledIds: extensionsForRun,
1169
- phase: 'assistant_partial',
1170
- runId: lifecycleRunId,
1171
- isSynthetic: true,
1172
- })
1173
- if (!partialMsg) return
1174
- const snapshotKey = JSON.stringify([
1175
- partialMsg.text,
1176
- partialMsg.thinking || '',
1177
- getToolEventsSnapshotKey(partialMsg.toolEvents || []),
1178
- ])
1179
- if (snapshotKey === lastPartialSnapshotKey) return
1180
- lastPartialSnapshotKey = snapshotKey
1181
- lastPartialSaveAt = Date.now()
1182
- upsertStreamingAssistantArtifact(current.messages, partialMsg, {
1183
- minIndex: runMessageStartIndex,
1184
- minTime: runStartedAt,
1185
- })
1186
- fresh[sessionId] = current
1187
- saveSessions(fresh)
1188
- notify(`messages:${sessionId}`)
1189
- } catch { /* partial save is best-effort */ }
1190
- }
1191
-
1192
- const triggerPartialAssistantPersist = () => {
1193
- partialPersistChain = partialPersistChain
1194
- .catch(() => {})
1195
- .then(async () => {
1196
- await persistStreamingAssistantArtifact()
1197
- })
1198
- }
1199
-
1200
- const queuePartialAssistantPersist = (immediate = false) => {
1201
- if (partialPersistenceClosed) return
1202
- const now = Date.now()
1203
- const minIntervalMs = 400
1204
- if (immediate || now - lastPartialSaveAt >= minIntervalMs) {
1205
- if (partialSaveTimeout) {
1206
- clearTimeout(partialSaveTimeout)
1207
- partialSaveTimeout = null
1208
- }
1209
- triggerPartialAssistantPersist()
1210
- return
1211
- }
1212
- if (partialSaveTimeout) return
1213
- partialSaveTimeout = setTimeout(() => {
1214
- triggerPartialAssistantPersist()
1215
- }, minIntervalMs - (now - lastPartialSaveAt))
1216
- }
1217
-
1218
- const emit = (ev: SSEEvent) => {
1219
- let shouldPersistPartial = false
1220
- let immediatePartialPersist = false
1221
- if (ev.t === 'd' && typeof ev.text === 'string') {
1222
- streamingPartialText += ev.text
1223
- shouldPersistPartial = true
1224
- immediatePartialPersist = streamingPartialText.length === ev.text.length
1225
- }
1226
- if (ev.t === 'err' && typeof ev.text === 'string') {
1227
- const trimmed = ev.text.trim()
1228
- if (trimmed) {
1229
- streamErrors.push(trimmed)
1230
- if (streamErrors.length > 8) streamErrors.shift()
1231
- }
1232
- }
1233
- if (ev.t === 'thinking' && ev.text) {
1234
- thinkingText += ev.text
1235
- shouldPersistPartial = true
1236
- }
1237
- if (ev.t === 'md' && ev.text) {
1238
- try {
1239
- const mdPayload = JSON.parse(ev.text) as Record<string, unknown>
1240
- const usage = mdPayload.usage as { inputTokens?: number; outputTokens?: number; estimatedCost?: number } | undefined
1241
- if (usage) {
1242
- if (typeof usage.inputTokens === 'number') accumulatedUsage.inputTokens += usage.inputTokens
1243
- if (typeof usage.outputTokens === 'number') accumulatedUsage.outputTokens += usage.outputTokens
1244
- if (typeof usage.estimatedCost === 'number') accumulatedUsage.estimatedCost += usage.estimatedCost
1245
- }
1246
- } catch { /* ignore non-JSON md events */ }
1247
- }
1248
- collectToolEvent(ev, toolEvents)
1249
- if (ev.t === 'tool_call' || ev.t === 'tool_result') {
1250
- shouldPersistPartial = true
1251
- immediatePartialPersist = true
1252
- }
1253
- if (shouldPersistPartial) queuePartialAssistantPersist(immediatePartialPersist)
1254
- onEvent?.(ev)
1255
- }
1256
-
1257
- // Periodic partial save so a browser refresh doesn't lose the in-flight response.
1258
- const PARTIAL_SAVE_INTERVAL_MS = 3500
1259
- const partialSaveTimer = setInterval(() => {
1260
- persistStreamingAssistantArtifact()
1261
- }, PARTIAL_SAVE_INTERVAL_MS)
1262
-
1263
- const parseAndEmit = (raw: string) => {
1264
- const lines = raw.split('\n').filter(Boolean)
1265
- for (const line of lines) {
1266
- const ev = extractEventJson(line)
1267
- if (ev) emit(ev)
1268
- }
1269
- }
1270
-
1271
- let fullResponse = ''
1272
- let errorMessage: string | undefined
1273
- let preflightToolRoutingResult: Awaited<ReturnType<typeof runExclusiveDirectMemoryPreflight>> = null
1274
-
1275
- const requestedToolPreflightResponse = resolveRequestedToolPreflightResponse({
1276
- message,
1277
- enabledExtensions: extensionsForRun,
1278
- toolPolicy,
1279
- appSettings,
1280
- internal,
1281
- source,
1282
- session: sessionForRun,
72
+ const preflight = await runChatTurnPreflight({
73
+ prepared: preparedTurn,
74
+ emit: partialPersistence.emit,
75
+ toolEvents: partialPersistence.getToolEvents(),
1283
76
  })
1284
- if (requestedToolPreflightResponse) {
1285
- clearInterval(partialSaveTimer)
1286
- stopPartialAssistantPersistence()
1287
- emit({ t: 'd', text: requestedToolPreflightResponse })
1288
77
 
1289
- let persisted = false
1290
- if (!hideAssistantTranscript) {
1291
- const nextAssistantMessage = await applyMessageLifecycleHooks({
1292
- session,
1293
- message: {
1294
- role: 'assistant',
1295
- text: requestedToolPreflightResponse,
1296
- time: Date.now(),
1297
- },
1298
- enabledIds: extensionsForRun,
1299
- phase: 'assistant_final',
1300
- runId: lifecycleRunId,
1301
- isSynthetic: true,
1302
- })
1303
- if (nextAssistantMessage) {
1304
- session.messages.push(nextAssistantMessage)
1305
- session.lastActiveAt = Date.now()
1306
- saveSessions(sessions)
1307
- notify(`messages:${sessionId}`)
1308
- notify('sessions')
1309
- persisted = true
1310
- }
1311
- }
1312
-
1313
- return {
1314
- runId,
1315
- sessionId,
1316
- text: requestedToolPreflightResponse,
1317
- persisted,
1318
- toolEvents: [],
1319
- error: undefined,
1320
- }
1321
- }
1322
-
1323
- // Capture provider-reported usage for the direct (non-tools) path.
1324
- // Uses a mutable object because TS can't track callback mutations on plain variables.
1325
- const directUsage = { inputTokens: 0, outputTokens: 0, received: false }
1326
- const responseCacheConfig = resolveLlmResponseCacheConfig(appSettings)
1327
- let responseCacheHit = false
1328
- let responseCacheInput: LlmResponseCacheKeyInput | null = null
1329
- let durationMs = 0
1330
- const startTs = Date.now()
1331
- const endLlmPerf = perf.start('chat-execution', 'llm-round-trip', {
1332
- sessionId,
1333
- provider: providerType,
1334
- hasExtensions,
1335
- extensionCount: enabledSessionExtensions.length,
1336
- })
1337
- preflightToolRoutingResult = await runExclusiveDirectMemoryPreflight({
1338
- session: sessionForRun,
1339
- sessionId,
1340
- message,
1341
- effectiveMessage,
1342
- enabledExtensions: extensionsForRun,
1343
- toolPolicy,
1344
- appSettings,
1345
- internal,
1346
- source,
1347
- toolEvents,
1348
- emit,
1349
- })
1350
-
1351
- if (preflightToolRoutingResult) {
1352
- fullResponse = preflightToolRoutingResult.fullResponse
1353
- errorMessage = preflightToolRoutingResult.errorMessage
1354
- if (fullResponse) emit({ t: 'd', text: fullResponse })
1355
- clearInterval(partialSaveTimer)
1356
- stopPartialAssistantPersistence()
1357
- endLlmPerf({ durationMs: 0, cacheHit: false })
1358
- } else {
1359
- const abortController = new AbortController()
1360
- const abortFromOutside = () => abortController.abort()
1361
- if (signal) {
1362
- if (signal.aborted) abortController.abort()
1363
- else signal.addEventListener('abort', abortFromOutside)
1364
- }
1365
-
1366
- active.set(sessionId, {
1367
- runId: runId || null,
1368
- source,
1369
- kill: () => abortController.abort(),
78
+ if (preflight?.terminalResult) {
79
+ if (preflight.terminalResult.text) input.onEvent?.({ t: 'd', text: preflight.terminalResult.text })
80
+ partialPersistence.stop()
81
+ await partialPersistence.awaitIdle()
82
+ endTurnPerf({
83
+ durationMs: 0,
84
+ toolEventCount: preflight.terminalResult.toolEvents.length,
85
+ inputTokens: preflight.terminalResult.inputTokens || 0,
86
+ outputTokens: preflight.terminalResult.outputTokens || 0,
87
+ error: !!preflight.terminalResult.error,
1370
88
  })
1371
-
1372
- try {
1373
- // Heartbeat runs get a small tail of recent messages so the agent can see
1374
- // prior findings and avoid repeating the same searches. Full history is
1375
- // skipped to avoid blowing the context window on long-lived sessions.
1376
- // lightContext mode skips history entirely for maximum token savings.
1377
- const heartbeatHistory = isAutoRunNoHistory
1378
- ? (heartbeatLightContext ? [] : getSessionMessages(sessionId).slice(-6))
1379
- : undefined
1380
-
1381
- console.log(`[chat-execution] provider=${providerType}, hasExtensions=${hasExtensions}, localOpenClawNative=${useLocalOpenClawNativeRuntime}, imagePath=${resolvedImagePath || 'none'}, attachedFiles=${attachedFiles?.length || 0}, extensions=${enabledSessionExtensions.length}`)
1382
- if (hasExtensions) {
1383
- const result = await streamAgentChat({
1384
- session: sessionForRun,
1385
- message: effectiveMessage,
1386
- imagePath: resolvedImagePath,
1387
- imageUrl,
1388
- attachedFiles,
1389
- apiKey,
1390
- systemPrompt,
1391
- extraSystemContext: missionContextBlock ? [missionContextBlock] : undefined,
1392
- write: (raw) => parseAndEmit(raw),
1393
- history: heartbeatHistory ?? applyContextClearBoundary(getSessionMessages(sessionId)),
1394
- signal: abortController.signal,
1395
- })
1396
- fullResponse = result.finalResponse || result.fullText
1397
- } else {
1398
- let directHistorySnapshot = isAutoRunNoHistory
1399
- ? (heartbeatLightContext ? [] : getSessionMessages(sessionId).slice(-6))
1400
- : applyContextClearBoundary(getSessionMessages(sessionId))
1401
- responseCacheInput = {
1402
- provider: providerType,
1403
- model: sessionForRun.model,
1404
- apiEndpoint: sessionForRun.apiEndpoint || '',
1405
- systemPrompt,
1406
- message: effectiveMessage,
1407
- imagePath,
1408
- imageUrl,
1409
- attachedFiles,
1410
- history: directHistorySnapshot,
1411
- }
1412
- const canUseResponseCache = !internal && responseCacheConfig.enabled
1413
- const cached = canUseResponseCache
1414
- ? getCachedLlmResponse(responseCacheInput, responseCacheConfig)
1415
- : null
1416
- if (cached) {
1417
- responseCacheHit = true
1418
- fullResponse = cached.text
1419
- emit({
1420
- t: 'md',
1421
- text: JSON.stringify({
1422
- cache: {
1423
- hit: true,
1424
- ageMs: cached.ageMs,
1425
- provider: cached.provider,
1426
- model: cached.model,
1427
- },
1428
- }),
1429
- })
1430
- emit({ t: 'd', text: cached.text })
1431
- } else {
1432
- await runCapabilityHook(
1433
- 'llmInput',
1434
- {
1435
- session: sessionForRun,
1436
- runId: lifecycleRunId,
1437
- provider: providerType,
1438
- model: sessionForRun.model,
1439
- systemPrompt,
1440
- prompt: effectiveMessage,
1441
- historyMessages: directHistorySnapshot,
1442
- imagesCount: resolvedImagePath ? 1 : 0,
1443
- },
1444
- { enabledIds: extensionsForRun },
1445
- )
1446
- const doStreamChat = () => provider.handler.streamChat({
1447
- session: sessionForRun,
1448
- message: effectiveMessage,
1449
- imagePath: resolvedImagePath,
1450
- apiKey,
1451
- systemPrompt,
1452
- write: (raw: string) => parseAndEmit(raw),
1453
- active,
1454
- loadHistory: (sid: string) => {
1455
- if (sid === sessionId) return directHistorySnapshot
1456
- return isAutoRunNoHistory
1457
- ? getSessionMessages(sid).slice(-6)
1458
- : applyContextClearBoundary(getSessionMessages(sid))
1459
- },
1460
- onUsage: (u) => { directUsage.inputTokens = u.inputTokens; directUsage.outputTokens = u.outputTokens; directUsage.received = true },
1461
- signal: abortController.signal,
1462
- })
1463
- try {
1464
- fullResponse = await doStreamChat()
1465
- } catch (streamErr: unknown) {
1466
- // On context overflow, reduce history and retry once
1467
- const streamErrMsg = toErrorMessage(streamErr)
1468
- const streamStatus = (streamErr as Record<string, unknown>)?.status
1469
- if (typeof streamStatus === 'number' && streamStatus === 400 && CONTEXT_OVERFLOW_RE.test(streamErrMsg)) {
1470
- log.warn('chat-run', `Context overflow in direct path, reducing history and retrying`, {
1471
- sessionId, error: streamErrMsg, historyLen: directHistorySnapshot.length,
1472
- })
1473
- directHistorySnapshot = directHistorySnapshot.slice(-10)
1474
- fullResponse = await doStreamChat()
1475
- } else {
1476
- throw streamErr
1477
- }
1478
- }
1479
- await runCapabilityHook(
1480
- 'llmOutput',
1481
- {
1482
- session: sessionForRun,
1483
- runId: lifecycleRunId,
1484
- provider: providerType,
1485
- model: sessionForRun.model,
1486
- assistantTexts: fullResponse ? [fullResponse] : [],
1487
- response: fullResponse,
1488
- usage: directUsage.received
1489
- ? {
1490
- input: directUsage.inputTokens,
1491
- output: directUsage.outputTokens,
1492
- total: directUsage.inputTokens + directUsage.outputTokens,
1493
- estimatedCost: estimateCost(sessionForRun.model, directUsage.inputTokens, directUsage.outputTokens),
1494
- }
1495
- : undefined,
1496
- },
1497
- { enabledIds: extensionsForRun },
1498
- )
1499
- if (canUseResponseCache && responseCacheInput && fullResponse) {
1500
- setCachedLlmResponse(responseCacheInput, fullResponse, responseCacheConfig)
1501
- }
1502
- }
1503
- }
1504
- durationMs = Date.now() - startTs
1505
- endLlmPerf({ durationMs, cacheHit: responseCacheHit })
1506
- } catch (err: unknown) {
1507
- endLlmPerf({ error: true })
1508
- errorMessage = toErrorMessage(err)
1509
- const failureText = errorMessage || 'Run failed.'
1510
- markProviderFailure(providerType, failureText, sessionForRun.credentialId)
1511
- emit({ t: 'err', text: failureText })
1512
- log.error('chat-run', `Run failed for session ${sessionId}`, {
1513
- runId,
1514
- source,
1515
- internal,
1516
- error: failureText,
1517
- stack: err instanceof Error ? err.stack?.split('\n').slice(0, 6).join('\n') : undefined,
1518
- })
1519
- } finally {
1520
- clearInterval(partialSaveTimer)
1521
- stopPartialAssistantPersistence()
1522
- active.delete(sessionId)
1523
- notify('sessions')
1524
- if (signal) signal.removeEventListener('abort', abortFromOutside)
1525
- }
1526
- }
1527
- await partialPersistChain.catch(() => {})
1528
-
1529
- if (!errorMessage) {
1530
- markProviderSuccess(providerType, sessionForRun.credentialId)
1531
- }
1532
-
1533
- // Record usage for the direct (non-tools) streamChat path.
1534
- // streamAgentChat already calls appendUsage internally for the tools path.
1535
- if (!hasExtensions && fullResponse && !errorMessage && !responseCacheHit) {
1536
- const inputTokens = directUsage.received ? directUsage.inputTokens : Math.ceil(message.length / 4)
1537
- const outputTokens = directUsage.received ? directUsage.outputTokens : Math.ceil(fullResponse.length / 4)
1538
- const totalTokens = inputTokens + outputTokens
1539
- if (totalTokens > 0) {
1540
- const cost = estimateCost(sessionForRun.model, inputTokens, outputTokens)
1541
- const history = getSessionMessages(sessionId)
1542
- const usageRecord: UsageRecord = {
1543
- sessionId,
1544
- messageIndex: history.length,
1545
- model: sessionForRun.model,
1546
- provider: providerType,
1547
- inputTokens,
1548
- outputTokens,
1549
- totalTokens,
1550
- estimatedCost: cost,
1551
- timestamp: Date.now(),
1552
- durationMs,
1553
- agentId: sessionForRun.agentId || null,
1554
- projectId: sessionForRun.projectId || null,
1555
- }
1556
- appendUsage(sessionId, usageRecord)
1557
- emit({
1558
- t: 'md',
1559
- text: JSON.stringify({ usage: { inputTokens, outputTokens, totalTokens, estimatedCost: cost } }),
1560
- })
1561
- }
1562
- }
1563
-
1564
- const endPostProcessPerf = perf.start('chat-execution', 'post-process', { sessionId })
1565
- const toolRoutingResult = preflightToolRoutingResult || await runPostLlmToolRouting({
1566
- session: sessionForRun,
1567
- sessionId,
1568
- message,
1569
- effectiveMessage,
1570
- enabledExtensions: extensionsForRun,
1571
- toolPolicy,
1572
- appSettings,
1573
- internal,
1574
- source,
1575
- toolEvents,
1576
- emit,
1577
- }, fullResponse, errorMessage)
1578
-
1579
- fullResponse = toolRoutingResult.fullResponse
1580
- errorMessage = toolRoutingResult.errorMessage
1581
-
1582
- if (shouldAppendMissedRequestedToolNotice({
1583
- missedRequestedTools: toolRoutingResult.missedRequestedTools,
1584
- fullResponse,
1585
- errorMessage,
1586
- calledToolCount: toolRoutingResult.calledNames.size,
1587
- })) {
1588
- const notice = `Tool execution notice: requested tool(s) ${toolRoutingResult.missedRequestedTools.join(', ')} were not actually invoked in this run.`
1589
- emit({ t: 'err', text: notice })
1590
- const trimmedResponse = (fullResponse || '').trim()
1591
- fullResponse = trimmedResponse
1592
- ? `${trimmedResponse}\n\n${notice}`
1593
- : notice
89
+ return preflight.terminalResult
1594
90
  }
1595
91
 
1596
- const terminalError = deriveTerminalRunError({
1597
- errorMessage,
1598
- fullResponse: fullResponse || '',
1599
- streamErrors,
1600
- toolEvents,
1601
- internal,
92
+ const streamResult = await executePreparedChatTurn({
93
+ input,
94
+ prepared: preparedTurn,
95
+ partialPersistence,
96
+ preflightToolRoutingResult: preflight?.directMemoryResult || null,
1602
97
  })
1603
- if (terminalError && terminalError !== errorMessage) {
1604
- if (!errorMessage) {
1605
- log.warn('chat-run', `Run ended without a visible response for session ${sessionId}`, {
1606
- runId,
1607
- source,
1608
- internal,
1609
- provider: providerType,
1610
- messagePreview: effectiveMessage.slice(0, 200),
1611
- inferredError: terminalError,
1612
- })
1613
- }
1614
- errorMessage = terminalError
1615
- }
1616
-
1617
- const persistedToolEvents = dedupeConsecutiveToolEvents(pruneIncompleteToolEvents(toolEvents))
1618
- let finalText = (fullResponse || '').trim() || (!internal && errorMessage ? `Error: ${errorMessage}` : '')
1619
- if (extensionsForRun.length > 0 && finalText && !isHeartbeatRun) {
1620
- try {
1621
- finalText = await transformCapabilityText(
1622
- 'transformOutboundMessage',
1623
- { session: sessionForRun, text: finalText },
1624
- { enabledIds: extensionsForRun },
1625
- )
1626
- } catch { /* outbound transforms are non-critical */ }
1627
- }
1628
- finalText = reconcileConnectorDeliveryText(finalText, persistedToolEvents)
1629
- finalText = normalizeAssistantArtifactLinks(finalText, session.cwd)
1630
- finalText = applyExactOutputContract({
1631
- contract: await resolveExactOutputContractWithTimeout({
1632
- sessionId,
1633
- agentId: sessionForRun.agentId || null,
1634
- userMessage: message,
1635
- currentResponse: finalText,
1636
- toolEvents: persistedToolEvents,
1637
- internal,
1638
- source,
1639
- }),
1640
- text: finalText,
1641
- errorMessage,
1642
- toolEvents: persistedToolEvents,
1643
- })
1644
- const rawTextForPersistence = stripMainLoopMetaForPersistence(finalText)
1645
- const hiddenControlOnly = shouldSuppressHiddenControlText(rawTextForPersistence)
1646
- const textForPersistence = stripHiddenControlTokens(rawTextForPersistence)
1647
- const persistedText = getPersistedAssistantText(textForPersistence, persistedToolEvents)
1648
- let persistedResponseForHooks = textForPersistence
1649
-
1650
- if (isHeartbeatRun && rawTextForPersistence) {
1651
- const heartbeatStatus = extractHeartbeatStatus(rawTextForPersistence)
1652
- if (heartbeatStatus) emit({ t: 'status', text: JSON.stringify(heartbeatStatus) })
1653
- }
1654
-
1655
- // HEARTBEAT_OK suppression
1656
- const heartbeatConfig = input.heartbeatConfig
1657
- let heartbeatClassification: 'suppress' | 'strip' | 'keep' | null = null
1658
- if (isHeartbeatRun && rawTextForPersistence.length > 0) {
1659
- heartbeatClassification = classifyHeartbeatResponse(rawTextForPersistence, heartbeatConfig?.ackMaxChars ?? 300, toolEvents.length > 0)
1660
98
 
1661
- // Deduplication logic (nagging prevention)
1662
- // If the model repeats itself exactly within 24h, suppress the heartbeat alert.
1663
- if (heartbeatClassification !== 'suppress' && !toolEvents.length) {
1664
- const prevText = session.lastHeartbeatText || ''
1665
- const prevSentAt = session.lastHeartbeatSentAt || 0
1666
- const isDuplicate = prevText.trim() === persistedText.trim()
1667
- && (Date.now() - prevSentAt) < 24 * 60 * 60 * 1000
1668
- if (isDuplicate) {
1669
- heartbeatClassification = 'suppress'
1670
- log.info('heartbeat', `Duplicate heartbeat suppressed for session ${sessionId} (same text within 24h)`)
1671
- }
1672
- }
1673
- }
99
+ await partialPersistence.awaitIdle()
1674
100
 
1675
- // Emit WS notification for every heartbeat completion so UI can show pulse
1676
- if (isHeartbeatRun && session.agentId) {
1677
- notify(`heartbeat:agent:${session.agentId}`)
101
+ if (!streamResult.errorMessage) {
102
+ markProviderSuccess(preparedTurn.providerType, preparedTurn.sessionForRun.credentialId)
1678
103
  }
1679
104
 
1680
- const shouldPersistAssistant = !hiddenControlOnly
1681
- && !hideAssistantTranscript
1682
- && hasPersistableAssistantPayload(persistedText, thinkingText, persistedToolEvents)
1683
- && heartbeatClassification !== 'suppress'
1684
- && !(isHeartbeatRun && (
1685
- heartbeatConfig?.deliveryMode === 'silent'
1686
- || (heartbeatConfig?.deliveryMode === 'tool_only' && !isDirectConnectorSession(session))
1687
- ))
1688
-
1689
- const normalizeResumeId = (value: unknown): string | null =>
1690
- typeof value === 'string' && value.trim() ? value.trim() : null
1691
-
1692
- const fresh = loadSessions()
1693
- const current = fresh[sessionId]
1694
- let assistantPersisted = false
1695
- if (current) {
1696
- current.messages = Array.isArray(current.messages) ? current.messages : []
1697
- if (!isDirectConnectorSession(current) && current.connectorContext) {
1698
- current.connectorContext = undefined
1699
- }
1700
- const currentAgent = current.agentId ? loadAgents()[current.agentId] : null
1701
- pruneStreamingAssistantArtifacts(current.messages, {
1702
- minIndex: runMessageStartIndex,
1703
- minTime: runStartedAt,
1704
- })
1705
- const persistField = (key: string, value: unknown) => {
1706
- const normalized = normalizeResumeId(value)
1707
- if ((current as unknown as Record<string, unknown>)[key] !== normalized) {
1708
- ;(current as unknown as Record<string, unknown>)[key] = normalized
1709
- }
1710
- }
1711
-
1712
- persistField('claudeSessionId', session.claudeSessionId)
1713
- persistField('codexThreadId', session.codexThreadId)
1714
- persistField('opencodeSessionId', session.opencodeSessionId)
1715
-
1716
- const sourceResume = session.delegateResumeIds
1717
- if (sourceResume && typeof sourceResume === 'object') {
1718
- const currentResume = (current.delegateResumeIds && typeof current.delegateResumeIds === 'object')
1719
- ? current.delegateResumeIds
1720
- : {}
1721
- const sr = sourceResume as Record<string, unknown>
1722
- const cr = currentResume as Record<string, unknown>
1723
- const nextResume = {
1724
- claudeCode: normalizeResumeId(sr.claudeCode ?? cr.claudeCode),
1725
- codex: normalizeResumeId(sr.codex ?? cr.codex),
1726
- opencode: normalizeResumeId(sr.opencode ?? cr.opencode),
1727
- gemini: normalizeResumeId(sr.gemini ?? cr.gemini),
1728
- }
1729
- if (JSON.stringify(currentResume) !== JSON.stringify(nextResume)) {
1730
- current.delegateResumeIds = nextResume
1731
- }
1732
- }
1733
-
1734
- if (shouldPersistAssistant) {
1735
- const persistedKind = isHeartbeatRun ? 'heartbeat' : 'chat'
1736
- const nowTs = Date.now()
1737
- const nextAssistantMessage = await applyMessageLifecycleHooks({
1738
- session: current,
1739
- message: {
1740
- role: 'assistant',
1741
- text: persistedText,
1742
- time: nowTs,
1743
- thinking: thinkingText || undefined,
1744
- toolEvents: persistedToolEvents.length ? persistedToolEvents : undefined,
1745
- kind: persistedKind,
1746
- },
1747
- enabledIds: extensionsForRun,
1748
- phase: isHeartbeatRun ? 'heartbeat' : 'assistant_final',
1749
- runId: lifecycleRunId,
1750
- })
1751
- if (nextAssistantMessage) {
1752
- const previous = current.messages.at(-1)
1753
- const nextToolEvents = nextAssistantMessage.toolEvents || []
1754
- const nextKind = nextAssistantMessage.kind || persistedKind
1755
- if (shouldSuppressRedundantConnectorDeliveryFollowup({
1756
- previous,
1757
- nextText: nextAssistantMessage.text,
1758
- nextToolEvents,
1759
- nextKind,
1760
- now: nowTs,
1761
- })) {
1762
- persistedResponseForHooks = nextAssistantMessage.text
1763
- } else if ((previous?.streaming && previous?.runId === lifecycleRunId) || shouldReplaceRecentAssistantMessage({
1764
- previous,
1765
- nextToolEvents,
1766
- nextKind,
1767
- now: nowTs,
1768
- }) || shouldReplaceRecentConnectorFollowupMessage({
1769
- previous,
1770
- nextText: nextAssistantMessage.text,
1771
- nextToolEvents,
1772
- nextKind,
1773
- now: nowTs,
1774
- })) {
1775
- current.messages[current.messages.length - 1] = nextAssistantMessage
1776
- assistantPersisted = true
1777
- } else {
1778
- current.messages.push(nextAssistantMessage)
1779
- assistantPersisted = true
1780
- }
1781
- persistedResponseForHooks = nextAssistantMessage.text
1782
- if (assistantPersisted) {
1783
- if (isHeartbeatRun) {
1784
- current.lastHeartbeatText = nextAssistantMessage.text
1785
- current.lastHeartbeatSentAt = nowTs
1786
- }
1787
- try {
1788
- await runCapabilityHook('onMessage', { session: current, message: nextAssistantMessage }, { enabledIds: extensionsForRun })
1789
- } catch { /* onMessage hooks are non-critical */ }
1790
-
1791
- // Conversation tone detection
1792
- if (!internal) {
1793
- const tone = estimateConversationTone(nextAssistantMessage.text)
1794
- if (tone !== current.conversationTone) {
1795
- current.conversationTone = tone
1796
- }
1797
- }
1798
- }
1799
-
1800
- // Target routing for non-suppressed heartbeat alerts
1801
- if (
1802
- assistantPersisted
1803
- &&
1804
- isHeartbeatRun
1805
- && shouldAutoRouteHeartbeatAlerts(heartbeatConfig)
1806
- && heartbeatConfig?.target
1807
- && heartbeatConfig.target !== 'none'
1808
- ) {
1809
- try {
1810
- // eslint-disable-next-line @typescript-eslint/no-require-imports
1811
- const { sendConnectorMessage } = require('../connectors/manager')
1812
- let connectorId: string | undefined
1813
- let channelId: string | undefined
1814
- if (heartbeatConfig.target === 'last') {
1815
- const lastTarget = resolveHeartbeatLastConnectorTarget(current)
1816
- if (lastTarget) {
1817
- connectorId = lastTarget.connectorId
1818
- channelId = lastTarget.channelId
1819
- }
1820
- } else if (heartbeatConfig.target.includes(':')) {
1821
- const [cId, chId] = heartbeatConfig.target.split(':', 2)
1822
- connectorId = cId
1823
- channelId = chId
1824
- } else {
1825
- channelId = heartbeatConfig.target
1826
- }
1827
- if (channelId) {
1828
- sendConnectorMessage({ connectorId, channelId, text: nextAssistantMessage.text }).catch((err: unknown) => { log.warn('connector', 'Heartbeat connector delivery failed', { connectorId, channelId, sessionId, error: typeof err === 'object' && err !== null && 'message' in err ? (err as Error).message : String(err) }) })
1829
- }
1830
- } catch {
1831
- // Best effort — connector manager may not be loaded
1832
- }
1833
- }
1834
-
1835
- // Auto-discover connectors linked to this agent when no explicit target is set
1836
- // Skip if a real inbound message was handled recently — the agent just responded to it
1837
- if (
1838
- assistantPersisted
1839
- &&
1840
- isHeartbeatRun
1841
- && shouldAutoRouteHeartbeatAlerts(heartbeatConfig)
1842
- && !heartbeatConfig?.target
1843
- && isDirectConnectorSession(current)
1844
- ) {
1845
- const recentInbound = current.connectorContext?.lastInboundAt
1846
- && (Date.now() - current.connectorContext.lastInboundAt) < 60_000
1847
- const connectorId = typeof current.connectorContext?.connectorId === 'string'
1848
- ? current.connectorContext.connectorId.trim()
1849
- : ''
1850
- const channelId = typeof current.connectorContext?.channelId === 'string'
1851
- ? current.connectorContext.channelId.trim()
1852
- : ''
1853
- if (!recentInbound && channelId) {
1854
- try {
1855
- // eslint-disable-next-line @typescript-eslint/no-require-imports
1856
- const { sendConnectorMessage: sendMsg } = require('../connectors/manager')
1857
- sendMsg({ connectorId: connectorId || undefined, channelId, text: nextAssistantMessage.text }).catch((err: unknown) => { log.warn('connector', 'Auto-route connector delivery failed', { connectorId, channelId, sessionId, error: typeof err === 'object' && err !== null && 'message' in err ? (err as Error).message : String(err) }) })
1858
- } catch {
1859
- // Best effort — connector manager may not be loaded
1860
- }
1861
- }
1862
- }
1863
- }
1864
- }
1865
- if (isHeartbeatRun && heartbeatClassification === 'suppress') {
1866
- pruneSuppressedHeartbeatStreamMessage(current.messages)
1867
- }
1868
-
1869
- // P1: Prune old heartbeat messages to prevent context bloat.
1870
- // Long-running agents accumulate ~48 no-op messages/day; keep only the most recent 2.
1871
- if (isHeartbeatRun) {
1872
- const pruned = pruneOldHeartbeatMessages(current.messages)
1873
- if (pruned > 0) {
1874
- log.info('heartbeat', `Pruned ${pruned} old heartbeat message(s) from session ${sessionId}`)
1875
- }
1876
- }
1877
-
1878
- // Fire afterChatTurn hook for all enabled extensions (memory auto-save, logging, etc.)
1879
- try {
1880
- await runCapabilityHook('afterChatTurn', {
1881
- session: current,
1882
- message,
1883
- response: persistedResponseForHooks,
1884
- source,
1885
- internal,
1886
- toolEvents: persistedToolEvents,
1887
- }, { enabledIds: extensionsForRun })
1888
- } catch { /* afterChatTurn hooks are non-critical */ }
1889
-
1890
- // Don't extend idle timeout for heartbeat runs — only user-initiated activity counts
1891
- if (!isHeartbeatSource(source)) {
1892
- current.lastActiveAt = Date.now()
1893
- }
1894
-
1895
- refreshSessionIdentityState(current, currentAgent)
1896
- let resolvedMissionId = mission?.id || current.missionId || null
1897
- if (resolvedMissionId) {
1898
- const updatedMission = await applyMissionOutcomeForTurn({
1899
- session: current,
1900
- missionId: resolvedMissionId,
1901
- source,
1902
- runId: lifecycleRunId,
1903
- message,
1904
- assistantText: hiddenControlOnly ? '' : textForPersistence,
1905
- error: errorMessage || null,
1906
- toolEvents: persistedToolEvents,
1907
- })
1908
- if (updatedMission?.id) {
1909
- resolvedMissionId = updatedMission.id
1910
- current.missionId = updatedMission.id
1911
- }
1912
- }
1913
- try {
1914
- syncSessionArchiveMemory(current, { agent: currentAgent })
1915
- } catch { /* archive sync is best-effort */ }
1916
- fresh[sessionId] = current
1917
- saveSessions(fresh)
1918
- if (current.agentId && shouldAutoDraftSkillSuggestion({
1919
- assistantPersisted,
1920
- internal,
1921
- isHeartbeatRun,
1922
- agentAutoDraftSetting: currentAgent?.autoDraftSkillSuggestions === true,
1923
- toolEventCount: persistedToolEvents.length,
1924
- messageCount: current.messages.length,
1925
- })) {
1926
- try {
1927
- const { createSkillSuggestionFromSession } = await import('@/lib/server/skills/skill-suggestions')
1928
- await createSkillSuggestionFromSession(sessionId)
1929
- } catch {
1930
- // Reviewed skill drafting is best-effort.
1931
- }
1932
- }
1933
- notify(`messages:${sessionId}`)
1934
- }
105
+ const result = await finalizeChatTurn({
106
+ input,
107
+ prepared: preparedTurn,
108
+ partialPersistence,
109
+ fullResponse: streamResult.fullResponse,
110
+ errorMessage: streamResult.errorMessage,
111
+ initialToolRoutingResult: streamResult.toolRoutingResult,
112
+ responseCacheHit: streamResult.responseCacheHit,
113
+ directUsage: streamResult.directUsage,
114
+ durationMs: streamResult.durationMs,
115
+ emit: partialPersistence.emit,
116
+ })
1935
117
 
1936
- endPostProcessPerf({ toolEventCount: persistedToolEvents.length })
1937
118
  endTurnPerf({
1938
- durationMs,
1939
- toolEventCount: persistedToolEvents.length,
1940
- inputTokens: accumulatedUsage.inputTokens || 0,
1941
- outputTokens: accumulatedUsage.outputTokens || 0,
1942
- error: !!errorMessage,
119
+ durationMs: streamResult.durationMs,
120
+ toolEventCount: result.toolEvents.length,
121
+ inputTokens: result.inputTokens || 0,
122
+ outputTokens: result.outputTokens || 0,
123
+ error: !!result.error,
1943
124
  })
1944
125
 
1945
- return {
1946
- runId,
1947
- sessionId,
1948
- missionId: mission?.id || null,
1949
- text: hiddenControlOnly ? '' : textForPersistence,
1950
- persisted: assistantPersisted,
1951
- toolEvents: persistedToolEvents,
1952
- error: errorMessage,
1953
- inputTokens: accumulatedUsage.inputTokens || undefined,
1954
- outputTokens: accumulatedUsage.outputTokens || undefined,
1955
- estimatedCost: accumulatedUsage.estimatedCost || undefined,
1956
- }
126
+ return result
1957
127
  }