@swarmclawai/swarmclaw 0.7.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +12 -14
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +23 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +46 -3
  17. package/src/app/api/connectors/route.ts +12 -8
  18. package/src/app/api/external-agents/route.test.ts +165 -0
  19. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  20. package/src/app/api/gateways/[id]/route.ts +2 -0
  21. package/src/app/api/gateways/health-route.test.ts +135 -0
  22. package/src/app/api/gateways/route.ts +2 -0
  23. package/src/app/api/mcp-servers/route.test.ts +130 -0
  24. package/src/app/api/openclaw/deploy/route.ts +38 -5
  25. package/src/app/api/plugins/install/route.ts +46 -6
  26. package/src/app/api/plugins/marketplace/route.ts +48 -15
  27. package/src/app/api/preview-server/route.ts +26 -11
  28. package/src/app/api/projects/[id]/route.ts +6 -2
  29. package/src/app/api/projects/route.ts +4 -3
  30. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  31. package/src/app/api/schedules/route.test.ts +86 -0
  32. package/src/app/api/schedules/route.ts +6 -1
  33. package/src/app/api/secrets/[id]/route.ts +1 -0
  34. package/src/app/api/secrets/route.ts +2 -1
  35. package/src/app/api/settings/route.ts +2 -0
  36. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  37. package/src/app/api/setup/check-provider/route.ts +40 -10
  38. package/src/app/api/skills/[id]/route.ts +12 -0
  39. package/src/app/api/skills/import/route.ts +14 -12
  40. package/src/app/api/skills/route.ts +13 -1
  41. package/src/app/api/tasks/[id]/route.ts +10 -1
  42. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  43. package/src/app/api/tasks/import/github/route.ts +337 -0
  44. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  45. package/src/app/api/wallets/[id]/route.ts +79 -33
  46. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  47. package/src/app/api/wallets/route.ts +78 -61
  48. package/src/app/api/webhooks/[id]/route.ts +33 -6
  49. package/src/app/api/webhooks/route.test.ts +272 -0
  50. package/src/cli/index.js +1 -0
  51. package/src/cli/spec.js +1 -0
  52. package/src/components/agents/agent-card.tsx +9 -2
  53. package/src/components/agents/agent-chat-list.tsx +18 -2
  54. package/src/components/agents/agent-list.tsx +1 -0
  55. package/src/components/agents/agent-sheet.tsx +257 -38
  56. package/src/components/agents/inspector-panel.tsx +41 -0
  57. package/src/components/canvas/canvas-panel.tsx +236 -65
  58. package/src/components/chat/chat-area.tsx +36 -19
  59. package/src/components/chat/chat-card.tsx +36 -13
  60. package/src/components/chat/chat-header.tsx +48 -16
  61. package/src/components/chat/chat-list.tsx +28 -4
  62. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  63. package/src/components/chat/delegation-banner.test.ts +14 -1
  64. package/src/components/chat/delegation-banner.tsx +1 -1
  65. package/src/components/chat/message-bubble.tsx +208 -145
  66. package/src/components/chat/message-list.tsx +48 -19
  67. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  68. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  69. package/src/components/connectors/connector-health.tsx +1 -1
  70. package/src/components/connectors/connector-list.tsx +7 -2
  71. package/src/components/connectors/connector-sheet.tsx +337 -148
  72. package/src/components/gateways/gateway-sheet.tsx +2 -2
  73. package/src/components/layout/app-layout.tsx +40 -23
  74. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  76. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  77. package/src/components/plugins/plugin-list.tsx +45 -9
  78. package/src/components/plugins/plugin-sheet.tsx +55 -7
  79. package/src/components/projects/project-detail.tsx +217 -0
  80. package/src/components/projects/project-sheet.tsx +176 -4
  81. package/src/components/providers/provider-list.tsx +2 -1
  82. package/src/components/providers/provider-sheet.tsx +21 -2
  83. package/src/components/schedules/schedule-card.tsx +25 -1
  84. package/src/components/schedules/schedule-sheet.tsx +44 -2
  85. package/src/components/secrets/secret-sheet.tsx +21 -2
  86. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  87. package/src/components/shared/bottom-sheet.tsx +13 -3
  88. package/src/components/shared/command-palette.tsx +8 -1
  89. package/src/components/shared/confirm-dialog.tsx +19 -4
  90. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  91. package/src/components/shared/connector-platform-icon.tsx +39 -6
  92. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  93. package/src/components/shared/settings/section-capability-policy.tsx +45 -3
  94. package/src/components/shared/settings/section-voice.tsx +11 -3
  95. package/src/components/skills/skill-list.tsx +25 -0
  96. package/src/components/skills/skill-sheet.tsx +84 -12
  97. package/src/components/tasks/approvals-panel.tsx +289 -34
  98. package/src/components/tasks/task-board.tsx +410 -25
  99. package/src/components/tasks/task-card.tsx +66 -8
  100. package/src/components/tasks/task-sheet.tsx +16 -4
  101. package/src/components/ui/dialog.tsx +2 -2
  102. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  103. package/src/components/wallets/wallet-panel.tsx +435 -90
  104. package/src/components/wallets/wallet-section.tsx +198 -48
  105. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  106. package/src/lib/approval-display.ts +20 -0
  107. package/src/lib/canvas-content.ts +198 -0
  108. package/src/lib/chat-artifact-summary.ts +165 -0
  109. package/src/lib/chat-display.test.ts +91 -0
  110. package/src/lib/chat-display.ts +58 -0
  111. package/src/lib/chat-streaming-state.test.ts +47 -1
  112. package/src/lib/chat-streaming-state.ts +42 -0
  113. package/src/lib/ollama-model.ts +10 -0
  114. package/src/lib/openclaw-endpoint.test.ts +8 -0
  115. package/src/lib/openclaw-endpoint.ts +6 -1
  116. package/src/lib/plugin-install-cors.ts +46 -0
  117. package/src/lib/plugin-sources.test.ts +43 -0
  118. package/src/lib/plugin-sources.ts +77 -0
  119. package/src/lib/providers/ollama.ts +16 -6
  120. package/src/lib/providers/openclaw.test.ts +54 -0
  121. package/src/lib/providers/openclaw.ts +127 -11
  122. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  123. package/src/lib/schedule-dedupe.test.ts +66 -1
  124. package/src/lib/schedule-dedupe.ts +169 -12
  125. package/src/lib/schedule-origin.test.ts +20 -0
  126. package/src/lib/schedule-origin.ts +15 -0
  127. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  128. package/src/lib/server/agent-availability.ts +16 -0
  129. package/src/lib/server/agent-runtime-config.ts +12 -4
  130. package/src/lib/server/agent-thread-session.test.ts +51 -0
  131. package/src/lib/server/agent-thread-session.ts +7 -0
  132. package/src/lib/server/approval-match.ts +205 -0
  133. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  134. package/src/lib/server/approvals.ts +214 -1
  135. package/src/lib/server/assistant-control.test.ts +29 -0
  136. package/src/lib/server/assistant-control.ts +23 -0
  137. package/src/lib/server/build-llm.test.ts +79 -0
  138. package/src/lib/server/build-llm.ts +14 -4
  139. package/src/lib/server/canvas-content.test.ts +32 -0
  140. package/src/lib/server/canvas-content.ts +6 -0
  141. package/src/lib/server/capability-router.test.ts +33 -0
  142. package/src/lib/server/capability-router.ts +80 -19
  143. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  144. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  145. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  146. package/src/lib/server/chat-execution.ts +378 -73
  147. package/src/lib/server/clawhub-client.test.ts +14 -8
  148. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  149. package/src/lib/server/connectors/manager.test.ts +1147 -0
  150. package/src/lib/server/connectors/manager.ts +461 -137
  151. package/src/lib/server/connectors/pairing.ts +26 -5
  152. package/src/lib/server/connectors/types.ts +2 -0
  153. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  154. package/src/lib/server/connectors/whatsapp.ts +271 -47
  155. package/src/lib/server/context-manager.ts +6 -1
  156. package/src/lib/server/daemon-state.ts +84 -47
  157. package/src/lib/server/data-dir.test.ts +37 -0
  158. package/src/lib/server/data-dir.ts +20 -1
  159. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  160. package/src/lib/server/devserver-launch.test.ts +60 -0
  161. package/src/lib/server/devserver-launch.ts +85 -0
  162. package/src/lib/server/elevenlabs.test.ts +247 -1
  163. package/src/lib/server/elevenlabs.ts +147 -43
  164. package/src/lib/server/ethereum.ts +590 -0
  165. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  166. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  167. package/src/lib/server/eval/agent-regression.ts +383 -11
  168. package/src/lib/server/evm-swap.ts +475 -0
  169. package/src/lib/server/execution-log.ts +1 -0
  170. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  171. package/src/lib/server/heartbeat-service.ts +20 -11
  172. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  173. package/src/lib/server/heartbeat-wake.ts +338 -57
  174. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  175. package/src/lib/server/main-agent-loop.test.ts +260 -0
  176. package/src/lib/server/main-agent-loop.ts +559 -14
  177. package/src/lib/server/mcp-client.test.ts +16 -0
  178. package/src/lib/server/mcp-client.ts +25 -0
  179. package/src/lib/server/memory-integration.test.ts +719 -0
  180. package/src/lib/server/memory-policy.test.ts +43 -0
  181. package/src/lib/server/memory-policy.ts +132 -0
  182. package/src/lib/server/memory-tiers.test.ts +60 -0
  183. package/src/lib/server/memory-tiers.ts +16 -0
  184. package/src/lib/server/ollama-runtime.ts +58 -0
  185. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  186. package/src/lib/server/openclaw-deploy.ts +557 -81
  187. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  188. package/src/lib/server/openclaw-gateway.ts +10 -4
  189. package/src/lib/server/openclaw-health.test.ts +35 -0
  190. package/src/lib/server/openclaw-health.ts +215 -47
  191. package/src/lib/server/orchestrator-lg.ts +3 -2
  192. package/src/lib/server/orchestrator.ts +2 -0
  193. package/src/lib/server/plugins-advanced.test.ts +351 -0
  194. package/src/lib/server/plugins.ts +211 -6
  195. package/src/lib/server/project-context.ts +162 -0
  196. package/src/lib/server/project-utils.ts +150 -0
  197. package/src/lib/server/queue-advanced.test.ts +528 -0
  198. package/src/lib/server/queue-followups.test.ts +409 -2
  199. package/src/lib/server/queue-reconcile.test.ts +128 -0
  200. package/src/lib/server/queue.ts +527 -68
  201. package/src/lib/server/scheduler.ts +29 -1
  202. package/src/lib/server/session-note.test.ts +36 -0
  203. package/src/lib/server/session-note.ts +42 -0
  204. package/src/lib/server/session-run-manager.ts +83 -4
  205. package/src/lib/server/session-tools/canvas.ts +14 -12
  206. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  207. package/src/lib/server/session-tools/connector.test.ts +138 -0
  208. package/src/lib/server/session-tools/connector.ts +366 -54
  209. package/src/lib/server/session-tools/context.ts +17 -3
  210. package/src/lib/server/session-tools/crud.ts +484 -84
  211. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  212. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  213. package/src/lib/server/session-tools/delegate.ts +102 -10
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  215. package/src/lib/server/session-tools/discovery.ts +80 -12
  216. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  217. package/src/lib/server/session-tools/file.ts +43 -4
  218. package/src/lib/server/session-tools/human-loop.ts +35 -5
  219. package/src/lib/server/session-tools/index.ts +44 -9
  220. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  221. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  222. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  223. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  224. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  225. package/src/lib/server/session-tools/memory.test.ts +93 -0
  226. package/src/lib/server/session-tools/memory.ts +554 -75
  227. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  228. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  229. package/src/lib/server/session-tools/platform.ts +60 -19
  230. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  231. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  232. package/src/lib/server/session-tools/schedule.ts +6 -1
  233. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  234. package/src/lib/server/session-tools/shell.ts +22 -3
  235. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  236. package/src/lib/server/session-tools/wallet.ts +1374 -139
  237. package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
  238. package/src/lib/server/session-tools/web.ts +621 -70
  239. package/src/lib/server/skill-discovery.ts +128 -0
  240. package/src/lib/server/skill-eligibility.test.ts +84 -0
  241. package/src/lib/server/skill-eligibility.ts +95 -0
  242. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  243. package/src/lib/server/skill-prompt-budget.ts +125 -0
  244. package/src/lib/server/skills-normalize.test.ts +54 -0
  245. package/src/lib/server/skills-normalize.ts +372 -26
  246. package/src/lib/server/solana.ts +214 -29
  247. package/src/lib/server/storage.ts +65 -36
  248. package/src/lib/server/stream-agent-chat.test.ts +437 -2
  249. package/src/lib/server/stream-agent-chat.ts +957 -79
  250. package/src/lib/server/system-events.ts +1 -1
  251. package/src/lib/server/tool-aliases.ts +2 -0
  252. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  253. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  254. package/src/lib/server/tool-capability-policy.ts +29 -1
  255. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  256. package/src/lib/server/tool-loop-detection.ts +260 -0
  257. package/src/lib/server/tool-planning.test.ts +44 -0
  258. package/src/lib/server/tool-planning.ts +271 -0
  259. package/src/lib/server/wallet-execution.test.ts +198 -0
  260. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  261. package/src/lib/server/wallet-portfolio.ts +724 -0
  262. package/src/lib/server/wallet-service.test.ts +57 -0
  263. package/src/lib/server/wallet-service.ts +213 -0
  264. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  265. package/src/lib/server/watch-jobs.ts +17 -2
  266. package/src/lib/server/workspace-context.ts +111 -0
  267. package/src/lib/skill-save-payload.test.ts +39 -0
  268. package/src/lib/skill-save-payload.ts +37 -0
  269. package/src/lib/tasks.ts +28 -0
  270. package/src/lib/tool-definitions.ts +2 -1
  271. package/src/lib/tool-event-summary.test.ts +30 -0
  272. package/src/lib/tool-event-summary.ts +37 -0
  273. package/src/lib/validation/schemas.ts +1 -0
  274. package/src/lib/wallet-transactions.test.ts +75 -0
  275. package/src/lib/wallet-transactions.ts +43 -0
  276. package/src/lib/wallet.test.ts +17 -0
  277. package/src/lib/wallet.ts +183 -0
  278. package/src/proxy.test.ts +31 -0
  279. package/src/proxy.ts +34 -2
  280. package/src/stores/use-chat-store.ts +15 -1
  281. package/src/types/index.ts +249 -14
@@ -8,14 +8,24 @@ import { loadSettings, loadAgents, loadSkills, appendUsage } from './storage'
8
8
  import { estimateCost, buildPluginDefinitionCosts } from './cost'
9
9
  import { getPluginManager } from './plugins'
10
10
  import { loadRuntimeSettings, getAgentLoopRecursionLimit } from './runtime-settings'
11
+ import { buildSkillPromptText } from './skill-prompt-budget'
11
12
 
12
13
  import { logExecution } from './execution-log'
13
14
  import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
14
- import { expandPluginIds } from './tool-aliases'
15
- import type { Session, Message, UsageRecord, PluginInvocationRecord } from '@/types'
15
+ import { canonicalizePluginId, expandPluginIds } from './tool-aliases'
16
+ import type { Session, Message, UsageRecord, PluginInvocationRecord, MessageToolEvent } from '@/types'
16
17
  import { extractSuggestions } from './suggestions'
17
18
  import { buildIdentityContinuityContext } from './identity-continuity'
18
19
  import { enqueueSystemEvent } from './system-events'
20
+ import { resolveActiveProjectContext } from './project-context'
21
+ import {
22
+ getEnabledToolPlanningView,
23
+ getFirstToolForCapability,
24
+ getToolsForCapability,
25
+ TOOL_CAPABILITY,
26
+ } from './tool-planning'
27
+ import { ToolLoopTracker } from './tool-loop-detection'
28
+ import type { LoopDetectionResult } from './tool-loop-detection'
19
29
 
20
30
  /** Extract a breadcrumb title from notable tool completions (task/schedule/agent creation). */
21
31
  interface StreamAgentChatOpts {
@@ -46,8 +56,11 @@ function buildPluginCapabilityLines(enabledPlugins: string[], opts?: { platformA
46
56
  }
47
57
 
48
58
  export function buildToolDisciplineLines(enabledPlugins: string[]): string[] {
49
- const uniqueTools = Array.from(new Set(enabledPlugins.filter(Boolean))).sort()
59
+ const planning = getEnabledToolPlanningView(enabledPlugins)
60
+ const uniqueTools = planning.displayToolIds
50
61
  if (uniqueTools.length === 0) return []
62
+ const walletTools = getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.walletInspect)
63
+ const httpTools = getToolsForCapability(enabledPlugins, 'network.http')
51
64
 
52
65
  const lines = [
53
66
  `Enabled tools in this session: ${uniqueTools.map((toolId) => `\`${toolId}\``).join(', ')}.`,
@@ -59,33 +72,63 @@ export function buildToolDisciplineLines(enabledPlugins: string[]): string[] {
59
72
  lines.push(`Use direct platform tools exactly as named (${directPlatformTools.map((toolId) => `\`${toolId}\``).join(', ')}). Do not substitute \`manage_platform\` unless it is explicitly enabled.`)
60
73
  }
61
74
 
62
- if (uniqueTools.includes('files')) {
63
- lines.push('For `files`, include an explicit action whenever possible. Common patterns: `{"action":"list","dirPath":"."}`, `{"action":"read","filePath":"path/to/file.md"}`, and `{"action":"write","files":[{"path":"path/to/file.md","content":"..."}]}`.')
75
+ lines.push(...planning.disciplineGuidance)
76
+
77
+ const researchSearchTools = getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.researchSearch)
78
+ const researchFetchTools = getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.researchFetch)
79
+ const browserCaptureTools = getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.browserCapture)
80
+ const deliveryMediaTools = getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.deliveryMedia)
81
+ const deliveryVoiceTools = getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.deliveryVoiceNote)
82
+
83
+ if ((researchSearchTools.length || researchFetchTools.length) && browserCaptureTools.length) {
84
+ const researchLabel = [...researchSearchTools, ...researchFetchTools].map((toolName) => `\`${toolName}\``).join('/')
85
+ lines.push(`Research tools like ${researchLabel} gather sources and text, but they do not capture screenshots. Use \`${browserCaptureTools[0]}\` for screenshots or rendered page evidence.`)
86
+ lines.push(`When a task asks for both research and screenshots, use ${researchLabel} first to identify the right source URLs, then use \`${browserCaptureTools[0]}\` to capture the relevant page.`)
64
87
  }
65
88
 
66
- if (uniqueTools.includes('shell')) {
67
- lines.push('For `shell`, use `{"action":"execute","command":"..."}` for commands and `{"action":"status","processId":"..."}` or `{"action":"log","processId":"..."}` for long-lived processes.')
89
+ if (researchSearchTools.length) {
90
+ lines.push(`For current events, live conflicts, or “keep watching for updates” requests, use \`${researchSearchTools[0]}\` before answering. Do not rely on memory or unstated background knowledge for fresh developments.`)
68
91
  }
69
92
 
70
- if (uniqueTools.includes('web')) {
71
- lines.push('For `web`, use `{"action":"search","query":"..."}` to research and `{"action":"fetch","url":"https://..."}` to read a specific page.')
93
+ if (browserCaptureTools.length && deliveryMediaTools.length) {
94
+ lines.push(`When the user asks you to send screenshots or other media, capture the artifact first with \`${browserCaptureTools[0]}\`, then deliver that exact file or upload URL through \`${deliveryMediaTools[0]}\` instead of saying the capability is unavailable.`)
72
95
  }
73
96
 
74
- if (uniqueTools.includes('browser')) {
75
- lines.push('For `browser`, when the task includes a literal URL, pass that exact URL string to `{"action":"navigate","url":"..."}`. Do not invent placeholder URLs like `[Your URL]`, `Example_URL`, or `MockMailPage_URL`.')
76
- lines.push('For `browser` form work, prefer `{"action":"fill_form","fields":[{"element":"#email","value":"user@example.com"},{"element":"#password","value":"..."}]}`. A shorthand `form` object keyed by input id/name also works for simple forms.')
97
+ if (deliveryVoiceTools.length) {
98
+ lines.push(`If the user asks for a voice note and \`${deliveryVoiceTools[0]}\` is enabled, try it before saying voice notes are unsupported.`)
77
99
  }
78
100
 
79
- if (uniqueTools.includes('http_request')) {
80
- lines.push('For `http_request`, send exact literal URLs from the task or from prior tool results. Keep JSON request bodies as raw JSON strings.')
101
+ if (walletTools.length && (uniqueTools.includes('browser') || httpTools.length > 0)) {
102
+ lines.push(`For external wallet or trading workflows, inspect the available wallet first with \`${walletTools[0]}\` before browsing or calling third-party APIs.`)
103
+ lines.push('For dApps, exchanges, and wallet-connect flows, use a bounded loop: verify the wallet/tooling you control, attempt one concrete reversible step, then either execute the next real action or state the exact blocker. Do not keep browsing once the blocker is clear.')
104
+ lines.push('For swaps, purchases, and other live onchain tasks, do not shop across venues indefinitely. After a small number of failed API families, either use a direct onchain read path with the tools you have or state the blocker.')
81
105
  }
82
106
 
83
- if (uniqueTools.includes('email')) {
84
- lines.push('For `email`, send mail with `{"action":"send","to":"user@example.com","subject":"...","body":"..."}`. If delivery depends on SMTP setup, check `{"action":"status"}` before claiming success.')
107
+ if (uniqueTools.includes('browser')) {
108
+ lines.push('For browser form workflows, start with `read_page` or `extract_form_fields`, then prefer `fill_form` and `submit_form`. Only use raw `click`/`type`/`select` when you already have the exact target information from the current page.')
109
+ lines.push('When the task provides a literal URL or you are already on the correct page, keep working from that page state. Do not invent alternate domains, ports, or routes unless the current page explicitly links to them.')
85
110
  }
86
111
 
87
112
  if (uniqueTools.includes('ask_human')) {
88
- lines.push('For `ask_human`, when a workflow needs a code, approval, or out-of-band value from a person, do not guess or keep re-submitting blank forms. Use `{"action":"request_input","question":"..."}` and, for durable pauses, `{"action":"wait_for_reply","correlationId":"..."}`.')
113
+ lines.push('For human-loop tasks, use `ask_human` in order: `request_input` with a concrete question, `wait_for_reply` with the returned `correlationId`, then `list_mailbox` to read the `human_reply` payload. Use `ack_mailbox` with the reply envelope id once consumed, or omit `envelopeId` to ack the newest unread human reply. Do not loop on `status` without a `watchJobId` or `approvalId`.')
114
+ }
115
+
116
+ if (uniqueTools.includes('manage_schedules')) {
117
+ lines.push('Before creating a schedule, inspect existing schedules in this chat and reuse or update matching agent-created schedules instead of creating near-duplicates.')
118
+ lines.push('For one-off reminders, prefer `scheduleType: "once"`; reserve recurring schedules for work that truly needs to repeat.')
119
+ lines.push('When the user says stop, pause, cancel, or disable a reminder, list schedules first and pause or delete every matching schedule you created in this chat.')
120
+ }
121
+
122
+ if (uniqueTools.includes('schedule_wake')) {
123
+ lines.push('For a one-off conversational reminder in the current chat, prefer `schedule_wake` over creating a recurring schedule.')
124
+ }
125
+
126
+ if (uniqueTools.includes('manage_secrets')) {
127
+ lines.push('When a workflow reveals a password, app password, API key, recovery token, or other secret, store it with `manage_secrets` and do not echo the raw value in assistant text. Refer to the secret by name, service, or secret id instead.')
128
+ }
129
+
130
+ if (uniqueTools.includes('delegate') && (uniqueTools.includes('shell') || uniqueTools.includes('files') || uniqueTools.includes('edit_file'))) {
131
+ lines.push('When local workspace tools like `shell`, `files`, or `edit_file` are already enabled, prefer using them directly for straightforward coding and verification. Use `delegate` when you need a specialist backend, a second implementation pass, or parallel work.')
89
132
  }
90
133
 
91
134
  return lines
@@ -94,15 +137,46 @@ export function buildToolDisciplineLines(enabledPlugins: string[]): string[] {
94
137
  export function looksLikeOpenEndedDeliverableTask(text: string): boolean {
95
138
  const normalized = text.toLowerCase()
96
139
  if (!normalized.trim()) return false
97
- if (/```|package\.json|tsconfig|tsx?\b|jsx?\b|pytest|vitest|npm run|src\/|components\/|api\//.test(normalized)) return false
140
+ if (/```|package\.json|tsconfig|\btsx?\b|\bjsx?\b|pytest|vitest|npm run|src\/|components\/|api\//.test(normalized)) return false
98
141
  if (/\b(revise|revision|iterate|iteration|draft|deliverable|deliverables|offer|brief|copy|proposal|landing|outreach|plan|strategy|report|memo|document|docs?)\b/.test(normalized)) return true
99
- return isBroadGoal(text) && /(\.md\b|\.txt\b|copy|brief|proposal|plan|report|draft|document)/.test(normalized)
142
+ // Explicit file-save instructions (e.g. "create X and save it to /tmp/foo.html")
143
+ if (
144
+ /\b(create|build|generate|make|write|produce)\b/.test(normalized)
145
+ && /\b(save|write|output|export)\b[^.!?\n]{0,60}\b(to|as|in)\b[^.!?\n]{0,40}(\/|~\/|\.\/|\.[a-z]{2,5}\b)/.test(normalized)
146
+ ) {
147
+ return true
148
+ }
149
+ if (
150
+ isBroadGoal(text)
151
+ && /\b(create|build|generate|make|write|research|capture|take|start|produce)\b/.test(normalized)
152
+ && /\b(screenshot|screenshots|image|images|markdown|\.md\b|md\b|md files?|pdf|pdf files?|html|html\s+(?:page|file)|dashboard|site|sites|website|web page|webpage|dev server|dev servers|artifact|artifacts|topic|topics)\b/.test(normalized)
153
+ ) {
154
+ return true
155
+ }
156
+ return isBroadGoal(text) && /(\.md\b|\.txt\b|\.html\b|\.json\b|copy|brief|proposal|plan|report|draft|document|dashboard)/.test(normalized)
100
157
  }
101
158
 
102
- function getExplicitRequiredToolNames(userMessage: string, enabledPlugins: string[]): string[] {
159
+ /**
160
+ * Returns tool names that the user explicitly referenced by name in their message.
161
+ *
162
+ * Previously this used regex-based capability matching (matchToolCapabilitiesForMessage)
163
+ * to infer required tools from keywords like "send", "search", "screenshot". This caused
164
+ * false positives ("sends an HTTP request" forced connector_message_tool, "create a file"
165
+ * forced delivery tools) and extra continuation loops.
166
+ *
167
+ * OpenClaw's approach: trust the LLM to select the right tools based on prompt engineering
168
+ * (tool discipline lines, skill adherence header, system prompt). No regex-based forced
169
+ * tool requirements. The deliverable/execution followthrough mechanisms handle cases where
170
+ * the agent stops early.
171
+ *
172
+ * We now only force tools when the user explicitly names them (ask_human, email) — these
173
+ * are cases where the LLM has a known tendency to skip the tool and respond in prose.
174
+ */
175
+ export function getExplicitRequiredToolNames(userMessage: string, enabledPlugins: string[]): string[] {
103
176
  const normalized = userMessage.toLowerCase()
104
177
  const required: string[] = []
105
178
 
179
+ // Only force tools that the user explicitly names and the LLM tends to skip
106
180
  if (enabledPlugins.includes('ask_human')
107
181
  && (/\bask_human\b/.test(normalized) || /ask the human/.test(normalized) || /request_input/.test(normalized))) {
108
182
  required.push('ask_human')
@@ -124,6 +198,320 @@ const OPEN_ENDED_REVISION_BLOCK = [
124
198
  'If `files` is available, use it with explicit actions and paths to inspect and revise the artifacts.',
125
199
  ].join('\n')
126
200
 
201
+ function looksLikeExternalWalletTask(text: string): boolean {
202
+ const normalized = text.toLowerCase()
203
+ if (!normalized.trim()) return false
204
+ return /\b(wallet|wallet connect|walletconnect|trade|trading|exchange|dex|bridge|swap|deposit|withdraw|onchain|token|gas|hyperliquid|arbitrum|ethereum|solana|base|usdc|eth|sol)\b/.test(normalized)
205
+ }
206
+
207
+ function looksLikeBoundedExternalExecutionTask(text: string): boolean {
208
+ const normalized = text.toLowerCase()
209
+ if (!looksLikeExternalWalletTask(text)) return false
210
+ return /\b(live|swap|trade|buy|purchase|sell|mint|claim|execute|transact|transaction|approve|broadcast)\b/.test(normalized)
211
+ }
212
+
213
+ function getEnabledDisplayTool(enabledPlugins: string[], canonicalPluginId: string): string | null {
214
+ return getEnabledToolPlanningView(enabledPlugins).displayToolIds.find((toolId) => toolId === canonicalPluginId) || null
215
+ }
216
+
217
+ export function buildExternalWalletExecutionBlock(enabledPlugins: string[]): string {
218
+ const hasExecutionContext = Boolean(
219
+ getFirstToolForCapability(enabledPlugins, TOOL_CAPABILITY.walletInspect)
220
+ || getFirstToolForCapability(enabledPlugins, 'network.http')
221
+ || getEnabledDisplayTool(enabledPlugins, 'browser')
222
+ || getEnabledDisplayTool(enabledPlugins, 'manage_capabilities'),
223
+ )
224
+ if (!hasExecutionContext) return ''
225
+ const lines = [
226
+ '## External Service Execution',
227
+ 'Define a stop condition before exploring: either complete one concrete reversible action, or identify the exact blocker with evidence.',
228
+ 'A prose sentence saying approval is needed is not enough. When the next step is a wallet signature or transaction, trigger the actual wallet approval request through the tool.',
229
+ 'After one or two discovery bursts, stop exploring and summarize the blocker if execution still depends on a missing capability such as injected wallet signing, external credentials, or unavailable approvals.',
230
+ 'Do not mutate already confirmed identifiers unless newer tool evidence proves the earlier value was wrong.',
231
+ 'Never claim success on a trading or dApp task unless you either completed the reversible step with tool evidence or clearly stated the final missing step.',
232
+ ]
233
+ return lines.join('\n')
234
+ }
235
+
236
+ export function shouldForceExternalServiceSummary(params: {
237
+ userMessage: string
238
+ finalResponse: string
239
+ hasToolCalls: boolean
240
+ toolEventCount: number
241
+ }): boolean {
242
+ if (!looksLikeExternalWalletTask(params.userMessage)) return false
243
+ if (!params.hasToolCalls || params.toolEventCount === 0) return false
244
+ const trimmed = params.finalResponse.trim()
245
+ if (!trimmed) return true
246
+ if (/\b(blocker|blocked|cannot|can't|requires|need|missing|last reversible step|next step)\b/i.test(trimmed)) return false
247
+ if (trimmed.length >= 240 && !/(let me|i'll|i will|checking|verify|promising|look into|explore|access their interface)/i.test(trimmed)) return false
248
+ return /:$/.test(trimmed) || /(let me|i'll|i will|checking|verify|promising|look into|explore|access their interface)/i.test(trimmed) || trimmed.length < 240
249
+ }
250
+
251
+ function resolveToolAction(input: unknown): string {
252
+ if (input && typeof input === 'object' && !Array.isArray(input)) {
253
+ const action = (input as Record<string, unknown>).action
254
+ return typeof action === 'string' ? action.trim().toLowerCase() : ''
255
+ }
256
+ if (typeof input !== 'string') return ''
257
+ const trimmed = input.trim()
258
+ if (!trimmed.startsWith('{')) return ''
259
+ try {
260
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>
261
+ return typeof parsed.action === 'string' ? parsed.action.trim().toLowerCase() : ''
262
+ } catch {
263
+ return ''
264
+ }
265
+ }
266
+
267
+ export function shouldTerminateOnSuccessfulMemoryMutation(params: {
268
+ toolName: string
269
+ toolInput: unknown
270
+ toolOutput: string
271
+ }): boolean {
272
+ const canonicalToolName = canonicalizePluginId(params.toolName) || params.toolName
273
+ if (canonicalToolName !== 'memory') return false
274
+ const action = resolveToolAction(params.toolInput)
275
+ if (action !== 'store' && action !== 'update') return false
276
+ const output = extractSuggestions(params.toolOutput || '').clean.trim()
277
+ if (!output || /^error[:\s]/i.test(output)) return false
278
+ if (!/^(stored|updated) memory\b/i.test(output)) return false
279
+ return /no further memory lookup is needed unless the user asked you to verify/i.test(output)
280
+ }
281
+
282
+ function hasStateChangingWalletEvidence(toolEvents: MessageToolEvent[]): boolean {
283
+ return toolEvents.some((event) => {
284
+ const input = `${event.input || ''}\n${event.output || ''}`
285
+ return event.name === 'wallet_tool' && (
286
+ /"action":"send_transaction"/.test(input)
287
+ || /"action":"send"/.test(input)
288
+ || /"action":"sign_transaction"/.test(input)
289
+ || /"type":"plugin_wallet_action_request"/.test(input)
290
+ || /"type":"plugin_wallet_transfer_request"/.test(input)
291
+ || /"status":"broadcast"/.test(input)
292
+ )
293
+ })
294
+ }
295
+
296
+ function countExternalExecutionResearchSteps(toolEvents: MessageToolEvent[]): number {
297
+ return toolEvents.filter((event) => {
298
+ if (['http_request', 'web', 'web_search', 'web_fetch', 'browser'].includes(event.name)) return true
299
+ if (event.name !== 'wallet_tool') return false
300
+ return /"action":"(balance|address|transactions|call_contract|encode_contract_call)"/.test(event.input || '')
301
+ }).length
302
+ }
303
+
304
+ function countDistinctExternalResearchHosts(toolEvents: MessageToolEvent[]): number {
305
+ const hosts = new Set<string>()
306
+ for (const event of toolEvents) {
307
+ const candidates = [event.input || '', event.output || '']
308
+ for (const candidate of candidates) {
309
+ const matches = candidate.match(/https?:\/\/[^"'\\\s)]+/g) || []
310
+ for (const match of matches) {
311
+ try {
312
+ hosts.add(new URL(match).host.toLowerCase())
313
+ } catch {
314
+ // Ignore malformed URLs in model/tool text.
315
+ }
316
+ }
317
+ }
318
+ }
319
+ return hosts.size
320
+ }
321
+
322
+ function getWalletApprovalBoundaryAction(output: string): string | null {
323
+ if (!output.includes('plugin_wallet_')) return null
324
+ if (/"type":"plugin_wallet_transfer_request"/.test(output)) return 'send'
325
+ const actionMatch = output.match(/"action":"([^"]+)"/)
326
+ const action = actionMatch?.[1] || ''
327
+ if (!action) return null
328
+ const readOnlyActions = new Set([
329
+ 'balance',
330
+ 'address',
331
+ 'transactions',
332
+ 'encode_contract_call',
333
+ 'simulate_transaction',
334
+ ])
335
+ return readOnlyActions.has(action) ? null : action
336
+ }
337
+
338
+ export function isWalletSimulationResult(toolName: string, output: string): boolean {
339
+ return toolName === 'wallet_tool' && /"status":"simulated"/.test(output)
340
+ }
341
+
342
+ export function shouldForceExternalExecutionFollowthrough(params: {
343
+ userMessage: string
344
+ finalResponse: string
345
+ hasToolCalls: boolean
346
+ toolEvents: MessageToolEvent[]
347
+ }): boolean {
348
+ if (!looksLikeBoundedExternalExecutionTask(params.userMessage)) return false
349
+ if (!params.hasToolCalls || params.toolEvents.length < 4) return false
350
+ if (hasStateChangingWalletEvidence(params.toolEvents)) return false
351
+ const distinctHosts = countDistinctExternalResearchHosts(params.toolEvents)
352
+ const trimmed = params.finalResponse.trim()
353
+ if (!trimmed) return countExternalExecutionResearchSteps(params.toolEvents) >= 4 || distinctHosts >= 3
354
+ if (/\b(last reversible step|exact blocker|safest next action|blocked|cannot|can't|missing capability|no-key route unavailable)\b/i.test(trimmed)) {
355
+ return false
356
+ }
357
+ if (countExternalExecutionResearchSteps(params.toolEvents) < 4 && distinctHosts < 3) return false
358
+ return /(let me|i'll|i will|trying|research|query|check|look|promising|now let me|good -|good,)/i.test(trimmed) || trimmed.length < 500
359
+ }
360
+
361
+ function looksLikeIncompleteDeliverableResponse(text: string): boolean {
362
+ const trimmed = text.trim()
363
+ if (!trimmed) return true
364
+ if (trimmed.endsWith(':') || trimmed.endsWith('...') || trimmed.endsWith('…')) return true
365
+ const lastChunk = trimmed.slice(-400).toLowerCase()
366
+ return /\b(?:next|now|then|after that|moving on to|proceeding to)\b[^.!?\n]{0,120}\b(?:i(?:'ll| will)|create|build|write|capture|take|start|finish|generate)\b/.test(lastChunk)
367
+ || /\b(?:i(?:'ll| will)|let me)\s+(?:now|next)?\s*(?:create|build|write|capture|take|start|finish|generate|continue)\b/.test(lastChunk)
368
+ }
369
+
370
+ export function shouldForceDeliverableFollowthrough(params: {
371
+ userMessage: string
372
+ finalResponse: string
373
+ hasToolCalls: boolean
374
+ toolEvents: MessageToolEvent[]
375
+ }): boolean {
376
+ if (!looksLikeOpenEndedDeliverableTask(params.userMessage)) return false
377
+ if (!params.hasToolCalls || params.toolEvents.length === 0) return false
378
+ const trimmed = params.finalResponse.trim()
379
+ if (!trimmed) return params.toolEvents.length >= 2
380
+ if (
381
+ /\b(task complete|completed|finished|done|delivered|shared|sent|uploaded|attached)\b/i.test(trimmed)
382
+ && /(?:\/api\/uploads\/|https?:\/\/|`[^`\n]+\.(?:md|pdf|png|jpe?g|webp|gif|html|txt|zip)`)/i.test(trimmed)
383
+ ) {
384
+ return false
385
+ }
386
+ // If the user asked for file output but no file-write tool was used, force continuation
387
+ const userNormalized = params.userMessage.toLowerCase()
388
+ if (/\b(save|write|output)\b[^.!?\n]{0,60}\b(to|as)\b[^.!?\n]{0,40}(\/|~\/|\.[a-z]{2,5}\b)/.test(userNormalized)) {
389
+ const fileToolNames = ['write_file', 'edit_file', 'files', 'shell', 'execute_command']
390
+ const usedFileTools = params.toolEvents.some((e) => e.name && fileToolNames.includes(e.name))
391
+ if (!usedFileTools) return true
392
+ }
393
+ if (looksLikeIncompleteDeliverableResponse(trimmed)) return true
394
+ return trimmed.length < 120 && params.toolEvents.length >= 3
395
+ }
396
+
397
+ function updateStreamedToolEvents(events: MessageToolEvent[], event: { type: 'call' | 'result'; name: string; input?: string; output?: string; toolCallId?: string }) {
398
+ if (event.type === 'call') {
399
+ events.push({
400
+ name: event.name,
401
+ input: event.input || '',
402
+ toolCallId: event.toolCallId,
403
+ })
404
+ return
405
+ }
406
+ const index = event.toolCallId
407
+ ? events.findLastIndex((entry) => entry.toolCallId === event.toolCallId && !entry.output)
408
+ : events.findLastIndex((entry) => entry.name === event.name && !entry.output)
409
+ if (index === -1) return
410
+ events[index] = {
411
+ ...events[index],
412
+ output: event.output || '',
413
+ }
414
+ }
415
+
416
+ function renderToolEvidence(events: MessageToolEvent[]): string {
417
+ return events
418
+ .slice(-10)
419
+ .map((event, index) => [
420
+ `Tool ${index + 1}: ${event.name}`,
421
+ event.input ? `Input: ${event.input}` : '',
422
+ event.output ? `Output: ${event.output.slice(0, 1200)}` : '',
423
+ ].filter(Boolean).join('\n'))
424
+ .join('\n\n')
425
+ }
426
+
427
+ async function buildForcedExternalServiceSummary(params: {
428
+ llm: { invoke: (messages: HumanMessage[]) => Promise<{ content: unknown }> }
429
+ userMessage: string
430
+ fullText: string
431
+ toolEvents: MessageToolEvent[]
432
+ }): Promise<string | null> {
433
+ const prompt = [
434
+ 'You are finishing an interrupted external-service tool run.',
435
+ 'Do not call tools. Do not continue browsing.',
436
+ 'Based only on the objective, partial assistant text, and tool evidence below, produce a concise final status with exactly these headings:',
437
+ 'Last reversible step',
438
+ 'Exact blocker',
439
+ 'Safest next action',
440
+ '',
441
+ `Objective:\n${params.userMessage}`,
442
+ '',
443
+ `Partial assistant text:\n${params.fullText || '(none)'}`,
444
+ '',
445
+ `Tool evidence:\n${renderToolEvidence(params.toolEvents) || '(none)'}`,
446
+ ].join('\n')
447
+
448
+ try {
449
+ const response = await Promise.race([
450
+ params.llm.invoke([new HumanMessage(prompt)]),
451
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('forced-summary-timeout')), 10_000)),
452
+ ])
453
+ if (typeof response.content === 'string') return response.content.trim() || null
454
+ if (Array.isArray(response.content)) {
455
+ const text = response.content
456
+ .map((block: Record<string, unknown>) => (typeof block.text === 'string' ? block.text : ''))
457
+ .join('')
458
+ .trim()
459
+ return text || null
460
+ }
461
+ return null
462
+ } catch {
463
+ return null
464
+ }
465
+ }
466
+
467
+ function buildExternalExecutionFollowthroughPrompt(params: {
468
+ userMessage: string
469
+ fullText: string
470
+ toolEvents: MessageToolEvent[]
471
+ }): string {
472
+ return [
473
+ 'You are in a bounded external execution task and have already done enough research.',
474
+ 'Do not restart broad discovery. Do not ask the user for another prompt.',
475
+ 'Do not spend this continuation on more venue shopping. Use the already confirmed route unless one last fetch is strictly required to prepare execution.',
476
+ 'If several venue or aggregator APIs already failed, stop searching for more venues. Either use a direct onchain read path with the available wallet tools, or state the blocker.',
477
+ 'A prose approval request does not count as completion. If the next step is a sign/send/approve action, call the real wallet tool action so the runtime can create the approval request.',
478
+ 'Do not mutate already confirmed token addresses, router addresses, spender addresses, or network identifiers unless newer tool evidence proves the earlier value was wrong.',
479
+ 'Within this continuation, do exactly one of the following:',
480
+ '1. Take the next concrete execution step now using the existing tools and stop at the first approval boundary for a state-changing action.',
481
+ '2. If no safe executable step exists with the current tools, state the exact blocker with evidence.',
482
+ 'A successful continuation ends with one of these outcomes only: an approval request, a broadcast transaction, or a final blocker summary.',
483
+ 'Prefer the route sources and facts already confirmed in the tool evidence below. Do not keep shopping for new venues unless the current options are clearly unusable.',
484
+ 'If the tool evidence already includes enough information to prepare a contract call, approval, quote read, or transaction simulation, do that now instead of making another search or HTTP request.',
485
+ '',
486
+ `Objective:\n${params.userMessage}`,
487
+ '',
488
+ `Current partial response:\n${params.fullText || '(none)'}`,
489
+ '',
490
+ `Recent tool evidence:\n${renderToolEvidence(params.toolEvents) || '(none)'}`,
491
+ ].join('\n')
492
+ }
493
+
494
+ function buildDeliverableFollowthroughPrompt(params: {
495
+ userMessage: string
496
+ fullText: string
497
+ toolEvents: MessageToolEvent[]
498
+ }): string {
499
+ return [
500
+ 'You are in the middle of a multi-step deliverable and stopped after only a partial batch of work.',
501
+ 'Continue from the existing workspace and artifacts. Do not restart from scratch and do not ask the user to restate the request.',
502
+ 'Do not stop after one partial batch. Finish every requested deliverable that is still outstanding before concluding.',
503
+ 'If a requested artifact cannot be produced, say exactly which artifact is missing, what blocked it, and what you already completed.',
504
+ 'Use the existing files, screenshots, and generated outputs first. Inspect them if needed, then complete the remaining work.',
505
+ 'End with a concise grouped completion summary that lists exact file paths, upload URLs, localhost URLs/ports, and screenshots you produced.',
506
+ '',
507
+ `Objective:\n${params.userMessage}`,
508
+ '',
509
+ `Current partial response:\n${params.fullText || '(none)'}`,
510
+ '',
511
+ `Recent tool evidence:\n${renderToolEvidence(params.toolEvents) || '(none)'}`,
512
+ ].join('\n')
513
+ }
514
+
127
515
  /** Detect whether a user message is a broad, high-level goal that benefits from decomposition. */
128
516
  function isBroadGoal(text: string): boolean {
129
517
  if (text.length < 50) return false
@@ -140,10 +528,10 @@ const GOAL_DECOMPOSITION_BLOCK = [
140
528
  '## Goal Decomposition',
141
529
  'When you receive a broad, open-ended goal:',
142
530
  '1. Break it into 3-7 concrete, sequentially-executable subtasks before taking action.',
143
- '2. If manage_tasks is available, create a task for each subtask to track progress.',
144
- '3. Present the plan as a short checklist or numbered list in plain language.',
145
- '4. Execute the first subtask immediately — do not stop after planning.',
146
- '5. After each subtask, update progress and move to the next.',
531
+ '2. If manage_tasks is available, use it only for durable tracking: multi-turn work, delegation, explicit backlog requests, or work you expect to resume later. Do not create a task for every micro-step.',
532
+ '3. Present the plan as a short checklist or numbered list in plain language. If durable tracking is unnecessary, keep it inline instead of creating tasks.',
533
+ '4. Execute the first substantive subtask immediately — do not stop after planning.',
534
+ '5. Update only the durable tasks you actually created; otherwise just continue executing and report progress plainly.',
147
535
  ].join('\n')
148
536
 
149
537
  function buildAgenticExecutionPolicy(opts: {
@@ -153,6 +541,8 @@ function buildAgenticExecutionPolicy(opts: {
153
541
  heartbeatIntervalSec: number
154
542
  platformAssignScope?: 'self' | 'all'
155
543
  userMessage?: string
544
+ responseStyle?: 'concise' | 'normal' | 'detailed' | null
545
+ responseMaxChars?: number | null
156
546
  }) {
157
547
  const hasTooling = opts.enabledPlugins.length > 0
158
548
  const pluginLines = buildPluginCapabilityLines(opts.enabledPlugins, { platformAssignScope: opts.platformAssignScope })
@@ -166,9 +556,12 @@ function buildAgenticExecutionPolicy(opts: {
166
556
  hasTooling
167
557
  ? 'I take initiative — plan briefly, execute tools, evaluate, iterate until done. Never stop at advice when action is implied.'
168
558
  : 'No tools enabled. Be explicit about what tool access is needed.',
169
- 'Follow through on stated intentions with tool calls. Never claim results without tool evidence.',
559
+ 'IMPORTANT: If information was already mentioned in THIS conversation, answer from context — do NOT call memory_tool or web search to look it up again. Only use memory_tool to recall info from PREVIOUS conversations not in the current thread.',
560
+ 'If a skill applies to the task, follow its recommended approach first. Skill-specific commands are faster and more reliable than generic web search. Minimize tool calls — combine steps where possible.',
170
561
  'If a task explicitly names an enabled tool, use that tool before declaring success. A prose request is not a substitute for `ask_human`, and browser work is not a substitute for `email` delivery.',
171
562
  'When `ask_human` is enabled, collect required human input through the tool instead of asking for it only in plain assistant text.',
563
+ 'Do not narrate routine tool calls. Just call the tool and report the outcome. Only narrate when the step is complex, sensitive, or the user needs to understand what is happening.',
564
+ 'Do not repeat the same tool call with identical arguments. If a tool returns an error or empty result, try a different approach instead of retrying the same call.',
172
565
  opts.loopMode === 'ongoing'
173
566
  ? 'Loop: ONGOING — keep iterating until done, blocked, or limits reached.'
174
567
  : 'Loop: BOUNDED — execute multiple steps but finish within recursion budget.',
@@ -181,16 +574,15 @@ function buildAgenticExecutionPolicy(opts: {
181
574
  // Response behavior
182
575
  parts.push(
183
576
  '## Response Rules',
184
- 'NO_MESSAGE: reply with exactly this to suppress delivery for pure acknowledgments (ok/thanks/bye/emoji/lol).',
185
- 'Always reply to: questions, tasks, emotional sharing, or when you have something useful to add.',
186
- 'Execute by default only ask for confirmation on high-risk/irreversible actions. Do not end every response with a question.',
187
- 'Never repeat completed side effects. Verify state first.',
188
- 'If a tool returns an error or validation failure, do not claim the task succeeded. Retry with corrected arguments or explain the blocker plainly.',
189
- 'Prefer the most specific tool you already have. Example: use `manage_schedules` for schedules and `manage_tasks` for tasks; treat `manage_platform` as a fallback umbrella only when a specific `manage_*` tool is unavailable.',
190
- 'For recurring, cron, interval, or follow-up automation requests, use `manage_schedules` directly when it is available.',
191
- 'Delegation is optional, not a stopping condition. If one delegate backend is unavailable or unauthenticated, try another delegate backend or continue with your other tools.',
192
- 'If a required tool is missing, request access by name with `manage_capabilities` action `request_access` (for example `shell` or `manage_schedules`).',
193
- 'Only mention files, screenshots, URLs, or download links that were actually returned by tools. Copy returned links exactly; do not rewrite them or prepend extra prefixes like "sandbox:".',
577
+ 'NO_MESSAGE: reply with exactly this for pure acknowledgments (ok/thanks/bye/emoji).',
578
+ 'Execute by default only confirm on high-risk actions.',
579
+ 'If a tool errors, retry or explain the blocker. Never claim success without evidence.',
580
+ 'Keep responses concise. Bullet points over prose. After file operations, confirm the result briefly (path and status) without echoing the full file contents.',
581
+ opts.responseStyle === 'concise'
582
+ ? `IMPORTANT: Be extremely concise.${opts.responseMaxChars ? ` Keep responses under ${opts.responseMaxChars} characters.` : ' Target under 500 characters.'} Lead with the answer, skip preamble.`
583
+ : opts.responseStyle === 'detailed'
584
+ ? 'Provide thorough, detailed explanations when helpful.'
585
+ : '',
194
586
  `Heartbeat: if message is "${opts.heartbeatPrompt}", reply "HEARTBEAT_OK" unless you have a progress update.`,
195
587
  opts.heartbeatIntervalSec > 0 ? `Heartbeat cadence: ~${opts.heartbeatIntervalSec}s.` : '',
196
588
  )
@@ -198,6 +590,10 @@ function buildAgenticExecutionPolicy(opts: {
198
590
  if (toolDisciplineLines.length) parts.push('## Tool Discipline', ...toolDisciplineLines)
199
591
  if (pluginLines.length) parts.push('What I can do:\n' + pluginLines.join('\n'))
200
592
  if (opts.userMessage && isBroadGoal(opts.userMessage)) parts.push(GOAL_DECOMPOSITION_BLOCK)
593
+ if (opts.userMessage && looksLikeExternalWalletTask(opts.userMessage)) {
594
+ const externalExecutionBlock = buildExternalWalletExecutionBlock(opts.enabledPlugins)
595
+ if (externalExecutionBlock) parts.push(externalExecutionBlock)
596
+ }
201
597
  if (opts.userMessage && looksLikeOpenEndedDeliverableTask(opts.userMessage) && opts.enabledPlugins.some((toolId) => toolId === 'files' || toolId === 'edit_file')) {
202
598
  parts.push(OPEN_ENDED_REVISION_BLOCK)
203
599
  }
@@ -213,6 +609,52 @@ export interface StreamAgentChatResult {
213
609
  finalResponse: string
214
610
  }
215
611
 
612
+ function resolveToolOnlyFinalResponse(toolEvents: MessageToolEvent[] | undefined): string {
613
+ const events = Array.isArray(toolEvents) ? toolEvents : []
614
+ for (let index = events.length - 1; index >= 0; index--) {
615
+ const event = events[index]
616
+ const output = typeof event?.output === 'string'
617
+ ? extractSuggestions(event.output).clean.trim()
618
+ : ''
619
+ if (!output) continue
620
+ if (/^error[:\s]/i.test(output)) continue
621
+ if (output.startsWith('{') || output.startsWith('[')) continue
622
+ return output
623
+ }
624
+ return ''
625
+ }
626
+
627
+ export function resolveFinalStreamResponseText(params: {
628
+ fullText: string
629
+ lastSegment: string
630
+ lastSettledSegment: string
631
+ hasToolCalls: boolean
632
+ toolEvents?: MessageToolEvent[]
633
+ }): string {
634
+ const fullText = params.fullText || ''
635
+ if (!params.hasToolCalls) return fullText
636
+
637
+ const candidates = [
638
+ extractSuggestions(params.lastSegment || '').clean.trim(),
639
+ extractSuggestions(params.lastSettledSegment || '').clean.trim(),
640
+ extractSuggestions(fullText).clean.trim(),
641
+ resolveToolOnlyFinalResponse(params.toolEvents),
642
+ ]
643
+
644
+ return candidates.find((candidate) => candidate.length > 0) || ''
645
+ }
646
+
647
+ export function resolveContinuationAssistantText(params: {
648
+ iterationText: string
649
+ lastSegment: string
650
+ }): string {
651
+ const candidates = [
652
+ extractSuggestions(params.iterationText || '').clean.trim(),
653
+ extractSuggestions(params.lastSegment || '').clean.trim(),
654
+ ]
655
+ return candidates.find((candidate) => candidate.length > 0) || ''
656
+ }
657
+
216
658
  export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<StreamAgentChatResult> {
217
659
  const startTs = Date.now()
218
660
  const { session, message, imagePath, attachedFiles, apiKey, systemPrompt, write, history, fallbackCredentialIds, signal } = opts
@@ -275,6 +717,10 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
275
717
  let agentMcpServerIds: string[] | undefined
276
718
  let agentMcpDisabledTools: string[] | undefined
277
719
  let agentHeartbeatEnabled = false
720
+ let agentMemoryScopeMode: 'auto' | 'all' | 'global' | 'agent' | 'session' | 'project' | null = null
721
+ let agentResponseStyle: 'concise' | 'normal' | 'detailed' | null = null
722
+ let agentResponseMaxChars: number | null = null
723
+ const activeProjectContext = resolveActiveProjectContext(session)
278
724
  if (session.agentId) {
279
725
  const agents = loadAgents()
280
726
  const agent = agents[session.agentId]
@@ -282,6 +728,9 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
282
728
  agentMcpServerIds = agent?.mcpServerIds
283
729
  agentMcpDisabledTools = agent?.mcpDisabledTools
284
730
  agentHeartbeatEnabled = agent?.heartbeatEnabled === true
731
+ agentMemoryScopeMode = agent?.memoryScopeMode || null
732
+ agentResponseStyle = agent?.responseStyle || null
733
+ agentResponseMaxChars = agent?.responseMaxChars || null
285
734
  if (!hasProvidedSystemPrompt) {
286
735
  // Identity block — make sure the agent knows who it is
287
736
  const identityLines = [`## My Identity`, `My name is ${agent?.name || 'Agent'}.`]
@@ -294,17 +743,25 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
294
743
  if (agent?.systemPrompt) stateModifierParts.push(agent.systemPrompt)
295
744
  if (agent?.skillIds?.length) {
296
745
  const allSkills = loadSkills()
297
- for (const skillId of agent.skillIds) {
298
- const skill = allSkills[skillId]
299
- if (skill?.content) stateModifierParts.push(`## Skill: ${skill.name}\n${skill.content}`)
300
- }
746
+ const skillPromptText = buildSkillPromptText(allSkills, agent.skillIds)
747
+ if (skillPromptText) stateModifierParts.push(skillPromptText)
301
748
  }
749
+
750
+ // Auto-discover workspace/bundled skills not already in the DB
751
+ try {
752
+ const { discoverSkills } = await import('./skill-discovery')
753
+ const discovered = discoverSkills({ cwd: session.cwd })
754
+ if (discovered.length > 0) {
755
+ const discoveredBlock = discovered
756
+ .map(s => `- **${s.name}**: ${(s.description || '').slice(0, 120)}`)
757
+ .join('\n')
758
+ stateModifierParts.push(`## Available Skills\n${discoveredBlock}`)
759
+ }
760
+ } catch { /* non-critical */ }
302
761
  }
303
762
  }
304
763
 
305
- if (!hasProvidedSystemPrompt) {
306
- stateModifierParts.push('I\'m here to get things done. I take action, use my tools, and focus on outcomes.')
307
- }
764
+ // (conciseness and action-orientation are covered in the execution policy below)
308
765
 
309
766
  // Thinking level guidance (applies to all providers via system prompt)
310
767
  if (agentThinkingLevel) {
@@ -317,8 +774,21 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
317
774
  stateModifierParts.push(`## Reasoning Depth\n${thinkingGuidance[agentThinkingLevel]}`)
318
775
  }
319
776
 
320
- // Inject agent awareness (Phase 2: agents know about each other)
321
- if ((session.plugins || []).length > 0 && session.agentId) {
777
+ // Inject workspace context files only for agents with heartbeat enabled
778
+ // (these files provide goals and autonomous operating context)
779
+ if (!hasProvidedSystemPrompt && agentHeartbeatEnabled) {
780
+ try {
781
+ const { buildWorkspaceContext } = await import('./workspace-context')
782
+ const wsCtx = buildWorkspaceContext({ cwd: session.cwd })
783
+ if (wsCtx.block) stateModifierParts.push(wsCtx.block)
784
+ } catch {
785
+ // Workspace context is non-critical
786
+ }
787
+ }
788
+
789
+ // Inject agent awareness only if agent has delegation capabilities
790
+ const hasDelegation = sessionPlugins.some(p => p === 'delegate' || p === 'spawn_subagent')
791
+ if (hasDelegation && session.agentId) {
322
792
  try {
323
793
  const { buildAgentAwarenessBlock } = await import('./agent-registry')
324
794
  const awarenessBlock = buildAgentAwarenessBlock(session.agentId)
@@ -336,6 +806,43 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
336
806
  // Plugin context injection is non-critical
337
807
  }
338
808
 
809
+ if (!hasProvidedSystemPrompt && activeProjectContext.projectId) {
810
+ const projectLines = ['## Current Project']
811
+ if (activeProjectContext.project?.name) {
812
+ projectLines.push(`Active project: ${activeProjectContext.project.name}.`)
813
+ } else {
814
+ projectLines.push(`Active project ID: ${activeProjectContext.projectId}.`)
815
+ }
816
+ if (activeProjectContext.project?.description) {
817
+ projectLines.push(`Project description: ${activeProjectContext.project.description}`)
818
+ projectLines.push('Treat the project description above as authoritative context for who the project is for, what it is focused on, and which pilot priorities matter right now. If the user asks about the active project, answer from that description instead of saying the context is unavailable.')
819
+ }
820
+ if (activeProjectContext.objective) projectLines.push(`Project objective: ${activeProjectContext.objective}`)
821
+ if (activeProjectContext.audience) projectLines.push(`Who it is for: ${activeProjectContext.audience}`)
822
+ if (activeProjectContext.priorities.length > 0) projectLines.push(`Pilot priorities: ${activeProjectContext.priorities.join('; ')}`)
823
+ if (activeProjectContext.openObjectives.length > 0) projectLines.push(`Open objectives: ${activeProjectContext.openObjectives.join('; ')}`)
824
+ if (activeProjectContext.capabilityHints.length > 0) projectLines.push(`Suggested operating modes: ${activeProjectContext.capabilityHints.join('; ')}`)
825
+ if (activeProjectContext.credentialRequirements.length > 0) projectLines.push(`Credential and secret requirements: ${activeProjectContext.credentialRequirements.join('; ')}`)
826
+ if (activeProjectContext.successMetrics.length > 0) projectLines.push(`Success metrics: ${activeProjectContext.successMetrics.join('; ')}`)
827
+ if (activeProjectContext.heartbeatPrompt) projectLines.push(`Preferred heartbeat prompt: ${activeProjectContext.heartbeatPrompt}`)
828
+ if (activeProjectContext.heartbeatIntervalSec != null) projectLines.push(`Preferred heartbeat interval: ${activeProjectContext.heartbeatIntervalSec}s`)
829
+ if (activeProjectContext.resourceSummary) {
830
+ const summary = activeProjectContext.resourceSummary
831
+ const resourceBits = [
832
+ `open tasks ${summary.openTaskCount}`,
833
+ `active schedules ${summary.activeScheduleCount}`,
834
+ `project secrets ${summary.secretCount}`,
835
+ ]
836
+ if (summary.topTaskTitles.length > 0) projectLines.push(`Top open tasks: ${summary.topTaskTitles.join('; ')}`)
837
+ if (summary.scheduleNames.length > 0) projectLines.push(`Active schedules: ${summary.scheduleNames.join('; ')}`)
838
+ if (summary.secretNames.length > 0) projectLines.push(`Known project secrets: ${summary.secretNames.join('; ')}`)
839
+ projectLines.push(`Project resource summary: ${resourceBits.join(', ')}.`)
840
+ }
841
+ if (activeProjectContext.projectRoot) projectLines.push(`Workspace root: ${activeProjectContext.projectRoot}`)
842
+ projectLines.push('When creating project tasks, schedules, secrets, memories, or deliverables for this work, default them to the active project unless the user redirects you.')
843
+ stateModifierParts.push(projectLines.join('\n'))
844
+ }
845
+
339
846
  // Tell the LLM about available plugins and their access status
340
847
  {
341
848
  const agentEnabledSet = new Set(sessionPlugins)
@@ -358,21 +865,15 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
358
865
  }
359
866
  }
360
867
 
361
- const parts: string[] = []
362
- if (enabledButNoAccess.length > 0) {
363
- parts.push(
364
- `**Available but not assigned to me:** ${enabledButNoAccess.join(', ')}\n` +
365
- 'I can request access using `manage_capabilities` with action "request_access" or `request_tool_access`.',
366
- )
367
- }
868
+ const accessParts: string[] = []
368
869
  if (globallyDisabled.length > 0) {
369
- parts.push(`**Disabled site-wide:** ${globallyDisabled.join(', ')} — ask the user to enable these in Settings > Plugins first.`)
870
+ accessParts.push(`**Disabled site-wide:** ${globallyDisabled.join(', ')}`)
370
871
  }
371
872
  if (mcpDisabled.length > 0) {
372
- parts.push(`**MCP tools not available:** ${mcpDisabled.join(', ')}`)
873
+ accessParts.push(`**MCP tools not available:** ${mcpDisabled.join(', ')}`)
373
874
  }
374
- if (parts.length > 0) {
375
- stateModifierParts.push(`## Plugin Access\n${parts.join('\n')}`)
875
+ if (accessParts.length > 0) {
876
+ stateModifierParts.push(`## Plugin Access\n${accessParts.join('\n')}`)
376
877
  }
377
878
  }
378
879
 
@@ -396,6 +897,8 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
396
897
  heartbeatIntervalSec,
397
898
  platformAssignScope: agentPlatformAssignScope,
398
899
  userMessage: message,
900
+ responseStyle: agentResponseStyle,
901
+ responseMaxChars: agentResponseMaxChars,
399
902
  }),
400
903
  )
401
904
 
@@ -407,6 +910,11 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
407
910
  platformAssignScope: agentPlatformAssignScope,
408
911
  mcpServerIds: agentMcpServerIds,
409
912
  mcpDisabledTools: agentMcpDisabledTools,
913
+ projectId: activeProjectContext.projectId,
914
+ projectRoot: activeProjectContext.projectRoot,
915
+ projectName: activeProjectContext.project?.name || null,
916
+ projectDescription: activeProjectContext.project?.description || null,
917
+ memoryScopeMode: agentMemoryScopeMode,
410
918
  })
411
919
  const agent = createReactAgent({ llm, tools, stateModifier })
412
920
  const recursionLimit = getAgentLoopRecursionLimit(runtime)
@@ -570,13 +1078,16 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
570
1078
 
571
1079
  let fullText = ''
572
1080
  let lastSegment = ''
1081
+ let lastSettledSegment = ''
573
1082
  let hasToolCalls = false
574
1083
  let needsTextSeparator = false
575
1084
  let totalInputTokens = 0
576
1085
  let totalOutputTokens = 0
577
1086
  let accumulatedThinking = ''
578
1087
  const pluginInvocations: PluginInvocationRecord[] = []
1088
+ const streamedToolEvents: MessageToolEvent[] = []
579
1089
  let currentToolInputTokens = 0
1090
+ const boundedExternalExecutionTask = looksLikeBoundedExternalExecutionTask(message)
580
1091
 
581
1092
  // Plugin hooks: beforeAgentStart
582
1093
  const pluginMgr = getPluginManager()
@@ -599,20 +1110,49 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
599
1110
  const MAX_AUTO_CONTINUES = 3
600
1111
  const MAX_TRANSIENT_RETRIES = 2
601
1112
  const MAX_REQUIRED_TOOL_CONTINUES = 2
1113
+ const MAX_EXECUTION_FOLLOWTHROUGHS = 1
1114
+ const MAX_DELIVERABLE_FOLLOWTHROUGHS = 2
1115
+ const MAX_TOOL_SUMMARY_RETRIES = 1
602
1116
  let autoContinueCount = 0
603
1117
  let transientRetryCount = 0
604
1118
  let requiredToolContinueCount = 0
1119
+ let executionFollowthroughCount = 0
1120
+ let deliverableFollowthroughCount = 0
1121
+ let toolSummaryRetryCount = 0
605
1122
  const explicitRequiredToolNames = getExplicitRequiredToolNames(message, sessionPlugins)
606
1123
  const usedToolNames = new Set<string>()
1124
+ const loopTracker = new ToolLoopTracker()
1125
+ let loopDetectionTriggered: LoopDetectionResult | null = null
1126
+ let terminalToolResponse = ''
607
1127
 
608
1128
  try {
609
- const maxIterations = MAX_AUTO_CONTINUES + MAX_TRANSIENT_RETRIES + MAX_REQUIRED_TOOL_CONTINUES
1129
+ const maxIterations = MAX_AUTO_CONTINUES + MAX_TRANSIENT_RETRIES + MAX_REQUIRED_TOOL_CONTINUES + MAX_EXECUTION_FOLLOWTHROUGHS + MAX_DELIVERABLE_FOLLOWTHROUGHS + MAX_TOOL_SUMMARY_RETRIES
610
1130
  for (let iteration = 0; iteration <= maxIterations; iteration++) {
611
- let shouldContinue: 'recursion' | 'transient' | 'required_tool' | false = false
1131
+ let shouldContinue: 'recursion' | 'transient' | 'required_tool' | 'execution_followthrough' | 'deliverable_followthrough' | 'tool_summary' | false = false
612
1132
  let requiredToolReminderNames: string[] = []
613
1133
  let waitingForToolResult = false
614
1134
  let idleTimedOut = false
1135
+ let reachedExecutionBoundary = false
1136
+ let executionFollowthroughReason: 'research_limit' | 'post_simulation' | null = null
615
1137
  let idleTimer: ReturnType<typeof setTimeout> | null = null
1138
+ let iterationText = ''
1139
+ const iterationStartState: {
1140
+ fullText: string
1141
+ lastSegment: string
1142
+ lastSettledSegment: string
1143
+ needsTextSeparator: boolean
1144
+ accumulatedThinking: string
1145
+ hasToolCalls: boolean
1146
+ toolEventCount: number
1147
+ } = {
1148
+ fullText,
1149
+ lastSegment,
1150
+ lastSettledSegment,
1151
+ needsTextSeparator,
1152
+ accumulatedThinking,
1153
+ hasToolCalls,
1154
+ toolEventCount: streamedToolEvents.length,
1155
+ }
616
1156
 
617
1157
  // Fresh per-iteration controller so an internal LangGraph abort doesn't poison subsequent iterations.
618
1158
  // Linked to the parent so client disconnect / timeout still propagates.
@@ -637,6 +1177,13 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
637
1177
  }, 90_000)
638
1178
  }
639
1179
 
1180
+ // Dedup tracking: the tool() wrapper in session-tools/index.ts creates nested
1181
+ // tool invocations. LangGraph's streamEvents v2 emits on_tool_start/on_tool_end
1182
+ // at both the wrapper level and the inner invoke level. We track accepted run_ids
1183
+ // to suppress the duplicate nested events.
1184
+ const acceptedToolRunIds = new Set<string>()
1185
+ const seenToolInputKeys = new Set<string>()
1186
+
640
1187
  try {
641
1188
  armIdleWatchdog()
642
1189
  const eventStream = agent.streamEvents(
@@ -665,10 +1212,12 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
665
1212
  } else if (block.text) {
666
1213
  if (needsTextSeparator && fullText.length > 0) {
667
1214
  fullText += '\n\n'
1215
+ iterationText += '\n\n'
668
1216
  write(`data: ${JSON.stringify({ t: 'd', text: '\n\n' })}\n\n`)
669
1217
  needsTextSeparator = false
670
1218
  }
671
1219
  fullText += block.text
1220
+ iterationText += block.text
672
1221
  lastSegment += block.text
673
1222
  write(`data: ${JSON.stringify({ t: 'd', text: block.text })}\n\n`)
674
1223
  }
@@ -678,10 +1227,12 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
678
1227
  if (text) {
679
1228
  if (needsTextSeparator && fullText.length > 0) {
680
1229
  fullText += '\n\n'
1230
+ iterationText += '\n\n'
681
1231
  write(`data: ${JSON.stringify({ t: 'd', text: '\n\n' })}\n\n`)
682
1232
  needsTextSeparator = false
683
1233
  }
684
1234
  fullText += text
1235
+ iterationText += text
685
1236
  lastSegment += text
686
1237
  write(`data: ${JSON.stringify({ t: 'd', text })}\n\n`)
687
1238
  }
@@ -701,16 +1252,36 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
701
1252
  totalOutputTokens += usage.completionTokens || usage.output_tokens || usage.completion_tokens || 0
702
1253
  }
703
1254
  } else if (kind === 'on_tool_start') {
1255
+ const toolName = event.name || 'unknown'
1256
+ const input = event.data?.input
1257
+ const inputStr = typeof input === 'string' ? input : JSON.stringify(input)
1258
+
1259
+ // Dedup: skip nested duplicate from tool() wrapper in session-tools.
1260
+ // The wrapper creates a second on_tool_start with the same (name, input)
1261
+ // but a different run_id. We accept the first and reject the rest.
1262
+ const toolDedupeKey = `${toolName}::${inputStr}`
1263
+ if (seenToolInputKeys.has(toolDedupeKey)) {
1264
+ // Nested duplicate — don't emit SSE, don't log, don't track
1265
+ continue
1266
+ }
1267
+ seenToolInputKeys.add(toolDedupeKey)
1268
+ acceptedToolRunIds.add(event.run_id)
1269
+
704
1270
  clearIdleWatchdog()
705
1271
  waitingForToolResult = true
706
1272
  hasToolCalls = true
707
1273
  needsTextSeparator = true
1274
+ const settledSegment = extractSuggestions(lastSegment).clean.trim()
1275
+ if (settledSegment) lastSettledSegment = settledSegment
708
1276
  lastSegment = ''
709
- const toolName = event.name || 'unknown'
710
- usedToolNames.add(toolName)
711
- const input = event.data?.input
1277
+ usedToolNames.add(canonicalizePluginId(toolName) || toolName)
1278
+ // Shell-based HTTP (curl/wget/gh) satisfies research tool requirements —
1279
+ // don't force the agent to also use web_search when shell already fetched the data.
1280
+ if ((canonicalizePluginId(toolName) || toolName) === 'shell' && inputStr) {
1281
+ const cmdMatch = /curl|wget|http|gh\s+(issue|pr|api|repo|release|search|run)/.test(inputStr)
1282
+ if (cmdMatch) usedToolNames.add('web')
1283
+ }
712
1284
  // Estimate input tokens for plugin invocation tracking
713
- const inputStr = typeof input === 'string' ? input : JSON.stringify(input)
714
1285
  currentToolInputTokens = Math.ceil((inputStr?.length || 0) / 4)
715
1286
  logExecution(session.id, 'tool_call', `${toolName} invoked`, {
716
1287
  agentId: session.agentId,
@@ -720,8 +1291,19 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
720
1291
  t: 'tool_call',
721
1292
  toolName,
722
1293
  toolInput: inputStr,
1294
+ toolCallId: event.run_id,
723
1295
  })}\n\n`)
1296
+ updateStreamedToolEvents(streamedToolEvents, {
1297
+ type: 'call',
1298
+ name: toolName,
1299
+ input: inputStr,
1300
+ toolCallId: event.run_id,
1301
+ })
724
1302
  } else if (kind === 'on_tool_end') {
1303
+ // Dedup: skip on_tool_end for run_ids we didn't accept in on_tool_start
1304
+ if (!acceptedToolRunIds.has(event.run_id)) continue
1305
+ acceptedToolRunIds.delete(event.run_id)
1306
+
725
1307
  waitingForToolResult = false
726
1308
  armIdleWatchdog()
727
1309
  const toolName = event.name || 'unknown'
@@ -764,11 +1346,89 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
764
1346
  })
765
1347
  currentToolInputTokens = 0
766
1348
 
1349
+ // --- Tool loop detection (modelled after OpenClaw) ---
1350
+ const loopResult = loopTracker.record(toolName, event.data?.input, output)
1351
+ if (loopResult) {
1352
+ logExecution(session.id, 'loop_detection', loopResult.message, {
1353
+ agentId: session.agentId,
1354
+ detail: { detector: loopResult.detector, severity: loopResult.severity, toolName },
1355
+ })
1356
+ if (loopResult.severity === 'critical') {
1357
+ loopDetectionTriggered = loopResult
1358
+ write(`data: ${JSON.stringify({ t: 'status', text: JSON.stringify({ loopDetection: loopResult.detector, severity: 'critical', message: loopResult.message }) })}\n\n`)
1359
+ break
1360
+ }
1361
+ if (loopResult.severity === 'warning') {
1362
+ write(`data: ${JSON.stringify({ t: 'status', text: JSON.stringify({ loopDetection: loopResult.detector, severity: 'warning', message: loopResult.message }) })}\n\n`)
1363
+ }
1364
+ }
1365
+
767
1366
  write(`data: ${JSON.stringify({
768
1367
  t: 'tool_result',
769
1368
  toolName,
770
1369
  toolOutput: outputStr?.slice(0, 2000),
1370
+ toolCallId: event.run_id,
771
1371
  })}\n\n`)
1372
+ updateStreamedToolEvents(streamedToolEvents, {
1373
+ type: 'result',
1374
+ name: toolName,
1375
+ output: outputStr,
1376
+ toolCallId: event.run_id,
1377
+ })
1378
+ if (shouldTerminateOnSuccessfulMemoryMutation({
1379
+ toolName,
1380
+ toolInput: event.data?.input,
1381
+ toolOutput: outputStr || '',
1382
+ })) {
1383
+ terminalToolResponse = extractSuggestions(outputStr || '').clean.trim()
1384
+ if (terminalToolResponse) {
1385
+ lastSegment = terminalToolResponse
1386
+ lastSettledSegment = terminalToolResponse
1387
+ }
1388
+ logExecution(session.id, 'decision', 'Successful memory write is terminal for this turn.', {
1389
+ agentId: session.agentId,
1390
+ detail: { toolName, action: resolveToolAction(event.data?.input) || null },
1391
+ })
1392
+ write(`data: ${JSON.stringify({
1393
+ t: 'status',
1394
+ text: JSON.stringify({ terminalToolResult: 'memory_write' }),
1395
+ })}\n\n`)
1396
+ break
1397
+ }
1398
+ if (boundedExternalExecutionTask && getWalletApprovalBoundaryAction(outputStr || '')) {
1399
+ reachedExecutionBoundary = true
1400
+ write(`data: ${JSON.stringify({
1401
+ t: 'status',
1402
+ text: JSON.stringify({ executionBoundary: 'wallet_approval' }),
1403
+ })}\n\n`)
1404
+ break
1405
+ }
1406
+ if (
1407
+ boundedExternalExecutionTask
1408
+ && ['http_request', 'web', 'web_search', 'web_fetch', 'browser'].includes(toolName)
1409
+ && !hasStateChangingWalletEvidence(streamedToolEvents)
1410
+ && countExternalExecutionResearchSteps(streamedToolEvents) >= 5
1411
+ && countDistinctExternalResearchHosts(streamedToolEvents) >= 3
1412
+ ) {
1413
+ executionFollowthroughReason = 'research_limit'
1414
+ write(`data: ${JSON.stringify({
1415
+ t: 'status',
1416
+ text: JSON.stringify({ executionBoundary: 'research_limit' }),
1417
+ })}\n\n`)
1418
+ break
1419
+ }
1420
+ if (
1421
+ boundedExternalExecutionTask
1422
+ && !hasStateChangingWalletEvidence(streamedToolEvents)
1423
+ && isWalletSimulationResult(toolName, outputStr || '')
1424
+ ) {
1425
+ executionFollowthroughReason = 'post_simulation'
1426
+ write(`data: ${JSON.stringify({
1427
+ t: 'status',
1428
+ text: JSON.stringify({ executionBoundary: 'post_simulation' }),
1429
+ })}\n\n`)
1430
+ break
1431
+ }
772
1432
  }
773
1433
  }
774
1434
  } catch (innerErr: unknown) {
@@ -805,12 +1465,25 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
805
1465
  })
806
1466
  write(`data: ${JSON.stringify({ t: 'status', text: JSON.stringify({ autoContinue: autoContinueCount, maxContinues: MAX_AUTO_CONTINUES }) })}\n\n`)
807
1467
  } else if (isTransientAbort && transientRetryCount < MAX_TRANSIENT_RETRIES && !abortController.signal.aborted) {
1468
+ // Reset client-side accumulated state — partial text/tool events from the
1469
+ // failed iteration can't be un-sent, so tell the client to clear them.
1470
+ const hadPartialOutput = iterationText.length > 0 || streamedToolEvents.length > iterationStartState.toolEventCount
1471
+ fullText = iterationStartState.fullText
1472
+ lastSegment = iterationStartState.lastSegment
1473
+ lastSettledSegment = iterationStartState.lastSettledSegment
1474
+ needsTextSeparator = iterationStartState.needsTextSeparator
1475
+ accumulatedThinking = iterationStartState.accumulatedThinking
1476
+ hasToolCalls = iterationStartState.hasToolCalls
1477
+ streamedToolEvents.length = iterationStartState.toolEventCount
808
1478
  shouldContinue = 'transient'
809
1479
  transientRetryCount++
810
1480
  logExecution(session.id, 'decision', `Transient error, retrying (${transientRetryCount}/${MAX_TRANSIENT_RETRIES}): ${errMsg}`, {
811
1481
  agentId: session.agentId,
812
- detail: { errName, errMsg },
1482
+ detail: { errName, errMsg, hadPartialOutput },
813
1483
  })
1484
+ if (hadPartialOutput) {
1485
+ write(`data: ${JSON.stringify({ t: 'reset', text: iterationStartState.fullText })}\n\n`)
1486
+ }
814
1487
  write(`data: ${JSON.stringify({ t: 'status', text: JSON.stringify({ transientRetry: transientRetryCount, maxRetries: MAX_TRANSIENT_RETRIES, error: errMsg }) })}\n\n`)
815
1488
  } else {
816
1489
  // Non-retryable error or exhausted retries — rethrow to outer catch
@@ -821,8 +1494,38 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
821
1494
  abortController.signal.removeEventListener('abort', onParentAbort)
822
1495
  }
823
1496
 
1497
+ if (reachedExecutionBoundary) break
1498
+
1499
+ // Tool loop detection: critical severity stops the entire agent turn
1500
+ if (loopDetectionTriggered) {
1501
+ write(`data: ${JSON.stringify({ t: 'err', text: loopDetectionTriggered.message })}\n\n`)
1502
+ break
1503
+ }
1504
+
1505
+ if (
1506
+ executionFollowthroughReason
1507
+ && !shouldContinue
1508
+ && executionFollowthroughCount < MAX_EXECUTION_FOLLOWTHROUGHS
1509
+ ) {
1510
+ shouldContinue = 'execution_followthrough'
1511
+ executionFollowthroughCount++
1512
+ write(`data: ${JSON.stringify({
1513
+ t: 'status',
1514
+ text: JSON.stringify({
1515
+ externalExecutionFollowthrough: executionFollowthroughCount,
1516
+ maxFollowthroughs: MAX_EXECUTION_FOLLOWTHROUGHS,
1517
+ reason: executionFollowthroughReason,
1518
+ }),
1519
+ })}\n\n`)
1520
+ }
1521
+
824
1522
  if (!shouldContinue && explicitRequiredToolNames.length > 0 && requiredToolContinueCount < MAX_REQUIRED_TOOL_CONTINUES) {
825
- requiredToolReminderNames = explicitRequiredToolNames.filter((toolName) => !usedToolNames.has(toolName))
1523
+ // Canonicalize required tool names before comparing — tool planning uses
1524
+ // alias names (e.g. web_search) while LangGraph emits canonical names (e.g. web).
1525
+ requiredToolReminderNames = explicitRequiredToolNames.filter((toolName) => {
1526
+ const canonical = canonicalizePluginId(toolName) || toolName
1527
+ return !usedToolNames.has(toolName) && !usedToolNames.has(canonical)
1528
+ })
826
1529
  if (requiredToolReminderNames.length > 0) {
827
1530
  shouldContinue = 'required_tool'
828
1531
  requiredToolContinueCount++
@@ -837,21 +1540,151 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
837
1540
  }
838
1541
  }
839
1542
 
1543
+ if (!shouldContinue
1544
+ && executionFollowthroughCount < MAX_EXECUTION_FOLLOWTHROUGHS
1545
+ && shouldForceExternalExecutionFollowthrough({
1546
+ userMessage: message,
1547
+ finalResponse: resolveFinalStreamResponseText({
1548
+ fullText,
1549
+ lastSegment,
1550
+ lastSettledSegment,
1551
+ hasToolCalls,
1552
+ toolEvents: streamedToolEvents,
1553
+ }),
1554
+ hasToolCalls,
1555
+ toolEvents: streamedToolEvents,
1556
+ })) {
1557
+ shouldContinue = 'execution_followthrough'
1558
+ executionFollowthroughCount++
1559
+ write(`data: ${JSON.stringify({
1560
+ t: 'status',
1561
+ text: JSON.stringify({
1562
+ externalExecutionFollowthrough: executionFollowthroughCount,
1563
+ maxFollowthroughs: MAX_EXECUTION_FOLLOWTHROUGHS,
1564
+ }),
1565
+ })}\n\n`)
1566
+ }
1567
+
1568
+ if (!shouldContinue
1569
+ && deliverableFollowthroughCount < MAX_DELIVERABLE_FOLLOWTHROUGHS
1570
+ && shouldForceDeliverableFollowthrough({
1571
+ userMessage: message,
1572
+ finalResponse: resolveFinalStreamResponseText({
1573
+ fullText,
1574
+ lastSegment,
1575
+ lastSettledSegment,
1576
+ hasToolCalls,
1577
+ toolEvents: streamedToolEvents,
1578
+ }),
1579
+ hasToolCalls,
1580
+ toolEvents: streamedToolEvents,
1581
+ })) {
1582
+ shouldContinue = 'deliverable_followthrough'
1583
+ deliverableFollowthroughCount++
1584
+ write(`data: ${JSON.stringify({
1585
+ t: 'status',
1586
+ text: JSON.stringify({
1587
+ deliverableFollowthrough: deliverableFollowthroughCount,
1588
+ maxFollowthroughs: MAX_DELIVERABLE_FOLLOWTHROUGHS,
1589
+ }),
1590
+ })}\n\n`)
1591
+ }
1592
+
1593
+ // Generic fallback: tools were called but the model produced no text response.
1594
+ // This catches edge cases (e.g. after transient retry) where specialized
1595
+ // followthrough conditions don't match. Ask the LLM to summarize tool results.
1596
+ if (
1597
+ !shouldContinue
1598
+ && hasToolCalls
1599
+ && !fullText.trim()
1600
+ && streamedToolEvents.length > 0
1601
+ && toolSummaryRetryCount < MAX_TOOL_SUMMARY_RETRIES
1602
+ ) {
1603
+ shouldContinue = 'tool_summary'
1604
+ toolSummaryRetryCount++
1605
+ logExecution(session.id, 'decision', `Tools called but no text generated — forcing summary continuation`, {
1606
+ agentId: session.agentId,
1607
+ detail: { toolEventCount: streamedToolEvents.length, toolSummaryRetryCount },
1608
+ })
1609
+ write(`data: ${JSON.stringify({
1610
+ t: 'status',
1611
+ text: JSON.stringify({ toolSummary: toolSummaryRetryCount, reason: 'empty_response_after_tools' }),
1612
+ })}\n\n`)
1613
+ }
1614
+
840
1615
  if (!shouldContinue) break
841
1616
 
1617
+ const continuationAssistantText = resolveContinuationAssistantText({
1618
+ iterationText,
1619
+ lastSegment,
1620
+ })
1621
+
842
1622
  if (shouldContinue === 'recursion') {
843
- // Append accumulated text and a continue prompt
844
- if (fullText.trim()) {
845
- langchainMessages.push(new AIMessage({ content: fullText }))
1623
+ // Continue with only the newly produced assistant text from this
1624
+ // iteration, not the cumulative full transcript, or the model tends to
1625
+ // restart from earlier paragraphs on later followthrough turns.
1626
+ if (continuationAssistantText) {
1627
+ langchainMessages.push(new AIMessage({ content: continuationAssistantText }))
846
1628
  }
1629
+ const settledSegment = extractSuggestions(lastSegment).clean.trim()
1630
+ if (settledSegment) lastSettledSegment = settledSegment
847
1631
  langchainMessages.push(new HumanMessage({ content: 'Continue where you left off. Complete the remaining steps of the objective.' }))
848
1632
  lastSegment = ''
849
1633
  } else if (shouldContinue === 'required_tool') {
850
- if (fullText.trim()) {
851
- langchainMessages.push(new AIMessage({ content: fullText }))
1634
+ if (continuationAssistantText) {
1635
+ langchainMessages.push(new AIMessage({ content: continuationAssistantText }))
1636
+ }
1637
+ const settledSegment = extractSuggestions(lastSegment).clean.trim()
1638
+ if (settledSegment) lastSettledSegment = settledSegment
1639
+ langchainMessages.push(new HumanMessage({
1640
+ content: `You have not yet completed the required explicit tool step(s): ${requiredToolReminderNames.join(', ')}. Use those enabled tools now before declaring success. Do not replace ask_human with a plain-text request, do not replace outbound delivery tools with prose, and do not replace screenshot requests with text-only summaries.`,
1641
+ }))
1642
+ lastSegment = ''
1643
+ } else if (shouldContinue === 'execution_followthrough') {
1644
+ if (continuationAssistantText) {
1645
+ langchainMessages.push(new AIMessage({ content: continuationAssistantText }))
1646
+ }
1647
+ const settledSegment = extractSuggestions(lastSegment).clean.trim()
1648
+ if (settledSegment) lastSettledSegment = settledSegment
1649
+ langchainMessages.push(new HumanMessage({
1650
+ content: buildExternalExecutionFollowthroughPrompt({
1651
+ userMessage: message,
1652
+ fullText,
1653
+ toolEvents: streamedToolEvents,
1654
+ }),
1655
+ }))
1656
+ lastSegment = ''
1657
+ } else if (shouldContinue === 'deliverable_followthrough') {
1658
+ if (continuationAssistantText) {
1659
+ langchainMessages.push(new AIMessage({ content: continuationAssistantText }))
1660
+ }
1661
+ const settledSegment = extractSuggestions(lastSegment).clean.trim()
1662
+ if (settledSegment) lastSettledSegment = settledSegment
1663
+ langchainMessages.push(new HumanMessage({
1664
+ content: buildDeliverableFollowthroughPrompt({
1665
+ userMessage: message,
1666
+ fullText,
1667
+ toolEvents: streamedToolEvents,
1668
+ }),
1669
+ }))
1670
+ lastSegment = ''
1671
+ } else if (shouldContinue === 'tool_summary') {
1672
+ // Model called tools but produced no text — prompt it to summarize the results.
1673
+ if (continuationAssistantText) {
1674
+ langchainMessages.push(new AIMessage({ content: continuationAssistantText }))
852
1675
  }
1676
+ const toolSummaryLines = streamedToolEvents
1677
+ .filter((e) => e.output)
1678
+ .map((e) => `[${e.name}]: ${(e.output || '').slice(0, 500)}`)
1679
+ .slice(0, 6)
853
1680
  langchainMessages.push(new HumanMessage({
854
- content: `You have not yet completed the required explicit tool step(s): ${requiredToolReminderNames.join(', ')}. Use those enabled tools now before declaring success. Do not replace ask_human with a plain-text request, and do not replace email delivery with browser work or prose.`,
1681
+ content: [
1682
+ 'Your tool calls completed but you did not provide a response.',
1683
+ 'Here are the tool results:',
1684
+ ...toolSummaryLines,
1685
+ '',
1686
+ 'Now answer the original question using these results. Be concise and direct.',
1687
+ ].join('\n'),
855
1688
  }))
856
1689
  lastSegment = ''
857
1690
  } else if (shouldContinue === 'transient') {
@@ -885,13 +1718,38 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
885
1718
 
886
1719
  // Skip post-stream work if the client disconnected mid-stream
887
1720
  if (signal?.aborted) {
1721
+ let finalResponse = resolveFinalStreamResponseText({
1722
+ fullText,
1723
+ lastSegment,
1724
+ lastSettledSegment,
1725
+ hasToolCalls,
1726
+ toolEvents: streamedToolEvents,
1727
+ })
1728
+ if (shouldForceExternalServiceSummary({
1729
+ userMessage: message,
1730
+ finalResponse,
1731
+ hasToolCalls,
1732
+ toolEventCount: streamedToolEvents.length,
1733
+ })) {
1734
+ const forcedSummary = await buildForcedExternalServiceSummary({
1735
+ llm,
1736
+ userMessage: message,
1737
+ fullText,
1738
+ toolEvents: streamedToolEvents,
1739
+ })
1740
+ if (forcedSummary) {
1741
+ fullText = fullText.trim() ? `${fullText.trim()}\n\n${forcedSummary}` : forcedSummary
1742
+ finalResponse = forcedSummary
1743
+ }
1744
+ }
888
1745
  await cleanup()
889
- return { fullText, finalResponse: fullText }
1746
+ return { fullText, finalResponse }
890
1747
  }
891
1748
 
892
1749
  // Extract LLM-generated suggestions from the response and strip the tag
893
1750
  const extracted = extractSuggestions(fullText)
894
1751
  fullText = extracted.clean
1752
+ if (!fullText.trim() && terminalToolResponse) fullText = terminalToolResponse
895
1753
  if (extracted.suggestions) {
896
1754
  write(`data: ${JSON.stringify({ t: 'md', text: JSON.stringify({ suggestions: extracted.suggestions }) })}\n\n`)
897
1755
  }
@@ -934,6 +1792,35 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
934
1792
  })}\n\n`)
935
1793
  }
936
1794
 
1795
+ // If tools were called, finalResponse is the text from the last LLM turn only.
1796
+ // Fall back to fullText if the last segment is empty (e.g. agent ended on a tool call
1797
+ // with no summary text).
1798
+ // Strip suggestions tag from lastSegment too (connector delivery)
1799
+ let finalResponse = resolveFinalStreamResponseText({
1800
+ fullText,
1801
+ lastSegment,
1802
+ lastSettledSegment,
1803
+ hasToolCalls,
1804
+ toolEvents: streamedToolEvents,
1805
+ })
1806
+ if (shouldForceExternalServiceSummary({
1807
+ userMessage: message,
1808
+ finalResponse,
1809
+ hasToolCalls,
1810
+ toolEventCount: streamedToolEvents.length,
1811
+ })) {
1812
+ const forcedSummary = await buildForcedExternalServiceSummary({
1813
+ llm,
1814
+ userMessage: message,
1815
+ fullText,
1816
+ toolEvents: streamedToolEvents,
1817
+ })
1818
+ if (forcedSummary) {
1819
+ fullText = fullText.trim() ? `${fullText.trim()}\n\n${forcedSummary}` : forcedSummary
1820
+ finalResponse = forcedSummary
1821
+ }
1822
+ }
1823
+
937
1824
  // Plugin hooks: afterAgentComplete
938
1825
  await pluginMgr.runHook('afterAgentComplete', { session, response: fullText }, { enabledIds: sessionPlugins })
939
1826
 
@@ -949,14 +1836,5 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
949
1836
  // Clean up browser and other session resources
950
1837
  await cleanup()
951
1838
 
952
- // If tools were called, finalResponse is the text from the last LLM turn only.
953
- // Fall back to fullText if the last segment is empty (e.g. agent ended on a tool call
954
- // with no summary text).
955
- // Strip suggestions tag from lastSegment too (connector delivery)
956
- const cleanLastSegment = extractSuggestions(lastSegment).clean
957
- const finalResponse = hasToolCalls
958
- ? (cleanLastSegment.trim() || fullText)
959
- : fullText
960
-
961
1839
  return { fullText, finalResponse }
962
1840
  }