@johpaz/hive-sdk 0.1.4 → 0.1.6

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 (278) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +78 -23
  3. package/bun.lock +55 -29
  4. package/bunfig.toml +4 -2
  5. package/docs/API-AGENTS.md +78 -27
  6. package/docs/API-CONTEXT-COMPILER.md +31 -34
  7. package/docs/API-TOOLS-SKILLS-CHANNELS.md +58 -22
  8. package/docs/HIVE-HARNESS.md +1 -1
  9. package/docs/INDEX.md +4 -4
  10. package/docs/TEMPLATE-HIVE-APP.md +10 -10
  11. package/package.json +17 -12
  12. package/packages/cli/package.json +2 -2
  13. package/packages/cli/src/commands/create-app.test.ts +36 -7
  14. package/packages/cli/src/commands/init.ts +3 -3
  15. package/packages/cli/src/commands/run.ts +1 -1
  16. package/packages/cli/src/commands/test.ts +37 -25
  17. package/packages/cli/src/commands/trace.ts +30 -28
  18. package/packages/cli/templates/hive-app/.env.example +10 -2
  19. package/packages/cli/templates/hive-app/README.md +103 -0
  20. package/packages/cli/templates/hive-app/hive.config.ts +9 -3
  21. package/packages/cli/templates/hive-app/src/agents/coordinator.ts +8 -1
  22. package/packages/cli/templates/hive-app/src/main.ts +12 -19
  23. package/packages/core/package.json +13 -12
  24. package/packages/core/src/agent/acceptance-checks.ts +172 -0
  25. package/packages/core/src/agent/agent-catalog.ts +348 -0
  26. package/packages/core/src/agent/agent-loop.ts +1373 -0
  27. package/packages/core/src/agent/capability-search.ts +186 -0
  28. package/packages/core/src/agent/catalog-selector.ts +103 -0
  29. package/packages/core/src/agent/{Compaction.ts → compaction.ts} +86 -63
  30. package/packages/core/src/agent/context-compiler.ts +689 -0
  31. package/packages/core/src/agent/conversation-store.ts +381 -0
  32. package/packages/core/src/agent/curator.ts +276 -0
  33. package/packages/core/src/agent/delegation-runtime.ts +241 -0
  34. package/packages/core/src/agent/goal-runner.ts +323 -0
  35. package/packages/core/src/agent/index.ts +17 -12
  36. package/packages/core/src/agent/llm-client.ts +266 -0
  37. package/packages/core/src/agent/llm-providers/anthropic.ts +264 -0
  38. package/packages/core/src/agent/llm-providers/deepseek.ts +8 -0
  39. package/packages/core/src/agent/{providers → llm-providers}/gemini.ts +98 -60
  40. package/packages/core/src/agent/llm-providers/groq.ts +5 -0
  41. package/packages/core/src/agent/llm-providers/hiveagents.ts +253 -0
  42. package/packages/core/src/agent/{providers → llm-providers}/interface.ts +73 -13
  43. package/packages/core/src/agent/llm-providers/kimi.ts +8 -0
  44. package/packages/core/src/agent/llm-providers/minimax.ts +13 -0
  45. package/packages/core/src/agent/llm-providers/mistral.ts +5 -0
  46. package/packages/core/src/agent/llm-providers/modelscope.ts +5 -0
  47. package/packages/core/src/agent/llm-providers/nvidia.ts +5 -0
  48. package/packages/core/src/agent/{providers → llm-providers}/ollama.ts +31 -5
  49. package/packages/core/src/agent/llm-providers/openai-compat-base.ts +418 -0
  50. package/packages/core/src/agent/llm-providers/openai.ts +5 -0
  51. package/packages/core/src/agent/llm-providers/opencode-go.ts +9 -0
  52. package/packages/core/src/agent/llm-providers/openrouter.ts +5 -0
  53. package/packages/core/src/agent/llm-providers/qwen.ts +5 -0
  54. package/packages/core/src/agent/llm-providers/z-ai.ts +5 -0
  55. package/packages/core/src/agent/minimal-loadout.ts +47 -0
  56. package/packages/core/src/agent/playbook-selector.ts +119 -0
  57. package/packages/core/src/agent/{PromptBuilder.ts → prompt-builder.ts} +21 -22
  58. package/packages/core/src/{harness → agent}/proof-packet.ts +16 -21
  59. package/packages/core/src/agent/providers/index.ts +35 -16
  60. package/packages/core/src/agent/reflector.ts +320 -0
  61. package/packages/core/src/agent/routing-intent.ts +22 -0
  62. package/packages/core/src/{harness → agent}/run-epoch.ts +4 -3
  63. package/packages/core/src/{harness → agent}/run-store.ts +142 -81
  64. package/packages/core/src/agent/{Service.ts → service.ts} +37 -26
  65. package/packages/core/src/agent/skill-selector.ts +374 -0
  66. package/packages/core/src/agent/stuck-loop.ts +209 -0
  67. package/packages/core/src/agent/{selectors/ToolSelector.ts → tool-selector.ts} +188 -178
  68. package/packages/core/src/{ace/Tracer.ts → agent/tracer.ts} +37 -27
  69. package/packages/core/src/api/createAgent.test.ts +139 -27
  70. package/packages/core/src/api/createAgent.ts +232 -44
  71. package/packages/core/src/artifacts/store.ts +162 -0
  72. package/packages/core/src/canvas/canvas-manager.ts +161 -0
  73. package/packages/core/src/canvas/canvas.test.ts +8 -4
  74. package/packages/core/src/canvas/emitter.ts +131 -80
  75. package/packages/core/src/canvas/index.ts +1 -3
  76. package/packages/core/src/channels/base.ts +9 -1
  77. package/packages/core/src/channels/discord.ts +5 -4
  78. package/packages/core/src/channels/manager.ts +122 -30
  79. package/packages/core/src/channels/slack.ts +5 -4
  80. package/packages/core/src/channels/telegram.ts +36 -6
  81. package/packages/core/src/channels/webchat.ts +11 -10
  82. package/packages/core/src/channels/whatsapp.ts +23 -7
  83. package/packages/core/src/config/index.ts +13 -2
  84. package/packages/core/src/config/loader.ts +76 -29
  85. package/packages/core/src/ethics/EthicsGuard.test.ts +90 -36
  86. package/packages/core/src/ethics/EthicsGuard.ts +51 -47
  87. package/packages/core/src/events/agent-bus.ts +44 -68
  88. package/packages/core/src/events/channel-narration.ts +150 -0
  89. package/packages/core/src/events/narration.ts +82 -0
  90. package/packages/core/src/events/tool-narration.ts +62 -0
  91. package/packages/core/src/gateway/delegation-groups.ts +258 -0
  92. package/packages/core/src/{harness → gateway}/durable-queue.ts +102 -42
  93. package/packages/core/src/{harness → gateway}/job-store.ts +85 -48
  94. package/packages/core/src/gateway/lane-queue.ts +173 -0
  95. package/packages/core/src/gateway/notification-inbox.ts +57 -0
  96. package/packages/core/src/gateway/server.ts +1 -1
  97. package/packages/core/src/harness/index.ts +46 -27
  98. package/packages/core/src/index.ts +33 -27
  99. package/packages/core/src/mcp/hot-reload.ts +32 -23
  100. package/packages/core/src/mcp/index.ts +6 -3
  101. package/packages/core/src/mcp/singleton.ts +1 -4
  102. package/packages/core/src/mcp/tool-sync.ts +138 -0
  103. package/packages/core/src/memory/Scratchpad.test.ts +39 -20
  104. package/packages/core/src/memory/Scratchpad.ts +27 -34
  105. package/packages/core/src/multimodal/vision-service.ts +44 -38
  106. package/packages/core/src/resilience/retry.ts +95 -0
  107. package/packages/core/src/scheduler/CronScheduler.ts +334 -287
  108. package/packages/core/src/scheduler/index.ts +9 -7
  109. package/packages/core/src/scheduler/integration.ts +46 -26
  110. package/packages/core/src/scheduler/scheduler.test.ts +9 -13
  111. package/packages/core/src/scheduler/types.ts +7 -2
  112. package/packages/core/src/security/Pairing.ts +1 -1
  113. package/packages/core/src/skills/bundled/a2ui/a2ui_dashboard/SKILL.md +176 -0
  114. package/packages/core/src/skills/bundled/a2ui/a2ui_form/SKILL.md +202 -0
  115. package/packages/core/src/skills/bundled/a2ui/a2ui_interactive/SKILL.md +206 -0
  116. package/packages/core/src/skills/bundled/agents/agent_spawner/SKILL.md +173 -0
  117. package/packages/core/src/skills/bundled/agents/memory_manager/SKILL.md +143 -0
  118. package/packages/core/src/skills/bundled/agents/research_and_remember/SKILL.md +139 -0
  119. package/packages/core/src/skills/bundled/agents/task_orchestrator/SKILL.md +98 -0
  120. package/packages/core/src/skills/bundled/api/api_client/SKILL.md +132 -0
  121. package/packages/core/src/skills/bundled/cli/cli_pipeline/SKILL.md +135 -0
  122. package/packages/core/src/skills/bundled/cli/cli_safe_exec/SKILL.md +125 -0
  123. package/packages/core/src/skills/bundled/cli/software_engineering/SKILL.md +23 -0
  124. package/packages/core/src/skills/bundled/cron_manager/SKILL.md +188 -0
  125. package/packages/core/src/skills/bundled/cron_reminder/SKILL.md +112 -0
  126. package/packages/core/src/skills/bundled/filesystem/file_manager/SKILL.md +118 -0
  127. package/packages/core/src/skills/bundled/filesystem/file_read_and_summarize/SKILL.md +109 -0
  128. package/packages/core/src/skills/bundled/filesystem/file_writer/SKILL.md +129 -0
  129. package/packages/core/src/skills/bundled/filesystem/workspace_file_operator/SKILL.md +22 -0
  130. package/packages/core/src/skills/bundled/office/office_document_manager/SKILL.md +262 -0
  131. package/packages/core/src/skills/bundled/search_knowledge/capability_discovery/SKILL.md +75 -0
  132. package/packages/core/src/skills/bundled/web/browser_automate/SKILL.md +120 -0
  133. package/packages/core/src/skills/bundled/web/browser_scrape/SKILL.md +109 -0
  134. package/packages/core/src/skills/bundled/web/web_monitor/SKILL.md +127 -0
  135. package/packages/core/src/skills/bundled/web/web_research/SKILL.md +119 -0
  136. package/packages/core/src/skills/bundled-data.generated.ts +731 -2678
  137. package/packages/core/src/skills/skills.test.ts +52 -11
  138. package/packages/core/src/{harness → storage}/boot-id.ts +5 -2
  139. package/packages/core/src/storage/bootstrap.ts +151 -0
  140. package/packages/core/src/storage/causal-events.ts +84 -0
  141. package/packages/core/src/storage/collections.ts +680 -0
  142. package/packages/core/src/storage/crypto.ts +205 -74
  143. package/packages/core/src/{harness/db-helpers.ts → storage/hive.ts} +63 -7
  144. package/packages/core/src/storage/hivedb.ts +61 -0
  145. package/packages/core/src/storage/index.ts +111 -18
  146. package/packages/core/src/storage/model-id.ts +53 -0
  147. package/packages/core/src/storage/onboarding.ts +540 -972
  148. package/packages/core/src/storage/reconcile.ts +238 -0
  149. package/packages/core/src/storage/seed.ts +572 -406
  150. package/packages/core/src/storage/usage.ts +285 -225
  151. package/packages/core/src/storage/user-email.ts +11 -0
  152. package/packages/core/src/swarm/AgentExecutor.ts +1 -1
  153. package/packages/core/src/swarm/EventBridge.ts +1 -1
  154. package/packages/core/src/swarm/index.ts +12 -9
  155. package/packages/core/src/tool-runtime/index.ts +146 -23
  156. package/packages/core/src/tool-runtime/tool-worker.ts +2 -2
  157. package/packages/core/src/tool-runtime/worker-tools.ts +27 -0
  158. package/packages/core/src/{canvas/a2ui-tools.ts → tools/a2ui/index.ts} +17 -8
  159. package/packages/core/src/tools/agents/get-available-models.ts +36 -54
  160. package/packages/core/src/tools/agents/index.ts +784 -292
  161. package/packages/core/src/tools/api/api-request.test.ts +164 -0
  162. package/packages/core/src/tools/api/api-request.ts +174 -0
  163. package/packages/core/src/tools/api/index.ts +16 -0
  164. package/packages/core/src/tools/cli/index.ts +4 -0
  165. package/packages/core/src/tools/core/index.ts +281 -112
  166. package/packages/core/src/tools/cron/index.ts +121 -124
  167. package/packages/core/src/tools/index.ts +63 -78
  168. package/packages/core/src/tools/office/office-escribir-xlsx.ts +3 -1
  169. package/packages/core/src/tools/types.ts +3 -1
  170. package/packages/core/src/tools/web/artifact-inspect.ts +23 -0
  171. package/packages/core/src/tools/web/browser-backend.ts +129 -0
  172. package/packages/core/src/tools/web/browser-screenshot.ts +26 -5
  173. package/packages/core/src/tools/web/browser-service.ts +80 -35
  174. package/packages/core/src/tools/web/browser-type.ts +3 -8
  175. package/packages/core/src/tools/web/index.ts +4 -4
  176. package/packages/core/src/tools/web/webview-backend.ts +412 -0
  177. package/packages/core/src/voice/index.ts +89 -63
  178. package/packages/core/src/workers/agent.worker.ts +2 -2
  179. package/packages/core/src/workers/workers.test.ts +3 -10
  180. package/scripts/bump-version.ts +248 -0
  181. package/scripts/generate-skill-bundle.ts +108 -0
  182. package/test/acceptance-checks.test.ts +403 -0
  183. package/test/agent-loop-terminal-synthesis.test.ts +32 -0
  184. package/test/browser-backend.test.ts +308 -0
  185. package/test/catalog-agents-stay-enabled.test.ts +117 -0
  186. package/test/causal-events.test.ts +117 -0
  187. package/test/compaction.test.ts +105 -0
  188. package/test/context-compiler.test.ts +269 -0
  189. package/test/curator.test.ts +130 -0
  190. package/test/durable-queue.test.ts +114 -0
  191. package/test/harness-barrel.test.ts +64 -0
  192. package/test/hive-helpers.test.ts +130 -0
  193. package/test/hivedb-search.test.ts +189 -0
  194. package/test/internal-turns.test.ts +166 -0
  195. package/test/job-idempotency.test.ts +68 -0
  196. package/test/job-retry-backoff.test.ts +184 -0
  197. package/test/job-store.test.ts +381 -0
  198. package/test/llm-retry.test.ts +97 -0
  199. package/test/memory-perf.test.ts +774 -0
  200. package/test/minimal-loadout.test.ts +78 -0
  201. package/test/model-catalog.test.ts +105 -0
  202. package/test/preload.ts +12 -0
  203. package/test/reflector.test.ts +320 -0
  204. package/test/retention-cap.test.ts +91 -0
  205. package/test/retired-capabilities-pruned.test.ts +192 -0
  206. package/test/run-store.test.ts +355 -0
  207. package/test/scratchpad.test.ts +74 -0
  208. package/test/secrets-durability.test.ts +119 -0
  209. package/test/seed-model-reseed.test.ts +155 -0
  210. package/test/setup-agent-seed.test.ts +264 -0
  211. package/test/tool-inventory.test.ts +65 -0
  212. package/test/tool-runtime.test.ts +258 -0
  213. package/test/tool-selector-runtime-tools.test.ts +117 -0
  214. package/test/toon.test.ts +429 -0
  215. package/tsconfig.json +2 -0
  216. package/packages/core/src/ace/Curator.ts +0 -158
  217. package/packages/core/src/ace/Reflector.ts +0 -200
  218. package/packages/core/src/ace/index.ts +0 -4
  219. package/packages/core/src/agent/AgentRunner.ts +0 -711
  220. package/packages/core/src/agent/ContextCompiler.ts +0 -567
  221. package/packages/core/src/agent/ContextGuard.ts +0 -91
  222. package/packages/core/src/agent/ConversationStore.ts +0 -254
  223. package/packages/core/src/agent/Hooks.ts +0 -166
  224. package/packages/core/src/agent/StuckLoop.ts +0 -133
  225. package/packages/core/src/agent/providers/LLMClient.ts +0 -149
  226. package/packages/core/src/agent/providers/anthropic.ts +0 -212
  227. package/packages/core/src/agent/providers/openai-compat.ts +0 -231
  228. package/packages/core/src/agent/selectors/PlaybookSelector.ts +0 -121
  229. package/packages/core/src/agent/selectors/SkillSelector.ts +0 -322
  230. package/packages/core/src/agent/selectors/index.ts +0 -6
  231. package/packages/core/src/auth/auth.ts +0 -121
  232. package/packages/core/src/auth/index.ts +0 -1
  233. package/packages/core/src/canvas/CanvasManager.ts +0 -390
  234. package/packages/core/src/canvas/canvas-tools.ts +0 -448
  235. package/packages/core/src/harness/collections.ts +0 -98
  236. package/packages/core/src/harness/goal-verifier.ts +0 -141
  237. package/packages/core/src/harness/harness.test.ts +0 -236
  238. package/packages/core/src/harness/reconcile.ts +0 -149
  239. package/packages/core/src/mcp/MCPToolAdapter.ts +0 -176
  240. package/packages/core/src/multimodal/VisionService.ts +0 -293
  241. package/packages/core/src/scheduler/dag/AgentExecutor.ts +0 -53
  242. package/packages/core/src/scheduler/dag/DAGScheduler.ts +0 -250
  243. package/packages/core/src/scheduler/dag/EventBridge.ts +0 -122
  244. package/packages/core/src/scheduler/dag/TaskGraph.ts +0 -192
  245. package/packages/core/src/scheduler/dag/TaskNode.ts +0 -97
  246. package/packages/core/src/scheduler/dag/TaskResult.ts +0 -22
  247. package/packages/core/src/scheduler/dag/errors.ts +0 -37
  248. package/packages/core/src/scheduler/dag/index.ts +0 -26
  249. package/packages/core/src/scheduler/dag/presets/ResearchPreset.ts +0 -97
  250. package/packages/core/src/scheduler/dag/strategies/ParallelStrategy.ts +0 -21
  251. package/packages/core/src/scheduler/dag/strategies/PriorityStrategy.ts +0 -46
  252. package/packages/core/src/storage/HiveDBStorage.ts +0 -64
  253. package/packages/core/src/storage/SQLiteStorage.ts +0 -414
  254. package/packages/core/src/storage/hiveSeed.ts +0 -308
  255. package/packages/core/src/storage/hiveStorage.test.ts +0 -38
  256. package/packages/core/src/storage/schema.ts +0 -689
  257. package/packages/core/src/storage/storage.test.ts +0 -37
  258. package/packages/core/src/swarm/AgentBus.ts +0 -460
  259. package/packages/core/src/swarm/EventBus.ts +0 -169
  260. package/packages/core/src/swarm/WorkerPool.ts +0 -236
  261. package/packages/core/src/tools/bridge-events.ts +0 -26
  262. package/packages/core/src/tools/canvas/index.ts +0 -375
  263. package/packages/core/src/tools/codebridge/index.ts +0 -342
  264. package/packages/core/src/tools/meeting/index.ts +0 -353
  265. package/packages/core/src/tools/projects/index.ts +0 -37
  266. package/packages/core/src/tools/projects/project-create.ts +0 -94
  267. package/packages/core/src/tools/projects/project-done.ts +0 -66
  268. package/packages/core/src/tools/projects/project-fail.ts +0 -66
  269. package/packages/core/src/tools/projects/project-list.ts +0 -96
  270. package/packages/core/src/tools/projects/project-update.ts +0 -72
  271. package/packages/core/src/tools/projects/task-create.ts +0 -68
  272. package/packages/core/src/tools/projects/task-evaluate.ts +0 -93
  273. package/packages/core/src/tools/projects/task-update.ts +0 -93
  274. package/packages/core/src/tools/voice/index.ts +0 -104
  275. package/packages/core/src/tools/web/api-request.test.ts +0 -170
  276. package/packages/core/src/tools/web/api-request.ts +0 -239
  277. package/test/setup-db.ts +0 -216
  278. /package/packages/core/src/agent/{NativeTools.ts → native-tools.ts} +0 -0
@@ -0,0 +1,1373 @@
1
+ /**
2
+ * Agent Loop — native implementation, no LangGraph.
3
+ *
4
+ * Replaces supervisor.ts + graph.ts.
5
+ *
6
+ * Pattern:
7
+ * user message → context compiler → model call → [tool call → model call]* → response
8
+ *
9
+ * Exposes an async generator compatible with the existing providers/index.ts stream API:
10
+ * yield { agent: { messages: [AIMessage] } }
11
+ * yield { tools: { messages: [ToolMessage] } }
12
+ *
13
+ * Also used directly by runAgentIsolated() for worker tasks.
14
+ */
15
+
16
+ import { logger } from "../utils/logger"
17
+ import { col, fromIndexable } from "../storage/hive"
18
+ import { getHiveDb } from "../storage/hivedb"
19
+ import type { HiveDB, EventInput } from "@johpaz/hive-db"
20
+ import type { AgentDoc, TurnSource } from "../storage/collections"
21
+ import { callLLM, resolveProviderConfig, getDefaultLLM, type LLMMessage } from "./llm-client"
22
+ import { addMessage } from "./conversation-store"
23
+ import { saveTrace, recordLLMUsage } from "./tracer"
24
+ import { maybeCompact, clearOldToolResults } from "./compaction"
25
+ import { emitCanvas } from "../canvas/emitter"
26
+ import type { MCPClientManager } from "../mcp/index.ts"
27
+ import { compileContext } from "./context-compiler"
28
+ import { formatToolResult } from "../utils/toon"
29
+ import { resolveUserId, resolveAgentId } from "../storage/onboarding"
30
+ import type { ContentPart } from "../multimodal/types"
31
+ import { loadConfig } from "../config/loader"
32
+ import { executeToolBatch } from "../tool-runtime"
33
+ import { createStuckLoopDetector, getInterventionMessage, type StuckLoopState } from "./stuck-loop"
34
+ import {
35
+ createRun as createAgentRun,
36
+ checkpoint as checkpointRun,
37
+ completeRun,
38
+ failRun,
39
+ interruptRun,
40
+ getRun,
41
+ reclaimRun,
42
+ deserializeCheckpoint,
43
+ bumpTurn,
44
+ startLeaseRenewal,
45
+ stopLeaseRenewal,
46
+ type RunCheckpointState,
47
+ } from "./run-store"
48
+ import { publishNarration } from "../events/narration"
49
+ import { getNarration } from "../events/tool-narration"
50
+
51
+ const log = logger.child("agent-loop")
52
+
53
+ // Per-operation budget for a single LLM call — NOT an aggregate deadline for the
54
+ // whole turn. Each call gets its own fresh window; a slow-but-healthy multi-step
55
+ // turn (many quick operations) is never killed just for taking a while overall.
56
+ const LLM_CALL_TIMEOUT_MS = 3 * 60 * 1000
57
+
58
+ export class LLMCallTimeoutError extends Error {
59
+ constructor(ms: number) {
60
+ super(`LLM call timed out after ${ms}ms`)
61
+ this.name = "LLMCallTimeoutError"
62
+ }
63
+ }
64
+
65
+ export class AgentSynthesisError extends Error {
66
+ constructor(message: string, options?: { cause?: unknown }) {
67
+ super(message, options)
68
+ this.name = "AgentSynthesisError"
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Produce a terminal response with one bounded retry. Empty model output is a
74
+ * failure: callers must never turn an unknown outcome into a success message.
75
+ */
76
+ export async function synthesizeFinalResponse(
77
+ operation: () => Promise<string | null | undefined>,
78
+ ): Promise<string> {
79
+ let lastError: unknown
80
+ for (let attempt = 1; attempt <= 2; attempt++) {
81
+ try {
82
+ const content = (await operation())?.trim()
83
+ if (content) return content
84
+ lastError = new Error("The model returned an empty synthesis")
85
+ } catch (err) {
86
+ lastError = err
87
+ }
88
+ }
89
+
90
+ const detail = lastError instanceof Error ? lastError.message : String(lastError)
91
+ throw new AgentSynthesisError(
92
+ `No se pudo generar la respuesta final del agente después de 2 intentos: ${detail}`,
93
+ { cause: lastError },
94
+ )
95
+ }
96
+
97
+ /** Bounds a single async operation to its own timeout window, independent of any caller. */
98
+ export async function withTimeout<T>(op: () => Promise<T>, timeoutMs: number): Promise<T> {
99
+ let timer: ReturnType<typeof setTimeout>
100
+ const timeout = new Promise<never>((_, reject) => {
101
+ timer = setTimeout(() => reject(new LLMCallTimeoutError(timeoutMs)), timeoutMs)
102
+ })
103
+ try {
104
+ return await Promise.race([op(), timeout])
105
+ } finally {
106
+ clearTimeout(timer!)
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Append one G9 causal event (IntentLogged/StateTransition/ToolCall) to HiveDB's
112
+ * event log. Never throws: a broken causal log must never break the agent loop.
113
+ * See hiveBD's docs/AGENT_INTEGRATION.md for the payload vocabulary contract.
114
+ */
115
+ async function appendCausalEvent(
116
+ db: HiveDB,
117
+ input: {
118
+ agentId: string
119
+ streamId: string
120
+ kind: EventInput["kind"]
121
+ payload: Record<string, unknown>
122
+ causation?: number
123
+ correlation?: string
124
+ }
125
+ ): Promise<number | undefined> {
126
+ try {
127
+ return await db.append({
128
+ agentId: input.agentId,
129
+ streamId: input.streamId,
130
+ kind: input.kind,
131
+ payload: JSON.stringify(input.payload),
132
+ causation: input.causation,
133
+ correlation: input.correlation,
134
+ })
135
+ } catch (err) {
136
+ log.warn(`[agent-loop] causal event append failed (kind=${input.kind}): ${(err as Error).message}`)
137
+ return undefined
138
+ }
139
+ }
140
+
141
+ // ─── Types ────────────────────────────────────────────────────────────────────
142
+
143
+ export interface AgentLoopOptions {
144
+ agentId: string
145
+ userMessage: string | ContentPart[]
146
+ threadId: string
147
+ channel?: string
148
+ mcpManager?: MCPClientManager | null
149
+ /** System prompt override (from server.ts config) */
150
+ systemPromptOverride?: string
151
+ /** Worker mode: isolated context + single-task execution */
152
+ isolated?: boolean
153
+ taskContext?: string | ContentPart[]
154
+ onStep?: (step: StepEvent) => Promise<void>
155
+ onToken?: (token: string) => void
156
+ /** Live reasoning/thinking tokens as they stream, for display only. */
157
+ onReasoningToken?: (token: string) => void
158
+ /** User ID for context propagation */
159
+ userId?: string
160
+ /** Abort signal to stop generation mid-execution */
161
+ signal?: AbortSignal
162
+ /** Clean text for search selectors and tracing (extracted from userMessage if multimodal) */
163
+ rawUserMessage?: string
164
+ /** Extra tools to force into the LLM loadout (used by tests/evals). */
165
+ extraTools?: any[]
166
+ /** Run ID for an existing AgentRun — enables resume from checkpoint */
167
+ runId?: string
168
+ /** Stable originating chat-turn id used to group delegated work. */
169
+ turnId?: string
170
+ /** Durable task id when executing a delegated worker. */
171
+ taskId?: string
172
+ /** External routing/session id for progress delivery. */
173
+ sessionId?: string
174
+ /** Whether to resume from a previously saved checkpoint */
175
+ resume?: boolean
176
+ /** Run budget — overrides agent.max_iterations when set */
177
+ budget?: {
178
+ maxIterations?: number
179
+ maxTurns?: number | null
180
+ maxTokens?: number | null
181
+ }
182
+ /** Goal-based continuation parameters */
183
+ goal?: {
184
+ text: string
185
+ checkTool?: string
186
+ }
187
+ /** Whether to checkpoint this run durably (default false for chat; auto-promoted for long turns) */
188
+ durable?: boolean
189
+ /** Kind of run for the AgentRun record */
190
+ runKind?: "chat" | "worker" | "goal" | "cron" | "project"
191
+ /**
192
+ * Provenance to persist the trigger message with (default "message"). The
193
+ * trigger message is always persisted with role:"user" — including
194
+ * system-originated turns (async-delegation fan-in/fan-out notices from
195
+ * delegation-groups.ts and delegation-notify.ts), since AgentLoop.stream()
196
+ * extracts the last role:"user" message as the turn's trigger content.
197
+ * `historySource` records where it actually came from so history/compaction/
198
+ * LLM serialization can recognize and frame it without a second role.
199
+ */
200
+ historySource?: TurnSource
201
+ }
202
+
203
+ export type { StepEvent as AgentStepEvent }
204
+
205
+ export interface StepEvent {
206
+ type: "text" | "tool_call" | "tool_result"
207
+ message: string
208
+ toolName?: string
209
+ isError?: boolean
210
+ }
211
+
212
+ // ─── Stream chunk types (compatible with providers/index.ts) ─────────────────
213
+
214
+ export interface StreamChunk {
215
+ agent?: { messages: any[]; streamed?: boolean }
216
+ tools?: { messages: any[] }
217
+ usage?: { input_tokens: number; output_tokens: number }
218
+ }
219
+
220
+ // ─── Main agent loop ──────────────────────────────────────────────────────────
221
+
222
+ export async function* runAgent(
223
+ opts: AgentLoopOptions
224
+ ): AsyncGenerator<StreamChunk> {
225
+ const t0 = performance.now()
226
+
227
+ // Load agent config from DB
228
+ const agentsCol = await col<AgentDoc>("agents")
229
+ const agentEntry = await agentsCol.get(opts.agentId)
230
+ if (!agentEntry) throw new Error(`Agent not found: ${opts.agentId}`)
231
+ const agent = agentEntry.doc
232
+
233
+ const agentName = agent.name || opts.agentId
234
+ const maxIterations = opts.budget?.maxIterations ?? agent.max_iterations ?? 10
235
+
236
+ // ── Durable run tracking ─────────────────────────────────────────────────
237
+ let runId: string | null = opts.runId ?? null
238
+ let isDurable = !!opts.durable || !!opts.runId || !!opts.goal
239
+ const runKind = opts.runKind ?? (opts.isolated ? "worker" : "chat")
240
+ const DURABLE_PROMOTION_THRESHOLD = 6 // promote chat to durable after N iterations
241
+
242
+ // ── G9 causal event log (HiveDB) ─────────────────────────────────────────
243
+ // One stream per invocation (a chat turn, or a runAgentIsolated() task) — NOT
244
+ // the persistent chat threadId, since causalThread() reconstructs without a
245
+ // checkpointed projection and a months-old thread would be O(full history)
246
+ // on every call. On resume, opts.runId is reused so the stream isn't split.
247
+ const causalLogEnabled = !!loadConfig().causalLog?.enabled
248
+ const causalDb = causalLogEnabled ? await getHiveDb() : null
249
+ const causalStreamId = opts.runId || crypto.randomUUID()
250
+ // hive has no mid-turn topic-change classifier yet, so the whole stream shares
251
+ // one correlation id. objectiveDrift is technically wired but won't fire in
252
+ // practice until that heuristic exists (documented v1 limitation).
253
+ const causalCorrelationId = crypto.randomUUID()
254
+ let lastCausalSeq: number | undefined
255
+ if (causalDb) {
256
+ const intentText = opts.rawUserMessage || (typeof opts.userMessage === "string"
257
+ ? opts.userMessage
258
+ : Array.isArray(opts.userMessage)
259
+ ? opts.userMessage.filter(p => p.type === "text").map(p => (p as any).text).join("\n")
260
+ : String(opts.userMessage))
261
+ lastCausalSeq = await appendCausalEvent(causalDb, {
262
+ agentId: opts.agentId,
263
+ streamId: causalStreamId,
264
+ kind: "IntentLogged",
265
+ payload: { actor: opts.agentId, intent: intentText.slice(0, 2000) },
266
+ correlation: causalCorrelationId,
267
+ })
268
+ }
269
+
270
+ // Stuck-loop protection
271
+ const stuckDetector = createStuckLoopDetector(loadConfig())
272
+ let stuckState: StuckLoopState | undefined
273
+
274
+ // Resolve LLM provider config (default from DB when the agent has none configured)
275
+ let agentProvider = fromIndexable(agent.provider_id)
276
+ let agentModel = fromIndexable(agent.model_id)
277
+ if (!agentProvider || !agentModel) {
278
+ const defaultLLM = await getDefaultLLM()
279
+ if (!defaultLLM) throw new Error("No active LLM providers/models configured in the database")
280
+ agentProvider = agentProvider || defaultLLM.provider
281
+ agentModel = agentModel || defaultLLM.model
282
+ }
283
+ const providerCfg = await resolveProviderConfig(agentProvider, agentModel)
284
+
285
+ const cleanModel = providerCfg.model.replace(new RegExp(`^${providerCfg.provider}\\/`), "")
286
+ log.info(`[agent-loop] Starting: agent=${agentName} thread=${opts.threadId} provider=${providerCfg.provider}/${cleanModel}`)
287
+
288
+ emitCanvas("canvas:node_update", {
289
+ nodeId: opts.agentId,
290
+ changes: { status: "thinking" },
291
+ })
292
+
293
+ // Store the user message in conversation history
294
+ if (!opts.isolated) {
295
+ // If userMessage is multimodal, addMessage extracts text for history storage.
296
+ // historySource records provenance; system-originated turns (delegation
297
+ // fan-in) persist as role:"user" like everything else and stay off the
298
+ // visible transcript purely because their source is internal.
299
+ await addMessage(opts.threadId, "user", opts.userMessage, {
300
+ channel: opts.channel,
301
+ source: opts.historySource ?? "message",
302
+ })
303
+ // Run compaction if conversation history is getting large
304
+ await maybeCompact(
305
+ opts.threadId,
306
+ opts.channel && opts.userId
307
+ ? { channel: opts.channel, userId: opts.userId }
308
+ : undefined
309
+ )
310
+ }
311
+
312
+ // Compile context (system prompt + history + tools)
313
+ const ctx = await compileContext({
314
+ agentId: opts.agentId,
315
+ threadId: opts.threadId,
316
+ userMessage: opts.userMessage,
317
+ channel: opts.channel,
318
+ mcpManager: opts.mcpManager,
319
+ isolated: opts.isolated,
320
+ taskContext: opts.taskContext,
321
+ userId: opts.userId,
322
+ causalStreamId,
323
+ })
324
+
325
+ // Force extra tools into the loadout (tests/evals)
326
+ if (opts.extraTools?.length) {
327
+ const existingNames = new Set(ctx.tools.map((t: any) => t.function?.name))
328
+ for (const tool of opts.extraTools) {
329
+ const name = tool.function?.name || tool.name
330
+ if (name && !existingNames.has(name)) {
331
+ ctx.tools.push(tool)
332
+ existingNames.add(name)
333
+ log.info(`[agent-loop] Force-injected tool into loadout: ${name}`)
334
+ }
335
+ }
336
+ }
337
+
338
+ // Compose rather than discard: an override must still carry the
339
+ // conversation summary context-compiler folded in, or a compacted thread
340
+ // silently loses it whenever a caller supplies systemPromptOverride.
341
+ const systemPrompt = opts.systemPromptOverride
342
+ ? opts.systemPromptOverride + ctx.conversationSummarySection
343
+ : ctx.systemPrompt
344
+
345
+ // Build initial messages array for the model
346
+ let messages: LLMMessage[] = [
347
+ { role: "system", content: systemPrompt },
348
+ ...ctx.messages,
349
+ ]
350
+
351
+ // For isolated workers the user message is the task context, not from history
352
+ if (opts.isolated) {
353
+ messages.push({ role: "user", content: opts.userMessage })
354
+ }
355
+
356
+ // ── Resume from checkpoint ─────────────────────────────────────────────────
357
+ let injectedToolNames: string[] = []
358
+ let systemPromptSkillSections: string[] = []
359
+ let resumedFromPending = false
360
+ let iterations = 0
361
+ let totalInputTokens = 0
362
+ let totalOutputTokens = 0
363
+ let lastToolSignature = ""
364
+ let consecutiveRepeat = 0
365
+ let idleIterations = 0
366
+
367
+ if (opts.resume && runId) {
368
+ const existing = await getRun(runId)
369
+ if (existing && existing.state_json) {
370
+ const restored = deserializeCheckpoint(existing)
371
+ if (restored) {
372
+ messages = restored.messages
373
+ injectedToolNames = restored.injectedToolNames ?? []
374
+ systemPromptSkillSections = restored.systemPromptSkillSections ?? []
375
+ iterations = restored.iterations ?? 0
376
+ totalInputTokens = restored.totalInputTokens ?? 0
377
+ totalOutputTokens = restored.totalOutputTokens ?? 0
378
+ lastToolSignature = restored.lastToolSignature ?? ""
379
+ consecutiveRepeat = restored.consecutiveRepeat ?? 0
380
+ idleIterations = restored.idleIterations ?? 0
381
+ if (existing.pending_tool_calls_json) {
382
+ try {
383
+ const pending = JSON.parse(existing.pending_tool_calls_json)
384
+ const interruptedMsgs = pending.map((tc: any) => ({
385
+ role: "tool" as const,
386
+ content: "[interrupted] El proceso se reinició mientras esta herramienta corría. El resultado no está disponible — decidí si reintentar o continuar sin él.",
387
+ tool_call_id: tc.id,
388
+ }))
389
+ messages.push(...interruptedMsgs)
390
+ resumedFromPending = true
391
+ log.info(`[agent-loop] Resume: injected ${interruptedMsgs.length} synthetic [interrupted] tool message(s)`)
392
+ } catch { /* ignore bad json */ }
393
+ }
394
+ log.info(`[agent-loop] Resume: restored ${messages.length} messages, ${iterations} iterations from run ${runId}`)
395
+ }
396
+ }
397
+ }
398
+
399
+ // ── Create AgentRun if durable ────────────────────────────────────────────
400
+ if (!runId && isDurable) {
401
+ const run = await createAgentRun({
402
+ thread_id: opts.threadId,
403
+ agent_id: opts.agentId,
404
+ user_id: opts.userId ?? "",
405
+ channel: opts.channel ?? null,
406
+ kind: runKind,
407
+ max_iterations: maxIterations,
408
+ max_turns: opts.budget?.maxTurns ?? null,
409
+ max_tokens: opts.budget?.maxTokens ?? null,
410
+ goal: opts.goal?.text ?? null,
411
+ goal_check_tool: opts.goal?.checkTool ?? null,
412
+ resume_policy: "resume",
413
+ })
414
+ runId = run.id
415
+ log.info(`[agent-loop] Created durable run ${runId} (kind=${runKind})`)
416
+ }
417
+
418
+ // Re-running an existing run (e.g. a job re-claimed after a crash): reconcile
419
+ // may have left it "interrupted" with a stale boot_id, and the lease renewer
420
+ // self-stops unless status is "running" — take ownership before executing.
421
+ if (opts.runId && isDurable) {
422
+ await reclaimRun(opts.runId).catch((err) =>
423
+ log.warn(`[agent-loop] Failed to reclaim run ${opts.runId}: ${(err as Error).message}`)
424
+ )
425
+ }
426
+
427
+ // Start lease renewal timer if durable
428
+ if (runId && isDurable) {
429
+ startLeaseRenewal(runId)
430
+ }
431
+
432
+ // The try wraps the whole loop + synthesis + finalization WITHOUT re-indenting
433
+ // the body: it guarantees the durable run never stays "running" with a live
434
+ // lease when the loop throws or the consumer abandons the generator.
435
+ try {
436
+
437
+ let finalContent = ""
438
+ // Whether finalContent was already emitted to the stream (normal completion path
439
+ // yields it at the top of the iteration; internal breaks set finalContent without yielding)
440
+ let finalEmitted = false
441
+ let loopDetected = false
442
+ const PROGRESS_TOOLS = new Set(["browser_type", "browser_click", "browser_navigate"])
443
+
444
+ // ── The loop ────────────────────────────────────────────────────────────
445
+ while (iterations < maxIterations) {
446
+ if (opts.signal?.aborted) {
447
+ log.info(`[agent-loop] Aborted by signal at iteration ${iterations}`)
448
+ finalContent = "Generación detenida."
449
+ break
450
+ }
451
+
452
+ iterations++
453
+
454
+ const delegationGroupAtCall = opts.turnId && !opts.isolated
455
+ ? await import("../gateway/delegation-groups").then((mod) => mod.getDelegationGroup(opts.turnId!))
456
+ : null
457
+ let streamedThisCall = false
458
+ let response: Awaited<ReturnType<typeof callLLM>>
459
+ try {
460
+ response = await withTimeout(() => callLLM({
461
+ ...providerCfg,
462
+ messages: clearOldToolResults(messages) as LLMMessage[],
463
+ tools: ctx.tools.length > 0 ? ctx.tools : undefined,
464
+ signal: opts.signal,
465
+ onToken: opts.onToken && !delegationGroupAtCall
466
+ ? (token: string) => {
467
+ streamedThisCall = true
468
+ opts.onToken?.(token)
469
+ }
470
+ : undefined,
471
+ onReasoningToken: opts.onReasoningToken,
472
+ // Always requested; each provider decides internally whether/how to honor
473
+ // it based on its own model-capability checks (safe no-op otherwise).
474
+ thinking: { enabled: true },
475
+ }), LLM_CALL_TIMEOUT_MS)
476
+ } catch (err) {
477
+ if (err instanceof LLMCallTimeoutError) {
478
+ log.warn(`[agent-loop] ${err.message} at iteration ${iterations}. Breaking.`)
479
+ finalContent = "El modelo tardó demasiado en responder. Intentá de nuevo o simplificá la consulta."
480
+ break
481
+ }
482
+ throw err
483
+ }
484
+
485
+ if (
486
+ delegationGroupAtCall &&
487
+ (!response.tool_calls?.length || response.stop_reason !== "tool_calls")
488
+ ) {
489
+ response.content = ""
490
+ }
491
+
492
+ // Accumulate usage
493
+ if (response.usage) {
494
+ totalInputTokens += response.usage.input_tokens
495
+ totalOutputTokens += response.usage.output_tokens
496
+ }
497
+
498
+ // G9: record this LLM response as a causal "decision", chained off the
499
+ // previous decision (or the initial IntentLogged for the first one).
500
+ if (causalDb) {
501
+ const description = response.content?.trim()
502
+ || (response.tool_calls?.length
503
+ ? `Calling ${response.tool_calls.map((tc) => tc.function.name).join(", ")}`
504
+ : "(empty response)")
505
+ const seq = await appendCausalEvent(causalDb, {
506
+ agentId: opts.agentId,
507
+ streamId: causalStreamId,
508
+ kind: "StateTransition",
509
+ payload: { description: description.slice(0, 2000) },
510
+ causation: lastCausalSeq,
511
+ correlation: causalCorrelationId,
512
+ })
513
+ if (seq !== undefined) lastCausalSeq = seq
514
+ }
515
+
516
+ // Emit agent chunk (compatible with providers/index.ts)
517
+ const agentMsg: any = { content: response.content }
518
+ if (response.tool_calls?.length) agentMsg.tool_calls = response.tool_calls
519
+ yield { agent: { messages: [agentMsg], streamed: streamedThisCall } }
520
+
521
+ // Notify onStep for narration text
522
+ if (opts.onStep && response.content) {
523
+ await opts.onStep({ type: "text", message: response.content })
524
+ }
525
+
526
+ // ── Provider failure → surface it, but never let it enter the history ───
527
+ // callLLM returns errors as a normal response whose `content` is the error
528
+ // text. That text is for the user's screen only: persisting it would make
529
+ // the next turn replay a provider outage as something the agent "said".
530
+ if (response.stop_reason === "error") {
531
+ log.error(`[agent-loop] LLM call failed at iteration ${iterations}: ${response.error?.message ?? response.content}`)
532
+ finalContent = response.content?.trim() || ""
533
+ finalEmitted = true // already yielded above as the agent chunk
534
+ break
535
+ }
536
+
537
+ // ── No tool calls → final response ──────────────────────────────────
538
+ if (!response.tool_calls?.length || response.stop_reason !== "tool_calls") {
539
+ finalContent = response.content?.trim() || ""
540
+ finalEmitted = true // already yielded above as the agent chunk
541
+ // Only save to history if we have real content; empty → synthesis block will handle it
542
+ if (finalContent && !opts.isolated) {
543
+ await addMessage(opts.threadId, "assistant", finalContent)
544
+ }
545
+ break
546
+ }
547
+
548
+ // ── Tool calls → execute each tool ──────────────────────────────────
549
+ // Add assistant message with tool_calls to local messages array AND persist
550
+ messages.push({
551
+ role: "assistant",
552
+ content: response.content,
553
+ tool_calls: response.tool_calls,
554
+ reasoning_content: response.reasoning_content,
555
+ thinking_blocks: response.thinking_blocks,
556
+ })
557
+ // Note: assistant messages with tool_calls are NOT persisted to DB.
558
+ // Only the final text response to the user is saved.
559
+ // Tool-call round-tripping happens in-memory via the 'messages' array above.
560
+
561
+ for (const tc of response.tool_calls) {
562
+ const toolName = tc.function.name
563
+
564
+ emitCanvas("canvas:node_update", {
565
+ nodeId: opts.agentId,
566
+ changes: { status: "tool_call", currentTool: toolName },
567
+ })
568
+
569
+ if (opts.onStep) {
570
+ if (response.content) {
571
+ await opts.onStep({ type: "text", message: response.content })
572
+ }
573
+ await opts.onStep({
574
+ type: "tool_call",
575
+ toolName,
576
+ message: `Calling tool: \`${toolName}\``,
577
+ })
578
+ }
579
+ if (opts.turnId) {
580
+ await publishNarration({
581
+ turnId: opts.turnId,
582
+ threadId: opts.threadId,
583
+ channel: opts.channel,
584
+ userId: opts.userId,
585
+ sessionId: opts.sessionId,
586
+ agentId: opts.agentId,
587
+ agentName,
588
+ kind: "tool_call",
589
+ status: "running",
590
+ // Human-readable text, not the raw tool id — this label is what a
591
+ // WhatsApp/Telegram user reads.
592
+ label: `${agentName}: ${getNarration(toolName)}`,
593
+ dedupeKey: `tool_call:${iterations}:${tc.id}:${toolName}`,
594
+ })
595
+ }
596
+ }
597
+
598
+ const hiveConfig = loadConfig()
599
+
600
+ // ── Checkpoint: persist pending tool_calls BEFORE execution ──────────────
601
+ // If the process crashes mid-tool, the resume will inject synthetic
602
+ // [interrupted] tool messages instead of re-executing the tool.
603
+ if (runId && isDurable) {
604
+ try {
605
+ await checkpointRun(runId, {
606
+ version: 1,
607
+ messages: [...messages],
608
+ iterations,
609
+ totalInputTokens,
610
+ totalOutputTokens,
611
+ lastToolSignature,
612
+ consecutiveRepeat,
613
+ idleIterations,
614
+ injectedToolNames,
615
+ systemPromptSkillSections,
616
+ }, response.tool_calls)
617
+ } catch (err) {
618
+ log.warn(`[agent-loop] Pre-tool checkpoint failed: ${(err as Error).message}`)
619
+ }
620
+ }
621
+
622
+ const toolResults = await executeToolBatch({
623
+ toolCalls: response.tool_calls,
624
+ allTools: ctx.allTools,
625
+ toolConfig: {
626
+ user_id: opts.userId,
627
+ thread_id: opts.threadId,
628
+ channel: opts.channel,
629
+ workspace: agent.workspace ?? null,
630
+ // Tools read this to know who's calling them (task_delegate's parent
631
+ // lookup, agent_create's parent_id, bus_publish's sender) — was
632
+ // missing entirely before, so config.configurable.agent_id was always
633
+ // undefined inside every tool execute().
634
+ agent_id: opts.agentId,
635
+ run_id: runId ?? opts.runId,
636
+ turn_id: opts.turnId,
637
+ task_id: opts.taskId,
638
+ session_id: opts.sessionId,
639
+ },
640
+ hiveConfig,
641
+ workerPool: hiveConfig.tools?.workerPool,
642
+ signal: opts.signal,
643
+ })
644
+
645
+ for (const batchResult of toolResults) {
646
+ const tc = batchResult.toolCall
647
+ const toolName = batchResult.toolName
648
+ const toolResultJS = batchResult.result
649
+ const toolMs = batchResult.durationMs
650
+
651
+ // Encode TOON only for LLM consumption (with cost calculation)
652
+ const toolResultLLM = formatToolResult(toolResultJS, cleanModel)
653
+
654
+ log.info(`[agent-loop] Tool ${toolName} completed in ${toolMs}ms`)
655
+
656
+ // Log tool result preview (truncated to avoid flooding logs)
657
+ const resultPreview = toolResultLLM.length > 500
658
+ ? toolResultLLM.substring(0, 500) + `… (+${toolResultLLM.length - 500} chars)`
659
+ : toolResultLLM
660
+ log.info(`[agent-loop] Tool result [${toolName}]: ${resultPreview}`)
661
+
662
+ // Extract text for trace summary
663
+ const textMessage = typeof opts.userMessage === "string"
664
+ ? opts.userMessage
665
+ : Array.isArray(opts.userMessage)
666
+ ? opts.userMessage.filter(p => p.type === "text").map(p => (p as any).text).join("\n")
667
+ : String(opts.userMessage)
668
+
669
+ // Clean timestamp from message for trace
670
+ const cleanMessage = textMessage.replace(/^\[Timestamp:.*?\]\n/, "")
671
+
672
+ // Save tool call trace
673
+ saveTrace({
674
+ threadId: opts.threadId,
675
+ agentId: opts.agentId,
676
+ agentName,
677
+ toolUsed: toolName,
678
+ inputSummary: `${cleanMessage.substring(0, 200)} → ${toolName}`,
679
+ outputSummary: toolResultLLM.substring(0, 300),
680
+ success: !toolResultLLM.startsWith("[Tool Error]"),
681
+ errorMessage: toolResultLLM.startsWith("[Tool Error]") ? toolResultLLM : null,
682
+ durationMs: toolMs,
683
+ causalStreamId,
684
+ catalogAgentId: agent.source === "catalog" ? agent.id : undefined,
685
+ })
686
+
687
+ // G9: record the tool call, caused by the decision that requested it.
688
+ // Canonical outcome shape ("Ok" | "Timeout" | {Err}) — see
689
+ // hiveBD docs/AGENT_INTEGRATION.md; anything else silently counts as Ok.
690
+ if (causalDb) {
691
+ const outcome = batchResult.timedOut
692
+ ? "Timeout"
693
+ : batchResult.ok
694
+ ? "Ok"
695
+ : { Err: batchResult.error?.message ?? toolResultLLM.slice(0, 300) }
696
+ await appendCausalEvent(causalDb, {
697
+ agentId: opts.agentId,
698
+ streamId: causalStreamId,
699
+ kind: "ToolCall",
700
+ payload: { tool: toolName, latency_ms: toolMs, outcome },
701
+ causation: lastCausalSeq,
702
+ correlation: causalCorrelationId,
703
+ })
704
+ }
705
+
706
+ // Emit tool chunk (TOON encoded for LLM)
707
+ yield { tools: { messages: [{ content: toolResultLLM, tool_call_id: tc.id, name: toolName }] } }
708
+
709
+ if (opts.onStep) {
710
+ await opts.onStep({ type: "tool_result", message: toolResultLLM })
711
+ }
712
+ if (opts.turnId) {
713
+ await publishNarration({
714
+ turnId: opts.turnId,
715
+ threadId: opts.threadId,
716
+ channel: opts.channel,
717
+ userId: opts.userId,
718
+ sessionId: opts.sessionId,
719
+ agentId: opts.agentId,
720
+ agentName,
721
+ kind: "tool_result",
722
+ status: batchResult.ok ? "done" : "error",
723
+ label: batchResult.ok
724
+ ? `${agentName} recibió el resultado de ${toolName}`
725
+ : `${agentName} recibió un error de ${toolName}`,
726
+ detail: batchResult.ok ? null : batchResult.error?.message ?? null,
727
+ dedupeKey: `tool_result:${iterations}:${tc.id}:${toolName}`,
728
+ })
729
+ }
730
+
731
+ // Add tool result to messages for next model call (in-memory only, NOT persisted to DB)
732
+ messages.push({
733
+ role: "tool",
734
+ content: toolResultLLM,
735
+ tool_call_id: tc.id,
736
+ })
737
+
738
+ // Record tool call for stuck-loop detection
739
+ const errorMessage = toolResultLLM.startsWith("[Tool Error]") ? toolResultLLM : undefined
740
+ stuckDetector.recordToolCall(opts.threadId, toolName, tc.function.arguments as Record<string, unknown>, errorMessage)
741
+
742
+ // Dynamic tool injection: when search_knowledge finds tools (native or MCP), add them to ctx.tools
743
+ if (toolName === "search_knowledge") {
744
+ // Use JS object directly (no parse needed)
745
+ try {
746
+ const result = toolResultJS as any
747
+ const foundTools: Array<{ name: string }> = result?.tools ?? []
748
+ const foundMcpTools: Array<{ tool_name: string; full_name?: string; id?: string }> = result?.toolsmcp ?? []
749
+ const currentToolNames = new Set(ctx.tools.map((t: any) => t.function?.name))
750
+
751
+ // Track which tools were injected for skill lookup
752
+ const injectedTools: string[] = []
753
+
754
+ // Inject native tools
755
+ for (const found of foundTools) {
756
+ if (!currentToolNames.has(found.name)) {
757
+ let nativeTool = ctx.allTools.find(t => t.name === found.name)
758
+ // Fallback: try alternative naming (dots ↔ underscores for legacy DB names)
759
+ if (!nativeTool) {
760
+ const altName = found.name.includes(".")
761
+ ? found.name.replace(/\./g, "_")
762
+ : found.name.replace(/_/g, ".")
763
+ nativeTool = ctx.allTools.find(t => t.name === altName)
764
+ if (nativeTool) {
765
+ log.info(`[agent-loop] Resolved legacy tool name "${found.name}" → "${nativeTool.name}"`)
766
+ }
767
+ }
768
+ if (nativeTool) {
769
+ ctx.tools.push({
770
+ type: "function",
771
+ function: {
772
+ name: nativeTool.name,
773
+ description: (nativeTool as any).description ?? "",
774
+ parameters: (nativeTool as any).parameters ?? { type: "object", properties: {} },
775
+ },
776
+ })
777
+ log.info(`[agent-loop] Injected discovered native tool into loadout: ${nativeTool.name}`)
778
+ currentToolNames.add(found.name)
779
+ injectedTools.push(nativeTool.name)
780
+ } else {
781
+ log.warn(`[agent-loop] search_knowledge returned tool "${found.name}" but no matching executor found in allTools`)
782
+ }
783
+ }
784
+ }
785
+
786
+ // Inject MCP tools discovered via search_knowledge(type="mcp")
787
+ for (const found of foundMcpTools) {
788
+ // Use full_name (sanitized compound id) because ctx.allTools stores MCP tools
789
+ // under the sanitized name (e.g. "Instagram__mis_estadisticas_de_instagram"),
790
+ // NOT the original tool_name (e.g. "mis estadisticas de instagram").
791
+ const mcpFullName = found.full_name || found.id
792
+ log.debug(`[agent-loop] MCP discovery candidate: tool_name="${found.tool_name}", full_name="${found.full_name}", id="${found.id}", resolved="${mcpFullName}"`)
793
+ if (!currentToolNames.has(mcpFullName)) {
794
+ const mcpTool = ctx.allTools.find(t => t.name === mcpFullName)
795
+ if (mcpTool) {
796
+ ctx.tools.push({
797
+ type: "function",
798
+ function: {
799
+ name: mcpTool.name,
800
+ description: (mcpTool as any).description ?? "",
801
+ parameters: (mcpTool as any).parameters ?? { type: "object", properties: {} },
802
+ },
803
+ })
804
+ log.info(`[agent-loop] Injected discovered MCP tool into loadout: ${mcpTool.name}`)
805
+ currentToolNames.add(mcpFullName)
806
+ } else {
807
+ log.warn(`[agent-loop] MCP tool "${mcpFullName}" not found in allTools (available MCP: ${ctx.allTools.filter(t => t.name.includes('__')).map(t => t.name).join(', ')})`)
808
+ }
809
+ }
810
+ }
811
+
812
+ // Inject skills associated with the injected tools
813
+ if (injectedTools.length > 0) {
814
+ try {
815
+ const skillsCol = await col<import("../storage/collections").SkillDoc>("skills")
816
+ // Find skills that use any of the injected tools
817
+ const activeSkills = (await skillsCol.scan({})).filter(e => e.doc.active)
818
+ const skillsWithTools = activeSkills
819
+ .filter(e => injectedTools.some(t => e.doc.tools?.includes(t)))
820
+ .map(e => ({ name: e.doc.name, body: e.doc.body, tools: e.doc.tools }))
821
+
822
+ // Filter to only skills that actually contain the tools (not partial matches)
823
+ const matchingSkills = skillsWithTools.filter(s => {
824
+ const skillTools = s.tools?.split(",").map(t => t.trim()) ?? []
825
+ return injectedTools.some(injected => skillTools.includes(injected))
826
+ })
827
+
828
+ if (matchingSkills.length > 0) {
829
+ const skillSection = matchingSkills
830
+ .map(s => `## Skill: ${s.name}\n${s.body}`)
831
+ .join("\n\n")
832
+
833
+ // Add skill instructions to system prompt. messages[0] is
834
+ // always the system prompt by construction — there is
835
+ // exactly one system message in the array (see its
836
+ // construction above), so index instead of scanning.
837
+ const systemMsg = messages[0]?.role === "system" ? messages[0] : undefined
838
+ if (systemMsg && typeof systemMsg.content === "string") {
839
+ // Check if we already added this skill
840
+ const existingSkillNames = new Set(
841
+ (systemMsg.content.match(/## Skill: ([^\n]+)/g) || [])
842
+ .map(m => m.replace("## Skill: ", "").trim())
843
+ )
844
+
845
+ const newSkills = matchingSkills.filter(s => !existingSkillNames.has(s.name))
846
+ if (newSkills.length > 0) {
847
+ const newSkillSection = newSkills
848
+ .map(s => `## Skill: ${s.name}\n${s.body}`)
849
+ .join("\n\n")
850
+
851
+ systemMsg.content += `\n\n--- SKILL INSTRUCTIONS (Auto-loaded) ---\n${newSkillSection}`
852
+ log.info(`[agent-loop] Injected ${newSkills.length} skill(s) for tools: ${newSkills.map(s => s.name).join(", ")}`)
853
+ }
854
+ }
855
+ }
856
+ } catch (skillErr) {
857
+ log.warn(`[agent-loop] Failed to inject skills for tools: ${(skillErr as Error).message}`)
858
+ }
859
+ }
860
+ } catch (err) {
861
+ log.warn(`[agent-loop] search_knowledge tool injection failed: ${(err as Error).message}`)
862
+ }
863
+
864
+ // Enrich the tool result with skill instructions and playbook rules
865
+ try {
866
+ const result = toolResultJS as any
867
+ const foundSkills: Array<{ name: string; body?: string }> = result?.skills ?? []
868
+ const foundPlaybook: Array<{ rule: string; category?: string }> = result?.playbook ?? []
869
+
870
+ if (foundSkills.length > 0 || foundPlaybook.length > 0) {
871
+ const extras: string[] = []
872
+
873
+ if (foundSkills.some((s: any) => s.body)) {
874
+ const section = foundSkills
875
+ .filter((s: any) => s.body)
876
+ .map((s: any) => `## Skill: ${s.name}\n${s.body}`)
877
+ .join("\n\n")
878
+ extras.push(`\n\n--- SKILL INSTRUCTIONS ---\n${section}`)
879
+ }
880
+
881
+ if (foundPlaybook.length > 0) {
882
+ const section = foundPlaybook.map((p: any) => `- [${p.category ?? "general"}] ${p.rule}`).join("\n")
883
+ extras.push(`\n\n--- PLAYBOOK RULES ---\n${section}`)
884
+ }
885
+
886
+ if (extras.length > 0) {
887
+ const lastMsg = messages[messages.length - 1]
888
+ if (lastMsg?.role === "tool") {
889
+ lastMsg.content += extras.join("")
890
+ log.info(`[agent-loop] Enriched search_knowledge result with ${foundSkills.length} skill(s) and ${foundPlaybook.length} rule(s)`)
891
+ }
892
+ }
893
+ }
894
+ } catch (err) {
895
+ log.warn(`[agent-loop] search_knowledge enrichment failed: ${(err as Error).message}`)
896
+ }
897
+ }
898
+
899
+ // Loop detection: same tool + same args called consecutively → break
900
+ const sig = `${toolName}:${JSON.stringify(tc.function.arguments)}`
901
+ if (sig === lastToolSignature) {
902
+ consecutiveRepeat++
903
+ if (consecutiveRepeat >= 2) {
904
+ log.warn(`[agent-loop] Loop detected: "${toolName}" x${consecutiveRepeat + 1} with same args. Breaking.`)
905
+ finalContent = "No pude completar la tarea porque no encontré las herramientas necesarias para ello."
906
+ loopDetected = true
907
+ }
908
+ } else {
909
+ lastToolSignature = sig
910
+ consecutiveRepeat = 0
911
+ }
912
+ }
913
+
914
+ if (loopDetected) break
915
+
916
+ // Check for stuck loop after each iteration
917
+ stuckState = stuckDetector.check(opts.threadId)
918
+ if (stuckState.detected) {
919
+ const intervention = getInterventionMessage(stuckState)
920
+ log.warn(`[agent-loop] ${intervention}`)
921
+
922
+ if (stuckState.count >= 4) {
923
+ // Critical: break and notify user instead of looping forever
924
+ finalContent = intervention
925
+ loopDetected = true
926
+ emitCanvas("canvas:node_update", {
927
+ nodeId: opts.agentId,
928
+ changes: { status: "stuck", currentTool: stuckState.toolName },
929
+ })
930
+ break
931
+ } else {
932
+ // Warning: inject intervention message so the model changes strategy
933
+ messages.push({
934
+ role: "user",
935
+ content: intervention,
936
+ })
937
+ }
938
+ }
939
+
940
+ // Stall detection: browser task inspecting the page repeatedly without acting on it.
941
+ // Only iterations that actually used browser tools count — filesystem/knowledge/etc.
942
+ // tool calls are real progress for non-browser tasks and must not trip this heuristic.
943
+ const usedBrowserTools = toolResults.some((r) => r.toolName.startsWith("browser_"))
944
+ const hadProgress = toolResults.some(
945
+ (r) => PROGRESS_TOOLS.has(r.toolName) && !String(r.result).startsWith("[Tool Error]")
946
+ )
947
+ if (hadProgress) {
948
+ idleIterations = 0
949
+ } else if (usedBrowserTools) {
950
+ idleIterations++
951
+ }
952
+ if (idleIterations >= 3 && idleIterations < 5) {
953
+ const stallMsg = "ADVERTENCIA: Llevas varios pasos sin modificar la página. Si ya completaste el formulario, responde al usuario. Si no, avanza con browser_type/browser_click en lugar de seguir inspeccionando."
954
+ log.warn(`[agent-loop] ${stallMsg}`)
955
+ messages.push({ role: "user", content: stallMsg })
956
+ } else if (idleIterations >= 5) {
957
+ const stallMsg = "No logré avanzar en el formulario después de varios intentos. Puede que la página no sea compatible o que falten instrucciones. Te sugiero revisar la URL o darme más detalles."
958
+ log.warn(`[agent-loop] Stall break: ${stallMsg}`)
959
+ finalContent = stallMsg
960
+ emitCanvas("canvas:node_update", {
961
+ nodeId: opts.agentId,
962
+ changes: { status: "stuck", currentTool: "NO_PROGRESS" },
963
+ })
964
+ break
965
+ }
966
+
967
+ emitCanvas("canvas:node_update", {
968
+ nodeId: opts.agentId,
969
+ changes: { status: "thinking", currentTool: null },
970
+ })
971
+
972
+ // ── Post-tool checkpoint (pending_tool_calls cleared) ───────────────────
973
+ // Track injected tools for checkpoint
974
+ if (toolResults.some(r => r.toolName === "search_knowledge")) {
975
+ const alreadyTracked = new Set(injectedToolNames)
976
+ for (const tr of toolResults) {
977
+ if (tr.toolName !== "search_knowledge") {
978
+ const nativeTool = ctx.allTools.find(t => t.name === tr.toolName)
979
+ if (nativeTool && !alreadyTracked.has(tr.toolName)) {
980
+ injectedToolNames.push(tr.toolName)
981
+ alreadyTracked.add(tr.toolName)
982
+ }
983
+ }
984
+ }
985
+ }
986
+
987
+ // Auto-promote to durable for long-running chat turns
988
+ if (!isDurable && !opts.isolated && iterations >= DURABLE_PROMOTION_THRESHOLD) {
989
+ isDurable = true
990
+ const promoted = await createAgentRun({
991
+ thread_id: opts.threadId,
992
+ agent_id: opts.agentId,
993
+ user_id: opts.userId ?? "",
994
+ channel: opts.channel ?? null,
995
+ kind: "chat",
996
+ max_iterations: maxIterations,
997
+ resume_policy: "resume",
998
+ })
999
+ runId = promoted.id
1000
+ startLeaseRenewal(runId)
1001
+ log.info(`[agent-loop] Auto-promoted to durable run ${runId} at iteration ${iterations}`)
1002
+ }
1003
+
1004
+ if (runId && isDurable) {
1005
+ try {
1006
+ await checkpointRun(runId, {
1007
+ version: 1,
1008
+ messages: [...messages],
1009
+ iterations,
1010
+ totalInputTokens,
1011
+ totalOutputTokens,
1012
+ lastToolSignature,
1013
+ consecutiveRepeat,
1014
+ idleIterations,
1015
+ injectedToolNames,
1016
+ systemPromptSkillSections,
1017
+ }, null) // null = no pending tool calls (tools just completed)
1018
+ } catch (err) {
1019
+ log.warn(`[agent-loop] Post-tool checkpoint failed: ${(err as Error).message}`)
1020
+ }
1021
+ }
1022
+
1023
+ // Budget check: tokens
1024
+ if (opts.budget?.maxTokens && (totalInputTokens + totalOutputTokens) >= opts.budget.maxTokens) {
1025
+ log.info(`[agent-loop] Token budget exhausted (${totalInputTokens + totalOutputTokens}/${opts.budget.maxTokens})`)
1026
+ break
1027
+ }
1028
+ }
1029
+
1030
+ // ── Synthesis call when max iterations hit without a text response ────────
1031
+ // The agent spent all iterations on tool calls and never produced a final message.
1032
+ // Make one extra call without tools so it summarizes what it did.
1033
+ if (!finalContent) {
1034
+ const pendingDelegation = opts.turnId && !opts.isolated
1035
+ ? await import("../gateway/delegation-groups").then((mod) => mod.getDelegationGroup(opts.turnId!))
1036
+ : null
1037
+ if (pendingDelegation) {
1038
+ log.info(`[agent-loop] Suppressing terminal synthesis while delegation group ${opts.turnId} is pending`)
1039
+ finalContent = ""
1040
+ } else {
1041
+ log.info(`[agent-loop] Max iterations hit with no text response — requesting synthesis (isolated=${!!opts.isolated})`)
1042
+ messages.push({
1043
+ role: "user",
1044
+ content: "Basándote en lo que hiciste hasta ahora, responde al usuario con un resumen claro y estrictamente factual de lo completado, lo pendiente o los errores. No declares éxito sin evidencia. Sé conciso.",
1045
+ })
1046
+ let synthesisAttempt = 0
1047
+ finalContent = await synthesizeFinalResponse(async () => {
1048
+ synthesisAttempt++
1049
+ if (synthesisAttempt > 1) {
1050
+ log.warn("[agent-loop] Retrying terminal synthesis after an empty or failed response")
1051
+ }
1052
+ const synthesis = await callLLM({
1053
+ ...providerCfg,
1054
+ messages: clearOldToolResults(messages) as LLMMessage[],
1055
+ tools: undefined, // no tools — force text response
1056
+ })
1057
+ if (synthesis.usage) {
1058
+ totalInputTokens += synthesis.usage.input_tokens
1059
+ totalOutputTokens += synthesis.usage.output_tokens
1060
+ }
1061
+ // A provider failure comes back as non-empty `content`, which the
1062
+ // empty-content check above would happily accept as a valid synthesis and
1063
+ // persist. Raise instead so the retry/AgentSynthesisError path runs.
1064
+ if (synthesis.stop_reason === "error") {
1065
+ throw new Error(synthesis.error?.message ?? synthesis.content)
1066
+ }
1067
+ return synthesis.content
1068
+ })
1069
+ if (!opts.isolated) {
1070
+ await addMessage(opts.threadId, "assistant", finalContent)
1071
+ }
1072
+ yield { agent: { messages: [{ content: finalContent }] } }
1073
+ }
1074
+ } else if (!finalEmitted) {
1075
+ // Internal break (stall, loop detected, stuck, timeout, abort) set finalContent
1076
+ // without yielding it — emit and persist it so the user gets a non-empty response
1077
+ if (!opts.isolated) {
1078
+ await addMessage(opts.threadId, "assistant", finalContent)
1079
+ }
1080
+ yield { agent: { messages: [{ content: finalContent }] } }
1081
+ }
1082
+
1083
+ // Emit final usage so consumers (e.g. AgentRunner) can surface real token counts
1084
+ if (totalInputTokens > 0 || totalOutputTokens > 0) {
1085
+ yield { usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens } }
1086
+ }
1087
+
1088
+ // ── Post-loop ────────────────────────────────────────────────────────────
1089
+ const durationMs = Math.round(performance.now() - t0)
1090
+
1091
+ emitCanvas("canvas:node_update", {
1092
+ nodeId: opts.agentId,
1093
+ changes: { status: "idle", currentTool: null },
1094
+ })
1095
+
1096
+ // Record usage
1097
+ recordLLMUsage({
1098
+ provider: providerCfg.provider,
1099
+ model: providerCfg.model,
1100
+ inputTokens: totalInputTokens,
1101
+ outputTokens: totalOutputTokens,
1102
+ })
1103
+
1104
+ // Extract text for trace summary
1105
+ const textMessageFinal = opts.rawUserMessage || (typeof opts.userMessage === "string"
1106
+ ? opts.userMessage
1107
+ : Array.isArray(opts.userMessage)
1108
+ ? opts.userMessage.filter(p => p.type === "text").map(p => (p as any).text).join("\n")
1109
+ : String(opts.userMessage))
1110
+
1111
+ // Save overall trace
1112
+ const cleanMessageFinal = textMessageFinal.replace(/^\[Timestamp:.*?\]\n/, "")
1113
+ saveTrace({
1114
+ threadId: opts.threadId,
1115
+ agentId: opts.agentId,
1116
+ agentName,
1117
+ inputSummary: cleanMessageFinal.substring(0, 300),
1118
+ outputSummary: finalContent.substring(0, 300),
1119
+ success: true,
1120
+ durationMs,
1121
+ tokensUsed: totalInputTokens + totalOutputTokens,
1122
+ causalStreamId,
1123
+ catalogAgentId: agent.source === "catalog" ? agent.id : undefined,
1124
+ })
1125
+
1126
+ log.info(
1127
+ `[agent-loop] Done: agent=${agentName} iterations=${iterations} ` +
1128
+ `tokens=${totalInputTokens + totalOutputTokens} elapsed=${durationMs}ms`
1129
+ )
1130
+
1131
+ // ── Durable run finalization ──────────────────────────────────────────────
1132
+ if (runId && isDurable) {
1133
+ // Ensure final iteration count is persisted (loop may have broken before the
1134
+ // in-loop post-tool checkpoint was reached, e.g. when LLM returns text immediately).
1135
+ try {
1136
+ await checkpointRun(runId, {
1137
+ version: 1,
1138
+ messages: [...messages],
1139
+ iterations,
1140
+ totalInputTokens,
1141
+ totalOutputTokens,
1142
+ lastToolSignature,
1143
+ consecutiveRepeat,
1144
+ idleIterations,
1145
+ injectedToolNames,
1146
+ systemPromptSkillSections,
1147
+ }, null)
1148
+ } catch { /* best-effort */ }
1149
+ stopLeaseRenewal(runId)
1150
+ if (opts.signal?.aborted) {
1151
+ await interruptRun(runId, "Generation aborted by signal").catch(() => {})
1152
+ } else {
1153
+ await completeRun(runId).catch(() => {})
1154
+ }
1155
+ }
1156
+
1157
+ } catch (err) {
1158
+ // The loop threw (LLM/tool error): release the run so reconcile never sees
1159
+ // a phantom "running" row with a self-renewing lease.
1160
+ if (runId && isDurable) {
1161
+ if (opts.signal?.aborted) {
1162
+ await interruptRun(runId, "Generation aborted by signal").catch(() => {})
1163
+ } else {
1164
+ await failRun(runId, (err as Error).message).catch(() => {})
1165
+ }
1166
+ }
1167
+ throw err
1168
+ } finally {
1169
+ if (runId) {
1170
+ stopLeaseRenewal(runId)
1171
+ if (isDurable) {
1172
+ // Consumer abandoned the generator (break/.return()) before the normal
1173
+ // finalization ran: leave the run resumable instead of phantom-running.
1174
+ const finalState = await getRun(runId).catch(() => null)
1175
+ if (finalState && finalState.status === "running") {
1176
+ await interruptRun(runId, "Loop terminated early without finalization").catch(() => {})
1177
+ }
1178
+ }
1179
+ }
1180
+ }
1181
+ }
1182
+
1183
+ // ─── Isolated worker execution (Fase 4.4) ───────────────────────────────────
1184
+
1185
+ /**
1186
+ * Run a worker agent in an isolated context.
1187
+ * Returns the final response string.
1188
+ *
1189
+ * Passing `runId` + `durable` links the run to an existing AgentRun so the
1190
+ * worker checkpoints per round-trip and can resume mid-task after a crash.
1191
+ */
1192
+ export interface IsolatedAgentOptions {
1193
+ agentId: string
1194
+ taskDescription: string | ContentPart[]
1195
+ threadId: string
1196
+ mcpManager?: MCPClientManager | null
1197
+ runId?: string
1198
+ resume?: boolean
1199
+ durable?: boolean
1200
+ signal?: AbortSignal
1201
+ turnId?: string
1202
+ taskId?: string
1203
+ userId?: string
1204
+ channel?: string
1205
+ sessionId?: string
1206
+ }
1207
+
1208
+ export async function runAgentIsolatedDetailed(
1209
+ opts: IsolatedAgentOptions,
1210
+ ): Promise<{ content: string; toolEvidence: string[] }> {
1211
+ let lastContent = ""
1212
+ const toolEvidence: string[] = []
1213
+ for await (const chunk of runAgent({
1214
+ agentId: opts.agentId,
1215
+ userMessage: opts.taskDescription,
1216
+ threadId: opts.threadId,
1217
+ isolated: true,
1218
+ taskContext: opts.taskDescription,
1219
+ mcpManager: opts.mcpManager,
1220
+ runId: opts.runId,
1221
+ resume: opts.resume,
1222
+ durable: opts.durable,
1223
+ signal: opts.signal,
1224
+ turnId: opts.turnId,
1225
+ taskId: opts.taskId,
1226
+ userId: opts.userId,
1227
+ channel: opts.channel,
1228
+ sessionId: opts.sessionId,
1229
+ })) {
1230
+ if (chunk.agent?.messages?.[0]?.content) {
1231
+ lastContent = chunk.agent.messages[0].content
1232
+ }
1233
+ for (const message of chunk.tools?.messages ?? []) {
1234
+ const raw = typeof message.content === "string" ? message.content : JSON.stringify(message.content)
1235
+ const safe = raw
1236
+ .replace(/data:[a-z0-9.+-]+\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+/gi, "[REDACTED_BINARY]")
1237
+ .replace(/[A-Za-z0-9+/]{1000,}={0,2}/g, "[REDACTED_BINARY]")
1238
+ toolEvidence.push(`${message.name ?? "tool"}: ${safe.slice(0, 4000)}`)
1239
+ if (toolEvidence.length > 8) toolEvidence.shift()
1240
+ }
1241
+ }
1242
+ return { content: lastContent, toolEvidence }
1243
+ }
1244
+
1245
+ export async function runAgentIsolated(opts: IsolatedAgentOptions): Promise<string> {
1246
+ return (await runAgentIsolatedDetailed(opts)).content
1247
+ }
1248
+
1249
+ // ─── Shim: AgentLoop class with stream() compatible with providers/index.ts ──
1250
+
1251
+ export class AgentLoop {
1252
+ private mcpManager: MCPClientManager | null = null
1253
+
1254
+ setMCPManager(m: MCPClientManager) {
1255
+ this.mcpManager = m
1256
+ }
1257
+
1258
+ /**
1259
+ * Returns an async iterable that emits chunks compatible with
1260
+ * the existing providers/index.ts stream consumer.
1261
+ */
1262
+ async *stream(
1263
+ input: { messages: Array<{ role: string; content: string | ContentPart[] }> },
1264
+ config: {
1265
+ configurable?: {
1266
+ thread_id?: string
1267
+ agent_id?: string
1268
+ user_id?: string
1269
+ system_prompt?: string
1270
+ channel?: string
1271
+ raw_user_message?: string
1272
+ /** Durable run options — checkpoint/resume via agentRuns. */
1273
+ run_id?: string
1274
+ resume?: boolean
1275
+ durable?: boolean
1276
+ turn_id?: string
1277
+ session_id?: string
1278
+ /** See AgentLoopOptions.historySource. */
1279
+ history_source?: TurnSource
1280
+ }
1281
+ signal?: AbortSignal
1282
+ onToken?: (token: string) => void
1283
+ onReasoningToken?: (token: string) => void
1284
+ onStep?: (step: StepEvent) => Promise<void>
1285
+ /** Extra tools to force into the LLM loadout (tests/evals). */
1286
+ extraTools?: any[]
1287
+ }
1288
+ ): AsyncIterable<StreamChunk> {
1289
+ // Resolve from database with priority: explicit param → DB lookup → single user/agent
1290
+ const threadId = config.configurable?.thread_id || (await resolveUserId({})) || "default"
1291
+ const agentId = config.configurable?.agent_id || (await resolveAgentId(config.configurable?.agent_id)) || (await this._resolveCoordinatorId()) || "main"
1292
+ const systemPromptOverride = config.configurable?.system_prompt
1293
+ const channel = config.configurable?.channel
1294
+ const userId = config.configurable?.user_id || (await resolveUserId({
1295
+ channel: config.configurable?.channel ? (config.configurable?.channel as string).split(':')[0] : null,
1296
+ channelUserId: config.configurable?.thread_id
1297
+ }))
1298
+
1299
+ // Log MCP Manager status
1300
+ log.info(`[AgentLoop.stream] MCP Manager available: ${this.mcpManager !== null}`)
1301
+ if (this.mcpManager) {
1302
+ try {
1303
+ const servers = this.mcpManager.listServers?.() || []
1304
+ log.info(`[AgentLoop.stream] MCP servers: ${servers.length} registered`)
1305
+ for (const s of servers) {
1306
+ log.info(` - ${s.name}: ${s.status} (${s.tools?.length || 0} tools)`)
1307
+ }
1308
+ } catch (e) {
1309
+ log.warn(`[AgentLoop.stream] Failed to list MCP servers: ${(e as Error).message}`)
1310
+ }
1311
+ }
1312
+
1313
+ // Extract the last user message from the input
1314
+ const lastUserMsg = [...input.messages].reverse().find((m) => m.role === "user")
1315
+ const userMessage = lastUserMsg?.content || ""
1316
+
1317
+ // Use clean message (without timestamp) for the search selectors
1318
+ const rawUserMessage = config.configurable?.raw_user_message ||
1319
+ (typeof userMessage === "string" ? userMessage : userMessage.filter(p => p.type === "text").map(p => (p as any).text).join("\n"))
1320
+
1321
+ yield* runAgent({
1322
+ agentId,
1323
+ userMessage, // FULL MULTIMODAL MESSAGE
1324
+ rawUserMessage, // CLEAN TEXT for search selectors
1325
+ threadId,
1326
+ channel,
1327
+ systemPromptOverride,
1328
+ mcpManager: this.mcpManager,
1329
+ userId,
1330
+ signal: config.signal,
1331
+ onToken: config.onToken,
1332
+ onReasoningToken: config.onReasoningToken,
1333
+ onStep: config.onStep,
1334
+ extraTools: config.extraTools,
1335
+ historySource: config.configurable?.history_source,
1336
+ runId: config.configurable?.run_id,
1337
+ resume: config.configurable?.resume,
1338
+ durable: config.configurable?.durable,
1339
+ turnId: config.configurable?.turn_id,
1340
+ sessionId: config.configurable?.session_id,
1341
+ runKind: "chat",
1342
+ })
1343
+ }
1344
+
1345
+ private async _resolveCoordinatorId(): Promise<string> {
1346
+ // Use the storage helper to get coordinator agent ID from database
1347
+ const coordinatorId = await resolveAgentId(null);
1348
+ return coordinatorId || "main";
1349
+ }
1350
+ }
1351
+
1352
+ // Singleton
1353
+ let _agentLoop: AgentLoop | null = null
1354
+
1355
+ export function getAgentLoop(): AgentLoop | null {
1356
+ return _agentLoop
1357
+ }
1358
+
1359
+ export function buildAgentLoop(opts: { mcpManager?: MCPClientManager | null } = {}): AgentLoop {
1360
+ _agentLoop = new AgentLoop()
1361
+ if (opts.mcpManager) {
1362
+ _agentLoop.setMCPManager(opts.mcpManager)
1363
+ log.info("[buildAgentLoop] MCP Manager set successfully")
1364
+ } else {
1365
+ log.warn("[buildAgentLoop] No MCP Manager provided, agent will not have MCP tools")
1366
+ }
1367
+ return _agentLoop
1368
+ }
1369
+
1370
+ export async function rebuildAgentLoop(opts: { mcpManager?: MCPClientManager | null } = {}): Promise<AgentLoop> {
1371
+ _agentLoop = null
1372
+ return buildAgentLoop(opts)
1373
+ }