@swarmclawai/swarmclaw 1.2.1 → 1.2.3

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