@swarmclawai/swarmclaw 0.7.8 → 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 (251) hide show
  1. package/README.md +12 -15
  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 +22 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +26 -1
  17. package/src/app/api/external-agents/route.test.ts +165 -0
  18. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  19. package/src/app/api/gateways/[id]/route.ts +2 -0
  20. package/src/app/api/gateways/health-route.test.ts +135 -0
  21. package/src/app/api/gateways/route.ts +2 -0
  22. package/src/app/api/mcp-servers/route.test.ts +130 -0
  23. package/src/app/api/openclaw/deploy/route.ts +38 -5
  24. package/src/app/api/plugins/install/route.ts +46 -6
  25. package/src/app/api/plugins/marketplace/route.ts +48 -15
  26. package/src/app/api/preview-server/route.ts +26 -11
  27. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  28. package/src/app/api/schedules/route.test.ts +86 -0
  29. package/src/app/api/schedules/route.ts +6 -1
  30. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  31. package/src/app/api/setup/check-provider/route.ts +40 -10
  32. package/src/app/api/skills/[id]/route.ts +12 -0
  33. package/src/app/api/skills/import/route.ts +14 -12
  34. package/src/app/api/skills/route.ts +13 -1
  35. package/src/app/api/tasks/[id]/route.ts +10 -1
  36. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  37. package/src/app/api/tasks/import/github/route.ts +337 -0
  38. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  39. package/src/app/api/wallets/[id]/route.ts +79 -33
  40. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  41. package/src/app/api/wallets/route.ts +78 -61
  42. package/src/app/api/webhooks/[id]/route.ts +33 -6
  43. package/src/app/api/webhooks/route.test.ts +272 -0
  44. package/src/cli/index.js +1 -0
  45. package/src/cli/spec.js +1 -0
  46. package/src/components/agents/agent-card.tsx +9 -2
  47. package/src/components/agents/agent-chat-list.tsx +18 -2
  48. package/src/components/agents/agent-list.tsx +1 -0
  49. package/src/components/agents/agent-sheet.tsx +73 -24
  50. package/src/components/agents/inspector-panel.tsx +41 -0
  51. package/src/components/canvas/canvas-panel.tsx +236 -65
  52. package/src/components/chat/chat-card.tsx +36 -13
  53. package/src/components/chat/chat-header.tsx +44 -16
  54. package/src/components/chat/chat-list.tsx +28 -4
  55. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  56. package/src/components/chat/message-bubble.tsx +208 -145
  57. package/src/components/chat/message-list.tsx +48 -19
  58. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  59. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  60. package/src/components/connectors/connector-health.tsx +1 -1
  61. package/src/components/connectors/connector-list.tsx +7 -2
  62. package/src/components/connectors/connector-sheet.tsx +337 -148
  63. package/src/components/gateways/gateway-sheet.tsx +2 -2
  64. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  65. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  66. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  67. package/src/components/plugins/plugin-list.tsx +45 -9
  68. package/src/components/plugins/plugin-sheet.tsx +55 -7
  69. package/src/components/providers/provider-list.tsx +2 -1
  70. package/src/components/providers/provider-sheet.tsx +21 -2
  71. package/src/components/schedules/schedule-card.tsx +25 -1
  72. package/src/components/schedules/schedule-sheet.tsx +44 -2
  73. package/src/components/secrets/secret-sheet.tsx +21 -2
  74. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  75. package/src/components/shared/bottom-sheet.tsx +13 -3
  76. package/src/components/shared/command-palette.tsx +8 -1
  77. package/src/components/shared/confirm-dialog.tsx +19 -4
  78. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  79. package/src/components/shared/connector-platform-icon.tsx +39 -6
  80. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  81. package/src/components/shared/settings/section-capability-policy.tsx +7 -3
  82. package/src/components/skills/skill-list.tsx +25 -0
  83. package/src/components/skills/skill-sheet.tsx +84 -12
  84. package/src/components/tasks/approvals-panel.tsx +191 -95
  85. package/src/components/tasks/task-board.tsx +273 -2
  86. package/src/components/tasks/task-card.tsx +38 -9
  87. package/src/components/ui/dialog.tsx +2 -2
  88. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  89. package/src/components/wallets/wallet-panel.tsx +435 -90
  90. package/src/components/wallets/wallet-section.tsx +198 -48
  91. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  92. package/src/lib/approval-display.ts +20 -0
  93. package/src/lib/canvas-content.ts +198 -0
  94. package/src/lib/chat-artifact-summary.ts +165 -0
  95. package/src/lib/chat-display.test.ts +91 -0
  96. package/src/lib/chat-display.ts +58 -0
  97. package/src/lib/chat-streaming-state.test.ts +47 -1
  98. package/src/lib/chat-streaming-state.ts +42 -0
  99. package/src/lib/ollama-model.ts +10 -0
  100. package/src/lib/openclaw-endpoint.test.ts +8 -0
  101. package/src/lib/openclaw-endpoint.ts +6 -1
  102. package/src/lib/plugin-install-cors.ts +46 -0
  103. package/src/lib/plugin-sources.test.ts +43 -0
  104. package/src/lib/plugin-sources.ts +77 -0
  105. package/src/lib/providers/ollama.ts +16 -6
  106. package/src/lib/providers/openclaw.test.ts +54 -0
  107. package/src/lib/providers/openclaw.ts +127 -11
  108. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  109. package/src/lib/schedule-dedupe.test.ts +66 -1
  110. package/src/lib/schedule-dedupe.ts +169 -12
  111. package/src/lib/schedule-origin.test.ts +20 -0
  112. package/src/lib/schedule-origin.ts +15 -0
  113. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  114. package/src/lib/server/agent-availability.ts +16 -0
  115. package/src/lib/server/agent-runtime-config.ts +12 -4
  116. package/src/lib/server/agent-thread-session.test.ts +51 -0
  117. package/src/lib/server/agent-thread-session.ts +7 -0
  118. package/src/lib/server/approval-match.ts +205 -0
  119. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  120. package/src/lib/server/approvals.ts +214 -1
  121. package/src/lib/server/assistant-control.test.ts +29 -0
  122. package/src/lib/server/assistant-control.ts +23 -0
  123. package/src/lib/server/build-llm.test.ts +79 -0
  124. package/src/lib/server/build-llm.ts +14 -4
  125. package/src/lib/server/canvas-content.test.ts +32 -0
  126. package/src/lib/server/canvas-content.ts +6 -0
  127. package/src/lib/server/capability-router.test.ts +11 -0
  128. package/src/lib/server/capability-router.ts +26 -1
  129. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  130. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  131. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  132. package/src/lib/server/chat-execution.ts +353 -72
  133. package/src/lib/server/clawhub-client.test.ts +14 -8
  134. package/src/lib/server/connectors/manager.test.ts +1147 -0
  135. package/src/lib/server/connectors/manager.ts +362 -63
  136. package/src/lib/server/connectors/pairing.ts +26 -5
  137. package/src/lib/server/connectors/types.ts +2 -0
  138. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  139. package/src/lib/server/connectors/whatsapp.ts +271 -47
  140. package/src/lib/server/context-manager.ts +6 -1
  141. package/src/lib/server/daemon-state.ts +1 -1
  142. package/src/lib/server/data-dir.test.ts +37 -0
  143. package/src/lib/server/data-dir.ts +20 -1
  144. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  145. package/src/lib/server/devserver-launch.test.ts +60 -0
  146. package/src/lib/server/devserver-launch.ts +85 -0
  147. package/src/lib/server/elevenlabs.test.ts +189 -1
  148. package/src/lib/server/elevenlabs.ts +147 -43
  149. package/src/lib/server/ethereum.ts +590 -0
  150. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  151. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  152. package/src/lib/server/eval/agent-regression.ts +383 -11
  153. package/src/lib/server/evm-swap.ts +475 -0
  154. package/src/lib/server/execution-log.ts +1 -0
  155. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  156. package/src/lib/server/heartbeat-service.ts +15 -10
  157. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  158. package/src/lib/server/heartbeat-wake.ts +338 -57
  159. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  160. package/src/lib/server/mcp-client.test.ts +16 -0
  161. package/src/lib/server/mcp-client.ts +25 -0
  162. package/src/lib/server/memory-integration.test.ts +719 -0
  163. package/src/lib/server/memory-policy.test.ts +43 -0
  164. package/src/lib/server/memory-policy.ts +132 -0
  165. package/src/lib/server/memory-tiers.test.ts +60 -0
  166. package/src/lib/server/memory-tiers.ts +16 -0
  167. package/src/lib/server/ollama-runtime.ts +58 -0
  168. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  169. package/src/lib/server/openclaw-deploy.ts +557 -81
  170. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  171. package/src/lib/server/openclaw-gateway.ts +10 -4
  172. package/src/lib/server/openclaw-health.test.ts +35 -0
  173. package/src/lib/server/openclaw-health.ts +215 -47
  174. package/src/lib/server/orchestrator-lg.ts +2 -2
  175. package/src/lib/server/plugins-advanced.test.ts +351 -0
  176. package/src/lib/server/plugins.ts +205 -5
  177. package/src/lib/server/queue-advanced.test.ts +528 -0
  178. package/src/lib/server/queue-followups.test.ts +262 -0
  179. package/src/lib/server/queue-reconcile.test.ts +128 -0
  180. package/src/lib/server/queue.ts +293 -61
  181. package/src/lib/server/scheduler.ts +29 -1
  182. package/src/lib/server/session-note.test.ts +36 -0
  183. package/src/lib/server/session-note.ts +42 -0
  184. package/src/lib/server/session-run-manager.ts +52 -4
  185. package/src/lib/server/session-tools/canvas.ts +14 -12
  186. package/src/lib/server/session-tools/connector.test.ts +138 -0
  187. package/src/lib/server/session-tools/connector.ts +348 -61
  188. package/src/lib/server/session-tools/context.ts +12 -3
  189. package/src/lib/server/session-tools/crud.ts +221 -10
  190. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  191. package/src/lib/server/session-tools/delegate.ts +64 -8
  192. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  193. package/src/lib/server/session-tools/discovery.ts +80 -12
  194. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  195. package/src/lib/server/session-tools/file.ts +43 -4
  196. package/src/lib/server/session-tools/human-loop.ts +35 -5
  197. package/src/lib/server/session-tools/index.ts +44 -9
  198. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  199. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  200. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  201. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  202. package/src/lib/server/session-tools/memory.test.ts +93 -0
  203. package/src/lib/server/session-tools/memory.ts +546 -79
  204. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  205. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  206. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  207. package/src/lib/server/session-tools/schedule.ts +6 -1
  208. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  209. package/src/lib/server/session-tools/shell.ts +22 -3
  210. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  211. package/src/lib/server/session-tools/wallet.ts +1374 -139
  212. package/src/lib/server/session-tools/web-inputs.test.ts +162 -1
  213. package/src/lib/server/session-tools/web.ts +468 -64
  214. package/src/lib/server/skill-discovery.ts +128 -0
  215. package/src/lib/server/skill-eligibility.test.ts +84 -0
  216. package/src/lib/server/skill-eligibility.ts +95 -0
  217. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  218. package/src/lib/server/skill-prompt-budget.ts +125 -0
  219. package/src/lib/server/skills-normalize.test.ts +54 -0
  220. package/src/lib/server/skills-normalize.ts +372 -26
  221. package/src/lib/server/solana.ts +214 -29
  222. package/src/lib/server/storage.ts +65 -36
  223. package/src/lib/server/stream-agent-chat.test.ts +419 -9
  224. package/src/lib/server/stream-agent-chat.ts +887 -83
  225. package/src/lib/server/system-events.ts +1 -1
  226. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  227. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  228. package/src/lib/server/tool-loop-detection.ts +260 -0
  229. package/src/lib/server/tool-planning.ts +4 -2
  230. package/src/lib/server/wallet-execution.test.ts +198 -0
  231. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  232. package/src/lib/server/wallet-portfolio.ts +724 -0
  233. package/src/lib/server/wallet-service.test.ts +57 -0
  234. package/src/lib/server/wallet-service.ts +213 -0
  235. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  236. package/src/lib/server/watch-jobs.ts +17 -2
  237. package/src/lib/server/workspace-context.ts +111 -0
  238. package/src/lib/skill-save-payload.test.ts +39 -0
  239. package/src/lib/skill-save-payload.ts +37 -0
  240. package/src/lib/tasks.ts +28 -0
  241. package/src/lib/tool-event-summary.test.ts +30 -0
  242. package/src/lib/tool-event-summary.ts +37 -0
  243. package/src/lib/validation/schemas.ts +1 -0
  244. package/src/lib/wallet-transactions.test.ts +75 -0
  245. package/src/lib/wallet-transactions.ts +43 -0
  246. package/src/lib/wallet.test.ts +17 -0
  247. package/src/lib/wallet.ts +183 -0
  248. package/src/proxy.test.ts +31 -0
  249. package/src/proxy.ts +34 -2
  250. package/src/stores/use-chat-store.ts +15 -1
  251. package/src/types/index.ts +210 -14
@@ -8,11 +8,12 @@ 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
15
  import { canonicalizePluginId, expandPluginIds } from './tool-aliases'
15
- import type { Session, Message, UsageRecord, PluginInvocationRecord } from '@/types'
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'
@@ -21,9 +22,10 @@ import {
21
22
  getEnabledToolPlanningView,
22
23
  getFirstToolForCapability,
23
24
  getToolsForCapability,
24
- matchToolCapabilitiesForMessage,
25
25
  TOOL_CAPABILITY,
26
26
  } from './tool-planning'
27
+ import { ToolLoopTracker } from './tool-loop-detection'
28
+ import type { LoopDetectionResult } from './tool-loop-detection'
27
29
 
28
30
  /** Extract a breadcrumb title from notable tool completions (task/schedule/agent creation). */
29
31
  interface StreamAgentChatOpts {
@@ -57,6 +59,8 @@ export function buildToolDisciplineLines(enabledPlugins: string[]): string[] {
57
59
  const planning = getEnabledToolPlanningView(enabledPlugins)
58
60
  const uniqueTools = planning.displayToolIds
59
61
  if (uniqueTools.length === 0) return []
62
+ const walletTools = getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.walletInspect)
63
+ const httpTools = getToolsForCapability(enabledPlugins, 'network.http')
60
64
 
61
65
  const lines = [
62
66
  `Enabled tools in this session: ${uniqueTools.map((toolId) => `\`${toolId}\``).join(', ')}.`,
@@ -82,6 +86,10 @@ export function buildToolDisciplineLines(enabledPlugins: string[]): string[] {
82
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.`)
83
87
  }
84
88
 
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.`)
91
+ }
92
+
85
93
  if (browserCaptureTools.length && deliveryMediaTools.length) {
86
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.`)
87
95
  }
@@ -90,48 +98,85 @@ export function buildToolDisciplineLines(enabledPlugins: string[]): string[] {
90
98
  lines.push(`If the user asks for a voice note and \`${deliveryVoiceTools[0]}\` is enabled, try it before saying voice notes are unsupported.`)
91
99
  }
92
100
 
93
- return lines
94
- }
95
-
96
- export function looksLikeOpenEndedDeliverableTask(text: string): boolean {
97
- const normalized = text.toLowerCase()
98
- if (!normalized.trim()) return false
99
- if (/```|package\.json|tsconfig|tsx?\b|jsx?\b|pytest|vitest|npm run|src\/|components\/|api\//.test(normalized)) return false
100
- 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
101
- return isBroadGoal(text) && /(\.md\b|\.txt\b|copy|brief|proposal|plan|report|draft|document)/.test(normalized)
102
- }
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.')
105
+ }
103
106
 
104
- export function getExplicitRequiredToolNames(userMessage: string, enabledPlugins: string[]): string[] {
105
- const normalized = userMessage.toLowerCase()
106
- const required: string[] = []
107
- const matchedCapabilities = matchToolCapabilitiesForMessage(enabledPlugins, userMessage)
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.')
110
+ }
108
111
 
109
- const requireCapability = (capability: string) => {
110
- const toolName = matchedCapabilities.get(capability)?.[0] || getFirstToolForCapability(enabledPlugins, capability)
111
- if (toolName && !required.includes(toolName)) required.push(toolName)
112
+ if (uniqueTools.includes('ask_human')) {
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`.')
112
114
  }
113
115
 
114
- if (matchedCapabilities.has(TOOL_CAPABILITY.researchSearch)) {
115
- requireCapability(TOOL_CAPABILITY.researchSearch)
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.')
116
120
  }
117
121
 
118
- if (matchedCapabilities.has(TOOL_CAPABILITY.researchFetch)) {
119
- requireCapability(TOOL_CAPABILITY.researchFetch)
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.')
120
124
  }
121
125
 
122
- if (matchedCapabilities.has(TOOL_CAPABILITY.browserCapture)) {
123
- requireCapability(TOOL_CAPABILITY.browserCapture)
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.')
124
128
  }
125
129
 
126
- if (matchedCapabilities.has(TOOL_CAPABILITY.deliveryVoiceNote)) {
127
- requireCapability(TOOL_CAPABILITY.deliveryVoiceNote)
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.')
128
132
  }
129
133
 
130
- if (matchedCapabilities.has(TOOL_CAPABILITY.deliveryMedia) || matchedCapabilities.has(TOOL_CAPABILITY.deliveryMessage)) {
131
- requireCapability(TOOL_CAPABILITY.deliveryMedia)
132
- requireCapability(TOOL_CAPABILITY.deliveryMessage)
134
+ return lines
135
+ }
136
+
137
+ export function looksLikeOpenEndedDeliverableTask(text: string): boolean {
138
+ const normalized = text.toLowerCase()
139
+ if (!normalized.trim()) return false
140
+ if (/```|package\.json|tsconfig|\btsx?\b|\bjsx?\b|pytest|vitest|npm run|src\/|components\/|api\//.test(normalized)) return false
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
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
133
155
  }
156
+ return isBroadGoal(text) && /(\.md\b|\.txt\b|\.html\b|\.json\b|copy|brief|proposal|plan|report|draft|document|dashboard)/.test(normalized)
157
+ }
134
158
 
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[] {
176
+ const normalized = userMessage.toLowerCase()
177
+ const required: string[] = []
178
+
179
+ // Only force tools that the user explicitly names and the LLM tends to skip
135
180
  if (enabledPlugins.includes('ask_human')
136
181
  && (/\bask_human\b/.test(normalized) || /ask the human/.test(normalized) || /request_input/.test(normalized))) {
137
182
  required.push('ask_human')
@@ -153,6 +198,320 @@ const OPEN_ENDED_REVISION_BLOCK = [
153
198
  'If `files` is available, use it with explicit actions and paths to inspect and revise the artifacts.',
154
199
  ].join('\n')
155
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
+
156
515
  /** Detect whether a user message is a broad, high-level goal that benefits from decomposition. */
157
516
  function isBroadGoal(text: string): boolean {
158
517
  if (text.length < 50) return false
@@ -182,6 +541,8 @@ function buildAgenticExecutionPolicy(opts: {
182
541
  heartbeatIntervalSec: number
183
542
  platformAssignScope?: 'self' | 'all'
184
543
  userMessage?: string
544
+ responseStyle?: 'concise' | 'normal' | 'detailed' | null
545
+ responseMaxChars?: number | null
185
546
  }) {
186
547
  const hasTooling = opts.enabledPlugins.length > 0
187
548
  const pluginLines = buildPluginCapabilityLines(opts.enabledPlugins, { platformAssignScope: opts.platformAssignScope })
@@ -195,9 +556,12 @@ function buildAgenticExecutionPolicy(opts: {
195
556
  hasTooling
196
557
  ? 'I take initiative — plan briefly, execute tools, evaluate, iterate until done. Never stop at advice when action is implied.'
197
558
  : 'No tools enabled. Be explicit about what tool access is needed.',
198
- '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.',
199
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.',
200
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.',
201
565
  opts.loopMode === 'ongoing'
202
566
  ? 'Loop: ONGOING — keep iterating until done, blocked, or limits reached.'
203
567
  : 'Loop: BOUNDED — execute multiple steps but finish within recursion budget.',
@@ -210,16 +574,15 @@ function buildAgenticExecutionPolicy(opts: {
210
574
  // Response behavior
211
575
  parts.push(
212
576
  '## Response Rules',
213
- 'NO_MESSAGE: reply with exactly this to suppress delivery for pure acknowledgments (ok/thanks/bye/emoji/lol).',
214
- 'Always reply to: questions, tasks, emotional sharing, or when you have something useful to add.',
215
- 'Execute by default only ask for confirmation on high-risk/irreversible actions. Do not end every response with a question.',
216
- 'Never repeat completed side effects. Verify state first.',
217
- 'If a tool returns an error or validation failure, do not claim the task succeeded. Retry with corrected arguments or explain the blocker plainly.',
218
- '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.',
219
- 'For recurring, cron, interval, or follow-up automation requests, use `manage_schedules` directly when it is available.',
220
- '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.',
221
- 'If a required tool is missing, request access by name with `manage_capabilities` action `request_access` (for example `shell` or `manage_schedules`).',
222
- '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
+ : '',
223
586
  `Heartbeat: if message is "${opts.heartbeatPrompt}", reply "HEARTBEAT_OK" unless you have a progress update.`,
224
587
  opts.heartbeatIntervalSec > 0 ? `Heartbeat cadence: ~${opts.heartbeatIntervalSec}s.` : '',
225
588
  )
@@ -227,6 +590,10 @@ function buildAgenticExecutionPolicy(opts: {
227
590
  if (toolDisciplineLines.length) parts.push('## Tool Discipline', ...toolDisciplineLines)
228
591
  if (pluginLines.length) parts.push('What I can do:\n' + pluginLines.join('\n'))
229
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
+ }
230
597
  if (opts.userMessage && looksLikeOpenEndedDeliverableTask(opts.userMessage) && opts.enabledPlugins.some((toolId) => toolId === 'files' || toolId === 'edit_file')) {
231
598
  parts.push(OPEN_ENDED_REVISION_BLOCK)
232
599
  }
@@ -242,6 +609,52 @@ export interface StreamAgentChatResult {
242
609
  finalResponse: string
243
610
  }
244
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
+
245
658
  export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<StreamAgentChatResult> {
246
659
  const startTs = Date.now()
247
660
  const { session, message, imagePath, attachedFiles, apiKey, systemPrompt, write, history, fallbackCredentialIds, signal } = opts
@@ -305,6 +718,8 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
305
718
  let agentMcpDisabledTools: string[] | undefined
306
719
  let agentHeartbeatEnabled = false
307
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
308
723
  const activeProjectContext = resolveActiveProjectContext(session)
309
724
  if (session.agentId) {
310
725
  const agents = loadAgents()
@@ -314,6 +729,8 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
314
729
  agentMcpDisabledTools = agent?.mcpDisabledTools
315
730
  agentHeartbeatEnabled = agent?.heartbeatEnabled === true
316
731
  agentMemoryScopeMode = agent?.memoryScopeMode || null
732
+ agentResponseStyle = agent?.responseStyle || null
733
+ agentResponseMaxChars = agent?.responseMaxChars || null
317
734
  if (!hasProvidedSystemPrompt) {
318
735
  // Identity block — make sure the agent knows who it is
319
736
  const identityLines = [`## My Identity`, `My name is ${agent?.name || 'Agent'}.`]
@@ -326,17 +743,25 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
326
743
  if (agent?.systemPrompt) stateModifierParts.push(agent.systemPrompt)
327
744
  if (agent?.skillIds?.length) {
328
745
  const allSkills = loadSkills()
329
- for (const skillId of agent.skillIds) {
330
- const skill = allSkills[skillId]
331
- if (skill?.content) stateModifierParts.push(`## Skill: ${skill.name}\n${skill.content}`)
332
- }
746
+ const skillPromptText = buildSkillPromptText(allSkills, agent.skillIds)
747
+ if (skillPromptText) stateModifierParts.push(skillPromptText)
333
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 */ }
334
761
  }
335
762
  }
336
763
 
337
- if (!hasProvidedSystemPrompt) {
338
- stateModifierParts.push('I\'m here to get things done. I take action, use my tools, and focus on outcomes.')
339
- }
764
+ // (conciseness and action-orientation are covered in the execution policy below)
340
765
 
341
766
  // Thinking level guidance (applies to all providers via system prompt)
342
767
  if (agentThinkingLevel) {
@@ -349,8 +774,21 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
349
774
  stateModifierParts.push(`## Reasoning Depth\n${thinkingGuidance[agentThinkingLevel]}`)
350
775
  }
351
776
 
352
- // Inject agent awareness (Phase 2: agents know about each other)
353
- 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) {
354
792
  try {
355
793
  const { buildAgentAwarenessBlock } = await import('./agent-registry')
356
794
  const awarenessBlock = buildAgentAwarenessBlock(session.agentId)
@@ -427,21 +865,15 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
427
865
  }
428
866
  }
429
867
 
430
- const parts: string[] = []
431
- if (enabledButNoAccess.length > 0) {
432
- parts.push(
433
- `**Available but not assigned to me:** ${enabledButNoAccess.join(', ')}\n` +
434
- 'I can request access using `manage_capabilities` with action "request_access" or `request_tool_access`.',
435
- )
436
- }
868
+ const accessParts: string[] = []
437
869
  if (globallyDisabled.length > 0) {
438
- 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(', ')}`)
439
871
  }
440
872
  if (mcpDisabled.length > 0) {
441
- parts.push(`**MCP tools not available:** ${mcpDisabled.join(', ')}`)
873
+ accessParts.push(`**MCP tools not available:** ${mcpDisabled.join(', ')}`)
442
874
  }
443
- if (parts.length > 0) {
444
- stateModifierParts.push(`## Plugin Access\n${parts.join('\n')}`)
875
+ if (accessParts.length > 0) {
876
+ stateModifierParts.push(`## Plugin Access\n${accessParts.join('\n')}`)
445
877
  }
446
878
  }
447
879
 
@@ -465,6 +897,8 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
465
897
  heartbeatIntervalSec,
466
898
  platformAssignScope: agentPlatformAssignScope,
467
899
  userMessage: message,
900
+ responseStyle: agentResponseStyle,
901
+ responseMaxChars: agentResponseMaxChars,
468
902
  }),
469
903
  )
470
904
 
@@ -644,13 +1078,16 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
644
1078
 
645
1079
  let fullText = ''
646
1080
  let lastSegment = ''
1081
+ let lastSettledSegment = ''
647
1082
  let hasToolCalls = false
648
1083
  let needsTextSeparator = false
649
1084
  let totalInputTokens = 0
650
1085
  let totalOutputTokens = 0
651
1086
  let accumulatedThinking = ''
652
1087
  const pluginInvocations: PluginInvocationRecord[] = []
1088
+ const streamedToolEvents: MessageToolEvent[] = []
653
1089
  let currentToolInputTokens = 0
1090
+ const boundedExternalExecutionTask = looksLikeBoundedExternalExecutionTask(message)
654
1091
 
655
1092
  // Plugin hooks: beforeAgentStart
656
1093
  const pluginMgr = getPluginManager()
@@ -673,20 +1110,49 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
673
1110
  const MAX_AUTO_CONTINUES = 3
674
1111
  const MAX_TRANSIENT_RETRIES = 2
675
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
676
1116
  let autoContinueCount = 0
677
1117
  let transientRetryCount = 0
678
1118
  let requiredToolContinueCount = 0
1119
+ let executionFollowthroughCount = 0
1120
+ let deliverableFollowthroughCount = 0
1121
+ let toolSummaryRetryCount = 0
679
1122
  const explicitRequiredToolNames = getExplicitRequiredToolNames(message, sessionPlugins)
680
1123
  const usedToolNames = new Set<string>()
1124
+ const loopTracker = new ToolLoopTracker()
1125
+ let loopDetectionTriggered: LoopDetectionResult | null = null
1126
+ let terminalToolResponse = ''
681
1127
 
682
1128
  try {
683
- 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
684
1130
  for (let iteration = 0; iteration <= maxIterations; iteration++) {
685
- let shouldContinue: 'recursion' | 'transient' | 'required_tool' | false = false
1131
+ let shouldContinue: 'recursion' | 'transient' | 'required_tool' | 'execution_followthrough' | 'deliverable_followthrough' | 'tool_summary' | false = false
686
1132
  let requiredToolReminderNames: string[] = []
687
1133
  let waitingForToolResult = false
688
1134
  let idleTimedOut = false
1135
+ let reachedExecutionBoundary = false
1136
+ let executionFollowthroughReason: 'research_limit' | 'post_simulation' | null = null
689
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
+ }
690
1156
 
691
1157
  // Fresh per-iteration controller so an internal LangGraph abort doesn't poison subsequent iterations.
692
1158
  // Linked to the parent so client disconnect / timeout still propagates.
@@ -711,6 +1177,13 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
711
1177
  }, 90_000)
712
1178
  }
713
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
+
714
1187
  try {
715
1188
  armIdleWatchdog()
716
1189
  const eventStream = agent.streamEvents(
@@ -739,10 +1212,12 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
739
1212
  } else if (block.text) {
740
1213
  if (needsTextSeparator && fullText.length > 0) {
741
1214
  fullText += '\n\n'
1215
+ iterationText += '\n\n'
742
1216
  write(`data: ${JSON.stringify({ t: 'd', text: '\n\n' })}\n\n`)
743
1217
  needsTextSeparator = false
744
1218
  }
745
1219
  fullText += block.text
1220
+ iterationText += block.text
746
1221
  lastSegment += block.text
747
1222
  write(`data: ${JSON.stringify({ t: 'd', text: block.text })}\n\n`)
748
1223
  }
@@ -752,10 +1227,12 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
752
1227
  if (text) {
753
1228
  if (needsTextSeparator && fullText.length > 0) {
754
1229
  fullText += '\n\n'
1230
+ iterationText += '\n\n'
755
1231
  write(`data: ${JSON.stringify({ t: 'd', text: '\n\n' })}\n\n`)
756
1232
  needsTextSeparator = false
757
1233
  }
758
1234
  fullText += text
1235
+ iterationText += text
759
1236
  lastSegment += text
760
1237
  write(`data: ${JSON.stringify({ t: 'd', text })}\n\n`)
761
1238
  }
@@ -775,16 +1252,36 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
775
1252
  totalOutputTokens += usage.completionTokens || usage.output_tokens || usage.completion_tokens || 0
776
1253
  }
777
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
+
778
1270
  clearIdleWatchdog()
779
1271
  waitingForToolResult = true
780
1272
  hasToolCalls = true
781
1273
  needsTextSeparator = true
1274
+ const settledSegment = extractSuggestions(lastSegment).clean.trim()
1275
+ if (settledSegment) lastSettledSegment = settledSegment
782
1276
  lastSegment = ''
783
- const toolName = event.name || 'unknown'
784
1277
  usedToolNames.add(canonicalizePluginId(toolName) || toolName)
785
- const input = event.data?.input
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
+ }
786
1284
  // Estimate input tokens for plugin invocation tracking
787
- const inputStr = typeof input === 'string' ? input : JSON.stringify(input)
788
1285
  currentToolInputTokens = Math.ceil((inputStr?.length || 0) / 4)
789
1286
  logExecution(session.id, 'tool_call', `${toolName} invoked`, {
790
1287
  agentId: session.agentId,
@@ -794,8 +1291,19 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
794
1291
  t: 'tool_call',
795
1292
  toolName,
796
1293
  toolInput: inputStr,
1294
+ toolCallId: event.run_id,
797
1295
  })}\n\n`)
1296
+ updateStreamedToolEvents(streamedToolEvents, {
1297
+ type: 'call',
1298
+ name: toolName,
1299
+ input: inputStr,
1300
+ toolCallId: event.run_id,
1301
+ })
798
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
+
799
1307
  waitingForToolResult = false
800
1308
  armIdleWatchdog()
801
1309
  const toolName = event.name || 'unknown'
@@ -838,11 +1346,89 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
838
1346
  })
839
1347
  currentToolInputTokens = 0
840
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
+
841
1366
  write(`data: ${JSON.stringify({
842
1367
  t: 'tool_result',
843
1368
  toolName,
844
1369
  toolOutput: outputStr?.slice(0, 2000),
1370
+ toolCallId: event.run_id,
845
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
+ }
846
1432
  }
847
1433
  }
848
1434
  } catch (innerErr: unknown) {
@@ -879,12 +1465,25 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
879
1465
  })
880
1466
  write(`data: ${JSON.stringify({ t: 'status', text: JSON.stringify({ autoContinue: autoContinueCount, maxContinues: MAX_AUTO_CONTINUES }) })}\n\n`)
881
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
882
1478
  shouldContinue = 'transient'
883
1479
  transientRetryCount++
884
1480
  logExecution(session.id, 'decision', `Transient error, retrying (${transientRetryCount}/${MAX_TRANSIENT_RETRIES}): ${errMsg}`, {
885
1481
  agentId: session.agentId,
886
- detail: { errName, errMsg },
1482
+ detail: { errName, errMsg, hadPartialOutput },
887
1483
  })
1484
+ if (hadPartialOutput) {
1485
+ write(`data: ${JSON.stringify({ t: 'reset', text: iterationStartState.fullText })}\n\n`)
1486
+ }
888
1487
  write(`data: ${JSON.stringify({ t: 'status', text: JSON.stringify({ transientRetry: transientRetryCount, maxRetries: MAX_TRANSIENT_RETRIES, error: errMsg }) })}\n\n`)
889
1488
  } else {
890
1489
  // Non-retryable error or exhausted retries — rethrow to outer catch
@@ -895,8 +1494,38 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
895
1494
  abortController.signal.removeEventListener('abort', onParentAbort)
896
1495
  }
897
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
+
898
1522
  if (!shouldContinue && explicitRequiredToolNames.length > 0 && requiredToolContinueCount < MAX_REQUIRED_TOOL_CONTINUES) {
899
- 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
+ })
900
1529
  if (requiredToolReminderNames.length > 0) {
901
1530
  shouldContinue = 'required_tool'
902
1531
  requiredToolContinueCount++
@@ -911,23 +1540,153 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
911
1540
  }
912
1541
  }
913
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
+
914
1615
  if (!shouldContinue) break
915
1616
 
1617
+ const continuationAssistantText = resolveContinuationAssistantText({
1618
+ iterationText,
1619
+ lastSegment,
1620
+ })
1621
+
916
1622
  if (shouldContinue === 'recursion') {
917
- // Append accumulated text and a continue prompt
918
- if (fullText.trim()) {
919
- 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 }))
920
1628
  }
1629
+ const settledSegment = extractSuggestions(lastSegment).clean.trim()
1630
+ if (settledSegment) lastSettledSegment = settledSegment
921
1631
  langchainMessages.push(new HumanMessage({ content: 'Continue where you left off. Complete the remaining steps of the objective.' }))
922
1632
  lastSegment = ''
923
1633
  } else if (shouldContinue === 'required_tool') {
924
- if (fullText.trim()) {
925
- langchainMessages.push(new AIMessage({ content: fullText }))
1634
+ if (continuationAssistantText) {
1635
+ langchainMessages.push(new AIMessage({ content: continuationAssistantText }))
926
1636
  }
1637
+ const settledSegment = extractSuggestions(lastSegment).clean.trim()
1638
+ if (settledSegment) lastSettledSegment = settledSegment
927
1639
  langchainMessages.push(new HumanMessage({
928
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.`,
929
1641
  }))
930
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 }))
1675
+ }
1676
+ const toolSummaryLines = streamedToolEvents
1677
+ .filter((e) => e.output)
1678
+ .map((e) => `[${e.name}]: ${(e.output || '').slice(0, 500)}`)
1679
+ .slice(0, 6)
1680
+ langchainMessages.push(new HumanMessage({
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'),
1688
+ }))
1689
+ lastSegment = ''
931
1690
  } else if (shouldContinue === 'transient') {
932
1691
  // Short delay before retrying transient errors (API timeout, rate limit, etc.)
933
1692
  await new Promise((r) => setTimeout(r, 2000 * transientRetryCount))
@@ -959,13 +1718,38 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
959
1718
 
960
1719
  // Skip post-stream work if the client disconnected mid-stream
961
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
+ }
962
1745
  await cleanup()
963
- return { fullText, finalResponse: fullText }
1746
+ return { fullText, finalResponse }
964
1747
  }
965
1748
 
966
1749
  // Extract LLM-generated suggestions from the response and strip the tag
967
1750
  const extracted = extractSuggestions(fullText)
968
1751
  fullText = extracted.clean
1752
+ if (!fullText.trim() && terminalToolResponse) fullText = terminalToolResponse
969
1753
  if (extracted.suggestions) {
970
1754
  write(`data: ${JSON.stringify({ t: 'md', text: JSON.stringify({ suggestions: extracted.suggestions }) })}\n\n`)
971
1755
  }
@@ -1008,6 +1792,35 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
1008
1792
  })}\n\n`)
1009
1793
  }
1010
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
+
1011
1824
  // Plugin hooks: afterAgentComplete
1012
1825
  await pluginMgr.runHook('afterAgentComplete', { session, response: fullText }, { enabledIds: sessionPlugins })
1013
1826
 
@@ -1023,14 +1836,5 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
1023
1836
  // Clean up browser and other session resources
1024
1837
  await cleanup()
1025
1838
 
1026
- // If tools were called, finalResponse is the text from the last LLM turn only.
1027
- // Fall back to fullText if the last segment is empty (e.g. agent ended on a tool call
1028
- // with no summary text).
1029
- // Strip suggestions tag from lastSegment too (connector delivery)
1030
- const cleanLastSegment = extractSuggestions(lastSegment).clean
1031
- const finalResponse = hasToolCalls
1032
- ? (cleanLastSegment.trim() || fullText)
1033
- : fullText
1034
-
1035
1839
  return { fullText, finalResponse }
1036
1840
  }