@tenex-chat/backend 0.9.1

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 (427) hide show
  1. package/README.md +194 -0
  2. package/dist/backend-wrapper.cjs +3 -0
  3. package/dist/src/index.js +331928 -0
  4. package/package.json +103 -0
  5. package/src/agents/AgentRegistry.ts +418 -0
  6. package/src/agents/AgentStorage.ts +1133 -0
  7. package/src/agents/ConfigResolver.ts +229 -0
  8. package/src/agents/agent-installer.ts +236 -0
  9. package/src/agents/agent-loader.ts +241 -0
  10. package/src/agents/constants.ts +82 -0
  11. package/src/agents/errors.ts +48 -0
  12. package/src/agents/execution/AgentExecutor.ts +561 -0
  13. package/src/agents/execution/ExecutionContextFactory.ts +112 -0
  14. package/src/agents/execution/MessageCompiler.ts +597 -0
  15. package/src/agents/execution/MessageSyncer.ts +100 -0
  16. package/src/agents/execution/PostCompletionChecker.ts +278 -0
  17. package/src/agents/execution/ProgressMonitor.ts +50 -0
  18. package/src/agents/execution/RALResolver.ts +177 -0
  19. package/src/agents/execution/SessionManager.ts +181 -0
  20. package/src/agents/execution/StreamCallbacks.ts +312 -0
  21. package/src/agents/execution/StreamExecutionHandler.ts +579 -0
  22. package/src/agents/execution/StreamSetup.ts +313 -0
  23. package/src/agents/execution/ToolEventHandlers.ts +239 -0
  24. package/src/agents/execution/ToolExecutionTracker.ts +498 -0
  25. package/src/agents/execution/ToolResultUtils.ts +97 -0
  26. package/src/agents/execution/ToolSupervisionWrapper.ts +174 -0
  27. package/src/agents/execution/constants.ts +16 -0
  28. package/src/agents/execution/index.ts +3 -0
  29. package/src/agents/execution/types.ts +96 -0
  30. package/src/agents/execution/utils.ts +26 -0
  31. package/src/agents/index.ts +4 -0
  32. package/src/agents/script-installer.ts +266 -0
  33. package/src/agents/supervision/SupervisorLLMService.ts +253 -0
  34. package/src/agents/supervision/SupervisorOrchestrator.ts +471 -0
  35. package/src/agents/supervision/heuristics/ConsecutiveToolsWithoutTodoHeuristic.ts +73 -0
  36. package/src/agents/supervision/heuristics/DelegationClaimHeuristic.ts +80 -0
  37. package/src/agents/supervision/heuristics/HeuristicRegistry.ts +114 -0
  38. package/src/agents/supervision/heuristics/PendingTodosHeuristic.ts +93 -0
  39. package/src/agents/supervision/heuristics/SilentAgentHeuristic.ts +54 -0
  40. package/src/agents/supervision/heuristics/index.ts +5 -0
  41. package/src/agents/supervision/index.ts +28 -0
  42. package/src/agents/supervision/registerHeuristics.ts +110 -0
  43. package/src/agents/supervision/supervisionHealthCheck.ts +123 -0
  44. package/src/agents/supervision/types.ts +171 -0
  45. package/src/agents/tool-names.ts +46 -0
  46. package/src/agents/tool-normalization.ts +184 -0
  47. package/src/agents/types/index.ts +2 -0
  48. package/src/agents/types/runtime.ts +74 -0
  49. package/src/agents/types/storage.ts +145 -0
  50. package/src/commands/agent/import/index.ts +6 -0
  51. package/src/commands/agent/import/openclaw-distiller.ts +57 -0
  52. package/src/commands/agent/import/openclaw-reader.ts +141 -0
  53. package/src/commands/agent/import/openclaw.ts +154 -0
  54. package/src/commands/agent/index.ts +6 -0
  55. package/src/commands/agent.ts +215 -0
  56. package/src/commands/daemon.ts +198 -0
  57. package/src/commands/doctor.ts +134 -0
  58. package/src/commands/setup/embed.ts +228 -0
  59. package/src/commands/setup/global-system-prompt.ts +223 -0
  60. package/src/commands/setup/image.ts +179 -0
  61. package/src/commands/setup/index.ts +16 -0
  62. package/src/commands/setup/interactive.ts +95 -0
  63. package/src/commands/setup/llm.ts +38 -0
  64. package/src/commands/setup/onboarding.ts +294 -0
  65. package/src/commands/setup/providers.ts +27 -0
  66. package/src/constants.ts +34 -0
  67. package/src/conversations/ConversationDiskReader.ts +148 -0
  68. package/src/conversations/ConversationRegistry.ts +728 -0
  69. package/src/conversations/ConversationStore.ts +868 -0
  70. package/src/conversations/MessageBuilder.ts +866 -0
  71. package/src/conversations/executionTime.ts +62 -0
  72. package/src/conversations/formatters/DelegationXmlFormatter.ts +64 -0
  73. package/src/conversations/formatters/ThreadedConversationFormatter.ts +303 -0
  74. package/src/conversations/formatters/index.ts +9 -0
  75. package/src/conversations/formatters/utils/MessageFormatter.ts +46 -0
  76. package/src/conversations/formatters/utils/TimestampFormatter.ts +56 -0
  77. package/src/conversations/formatters/utils/TreeBuilder.ts +131 -0
  78. package/src/conversations/formatters/utils/TreeRenderer.ts +49 -0
  79. package/src/conversations/index.ts +2 -0
  80. package/src/conversations/persistence/ToolMessageStorage.ts +143 -0
  81. package/src/conversations/search/ConversationIndexManager.ts +393 -0
  82. package/src/conversations/search/QueryParser.ts +114 -0
  83. package/src/conversations/search/SearchEngine.ts +175 -0
  84. package/src/conversations/search/SnippetExtractor.ts +345 -0
  85. package/src/conversations/search/embeddings/ConversationEmbeddingService.ts +484 -0
  86. package/src/conversations/search/embeddings/ConversationIndexingJob.ts +320 -0
  87. package/src/conversations/search/embeddings/IndexingStateManager.ts +338 -0
  88. package/src/conversations/search/embeddings/index.ts +18 -0
  89. package/src/conversations/search/index.ts +49 -0
  90. package/src/conversations/search/types.ts +124 -0
  91. package/src/conversations/services/CategoryManager.ts +160 -0
  92. package/src/conversations/services/ConversationResolver.ts +296 -0
  93. package/src/conversations/services/ConversationSummarizer.ts +234 -0
  94. package/src/conversations/services/MetadataDebounceManager.ts +188 -0
  95. package/src/conversations/services/index.ts +2 -0
  96. package/src/conversations/types.ts +148 -0
  97. package/src/conversations/utils/content-utils.ts +69 -0
  98. package/src/conversations/utils/image-placeholder.ts +281 -0
  99. package/src/conversations/utils/image-url-utils.ts +171 -0
  100. package/src/conversations/utils/multimodal-content.ts +90 -0
  101. package/src/conversations/utils/tool-result-truncator.ts +159 -0
  102. package/src/daemon/Daemon.ts +1883 -0
  103. package/src/daemon/ProjectRuntime.ts +657 -0
  104. package/src/daemon/RestartState.ts +152 -0
  105. package/src/daemon/RuntimeLifecycle.ts +268 -0
  106. package/src/daemon/SubscriptionManager.ts +305 -0
  107. package/src/daemon/UnixSocketTransport.ts +318 -0
  108. package/src/daemon/filters/SubscriptionFilterBuilder.ts +119 -0
  109. package/src/daemon/index.ts +9 -0
  110. package/src/daemon/routing/DaemonRouter.ts +491 -0
  111. package/src/daemon/types.ts +150 -0
  112. package/src/daemon/utils/routing-log.ts +76 -0
  113. package/src/daemon/utils/telemetry.ts +173 -0
  114. package/src/event-handler/agentDeletion.ts +383 -0
  115. package/src/event-handler/index.ts +749 -0
  116. package/src/event-handler/newConversation.ts +165 -0
  117. package/src/event-handler/project.ts +166 -0
  118. package/src/event-handler/reply.ts +18 -0
  119. package/src/events/NDKAgentDefinition.ts +292 -0
  120. package/src/events/NDKAgentLesson.ts +106 -0
  121. package/src/events/NDKEventMetadata.ts +34 -0
  122. package/src/events/NDKMCPTool.ts +60 -0
  123. package/src/events/NDKProjectStatus.ts +384 -0
  124. package/src/events/index.ts +4 -0
  125. package/src/index.ts +126 -0
  126. package/src/lib/agent-home.ts +334 -0
  127. package/src/lib/error-formatter.ts +200 -0
  128. package/src/lib/fs/filesystem.ts +128 -0
  129. package/src/lib/fs/index.ts +1 -0
  130. package/src/lib/json-parser.ts +30 -0
  131. package/src/lib/string.ts +15 -0
  132. package/src/lib/time.ts +74 -0
  133. package/src/llm/ChunkHandler.ts +277 -0
  134. package/src/llm/FinishHandler.ts +250 -0
  135. package/src/llm/LLMConfigEditor.ts +154 -0
  136. package/src/llm/LLMServiceFactory.ts +230 -0
  137. package/src/llm/MessageProcessor.ts +90 -0
  138. package/src/llm/RecordingState.ts +37 -0
  139. package/src/llm/StreamPublisher.ts +40 -0
  140. package/src/llm/TracingUtils.ts +77 -0
  141. package/src/llm/chunk-validators.ts +57 -0
  142. package/src/llm/constants.ts +6 -0
  143. package/src/llm/index.ts +12 -0
  144. package/src/llm/meta/MetaModelResolver.ts +352 -0
  145. package/src/llm/meta/index.ts +11 -0
  146. package/src/llm/middleware/flight-recorder.ts +188 -0
  147. package/src/llm/providers/MockProvider.ts +332 -0
  148. package/src/llm/providers/agent/ClaudeCodeProvider.ts +343 -0
  149. package/src/llm/providers/agent/ClaudeCodeToolsAdapter.ts +203 -0
  150. package/src/llm/providers/agent/CodexAppServerProvider.ts +214 -0
  151. package/src/llm/providers/agent/CodexAppServerToolsAdapter.ts +91 -0
  152. package/src/llm/providers/agent/index.ts +10 -0
  153. package/src/llm/providers/base/AgentProvider.ts +107 -0
  154. package/src/llm/providers/base/BaseProvider.ts +114 -0
  155. package/src/llm/providers/base/StandardProvider.ts +38 -0
  156. package/src/llm/providers/base/index.ts +9 -0
  157. package/src/llm/providers/index.ts +106 -0
  158. package/src/llm/providers/key-manager.ts +238 -0
  159. package/src/llm/providers/ollama-models.ts +105 -0
  160. package/src/llm/providers/openrouter-models.ts +102 -0
  161. package/src/llm/providers/provider-ids.ts +18 -0
  162. package/src/llm/providers/registry/ProviderRegistry.ts +414 -0
  163. package/src/llm/providers/registry/index.ts +7 -0
  164. package/src/llm/providers/standard/AnthropicProvider.ts +71 -0
  165. package/src/llm/providers/standard/OllamaProvider.ts +59 -0
  166. package/src/llm/providers/standard/OpenAIProvider.ts +44 -0
  167. package/src/llm/providers/standard/OpenRouterProvider.ts +103 -0
  168. package/src/llm/providers/standard/index.ts +10 -0
  169. package/src/llm/providers/types.ts +194 -0
  170. package/src/llm/providers/usage-metadata.ts +78 -0
  171. package/src/llm/service.ts +713 -0
  172. package/src/llm/types.ts +167 -0
  173. package/src/llm/utils/ConfigurationManager.ts +650 -0
  174. package/src/llm/utils/ConfigurationTester.ts +229 -0
  175. package/src/llm/utils/ModelSelector.ts +212 -0
  176. package/src/llm/utils/ProviderConfigUI.ts +177 -0
  177. package/src/llm/utils/claudeCodePromptCompiler.ts +141 -0
  178. package/src/llm/utils/codex-models.ts +53 -0
  179. package/src/llm/utils/context-window-cache.ts +30 -0
  180. package/src/llm/utils/models-dev-cache.ts +267 -0
  181. package/src/llm/utils/provider-setup.ts +50 -0
  182. package/src/llm/utils/tool-errors.ts +78 -0
  183. package/src/llm/utils/usage.ts +74 -0
  184. package/src/logging/EventRoutingLogger.ts +205 -0
  185. package/src/nostr/AgentEventDecoder.ts +357 -0
  186. package/src/nostr/AgentEventEncoder.ts +677 -0
  187. package/src/nostr/AgentProfilePublisher.ts +657 -0
  188. package/src/nostr/AgentPublisher.ts +437 -0
  189. package/src/nostr/BlossomService.ts +226 -0
  190. package/src/nostr/InterventionPublisher.ts +132 -0
  191. package/src/nostr/TagExtractor.ts +228 -0
  192. package/src/nostr/collectEvents.ts +83 -0
  193. package/src/nostr/constants.ts +38 -0
  194. package/src/nostr/encryption.ts +26 -0
  195. package/src/nostr/index.ts +31 -0
  196. package/src/nostr/keys.ts +17 -0
  197. package/src/nostr/kinds.ts +37 -0
  198. package/src/nostr/ndkClient.ts +72 -0
  199. package/src/nostr/relays.ts +43 -0
  200. package/src/nostr/trace-context.ts +39 -0
  201. package/src/nostr/types.ts +227 -0
  202. package/src/nostr/utils.ts +84 -0
  203. package/src/prompts/core/FragmentRegistry.ts +30 -0
  204. package/src/prompts/core/PromptBuilder.ts +98 -0
  205. package/src/prompts/core/index.ts +3 -0
  206. package/src/prompts/core/types.ts +13 -0
  207. package/src/prompts/fragments/00-global-system-prompt.ts +44 -0
  208. package/src/prompts/fragments/01-agent-identity.ts +69 -0
  209. package/src/prompts/fragments/02-agent-home-directory.ts +114 -0
  210. package/src/prompts/fragments/03-system-reminders-explanation.ts +14 -0
  211. package/src/prompts/fragments/04-relay-configuration.ts +38 -0
  212. package/src/prompts/fragments/05-delegation-chain.ts +45 -0
  213. package/src/prompts/fragments/06-agent-todos.ts +74 -0
  214. package/src/prompts/fragments/06-todo-usage-guidance.ts +34 -0
  215. package/src/prompts/fragments/07-meta-project-context.ts +234 -0
  216. package/src/prompts/fragments/08-active-conversations.ts +382 -0
  217. package/src/prompts/fragments/09-recent-conversations.ts +153 -0
  218. package/src/prompts/fragments/10-referenced-article.ts +21 -0
  219. package/src/prompts/fragments/11-nudges.ts +134 -0
  220. package/src/prompts/fragments/12-skills.ts +127 -0
  221. package/src/prompts/fragments/13-available-nudges.ts +122 -0
  222. package/src/prompts/fragments/15-available-agents.ts +53 -0
  223. package/src/prompts/fragments/16-stay-in-your-lane.ts +41 -0
  224. package/src/prompts/fragments/17-todo-before-delegation.ts +39 -0
  225. package/src/prompts/fragments/20-voice-mode.ts +62 -0
  226. package/src/prompts/fragments/22-scheduled-tasks.ts +175 -0
  227. package/src/prompts/fragments/24-retrieved-lessons.ts +26 -0
  228. package/src/prompts/fragments/25-rag-instructions.ts +333 -0
  229. package/src/prompts/fragments/26-mcp-resources.ts +237 -0
  230. package/src/prompts/fragments/27-memorized-reports.ts +77 -0
  231. package/src/prompts/fragments/28-agent-directed-monitoring.ts +32 -0
  232. package/src/prompts/fragments/29-rag-collections.ts +50 -0
  233. package/src/prompts/fragments/30-worktree-context.ts +98 -0
  234. package/src/prompts/fragments/31-agents-md-guidance.ts +96 -0
  235. package/src/prompts/fragments/32-process-metrics.ts +72 -0
  236. package/src/prompts/fragments/debug-mode.ts +48 -0
  237. package/src/prompts/fragments/delegation-completion.ts +44 -0
  238. package/src/prompts/fragments/index.ts +91 -0
  239. package/src/prompts/index.ts +21 -0
  240. package/src/prompts/utils/systemPromptBuilder.ts +777 -0
  241. package/src/scripts/migrate-prefix-index.ts +157 -0
  242. package/src/services/AgentDefinitionMonitor.ts +701 -0
  243. package/src/services/ConfigService.ts +723 -0
  244. package/src/services/CooldownRegistry.ts +199 -0
  245. package/src/services/LLMOperationsRegistry.ts +424 -0
  246. package/src/services/OwnerAgentListService.ts +354 -0
  247. package/src/services/PubkeyService.ts +308 -0
  248. package/src/services/agents/AgentMetadataStore.ts +72 -0
  249. package/src/services/agents/AgentResolution.ts +59 -0
  250. package/src/services/agents/EscalationService.ts +281 -0
  251. package/src/services/agents/NDKAgentDiscovery.ts +95 -0
  252. package/src/services/agents/index.ts +7 -0
  253. package/src/services/agents-md/AgentsMdService.ts +184 -0
  254. package/src/services/agents-md/SystemReminderInjector.ts +238 -0
  255. package/src/services/agents-md/index.ts +11 -0
  256. package/src/services/apns/APNsClient.ts +203 -0
  257. package/src/services/apns/APNsService.ts +358 -0
  258. package/src/services/apns/index.ts +11 -0
  259. package/src/services/apns/types.ts +80 -0
  260. package/src/services/compression/CompressionService.ts +445 -0
  261. package/src/services/compression/compression-schema.ts +28 -0
  262. package/src/services/compression/compression-types.ts +74 -0
  263. package/src/services/compression/compression-utils.ts +587 -0
  264. package/src/services/config/types.ts +394 -0
  265. package/src/services/dispatch/AgentDispatchService.ts +937 -0
  266. package/src/services/dispatch/AgentRouter.ts +181 -0
  267. package/src/services/dispatch/DelegationCompletionHandler.ts +232 -0
  268. package/src/services/embedding/EmbeddingProvider.ts +188 -0
  269. package/src/services/embedding/index.ts +5 -0
  270. package/src/services/event-context/EventContextService.ts +108 -0
  271. package/src/services/event-context/index.ts +2 -0
  272. package/src/services/heuristics/ContextBuilder.ts +106 -0
  273. package/src/services/heuristics/HeuristicEngine.ts +200 -0
  274. package/src/services/heuristics/formatters.ts +58 -0
  275. package/src/services/heuristics/index.ts +12 -0
  276. package/src/services/heuristics/rules/index.ts +25 -0
  277. package/src/services/heuristics/rules/todoBeforeDelegation.ts +69 -0
  278. package/src/services/heuristics/rules/todoReminderOnToolUse.ts +63 -0
  279. package/src/services/heuristics/types.ts +144 -0
  280. package/src/services/image/ImageGenerationService.ts +389 -0
  281. package/src/services/image/index.ts +12 -0
  282. package/src/services/intervention/InterventionService.ts +1352 -0
  283. package/src/services/intervention/index.ts +7 -0
  284. package/src/services/mcp/MCPManager.ts +683 -0
  285. package/src/services/mcp/McpNotificationDelivery.ts +139 -0
  286. package/src/services/mcp/McpSubscriptionService.ts +653 -0
  287. package/src/services/mcp/mcpInstaller.ts +130 -0
  288. package/src/services/nip46/Nip46SigningLog.ts +81 -0
  289. package/src/services/nip46/Nip46SigningService.ts +467 -0
  290. package/src/services/nip46/index.ts +4 -0
  291. package/src/services/nudge/NudgeService.ts +224 -0
  292. package/src/services/nudge/NudgeWhitelistService.ts +382 -0
  293. package/src/services/nudge/index.ts +5 -0
  294. package/src/services/nudge/types.ts +83 -0
  295. package/src/services/projects/ProjectContext.ts +672 -0
  296. package/src/services/projects/ProjectContextStore.ts +102 -0
  297. package/src/services/projects/index.ts +6 -0
  298. package/src/services/prompt-compiler/index.ts +15 -0
  299. package/src/services/prompt-compiler/prompt-compiler-service.ts +1143 -0
  300. package/src/services/pubkey-gate/PubkeyGateService.ts +93 -0
  301. package/src/services/pubkey-gate/index.ts +1 -0
  302. package/src/services/rag/EmbeddingProviderFactory.ts +292 -0
  303. package/src/services/rag/LanceDBMaintenanceService.ts +211 -0
  304. package/src/services/rag/RAGDatabaseService.ts +173 -0
  305. package/src/services/rag/RAGOperations.ts +682 -0
  306. package/src/services/rag/RAGService.ts +240 -0
  307. package/src/services/rag/RagSubscriptionService.ts +618 -0
  308. package/src/services/rag/rag-utils.ts +174 -0
  309. package/src/services/ral/PendingDelegationsRegistry.ts +168 -0
  310. package/src/services/ral/RALRegistry.ts +2782 -0
  311. package/src/services/ral/index.ts +4 -0
  312. package/src/services/ral/types.ts +292 -0
  313. package/src/services/reports/LocalReportStore.ts +380 -0
  314. package/src/services/reports/ReportEmbeddingService.ts +430 -0
  315. package/src/services/reports/ReportService.ts +440 -0
  316. package/src/services/reports/articleUtils.ts +52 -0
  317. package/src/services/reports/index.ts +7 -0
  318. package/src/services/scheduling/SchedulerService.ts +1057 -0
  319. package/src/services/scheduling/errors.ts +14 -0
  320. package/src/services/scheduling/index.ts +7 -0
  321. package/src/services/scheduling/utils.ts +77 -0
  322. package/src/services/search/SearchProviderRegistry.ts +78 -0
  323. package/src/services/search/UnifiedSearchService.ts +218 -0
  324. package/src/services/search/index.ts +47 -0
  325. package/src/services/search/projectFilter.ts +22 -0
  326. package/src/services/search/providers/ConversationSearchProvider.ts +48 -0
  327. package/src/services/search/providers/LessonSearchProvider.ts +75 -0
  328. package/src/services/search/providers/ReportSearchProvider.ts +49 -0
  329. package/src/services/search/types.ts +144 -0
  330. package/src/services/skill/SkillService.ts +482 -0
  331. package/src/services/skill/index.ts +2 -0
  332. package/src/services/skill/types.ts +70 -0
  333. package/src/services/status/OperationsStatusService.ts +276 -0
  334. package/src/services/status/ProjectStatusService.ts +522 -0
  335. package/src/services/status/index.ts +11 -0
  336. package/src/services/storage/PrefixKVStore.ts +242 -0
  337. package/src/services/storage/index.ts +1 -0
  338. package/src/services/system-reminder/SystemReminderUtils.ts +96 -0
  339. package/src/services/system-reminder/index.ts +7 -0
  340. package/src/services/trust-pubkeys/TrustPubkeyService.ts +325 -0
  341. package/src/services/trust-pubkeys/index.ts +2 -0
  342. package/src/telemetry/ConversationSpanManager.ts +111 -0
  343. package/src/telemetry/EventLoopMonitor.ts +206 -0
  344. package/src/telemetry/LLMSpanRegistry.ts +20 -0
  345. package/src/telemetry/NostrSpanProcessor.ts +89 -0
  346. package/src/telemetry/ToolCallSpanProcessor.ts +66 -0
  347. package/src/telemetry/diagnostics.ts +27 -0
  348. package/src/telemetry/setup.ts +120 -0
  349. package/src/tools/implementations/agents_discover.ts +121 -0
  350. package/src/tools/implementations/agents_hire.ts +127 -0
  351. package/src/tools/implementations/agents_list.ts +96 -0
  352. package/src/tools/implementations/agents_publish.ts +611 -0
  353. package/src/tools/implementations/agents_read.ts +173 -0
  354. package/src/tools/implementations/agents_write.ts +200 -0
  355. package/src/tools/implementations/ask.ts +411 -0
  356. package/src/tools/implementations/change_model.ts +141 -0
  357. package/src/tools/implementations/conversation_get.ts +661 -0
  358. package/src/tools/implementations/conversation_list.ts +377 -0
  359. package/src/tools/implementations/conversation_search.ts +370 -0
  360. package/src/tools/implementations/delegate.ts +327 -0
  361. package/src/tools/implementations/delegate_crossproject.ts +209 -0
  362. package/src/tools/implementations/delegate_followup.ts +300 -0
  363. package/src/tools/implementations/fs_edit.ts +162 -0
  364. package/src/tools/implementations/fs_glob.ts +182 -0
  365. package/src/tools/implementations/fs_grep.ts +513 -0
  366. package/src/tools/implementations/fs_read.ts +332 -0
  367. package/src/tools/implementations/fs_write.ts +113 -0
  368. package/src/tools/implementations/generate_image.ts +259 -0
  369. package/src/tools/implementations/home_fs.ts +515 -0
  370. package/src/tools/implementations/kill.ts +651 -0
  371. package/src/tools/implementations/learn.ts +166 -0
  372. package/src/tools/implementations/lesson-formatter.ts +38 -0
  373. package/src/tools/implementations/lesson_delete.ts +164 -0
  374. package/src/tools/implementations/lesson_get.ts +105 -0
  375. package/src/tools/implementations/lessons_list.ts +153 -0
  376. package/src/tools/implementations/mcp_resource_read.ts +161 -0
  377. package/src/tools/implementations/mcp_subscribe.ts +158 -0
  378. package/src/tools/implementations/mcp_subscription_stop.ts +85 -0
  379. package/src/tools/implementations/nostr_fetch.ts +149 -0
  380. package/src/tools/implementations/nostr_publish_as_user.ts +353 -0
  381. package/src/tools/implementations/project_list.ts +146 -0
  382. package/src/tools/implementations/rag_add_documents.ts +573 -0
  383. package/src/tools/implementations/rag_create_collection.ts +65 -0
  384. package/src/tools/implementations/rag_delete_collection.ts +68 -0
  385. package/src/tools/implementations/rag_list_collections.ts +77 -0
  386. package/src/tools/implementations/rag_query.ts +107 -0
  387. package/src/tools/implementations/rag_subscription_create.ts +105 -0
  388. package/src/tools/implementations/rag_subscription_delete.ts +80 -0
  389. package/src/tools/implementations/rag_subscription_get.ts +123 -0
  390. package/src/tools/implementations/rag_subscription_list.ts +128 -0
  391. package/src/tools/implementations/report_delete.ts +79 -0
  392. package/src/tools/implementations/report_read.ts +160 -0
  393. package/src/tools/implementations/report_write.ts +278 -0
  394. package/src/tools/implementations/reports_list.ts +77 -0
  395. package/src/tools/implementations/schedule_task.ts +104 -0
  396. package/src/tools/implementations/schedule_task_cancel.ts +62 -0
  397. package/src/tools/implementations/schedule_task_once.ts +128 -0
  398. package/src/tools/implementations/schedule_tasks_list.ts +79 -0
  399. package/src/tools/implementations/search.ts +160 -0
  400. package/src/tools/implementations/shell.ts +553 -0
  401. package/src/tools/implementations/todo.ts +260 -0
  402. package/src/tools/implementations/upload_blob.ts +381 -0
  403. package/src/tools/implementations/web_fetch.ts +153 -0
  404. package/src/tools/implementations/web_search.ts +250 -0
  405. package/src/tools/registry.ts +670 -0
  406. package/src/tools/types.ts +177 -0
  407. package/src/tools/utils.ts +256 -0
  408. package/src/types/event-ids.ts +320 -0
  409. package/src/types/index.ts +46 -0
  410. package/src/utils/agentFetcher.ts +107 -0
  411. package/src/utils/cli-error.ts +29 -0
  412. package/src/utils/conversation-id.ts +27 -0
  413. package/src/utils/conversation-utils.ts +1 -0
  414. package/src/utils/delegation-chain.ts +357 -0
  415. package/src/utils/error-handler.ts +42 -0
  416. package/src/utils/git/gitignore.ts +69 -0
  417. package/src/utils/git/index.ts +2 -0
  418. package/src/utils/git/initializeGitRepo.ts +204 -0
  419. package/src/utils/git/worktree.ts +260 -0
  420. package/src/utils/lessonFormatter.ts +70 -0
  421. package/src/utils/lessonTrust.ts +24 -0
  422. package/src/utils/lockfile.ts +123 -0
  423. package/src/utils/logger.ts +149 -0
  424. package/src/utils/nostr-entity-parser.ts +365 -0
  425. package/src/utils/process.ts +49 -0
  426. package/src/wrapper.ts +262 -0
  427. package/tsconfig.json +41 -0
@@ -0,0 +1,2782 @@
1
+ import { trace } from "@opentelemetry/api";
2
+ import { EventEmitter, type DefaultEventMap } from "tseep";
3
+ import { getPubkeyService } from "@/services/PubkeyService";
4
+ import { INJECTION_ABORT_REASON, llmOpsRegistry } from "@/services/LLMOperationsRegistry";
5
+ import { shortenConversationId } from "@/utils/conversation-id";
6
+ import { logger } from "@/utils/logger";
7
+ import { PREFIX_LENGTH } from "@/utils/nostr-entity-parser";
8
+ import { ConversationStore } from "@/conversations/ConversationStore";
9
+ // Note: FullEventId type is available via @/types/event-ids for future typed method signatures
10
+ import type {
11
+ InjectionResult,
12
+ InjectionRole,
13
+ RALRegistryEntry,
14
+ PendingDelegation,
15
+ CompletedDelegation,
16
+ QueuedInjection,
17
+ DelegationMessage,
18
+ } from "./types";
19
+
20
+ /** Events emitted by RALRegistry */
21
+ export type RALRegistryEvents = DefaultEventMap & {
22
+ /** Emitted when any RAL state changes (streaming, tools, creation, cleanup) */
23
+ updated: (...args: [projectId: string, conversationId: string]) => void;
24
+ };
25
+
26
+ /**
27
+ * RAL = Reason-Act Loop
28
+ *
29
+ * Manages state for agent executions within conversations.
30
+ * Each RAL represents one execution cycle where the agent reasons about
31
+ * input and takes actions via tool calls.
32
+ *
33
+ * Simplified execution model: ONE active execution per agent at a time.
34
+ * New messages get injected into the active execution.
35
+ */
36
+ export class RALRegistry extends EventEmitter<RALRegistryEvents> {
37
+ private static instance: RALRegistry;
38
+
39
+ /**
40
+ * RAL states keyed by "agentPubkey:conversationId", value is Map of ralNumber -> RALRegistryEntry
41
+ * With simplified execution model, only one RAL is active per agent+conversation at a time.
42
+ */
43
+ private states: Map<string, Map<number, RALRegistryEntry>> = new Map();
44
+
45
+ /** Track next RAL number for each conversation */
46
+ private nextRalNumber: Map<string, number> = new Map();
47
+
48
+ /** Maps delegation event ID -> {key, ralNumber} for O(1) lookup */
49
+ private delegationToRal: Map<string, { key: string; ralNumber: number }> = new Map();
50
+
51
+ /** Maps RAL ID -> {key, ralNumber} for O(1) reverse lookup */
52
+ private ralIdToLocation: Map<string, { key: string; ralNumber: number }> = new Map();
53
+
54
+ /** Maps followupEventId -> delegationConversationId for resolving followup completion routing */
55
+ private followupToCanonical: Map<string, string> = new Map();
56
+
57
+ /** Abort controllers keyed by "key:ralNumber" */
58
+ private abortControllers: Map<string, AbortController> = new Map();
59
+
60
+ /** Delegations keyed by "agentPubkey:conversationId" - persists beyond RAL lifetime */
61
+ private conversationDelegations: Map<string, {
62
+ pending: Map<string, PendingDelegation>;
63
+ completed: Map<string, CompletedDelegation>;
64
+ }> = new Map();
65
+
66
+ private cleanupInterval: ReturnType<typeof setInterval> | null = null;
67
+
68
+ /**
69
+ * Set of agent+conversation keys that have been killed via abortWithCascade.
70
+ * Key format: "agentPubkey:conversationId" (same as other state maps).
71
+ * Used to prevent killed agents from publishing completion events
72
+ * (race condition where agent continues running during long tool execution).
73
+ *
74
+ * ISSUE 3 FIX: Using agentPubkey:conversationId scope ensures that killing
75
+ * one agent doesn't suppress completions for other agents in the same conversation.
76
+ */
77
+ private killedAgentConversations: Set<string> = new Set();
78
+
79
+ /** Maximum age for RAL states before cleanup (default: 24 hours) */
80
+ private static readonly STATE_TTL_MS = 24 * 60 * 60 * 1000;
81
+ /** Cleanup interval (default: 1 hour) */
82
+ private static readonly CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
83
+ /** Maximum queue size for injections (prevents DoS) */
84
+ private static readonly MAX_QUEUE_SIZE = 100;
85
+
86
+ private constructor() {
87
+ super();
88
+ this.startCleanupInterval();
89
+ }
90
+
91
+ private makeKey(agentPubkey: string, conversationId: string): string {
92
+ return `${agentPubkey}:${conversationId}`;
93
+ }
94
+
95
+ private makeAbortKey(key: string, ralNumber: number): string {
96
+ return `${key}:${ralNumber}`;
97
+ }
98
+
99
+ /**
100
+ * Emit an 'updated' event for a conversation.
101
+ * Called when streaming state, tool state, or RAL lifecycle changes.
102
+ * @param projectId - The project this RAL belongs to (for multi-project isolation)
103
+ * @param conversationId - The conversation ID
104
+ */
105
+ private emitUpdated(projectId: string, conversationId: string): void {
106
+ this.emit("updated", projectId, conversationId);
107
+ }
108
+
109
+ private getOrCreateConversationDelegations(key: string): {
110
+ pending: Map<string, PendingDelegation>;
111
+ completed: Map<string, CompletedDelegation>;
112
+ } {
113
+ let delegations = this.conversationDelegations.get(key);
114
+ if (!delegations) {
115
+ delegations = { pending: new Map(), completed: new Map() };
116
+ this.conversationDelegations.set(key, delegations);
117
+ }
118
+ return delegations;
119
+ }
120
+
121
+ /**
122
+ * Get pending delegations for a conversation, optionally filtered by RAL number
123
+ */
124
+ getConversationPendingDelegations(agentPubkey: string, conversationId: string, ralNumber?: number): PendingDelegation[] {
125
+ const key = this.makeKey(agentPubkey, conversationId);
126
+ const delegations = this.conversationDelegations.get(key);
127
+ if (!delegations) return [];
128
+ const pending = Array.from(delegations.pending.values());
129
+ return ralNumber !== undefined ? pending.filter(d => d.ralNumber === ralNumber) : pending;
130
+ }
131
+
132
+ /**
133
+ * Atomically merge new pending delegations into the registry.
134
+ * This method handles concurrent delegation calls safely by doing an atomic
135
+ * read-modify-write operation - it reads existing delegations, deduplicates,
136
+ * and writes back in a single operation without a read-then-write pattern
137
+ * that could drop updates.
138
+ *
139
+ * When a delegation with the same delegationConversationId already exists,
140
+ * this method merges fields from the new delegation into the existing one,
141
+ * preserving metadata updates (e.g., followupEventId for followup delegations).
142
+ *
143
+ * @param agentPubkey - The agent's pubkey
144
+ * @param conversationId - The conversation ID
145
+ * @param ralNumber - The RAL number for this execution
146
+ * @param newDelegations - New delegations to merge
147
+ * @returns Object with insert and merge counts for telemetry
148
+ */
149
+ mergePendingDelegations(
150
+ agentPubkey: string,
151
+ conversationId: string,
152
+ ralNumber: number,
153
+ newDelegations: PendingDelegation[]
154
+ ): { insertedCount: number; mergedCount: number } {
155
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
156
+ if (!ral) {
157
+ logger.warn("[RALRegistry] No RAL found to merge pending delegations", {
158
+ agentPubkey: agentPubkey.substring(0, 8),
159
+ conversationId: conversationId.substring(0, 8),
160
+ ralNumber,
161
+ });
162
+ return { insertedCount: 0, mergedCount: 0 };
163
+ }
164
+
165
+ const key = this.makeKey(agentPubkey, conversationId);
166
+ const convDelegations = this.getOrCreateConversationDelegations(key);
167
+ ral.lastActivityAt = Date.now();
168
+
169
+ let insertedCount = 0;
170
+ let mergedCount = 0;
171
+
172
+ // Atomically add/merge delegations, handling duplicates by merging fields
173
+ for (const d of newDelegations) {
174
+ const existing = convDelegations.pending.get(d.delegationConversationId);
175
+
176
+ if (existing) {
177
+ // Merge fields from new delegation into existing entry
178
+ // This preserves metadata updates (e.g., followupEventId) on retried delegations
179
+ const merged: PendingDelegation = {
180
+ ...existing,
181
+ ...d,
182
+ ralNumber, // Always use the current RAL number
183
+ };
184
+ convDelegations.pending.set(d.delegationConversationId, merged);
185
+
186
+ // Always refresh delegationToRal mapping (RAL number may have changed)
187
+ this.delegationToRal.set(d.delegationConversationId, { key, ralNumber });
188
+
189
+ // For followup delegations, ensure the followup event ID is also mapped
190
+ if (merged.type === "followup" && merged.followupEventId) {
191
+ this.delegationToRal.set(merged.followupEventId, { key, ralNumber });
192
+ // Also maintain reverse lookup for completion routing
193
+ this.followupToCanonical.set(merged.followupEventId, d.delegationConversationId);
194
+ }
195
+
196
+ mergedCount++;
197
+ } else {
198
+ // New delegation - insert it
199
+ const delegation = { ...d, ralNumber };
200
+ convDelegations.pending.set(d.delegationConversationId, delegation);
201
+
202
+ // Register delegation conversation ID -> RAL mappings (for routing delegation responses)
203
+ this.delegationToRal.set(d.delegationConversationId, { key, ralNumber });
204
+
205
+ // For followup delegations, also map the followup event ID
206
+ if (d.type === "followup" && d.followupEventId) {
207
+ this.delegationToRal.set(d.followupEventId, { key, ralNumber });
208
+ // Also maintain reverse lookup for completion routing
209
+ this.followupToCanonical.set(d.followupEventId, d.delegationConversationId);
210
+ }
211
+
212
+ // Increment O(1) counter for this RAL
213
+ this.incrementDelegationCounter(agentPubkey, conversationId, ralNumber);
214
+
215
+ insertedCount++;
216
+ }
217
+ }
218
+
219
+ trace.getActiveSpan()?.addEvent("ral.delegations_merged", {
220
+ "ral.id": ral.id,
221
+ "ral.number": ralNumber,
222
+ "delegation.inserted_count": insertedCount,
223
+ "delegation.merged_count": mergedCount,
224
+ "delegation.total_pending": convDelegations.pending.size,
225
+ });
226
+
227
+ return { insertedCount, mergedCount };
228
+ }
229
+
230
+ /**
231
+ * Get completed delegations for a conversation, optionally filtered by RAL number
232
+ */
233
+ getConversationCompletedDelegations(agentPubkey: string, conversationId: string, ralNumber?: number): CompletedDelegation[] {
234
+ const key = this.makeKey(agentPubkey, conversationId);
235
+ const delegations = this.conversationDelegations.get(key);
236
+ if (!delegations) return [];
237
+ const completed = Array.from(delegations.completed.values());
238
+ return ralNumber !== undefined ? completed.filter(d => d.ralNumber === ralNumber) : completed;
239
+ }
240
+
241
+ /**
242
+ * Clear completed delegations for a conversation, optionally filtered by RAL number.
243
+ * Called after delegation markers have been inserted into ConversationStore
244
+ * to prevent re-processing on subsequent executions.
245
+ */
246
+ clearCompletedDelegations(agentPubkey: string, conversationId: string, ralNumber?: number): void {
247
+ const key = this.makeKey(agentPubkey, conversationId);
248
+ const delegations = this.conversationDelegations.get(key);
249
+ if (!delegations) return;
250
+
251
+ if (ralNumber !== undefined) {
252
+ // Clear only completions for the specified RAL
253
+ for (const [id, completion] of delegations.completed) {
254
+ if (completion.ralNumber === ralNumber) {
255
+ this.delegationToRal.delete(id);
256
+ delegations.completed.delete(id);
257
+ }
258
+ }
259
+ } else {
260
+ // Clear all completions and their RAL mappings
261
+ for (const id of delegations.completed.keys()) {
262
+ this.delegationToRal.delete(id);
263
+ }
264
+ delegations.completed.clear();
265
+ }
266
+
267
+ trace.getActiveSpan()?.addEvent("ral.completed_delegations_cleared", {
268
+ "conversation.id": shortenConversationId(conversationId),
269
+ "ral.number": ralNumber ?? "all",
270
+ });
271
+ }
272
+
273
+ private startCleanupInterval(): void {
274
+ this.cleanupInterval = setInterval(() => {
275
+ this.cleanupExpiredStates();
276
+ }, RALRegistry.CLEANUP_INTERVAL_MS);
277
+ this.cleanupInterval.unref();
278
+ }
279
+
280
+ private cleanupExpiredStates(): void {
281
+ const now = Date.now();
282
+ let cleanedCount = 0;
283
+
284
+ for (const [key, rals] of this.states.entries()) {
285
+ for (const [ralNumber, state] of rals.entries()) {
286
+ if (now - state.lastActivityAt > RALRegistry.STATE_TTL_MS) {
287
+ this.clearRAL(state.agentPubkey, state.conversationId, ralNumber);
288
+ cleanedCount++;
289
+ logger.info("[RALRegistry] Cleaned up expired RAL state", {
290
+ agentPubkey: state.agentPubkey.substring(0, 8),
291
+ conversationId: state.conversationId.substring(0, 8),
292
+ ralNumber,
293
+ ageHours: Math.round((now - state.lastActivityAt) / (60 * 60 * 1000)),
294
+ });
295
+ }
296
+ }
297
+ // Clean up empty conversation entries
298
+ if (rals.size === 0) {
299
+ this.states.delete(key);
300
+ }
301
+ }
302
+
303
+ // ISSUE 1 FIX: Prune killed agent entries that correspond to cleaned states.
304
+ // This prevents unbounded growth of killedAgentConversations Set.
305
+ // We prune keys that no longer have active RAL states - if there's no state,
306
+ // the conversation is done and the kill marker is no longer needed.
307
+ let prunedKilledCount = 0;
308
+ for (const key of this.killedAgentConversations) {
309
+ if (!this.states.has(key)) {
310
+ this.killedAgentConversations.delete(key);
311
+ prunedKilledCount++;
312
+ }
313
+ }
314
+
315
+ if (prunedKilledCount > 0) {
316
+ logger.debug("[RALRegistry] Pruned stale killed agent entries", {
317
+ prunedKilledCount,
318
+ remainingKilledCount: this.killedAgentConversations.size,
319
+ });
320
+ }
321
+
322
+ if (cleanedCount > 0) {
323
+ logger.info("[RALRegistry] Cleanup complete", {
324
+ cleanedCount,
325
+ remainingConversations: this.states.size,
326
+ });
327
+ }
328
+ }
329
+
330
+ static getInstance(): RALRegistry {
331
+ if (!RALRegistry.instance) {
332
+ RALRegistry.instance = new RALRegistry();
333
+ }
334
+ return RALRegistry.instance;
335
+ }
336
+
337
+ /**
338
+ * Create a new RAL entry for an agent+conversation pair.
339
+ * Returns the RAL number assigned to this execution.
340
+ * @param projectId - The project this RAL belongs to (required for multi-project isolation)
341
+ */
342
+ create(
343
+ agentPubkey: string,
344
+ conversationId: string,
345
+ projectId: string,
346
+ originalTriggeringEventId?: string,
347
+ traceContext?: { traceId: string; spanId: string }
348
+ ): number {
349
+ const id = crypto.randomUUID();
350
+ const now = Date.now();
351
+ const key = this.makeKey(agentPubkey, conversationId);
352
+
353
+ // Get next RAL number for this conversation
354
+ const ralNumber = (this.nextRalNumber.get(key) || 0) + 1;
355
+ this.nextRalNumber.set(key, ralNumber);
356
+
357
+ const state: RALRegistryEntry = {
358
+ id,
359
+ ralNumber,
360
+ agentPubkey,
361
+ projectId,
362
+ conversationId,
363
+ queuedInjections: [],
364
+ isStreaming: false,
365
+ activeTools: new Map(),
366
+ createdAt: now,
367
+ lastActivityAt: now,
368
+ originalTriggeringEventId,
369
+ traceId: traceContext?.traceId,
370
+ executionSpanId: traceContext?.spanId,
371
+ accumulatedRuntime: 0,
372
+ lastReportedRuntime: 0,
373
+ };
374
+
375
+ // Get or create the conversation's RAL map
376
+ let rals = this.states.get(key);
377
+ if (!rals) {
378
+ rals = new Map();
379
+ this.states.set(key, rals);
380
+ }
381
+ rals.set(ralNumber, state);
382
+
383
+ // Track reverse lookup
384
+ this.ralIdToLocation.set(id, { key, ralNumber });
385
+
386
+ trace.getActiveSpan()?.addEvent("ral.created", {
387
+ "ral.id": id,
388
+ "ral.number": ralNumber,
389
+ "agent.pubkey": agentPubkey,
390
+ "conversation.id": shortenConversationId(conversationId),
391
+ });
392
+
393
+ // DEBUG: Log RAL creation
394
+ logger.info("[RALRegistry.create] RAL created", {
395
+ ralNumber,
396
+ agentPubkey: agentPubkey.substring(0, 8),
397
+ conversationId: conversationId.substring(0, 8),
398
+ projectId: projectId.substring(0, 20),
399
+ key,
400
+ });
401
+
402
+ // Emit update for OperationsStatusService
403
+ this.emitUpdated(projectId, conversationId);
404
+
405
+ return ralNumber;
406
+ }
407
+
408
+ /**
409
+ * Get all active RALs for an agent+conversation
410
+ */
411
+ getActiveRALs(agentPubkey: string, conversationId: string): RALRegistryEntry[] {
412
+ const key = this.makeKey(agentPubkey, conversationId);
413
+ const rals = this.states.get(key);
414
+ if (!rals) return [];
415
+ return Array.from(rals.values());
416
+ }
417
+
418
+ /**
419
+ * Get a specific RAL by number
420
+ */
421
+ getRAL(agentPubkey: string, conversationId: string, ralNumber: number): RALRegistryEntry | undefined {
422
+ const key = this.makeKey(agentPubkey, conversationId);
423
+ return this.states.get(key)?.get(ralNumber);
424
+ }
425
+
426
+ /**
427
+ * Get RAL state by RAL ID
428
+ */
429
+ getStateByRalId(ralId: string): RALRegistryEntry | undefined {
430
+ const location = this.ralIdToLocation.get(ralId);
431
+ if (!location) return undefined;
432
+ return this.states.get(location.key)?.get(location.ralNumber);
433
+ }
434
+
435
+ /**
436
+ * Get the current (most recent) RAL for an agent+conversation
437
+ * This is for backwards compatibility with code that expects a single RAL
438
+ */
439
+ getState(agentPubkey: string, conversationId: string): RALRegistryEntry | undefined {
440
+ const key = this.makeKey(agentPubkey, conversationId);
441
+ const rals = this.states.get(key);
442
+ if (!rals || rals.size === 0) return undefined;
443
+
444
+ // Return the RAL with the highest number (most recent)
445
+ let maxRal: RALRegistryEntry | undefined;
446
+ for (const ral of rals.values()) {
447
+ if (!maxRal || ral.ralNumber > maxRal.ralNumber) {
448
+ maxRal = ral;
449
+ }
450
+ }
451
+ return maxRal;
452
+ }
453
+
454
+ /**
455
+ * Get all RAL entries for a specific conversation (across all agents).
456
+ * Returns an array of entries that can be filtered for streaming/active agents.
457
+ * Used by OperationsStatusService to determine which agents are actively streaming.
458
+ */
459
+ getConversationEntries(conversationId: string): RALRegistryEntry[] {
460
+ const entries: RALRegistryEntry[] = [];
461
+ for (const rals of this.states.values()) {
462
+ for (const ral of rals.values()) {
463
+ if (ral.conversationId === conversationId) {
464
+ entries.push(ral);
465
+ }
466
+ }
467
+ }
468
+ return entries;
469
+ }
470
+
471
+ /**
472
+ * Set whether agent is currently streaming
473
+ */
474
+ setStreaming(agentPubkey: string, conversationId: string, ralNumber: number, isStreaming: boolean): void {
475
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
476
+ if (ral) {
477
+ ral.isStreaming = isStreaming;
478
+ ral.lastActivityAt = Date.now();
479
+
480
+ // Derive state from current RAL entry state:
481
+ // - If streaming starts: STREAMING
482
+ // - If streaming stops but any tool is running: ACTING (tool still executing)
483
+ // - If streaming stops and no tools: REASONING (thinking/preparing next action)
484
+ let newState: "STREAMING" | "ACTING" | "REASONING";
485
+ if (isStreaming) {
486
+ newState = "STREAMING";
487
+ } else if (ral.activeTools.size > 0) {
488
+ newState = "ACTING";
489
+ } else {
490
+ newState = "REASONING";
491
+ }
492
+
493
+ llmOpsRegistry.updateRALState(agentPubkey, conversationId, newState);
494
+
495
+ // Emit update for OperationsStatusService
496
+ this.emitUpdated(ral.projectId, conversationId);
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Mark the start of an LLM streaming session.
502
+ * Call this immediately before llmService.stream() to begin timing.
503
+ *
504
+ * @param lastUserMessage - The last user message that triggered this LLM call (for debugging)
505
+ */
506
+ startLLMStream(agentPubkey: string, conversationId: string, ralNumber: number, lastUserMessage?: string): void {
507
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
508
+ if (ral) {
509
+ const now = Date.now();
510
+ ral.llmStreamStartTime = now;
511
+ ral.lastRuntimeCheckpointAt = now; // Initialize checkpoint to stream start
512
+ ral.lastActivityAt = now;
513
+
514
+ // Include the last user message in telemetry for debugging
515
+ // Truncate to 1000 chars to avoid bloating traces
516
+ const truncatedMessage = lastUserMessage
517
+ ? (lastUserMessage.length > 1000 ? lastUserMessage.substring(0, 1000) + "..." : lastUserMessage)
518
+ : undefined;
519
+
520
+ trace.getActiveSpan()?.addEvent("ral.llm_stream_started", {
521
+ "ral.number": ralNumber,
522
+ "ral.accumulated_runtime_ms": ral.accumulatedRuntime,
523
+ ...(truncatedMessage && { "ral.last_user_message": truncatedMessage }),
524
+ });
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Mark the end of an LLM streaming session and accumulate the runtime.
530
+ * Call this in the finally block after llmService.stream() completes.
531
+ * @returns The total accumulated runtime in milliseconds
532
+ */
533
+ endLLMStream(agentPubkey: string, conversationId: string, ralNumber: number): number {
534
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
535
+ if (ral && ral.llmStreamStartTime !== undefined) {
536
+ const now = Date.now();
537
+ // Calculate TOTAL stream duration from original start (not from checkpoint)
538
+ const streamDuration = now - ral.llmStreamStartTime;
539
+ // Add only the time since last checkpoint (to avoid double-counting what was already consumed)
540
+ const checkpointTime = ral.lastRuntimeCheckpointAt ?? ral.llmStreamStartTime;
541
+ // Guard against clock rollback - keep runtime monotonic
542
+ const unreportedDuration = Math.max(0, now - checkpointTime);
543
+ ral.accumulatedRuntime += unreportedDuration;
544
+ // Clear both stream timing fields
545
+ ral.llmStreamStartTime = undefined;
546
+ ral.lastRuntimeCheckpointAt = undefined;
547
+ ral.lastActivityAt = now;
548
+
549
+ trace.getActiveSpan()?.addEvent("ral.llm_stream_ended", {
550
+ "ral.number": ralNumber,
551
+ "ral.stream_duration_ms": streamDuration,
552
+ "ral.accumulated_runtime_ms": ral.accumulatedRuntime,
553
+ });
554
+
555
+ return ral.accumulatedRuntime;
556
+ }
557
+ return ral?.accumulatedRuntime ?? 0;
558
+ }
559
+
560
+ /**
561
+ * Get the accumulated LLM runtime for a RAL
562
+ */
563
+ getAccumulatedRuntime(agentPubkey: string, conversationId: string, ralNumber: number): number {
564
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
565
+ return ral?.accumulatedRuntime ?? 0;
566
+ }
567
+
568
+ /**
569
+ * Get the unreported runtime (runtime accumulated since last publish) and mark it as reported.
570
+ * Returns the unreported runtime in milliseconds, then resets the counter.
571
+ * This is used for incremental runtime reporting in agent events.
572
+ *
573
+ * IMPORTANT: This method handles mid-stream runtime calculation. When called during an active
574
+ * LLM stream, it calculates the "live" runtime since the last checkpoint (or stream start),
575
+ * accumulates it, and updates the checkpoint timestamp. The original llmStreamStartTime is
576
+ * preserved so that endLLMStream() can still report correct total stream duration.
577
+ */
578
+ consumeUnreportedRuntime(agentPubkey: string, conversationId: string, ralNumber: number): number {
579
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
580
+ if (!ral) {
581
+ // DEBUG: RAL not found
582
+ logger.warn("[RALRegistry.consumeUnreportedRuntime] RAL not found", {
583
+ agentPubkey: agentPubkey.substring(0, 8),
584
+ conversationId: conversationId.substring(0, 8),
585
+ ralNumber,
586
+ });
587
+ return 0;
588
+ }
589
+
590
+ const now = Date.now();
591
+
592
+ // DEBUG: Log state before calculating
593
+ logger.info("[RALRegistry.consumeUnreportedRuntime] RAL state", {
594
+ agentPubkey: agentPubkey.substring(0, 8),
595
+ ralNumber,
596
+ llmStreamStartTime: ral.llmStreamStartTime,
597
+ lastRuntimeCheckpointAt: ral.lastRuntimeCheckpointAt,
598
+ accumulatedRuntime: ral.accumulatedRuntime,
599
+ lastReportedRuntime: ral.lastReportedRuntime,
600
+ });
601
+
602
+ // If there's an active LLM stream, capture the runtime since last checkpoint
603
+ // Use checkpoint if available, otherwise fall back to stream start
604
+ if (ral.llmStreamStartTime !== undefined) {
605
+ const checkpointTime = ral.lastRuntimeCheckpointAt ?? ral.llmStreamStartTime;
606
+ const liveStreamRuntime = now - checkpointTime;
607
+ ral.accumulatedRuntime += liveStreamRuntime;
608
+ // Update checkpoint only - preserve llmStreamStartTime for endLLMStream()
609
+ ral.lastRuntimeCheckpointAt = now;
610
+ }
611
+
612
+ const unreported = ral.accumulatedRuntime - ral.lastReportedRuntime;
613
+
614
+ // Guard against NaN or negative deltas (defensive programming)
615
+ // Repair lastReportedRuntime to prevent permanent suppression of future runtime
616
+ if (!Number.isFinite(unreported) || unreported < 0) {
617
+ logger.warn("[RALRegistry] Invalid runtime delta", {
618
+ unreported,
619
+ accumulated: ral.accumulatedRuntime,
620
+ lastReported: ral.lastReportedRuntime,
621
+ });
622
+ ral.lastReportedRuntime = ral.accumulatedRuntime;
623
+ return 0;
624
+ }
625
+
626
+ ral.lastReportedRuntime = ral.accumulatedRuntime;
627
+
628
+ if (unreported > 0) {
629
+ trace.getActiveSpan()?.addEvent("ral.runtime_consumed", {
630
+ "ral.number": ralNumber,
631
+ "ral.unreported_runtime_ms": unreported,
632
+ "ral.accumulated_runtime_ms": ral.accumulatedRuntime,
633
+ });
634
+ }
635
+
636
+ return unreported;
637
+ }
638
+
639
+ /**
640
+ * Get the unreported runtime without consuming it.
641
+ * Use consumeUnreportedRuntime() when publishing events.
642
+ *
643
+ * NOTE: This also calculates "live" runtime during active streams for accurate preview.
644
+ */
645
+ getUnreportedRuntime(agentPubkey: string, conversationId: string, ralNumber: number): number {
646
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
647
+ if (!ral) return 0;
648
+
649
+ // Calculate current accumulated + live stream time since checkpoint for accurate preview
650
+ let effectiveAccumulated = ral.accumulatedRuntime;
651
+ if (ral.llmStreamStartTime !== undefined) {
652
+ const checkpointTime = ral.lastRuntimeCheckpointAt ?? ral.llmStreamStartTime;
653
+ effectiveAccumulated += Date.now() - checkpointTime;
654
+ }
655
+
656
+ return effectiveAccumulated - ral.lastReportedRuntime;
657
+ }
658
+
659
+ /**
660
+ * Set pending delegations for a specific RAL (delegation tracking only, not message storage).
661
+ *
662
+ * WARNING: This method replaces ALL pending delegations for the given RAL.
663
+ * For concurrent-safe updates that preserve existing delegations, use mergePendingDelegations().
664
+ *
665
+ * @deprecated Prefer mergePendingDelegations() for concurrent-safe updates.
666
+ * This method exists for backwards compatibility and specific use cases where
667
+ * complete replacement semantics are required.
668
+ */
669
+ setPendingDelegations(
670
+ agentPubkey: string,
671
+ conversationId: string,
672
+ ralNumber: number,
673
+ pendingDelegations: PendingDelegation[]
674
+ ): void {
675
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
676
+ if (!ral) {
677
+ logger.warn("[RALRegistry] No RAL found to set pending delegations", {
678
+ agentPubkey: agentPubkey.substring(0, 8),
679
+ conversationId: conversationId.substring(0, 8),
680
+ ralNumber,
681
+ });
682
+ return;
683
+ }
684
+
685
+ const key = this.makeKey(agentPubkey, conversationId);
686
+ const convDelegations = this.getOrCreateConversationDelegations(key);
687
+ ral.lastActivityAt = Date.now();
688
+
689
+ // Clear existing pending for this RAL, then add new ones
690
+ for (const [id, d] of convDelegations.pending) {
691
+ if (d.ralNumber === ralNumber) {
692
+ convDelegations.pending.delete(id);
693
+ this.delegationToRal.delete(id);
694
+ // Also clean up followup event ID mappings
695
+ if (d.type === "followup" && d.followupEventId) {
696
+ this.delegationToRal.delete(d.followupEventId);
697
+ this.followupToCanonical.delete(d.followupEventId);
698
+ }
699
+ }
700
+ }
701
+
702
+ // Add new pending delegations using the shared helper logic
703
+ // Note: We don't use mergePendingDelegations here because we've already cleared
704
+ // existing delegations for this RAL - merge semantics would be redundant
705
+ for (const d of pendingDelegations) {
706
+ // Ensure ralNumber is set
707
+ const delegation = { ...d, ralNumber };
708
+ convDelegations.pending.set(d.delegationConversationId, delegation);
709
+
710
+ // Register delegation conversation ID -> RAL mappings (for routing delegation responses)
711
+ this.delegationToRal.set(d.delegationConversationId, { key, ralNumber });
712
+
713
+ // For followup delegations, also map the followup event ID
714
+ // This ensures responses e-tagging either the original or followup are routed correctly
715
+ if (d.type === "followup" && d.followupEventId) {
716
+ this.delegationToRal.set(d.followupEventId, { key, ralNumber });
717
+ // Also maintain reverse lookup for completion routing
718
+ this.followupToCanonical.set(d.followupEventId, d.delegationConversationId);
719
+ }
720
+ }
721
+
722
+ trace.getActiveSpan()?.addEvent("ral.delegations_set", {
723
+ "ral.id": ral.id,
724
+ "ral.number": ralNumber,
725
+ "delegation.pending_count": pendingDelegations.length,
726
+ });
727
+ }
728
+
729
+ /**
730
+ * Record a delegation completion (looks up RAL from delegation event ID).
731
+ * Builds a transcript from the pending delegation's prompt and the response.
732
+ * For followups, appends both the followup prompt and response to the transcript.
733
+ * Returns location info for the caller to use for resumption.
734
+ *
735
+ * INVARIANT: Completions for killed delegations are rejected at the domain layer.
736
+ * This prevents the race condition where a delegation completes after being killed
737
+ * via the kill tool but before the abort fully propagates.
738
+ *
739
+ * @param completion.fullTranscript - Optional rich transcript to use instead of
740
+ * constructing a synthetic 2-message transcript. Useful for capturing user
741
+ * interventions and multi-turn exchanges within a delegation.
742
+ */
743
+ recordCompletion(completion: {
744
+ delegationConversationId: string;
745
+ recipientPubkey: string;
746
+ response: string;
747
+ completedAt: number;
748
+ /** If provided, use this transcript instead of constructing from prompt + response */
749
+ fullTranscript?: DelegationMessage[];
750
+ }): { agentPubkey: string; conversationId: string; ralNumber: number } | undefined {
751
+ const location = this.delegationToRal.get(completion.delegationConversationId);
752
+ if (!location) return undefined;
753
+
754
+ const [agentPubkey, conversationId] = location.key.split(":");
755
+ const convDelegations = this.conversationDelegations.get(location.key);
756
+ if (!convDelegations) return undefined;
757
+
758
+ // Resolve followup event ID to canonical delegation conversation ID if needed
759
+ // The pending map is keyed by the original delegationConversationId, not the followupEventId
760
+ const canonicalId = this.followupToCanonical.get(completion.delegationConversationId)
761
+ ?? completion.delegationConversationId;
762
+
763
+ const pendingDelegation = convDelegations.pending.get(canonicalId);
764
+ if (!pendingDelegation) {
765
+ logger.warn("[RALRegistry] No pending delegation found for completion", {
766
+ delegationConversationId: completion.delegationConversationId.substring(0, 8),
767
+ });
768
+ return undefined;
769
+ }
770
+
771
+ // DOMAIN INVARIANT: Reject completions for killed delegations.
772
+ // This is the authoritative check - no caller can bypass it.
773
+ if (pendingDelegation.killed) {
774
+ trace.getActiveSpan()?.addEvent("ral.completion_rejected_killed", {
775
+ "delegation.conversation_id": shortenConversationId(canonicalId),
776
+ "delegation.killed_at": pendingDelegation.killedAt,
777
+ });
778
+ logger.info("[RALRegistry.recordCompletion] Rejected completion - delegation was killed", {
779
+ delegationConversationId: canonicalId.substring(0, 12),
780
+ killedAt: pendingDelegation.killedAt,
781
+ });
782
+ return undefined;
783
+ }
784
+
785
+ // Only record completion if the response is from the delegatee, not the delegator
786
+ // A message from the delegator (e.g., delegate_followup) is not a completion response
787
+ if (completion.recipientPubkey !== pendingDelegation.recipientPubkey) {
788
+ logger.debug("[RALRegistry] Ignoring completion - sender is not the delegatee", {
789
+ delegationConversationId: completion.delegationConversationId.substring(0, 8),
790
+ expectedRecipient: pendingDelegation.recipientPubkey.substring(0, 8),
791
+ actualSender: completion.recipientPubkey.substring(0, 8),
792
+ });
793
+ return undefined;
794
+ }
795
+
796
+ // Update RAL activity if it still exists
797
+ const ral = this.states.get(location.key)?.get(location.ralNumber);
798
+ if (ral) {
799
+ ral.lastActivityAt = Date.now();
800
+ }
801
+
802
+ // Check if this delegation already has a completed entry (followup case)
803
+ // Use canonical ID for lookups since completed entries are keyed by original delegation ID
804
+ const existingCompletion = convDelegations.completed.get(canonicalId);
805
+
806
+ if (existingCompletion) {
807
+ // Append to existing transcript
808
+ if (completion.fullTranscript) {
809
+ // Use provided full transcript - replace entire transcript
810
+ existingCompletion.transcript = completion.fullTranscript;
811
+ } else {
812
+ // Fall back to appending the followup prompt and response
813
+ existingCompletion.transcript.push({
814
+ senderPubkey: pendingDelegation.senderPubkey,
815
+ recipientPubkey: pendingDelegation.recipientPubkey,
816
+ content: pendingDelegation.prompt,
817
+ timestamp: completion.completedAt - 1, // Just before the response
818
+ });
819
+ existingCompletion.transcript.push({
820
+ senderPubkey: completion.recipientPubkey,
821
+ recipientPubkey: pendingDelegation.senderPubkey,
822
+ content: completion.response,
823
+ timestamp: completion.completedAt,
824
+ });
825
+ }
826
+
827
+ // Update ralNumber to the followup's RAL so findResumableRAL finds the correct RAL
828
+ existingCompletion.ralNumber = pendingDelegation.ralNumber;
829
+
830
+ trace.getActiveSpan()?.addEvent("ral.followup_response_appended", {
831
+ "ral.id": ral?.id,
832
+ "ral.number": location.ralNumber,
833
+ "delegation.completed_conversation_id": shortenConversationId(completion.delegationConversationId),
834
+ "delegation.transcript_length": existingCompletion.transcript.length,
835
+ });
836
+ } else {
837
+ // Create new completed delegation with transcript
838
+ // Use provided fullTranscript if available, otherwise construct synthetic 2-message transcript
839
+ const transcript: DelegationMessage[] = completion.fullTranscript ?? [
840
+ {
841
+ senderPubkey: pendingDelegation.senderPubkey,
842
+ recipientPubkey: pendingDelegation.recipientPubkey,
843
+ content: pendingDelegation.prompt,
844
+ timestamp: completion.completedAt - 1,
845
+ },
846
+ {
847
+ senderPubkey: completion.recipientPubkey,
848
+ recipientPubkey: pendingDelegation.senderPubkey,
849
+ content: completion.response,
850
+ timestamp: completion.completedAt,
851
+ },
852
+ ];
853
+
854
+ convDelegations.completed.set(canonicalId, {
855
+ delegationConversationId: canonicalId,
856
+ recipientPubkey: completion.recipientPubkey,
857
+ senderPubkey: pendingDelegation.senderPubkey,
858
+ ralNumber: pendingDelegation.ralNumber,
859
+ transcript,
860
+ completedAt: completion.completedAt,
861
+ status: "completed",
862
+ });
863
+
864
+ const remainingPending = this.getConversationPendingDelegations(agentPubkey, conversationId, location.ralNumber).length - 1;
865
+ trace.getActiveSpan()?.addEvent("ral.completion_recorded", {
866
+ "ral.id": ral?.id,
867
+ "ral.number": location.ralNumber,
868
+ "delegation.completed_conversation_id": shortenConversationId(completion.delegationConversationId),
869
+ "delegation.remaining_pending": remainingPending,
870
+ });
871
+ }
872
+
873
+ // Remove from pending using canonical ID
874
+ convDelegations.pending.delete(canonicalId);
875
+
876
+ // Decrement O(1) counter for this RAL
877
+ this.decrementDelegationCounter(agentPubkey, conversationId, location.ralNumber);
878
+
879
+ return { agentPubkey, conversationId, ralNumber: location.ralNumber };
880
+ }
881
+
882
+ /**
883
+ * Queue a message for injection and abort streaming runs if needed.
884
+ */
885
+ injectMessage(params: {
886
+ agentPubkey: string;
887
+ conversationId: string;
888
+ message: string;
889
+ role?: InjectionRole;
890
+ }): InjectionResult {
891
+ const {
892
+ agentPubkey,
893
+ conversationId,
894
+ message,
895
+ role = "user",
896
+ } = params;
897
+ const activeRal = this.getState(agentPubkey, conversationId);
898
+
899
+ if (!activeRal) {
900
+ return {
901
+ queued: false,
902
+ aborted: false,
903
+ };
904
+ }
905
+
906
+ this.queueMessage(agentPubkey, conversationId, activeRal.ralNumber, role, message);
907
+
908
+ const messageLength = message.length;
909
+ let aborted = false;
910
+
911
+ if (activeRal.isStreaming) {
912
+ aborted = llmOpsRegistry.stopByAgentAndConversation(
913
+ agentPubkey,
914
+ conversationId,
915
+ INJECTION_ABORT_REASON
916
+ );
917
+
918
+ trace.getActiveSpan()?.addEvent("ral.injection_streaming", {
919
+ "ral.id": activeRal.id,
920
+ "ral.number": activeRal.ralNumber,
921
+ "injection.length": messageLength,
922
+ aborted,
923
+ });
924
+
925
+ if (aborted) {
926
+ logger.info("[RALRegistry] Aborted streaming execution for injection", {
927
+ agentPubkey: agentPubkey.substring(0, 8),
928
+ conversationId: conversationId.substring(0, 8),
929
+ ralNumber: activeRal.ralNumber,
930
+ injectionLength: messageLength,
931
+ });
932
+ }
933
+ }
934
+
935
+ trace.getActiveSpan()?.addEvent("ral.injection_queued", {
936
+ "ral.id": activeRal.id,
937
+ "ral.number": activeRal.ralNumber,
938
+ "injection.role": role,
939
+ "injection.length": messageLength,
940
+ "ral.is_streaming": activeRal.isStreaming,
941
+ });
942
+
943
+ return {
944
+ activeRal,
945
+ queued: true,
946
+ aborted,
947
+ };
948
+ }
949
+
950
+ /**
951
+ * Queue a system message for injection into a specific RAL
952
+ */
953
+ queueSystemMessage(
954
+ agentPubkey: string,
955
+ conversationId: string,
956
+ ralNumber: number,
957
+ message: string
958
+ ): void {
959
+ this.queueMessage(agentPubkey, conversationId, ralNumber, "system", message);
960
+ }
961
+
962
+ /**
963
+ * Queue a user message for injection into a specific RAL
964
+ */
965
+ queueUserMessage(
966
+ agentPubkey: string,
967
+ conversationId: string,
968
+ ralNumber: number,
969
+ message: string,
970
+ options?: { ephemeral?: boolean; senderPubkey?: string; eventId?: string }
971
+ ): void {
972
+ this.queueMessage(agentPubkey, conversationId, ralNumber, "user", message, options);
973
+ }
974
+
975
+ /**
976
+ * Queue a message with specified role for injection into a specific RAL
977
+ */
978
+ private queueMessage(
979
+ agentPubkey: string,
980
+ conversationId: string,
981
+ ralNumber: number,
982
+ role: "system" | "user",
983
+ message: string,
984
+ options?: { ephemeral?: boolean; senderPubkey?: string; eventId?: string }
985
+ ): void {
986
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
987
+ if (!ral) {
988
+ logger.warn("[RALRegistry] Cannot queue message - no RAL state", {
989
+ agentPubkey: agentPubkey.substring(0, 8),
990
+ conversationId: conversationId.substring(0, 8),
991
+ ralNumber,
992
+ role,
993
+ });
994
+ return;
995
+ }
996
+
997
+ if (ral.queuedInjections.length >= RALRegistry.MAX_QUEUE_SIZE) {
998
+ ral.queuedInjections.shift();
999
+ logger.warn("[RALRegistry] Queue full, dropping oldest message", {
1000
+ agentPubkey: agentPubkey.substring(0, 8),
1001
+ });
1002
+ }
1003
+
1004
+ ral.queuedInjections.push({
1005
+ role,
1006
+ content: message,
1007
+ queuedAt: Date.now(),
1008
+ ephemeral: options?.ephemeral,
1009
+ senderPubkey: options?.senderPubkey,
1010
+ eventId: options?.eventId,
1011
+ });
1012
+
1013
+ // Add telemetry for ephemeral injection queuing (useful for debugging supervision re-engagement)
1014
+ if (options?.ephemeral) {
1015
+ trace.getActiveSpan()?.addEvent("ral.ephemeral_correction_queued", {
1016
+ "ral.number": ralNumber,
1017
+ "message.length": message.length,
1018
+ "message.role": role,
1019
+ });
1020
+ }
1021
+ }
1022
+
1023
+ /**
1024
+ * Get and consume queued injections for a specific RAL
1025
+ * Injections are persisted to ConversationStore by the caller
1026
+ */
1027
+ getAndConsumeInjections(agentPubkey: string, conversationId: string, ralNumber: number): QueuedInjection[] {
1028
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
1029
+ if (!ral) return [];
1030
+
1031
+ if (ral.queuedInjections.length === 0) {
1032
+ return [];
1033
+ }
1034
+
1035
+ const injections = [...ral.queuedInjections];
1036
+ ral.queuedInjections = [];
1037
+
1038
+ trace.getActiveSpan()?.addEvent("ral.injections_consumed", {
1039
+ "ral.id": ral.id,
1040
+ "ral.number": ralNumber,
1041
+ "injection.count": injections.length,
1042
+ });
1043
+
1044
+ return injections;
1045
+ }
1046
+
1047
+ /**
1048
+ * Clear all queued injections for an agent's conversation.
1049
+ * Called by AgentDispatchService after MessageInjector successfully delivers a message.
1050
+ * This prevents hasOutstandingWork() from incorrectly reporting queued injections
1051
+ * that have already been delivered, which would cause the agent to use conversation()
1052
+ * instead of complete().
1053
+ */
1054
+ clearQueuedInjections(agentPubkey: string, conversationId: string): void {
1055
+ const key = this.makeKey(agentPubkey, conversationId);
1056
+ const ralMap = this.states.get(key);
1057
+ if (!ralMap) return;
1058
+
1059
+ let totalCleared = 0;
1060
+ for (const ral of ralMap.values()) {
1061
+ if (ral.queuedInjections.length > 0) {
1062
+ totalCleared += ral.queuedInjections.length;
1063
+ ral.queuedInjections = [];
1064
+ }
1065
+ }
1066
+
1067
+ if (totalCleared > 0) {
1068
+ trace.getActiveSpan()?.addEvent("ral.injections_cleared_after_delivery", {
1069
+ "agent.pubkey": agentPubkey.substring(0, 12),
1070
+ "conversation.id": shortenConversationId(conversationId),
1071
+ "cleared.count": totalCleared,
1072
+ });
1073
+ }
1074
+ }
1075
+
1076
+ /**
1077
+ * Track a tool as active or completed by its toolCallId.
1078
+ * Supports concurrent tool execution by tracking each tool independently.
1079
+ *
1080
+ * @param toolCallId - Unique identifier for this tool invocation
1081
+ * @param isActive - true when tool starts, false when tool completes
1082
+ * @param toolName - Optional tool name for logging/debugging
1083
+ */
1084
+ setToolActive(
1085
+ agentPubkey: string,
1086
+ conversationId: string,
1087
+ ralNumber: number,
1088
+ toolCallId: string,
1089
+ isActive: boolean,
1090
+ toolName?: string
1091
+ ): void {
1092
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
1093
+ if (!ral) return;
1094
+
1095
+ if (isActive) {
1096
+ // Store toolCallId -> tool info mapping (name + startedAt)
1097
+ const now = Date.now();
1098
+ ral.activeTools.set(toolCallId, { name: toolName ?? "unknown", startedAt: now });
1099
+ ral.toolStartedAt = now;
1100
+ // Maintain backward compatibility - set currentTool to most recent tool name
1101
+ ral.currentTool = toolName;
1102
+ } else {
1103
+ ral.activeTools.delete(toolCallId);
1104
+ // Update currentTool to another active tool if any remain, otherwise clear
1105
+ if (ral.activeTools.size === 0) {
1106
+ ral.currentTool = undefined;
1107
+ ral.toolStartedAt = undefined;
1108
+ } else {
1109
+ // Set currentTool to one of the remaining active tools, including its start time
1110
+ // Safe to use ! assertion: we're in the else branch where activeTools.size > 0
1111
+ const remainingToolInfo = ral.activeTools.values().next().value;
1112
+ if (remainingToolInfo) {
1113
+ ral.currentTool = remainingToolInfo.name;
1114
+ ral.toolStartedAt = remainingToolInfo.startedAt;
1115
+ } else {
1116
+ ral.currentTool = undefined;
1117
+ ral.toolStartedAt = undefined;
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ ral.lastActivityAt = Date.now();
1123
+
1124
+ // Derive state from activeTools:
1125
+ // - If any tools are active: ACTING
1126
+ // - If no tools but streaming: STREAMING
1127
+ // - If no tools and not streaming: REASONING
1128
+ let newState: "ACTING" | "STREAMING" | "REASONING";
1129
+ if (ral.activeTools.size > 0) {
1130
+ newState = "ACTING";
1131
+ } else if (ral.isStreaming) {
1132
+ newState = "STREAMING";
1133
+ } else {
1134
+ newState = "REASONING";
1135
+ }
1136
+
1137
+ llmOpsRegistry.updateRALState(agentPubkey, conversationId, newState);
1138
+
1139
+ // Emit update for OperationsStatusService
1140
+ this.emitUpdated(ral.projectId, conversationId);
1141
+
1142
+ trace.getActiveSpan()?.addEvent(isActive ? "ral.tool_started" : "ral.tool_completed", {
1143
+ "ral.number": ralNumber,
1144
+ "tool.call_id": toolCallId,
1145
+ "tool.name": toolName,
1146
+ "ral.active_tools_count": ral.activeTools.size,
1147
+ });
1148
+ }
1149
+
1150
+ /**
1151
+ * Clear a tool from the active set as a fallback.
1152
+ * Used by MessageSyncer when a tool result is observed without a prior tool-did-execute event.
1153
+ *
1154
+ * @param toolCallId - The tool call ID to clear
1155
+ * @returns true if the tool was found and removed, false otherwise
1156
+ */
1157
+ clearToolFallback(
1158
+ agentPubkey: string,
1159
+ conversationId: string,
1160
+ ralNumber: number,
1161
+ toolCallId: string
1162
+ ): boolean {
1163
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
1164
+ if (!ral) return false;
1165
+
1166
+ if (!ral.activeTools.has(toolCallId)) {
1167
+ return false; // Tool wasn't in active map
1168
+ }
1169
+
1170
+ ral.activeTools.delete(toolCallId);
1171
+ ral.lastActivityAt = Date.now();
1172
+
1173
+ // Update currentTool to another active tool if any remain, otherwise clear
1174
+ if (ral.activeTools.size === 0) {
1175
+ ral.currentTool = undefined;
1176
+ ral.toolStartedAt = undefined;
1177
+ } else {
1178
+ // Set currentTool to one of the remaining active tools, including its start time
1179
+ const remainingToolInfo = ral.activeTools.values().next().value;
1180
+ if (remainingToolInfo) {
1181
+ ral.currentTool = remainingToolInfo.name;
1182
+ ral.toolStartedAt = remainingToolInfo.startedAt;
1183
+ } else {
1184
+ ral.currentTool = undefined;
1185
+ ral.toolStartedAt = undefined;
1186
+ }
1187
+ }
1188
+
1189
+ // Update state
1190
+ let newState: "ACTING" | "STREAMING" | "REASONING";
1191
+ if (ral.activeTools.size > 0) {
1192
+ newState = "ACTING";
1193
+ } else if (ral.isStreaming) {
1194
+ newState = "STREAMING";
1195
+ } else {
1196
+ newState = "REASONING";
1197
+ }
1198
+
1199
+ llmOpsRegistry.updateRALState(agentPubkey, conversationId, newState);
1200
+
1201
+ // Emit update for OperationsStatusService
1202
+ this.emitUpdated(ral.projectId, conversationId);
1203
+
1204
+ trace.getActiveSpan()?.addEvent("ral.tool_cleared_fallback", {
1205
+ "ral.number": ralNumber,
1206
+ "tool.call_id": toolCallId,
1207
+ "ral.active_tools_count": ral.activeTools.size,
1208
+ });
1209
+
1210
+ return true;
1211
+ }
1212
+
1213
+ /**
1214
+ * Register an abort controller for a specific RAL
1215
+ */
1216
+ registerAbortController(
1217
+ agentPubkey: string,
1218
+ conversationId: string,
1219
+ ralNumber: number,
1220
+ controller: AbortController
1221
+ ): void {
1222
+ const key = this.makeKey(agentPubkey, conversationId);
1223
+ this.abortControllers.set(this.makeAbortKey(key, ralNumber), controller);
1224
+ }
1225
+
1226
+
1227
+ /**
1228
+ * Clear a specific RAL.
1229
+ * NOTE: Delegations persist in conversation storage - only clears RAL state.
1230
+ * The delegationToRal routing map stays intact for followup routing.
1231
+ */
1232
+ clearRAL(agentPubkey: string, conversationId: string, ralNumber: number): void {
1233
+ const key = this.makeKey(agentPubkey, conversationId);
1234
+ const rals = this.states.get(key);
1235
+ if (!rals) return;
1236
+
1237
+ const ral = rals.get(ralNumber);
1238
+ // Capture projectId before deletion for emitUpdated
1239
+ const projectId = ral?.projectId;
1240
+
1241
+ if (ral) {
1242
+ // Clean up reverse lookup
1243
+ this.ralIdToLocation.delete(ral.id);
1244
+ }
1245
+
1246
+ rals.delete(ralNumber);
1247
+ const abortKey = this.makeAbortKey(key, ralNumber);
1248
+ this.abortControllers.delete(abortKey);
1249
+
1250
+ // Clean up empty conversation entries
1251
+ if (rals.size === 0) {
1252
+ this.states.delete(key);
1253
+ }
1254
+
1255
+ trace.getActiveSpan()?.addEvent("ral.cleared", {
1256
+ "ral.number": ralNumber,
1257
+ });
1258
+
1259
+ // Emit update for OperationsStatusService (only if we had a valid projectId)
1260
+ if (projectId) {
1261
+ this.emitUpdated(projectId, conversationId);
1262
+ }
1263
+ }
1264
+
1265
+ /**
1266
+ * Clear all RALs for an agent+conversation.
1267
+ * Also cleans up conversation-level delegation storage and killed markers.
1268
+ */
1269
+ clear(agentPubkey: string, conversationId: string): void {
1270
+ const key = this.makeKey(agentPubkey, conversationId);
1271
+ const rals = this.states.get(key);
1272
+ if (rals) {
1273
+ for (const ralNumber of rals.keys()) {
1274
+ this.clearRAL(agentPubkey, conversationId, ralNumber);
1275
+ }
1276
+ }
1277
+
1278
+ // ISSUE 1 FIX: Clean up killed agent marker when clearing state.
1279
+ // This prevents unbounded growth and allows conversation ID reuse.
1280
+ this.killedAgentConversations.delete(key);
1281
+
1282
+ // Clean up conversation-level delegation storage and routing
1283
+ const convDelegations = this.conversationDelegations.get(key);
1284
+ if (convDelegations) {
1285
+ for (const [id, d] of convDelegations.pending) {
1286
+ this.delegationToRal.delete(id);
1287
+ // Also clean up followup event ID mappings
1288
+ if (d.type === "followup" && d.followupEventId) {
1289
+ this.delegationToRal.delete(d.followupEventId);
1290
+ this.followupToCanonical.delete(d.followupEventId);
1291
+ }
1292
+ }
1293
+ for (const id of convDelegations.completed.keys()) {
1294
+ this.delegationToRal.delete(id);
1295
+ }
1296
+ this.conversationDelegations.delete(key);
1297
+ }
1298
+
1299
+ // Reset the RAL number counter for this conversation
1300
+ this.nextRalNumber.delete(key);
1301
+ }
1302
+
1303
+ /**
1304
+ * Resolve a 12-character hex prefix to a full delegation conversation ID.
1305
+ * Scans all pending and completed delegations for matching prefixes,
1306
+ * including followup event IDs which users may also receive and try to use.
1307
+ *
1308
+ * This is a fallback resolver for edge cases where PrefixKVStore is not initialized
1309
+ * (MCP-only execution mode) or when there are timing races with event indexing.
1310
+ *
1311
+ * Supports resolving:
1312
+ * - Delegation conversation IDs (from pending/completed maps)
1313
+ * - Followup event IDs (from followupToCanonical map) - resolved to their canonical delegation ID
1314
+ *
1315
+ * @param prefix - 12-character hex prefix (must be lowercase)
1316
+ * @returns Full 64-char ID if unique match found, null if no match or ambiguous
1317
+ */
1318
+ resolveDelegationPrefix(prefix: string): string | null {
1319
+ const matches: string[] = [];
1320
+
1321
+ // Scan all conversation delegations
1322
+ for (const [_key, delegations] of this.conversationDelegations) {
1323
+ // Check pending delegations
1324
+ for (const [delegationId, _pendingDelegation] of delegations.pending) {
1325
+ if (delegationId.toLowerCase().startsWith(prefix)) {
1326
+ matches.push(delegationId);
1327
+ }
1328
+ }
1329
+
1330
+ // Check completed delegations
1331
+ for (const [delegationId, _completedDelegation] of delegations.completed) {
1332
+ if (delegationId.toLowerCase().startsWith(prefix)) {
1333
+ // Avoid duplicates (a delegation might be in both pending and completed during transitions)
1334
+ if (!matches.includes(delegationId)) {
1335
+ matches.push(delegationId);
1336
+ }
1337
+ }
1338
+ }
1339
+ }
1340
+
1341
+ // Also scan followupToCanonical map for followup event ID prefixes
1342
+ // Users receive followupEventId from delegate_followup responses and may try to use it
1343
+ for (const [followupId, canonicalId] of this.followupToCanonical) {
1344
+ if (followupId.toLowerCase().startsWith(prefix)) {
1345
+ // For followup IDs, return the canonical delegation conversation ID
1346
+ // since that's what the caller needs for further operations
1347
+ if (!matches.includes(canonicalId)) {
1348
+ matches.push(canonicalId);
1349
+ }
1350
+ }
1351
+ }
1352
+
1353
+ // Return unique match, null if ambiguous or not found
1354
+ if (matches.length === 1) {
1355
+ return matches[0];
1356
+ }
1357
+
1358
+ if (matches.length > 1) {
1359
+ logger.debug("[RALRegistry.resolveDelegationPrefix] Ambiguous prefix match", {
1360
+ prefix,
1361
+ matchCount: matches.length,
1362
+ });
1363
+ }
1364
+
1365
+ return null;
1366
+ }
1367
+
1368
+ /**
1369
+ * Canonicalize a delegation ID by resolving followup event IDs to their canonical
1370
+ * delegation conversation IDs. If the ID is not a followup event ID, returns it unchanged.
1371
+ *
1372
+ * This is used as a post-resolution step when PrefixKVStore resolves an ID that may
1373
+ * be a followup event ID. PrefixKVStore returns any matching ID, but delegate_followup
1374
+ * needs the canonical delegation conversation ID for proper routing and e-tags.
1375
+ *
1376
+ * Resolution order:
1377
+ * 1. Check followupToCanonical map (O(1) lookup for known followup IDs)
1378
+ * 2. Scan pending/completed delegations for followup entries with matching followupEventId
1379
+ * 3. Return unchanged if not found (treat as canonical)
1380
+ *
1381
+ * This handles edge cases where:
1382
+ * - MCP-only mode: followupToCanonical map may not be populated
1383
+ * - Cross-session: followup was created in a previous session and RAL state was cleared
1384
+ * - Full 64-char hex IDs provided directly instead of via prefix resolution
1385
+ *
1386
+ * @param id - A delegation conversation ID or followup event ID (64-char hex)
1387
+ * @returns The canonical delegation conversation ID (unchanged if not a followup)
1388
+ */
1389
+ canonicalizeDelegationId(id: string): string {
1390
+ // Fast path: check followupToCanonical map first (O(1))
1391
+ const fromMap = this.followupToCanonical.get(id);
1392
+ if (fromMap) {
1393
+ return fromMap;
1394
+ }
1395
+
1396
+ // Slow path: scan pending/completed delegations for followup entries
1397
+ // This handles cases where the followupToCanonical map isn't populated
1398
+ // (MCP-only mode, cross-session lookups, etc.)
1399
+ const normalizedId = id.toLowerCase();
1400
+ for (const [_key, delegations] of this.conversationDelegations) {
1401
+ // Check pending delegations for followup entries
1402
+ for (const [canonicalId, pending] of delegations.pending) {
1403
+ if (pending.type === "followup" && pending.followupEventId?.toLowerCase() === normalizedId) {
1404
+ logger.debug("[RALRegistry.canonicalizeDelegationId] Found canonical via pending scan", {
1405
+ followupId: id.substring(0, 12),
1406
+ canonicalId: canonicalId.substring(0, 12),
1407
+ });
1408
+ return canonicalId;
1409
+ }
1410
+ }
1411
+
1412
+ // Check completed delegations - they may have followup transcript entries
1413
+ // Note: completed delegations don't store followupEventId directly,
1414
+ // but we can still check if the ID matches any delegation conversation ID
1415
+ }
1416
+
1417
+ // Not a followup ID or not found - return unchanged (treat as canonical)
1418
+ return id;
1419
+ }
1420
+
1421
+ /**
1422
+ * Find delegation in conversation storage (doesn't require RAL to exist).
1423
+ * Used by delegate_followup to look up delegations even after RAL is cleared.
1424
+ * Handles both original delegation IDs and followup event IDs through the reverse lookup.
1425
+ */
1426
+ findDelegation(delegationEventId: string): {
1427
+ pending?: PendingDelegation;
1428
+ completed?: CompletedDelegation;
1429
+ agentPubkey: string;
1430
+ conversationId: string;
1431
+ ralNumber: number;
1432
+ } | undefined {
1433
+ const location = this.delegationToRal.get(delegationEventId);
1434
+ if (!location) return undefined;
1435
+
1436
+ const [agentPubkey, conversationId] = location.key.split(":");
1437
+ const convDelegations = this.conversationDelegations.get(location.key);
1438
+ if (!convDelegations) return undefined;
1439
+
1440
+ // Resolve followup event ID to canonical delegation conversation ID if needed
1441
+ // The pending/completed maps are keyed by the original delegationConversationId
1442
+ const canonicalId = this.followupToCanonical.get(delegationEventId) ?? delegationEventId;
1443
+
1444
+ return {
1445
+ pending: convDelegations.pending.get(canonicalId),
1446
+ completed: convDelegations.completed.get(canonicalId),
1447
+ agentPubkey,
1448
+ conversationId,
1449
+ ralNumber: location.ralNumber,
1450
+ };
1451
+ }
1452
+
1453
+ /**
1454
+ * Find the RAL that has a pending delegation (for routing responses)
1455
+ * Handles both original delegation IDs and followup event IDs through the reverse lookup.
1456
+ */
1457
+ findStateWaitingForDelegation(delegationEventId: string): RALRegistryEntry | undefined {
1458
+ const location = this.delegationToRal.get(delegationEventId);
1459
+ if (!location) return undefined;
1460
+
1461
+ const ral = this.states.get(location.key)?.get(location.ralNumber);
1462
+ if (!ral) return undefined;
1463
+
1464
+ // Resolve followup event ID to canonical delegation conversation ID if needed
1465
+ const canonicalId = this.followupToCanonical.get(delegationEventId) ?? delegationEventId;
1466
+
1467
+ // Check if delegation exists in conversation storage
1468
+ const convDelegations = this.conversationDelegations.get(location.key);
1469
+ const hasPending = convDelegations?.pending.has(canonicalId) ?? false;
1470
+ return hasPending ? ral : undefined;
1471
+ }
1472
+
1473
+ /**
1474
+ * Get the RAL key for a delegation event ID (for routing completions)
1475
+ */
1476
+ getRalKeyForDelegation(delegationEventId: string): string | undefined {
1477
+ return this.delegationToRal.get(delegationEventId)?.key;
1478
+ }
1479
+
1480
+ /**
1481
+ * Find a RAL that should be resumed (has completed delegations).
1482
+ * Used when a delegation response arrives to continue the delegator's execution.
1483
+ * The RAL is resumable regardless of pending delegation count - the agent
1484
+ * decides what to do (wait, acknowledge, follow-up, etc.)
1485
+ */
1486
+ findResumableRAL(agentPubkey: string, conversationId: string): RALRegistryEntry | undefined {
1487
+ const rals = this.getActiveRALs(agentPubkey, conversationId);
1488
+ return rals.find(ral => {
1489
+ const completed = this.getConversationCompletedDelegations(agentPubkey, conversationId, ral.ralNumber);
1490
+ return completed.length > 0;
1491
+ });
1492
+ }
1493
+
1494
+ /**
1495
+ * Find a RAL that has queued injections ready to process.
1496
+ */
1497
+ findRALWithInjections(agentPubkey: string, conversationId: string): RALRegistryEntry | undefined {
1498
+ const rals = this.getActiveRALs(agentPubkey, conversationId);
1499
+ return rals.find(ral => ral.queuedInjections.length > 0);
1500
+ }
1501
+
1502
+ /**
1503
+ * Build a message containing delegation results for injection into the RAL.
1504
+ * Shows complete conversation transcript for each delegation.
1505
+ * Uses shortened delegation IDs (PREFIX_LENGTH chars) for display; agents can use
1506
+ * these prefixes directly with delegate_followup which will resolve them.
1507
+ * Format: [@sender -> @recipient]: message content
1508
+ */
1509
+ /**
1510
+ * Helper to format transcript with error handling for name resolution.
1511
+ */
1512
+ private async formatTranscript(transcript: DelegationMessage[]): Promise<string[]> {
1513
+ const pubkeyService = getPubkeyService();
1514
+ const lines: string[] = [];
1515
+
1516
+ for (const msg of transcript) {
1517
+ try {
1518
+ const senderName = await pubkeyService.getName(msg.senderPubkey);
1519
+ const recipientName = await pubkeyService.getName(msg.recipientPubkey);
1520
+ lines.push(`[@${senderName} -> @${recipientName}]: ${msg.content}`);
1521
+ } catch (error) {
1522
+ // Fallback to shortened pubkeys on error
1523
+ const senderFallback = msg.senderPubkey.substring(0, 12);
1524
+ const recipientFallback = msg.recipientPubkey.substring(0, 12);
1525
+ lines.push(`[@${senderFallback} -> @${recipientFallback}]: ${msg.content}`);
1526
+ }
1527
+ }
1528
+
1529
+ return lines;
1530
+ }
1531
+
1532
+ /**
1533
+ * Helper to render a list of pending delegations with error handling.
1534
+ */
1535
+ private async renderPendingList(pending: PendingDelegation[]): Promise<string[]> {
1536
+ if (pending.length === 0) {
1537
+ return [];
1538
+ }
1539
+
1540
+ const pubkeyService = getPubkeyService();
1541
+ const lines: string[] = [];
1542
+
1543
+ lines.push("## Still Pending");
1544
+ for (const p of pending) {
1545
+ try {
1546
+ const recipientName = await pubkeyService.getName(p.recipientPubkey);
1547
+ lines.push(`- @${recipientName} (${p.delegationConversationId.substring(0, PREFIX_LENGTH)})`);
1548
+ } catch (error) {
1549
+ const fallbackName = p.recipientPubkey.substring(0, 12);
1550
+ lines.push(`- @${fallbackName} (${p.delegationConversationId.substring(0, PREFIX_LENGTH)})`);
1551
+ }
1552
+ }
1553
+ lines.push("");
1554
+
1555
+ return lines;
1556
+ }
1557
+
1558
+ /**
1559
+ * Helper to render delegation header (agent name + ID) with error handling.
1560
+ */
1561
+ private async renderDelegationHeader(
1562
+ recipientPubkey: string,
1563
+ conversationId: string,
1564
+ statusText: string
1565
+ ): Promise<string[]> {
1566
+ const pubkeyService = getPubkeyService();
1567
+ const lines: string[] = [];
1568
+
1569
+ try {
1570
+ const recipientName = await pubkeyService.getName(recipientPubkey);
1571
+ lines.push(`**@${recipientName} ${statusText}**`);
1572
+ } catch (error) {
1573
+ const fallbackName = recipientPubkey.substring(0, 12);
1574
+ lines.push(`**@${fallbackName} ${statusText}**`);
1575
+ }
1576
+
1577
+ lines.push("");
1578
+ lines.push(`## Delegation ID: ${conversationId.substring(0, PREFIX_LENGTH)}`);
1579
+
1580
+ return lines;
1581
+ }
1582
+
1583
+ /**
1584
+ * Build a message for completed delegations.
1585
+ * Shows the agent name, conversation ID, and full transcript.
1586
+ */
1587
+ async buildDelegationResultsMessage(
1588
+ completions: CompletedDelegation[],
1589
+ pending: PendingDelegation[] = []
1590
+ ): Promise<string> {
1591
+ if (completions.length === 0) {
1592
+ return "";
1593
+ }
1594
+
1595
+ const lines: string[] = [];
1596
+
1597
+ lines.push("# DELEGATION COMPLETED");
1598
+ lines.push("");
1599
+
1600
+ for (const c of completions) {
1601
+ lines.push(...await this.renderDelegationHeader(
1602
+ c.recipientPubkey,
1603
+ c.delegationConversationId,
1604
+ "has finished and returned their final response."
1605
+ ));
1606
+ lines.push("");
1607
+ lines.push("### Transcript:");
1608
+ lines.push(...await this.formatTranscript(c.transcript));
1609
+ lines.push("");
1610
+ }
1611
+
1612
+ // Show pending delegations if any remain
1613
+ if (pending.length > 0) {
1614
+ lines.push(...await this.renderPendingList(pending));
1615
+ }
1616
+
1617
+ return lines.join("\n");
1618
+ }
1619
+
1620
+ /**
1621
+ * Build a message for aborted delegations.
1622
+ * Shows the agent name, conversation ID, abort details, and partial transcript.
1623
+ * Format matches buildDelegationResultsMessage for consistency.
1624
+ */
1625
+ async buildDelegationAbortMessage(
1626
+ abortedDelegations: CompletedDelegation[],
1627
+ pending: PendingDelegation[] = []
1628
+ ): Promise<string> {
1629
+ if (abortedDelegations.length === 0) {
1630
+ return "";
1631
+ }
1632
+
1633
+ const lines: string[] = [];
1634
+
1635
+ // Header indicating abort event
1636
+ lines.push("# DELEGATION ABORTED");
1637
+ lines.push("");
1638
+
1639
+ // Format each aborted delegation
1640
+ for (const c of abortedDelegations) {
1641
+ if (c.status !== "aborted") continue; // Type guard for discriminated union
1642
+
1643
+ lines.push(...await this.renderDelegationHeader(
1644
+ c.recipientPubkey,
1645
+ c.delegationConversationId,
1646
+ "was aborted and did not complete their task."
1647
+ ));
1648
+
1649
+ // Add abort-specific metadata with error handling for timestamp
1650
+ try {
1651
+ lines.push(`**Aborted at:** ${new Date(c.completedAt).toISOString()}`);
1652
+ } catch {
1653
+ lines.push(`**Aborted at:** (invalid timestamp)`);
1654
+ }
1655
+ lines.push(`**Reason:** ${c.abortReason}`);
1656
+ lines.push("");
1657
+
1658
+ // Show partial transcript if available
1659
+ if (c.transcript && c.transcript.length > 0) {
1660
+ lines.push("### Partial Progress:");
1661
+ lines.push(...await this.formatTranscript(c.transcript));
1662
+ } else {
1663
+ lines.push("### Partial Progress:");
1664
+ lines.push("(No messages exchanged before abort)");
1665
+ }
1666
+ lines.push("");
1667
+ }
1668
+
1669
+ // Show remaining pending delegations if any
1670
+ if (pending.length > 0) {
1671
+ lines.push(...await this.renderPendingList(pending));
1672
+ }
1673
+
1674
+ return lines.join("\n");
1675
+ }
1676
+
1677
+ /**
1678
+ * Determine if a new message should wake up an execution.
1679
+ *
1680
+ * This is the KEY decision point in the simplified model:
1681
+ * - If agent is actively streaming: Don't wake up, message is injected
1682
+ * - If agent is waiting on delegations: Wake up to process
1683
+ * - If no RAL exists: Wake up (start new execution)
1684
+ *
1685
+ * @returns true if execution should be started/resumed, false if injection is sufficient
1686
+ */
1687
+ shouldWakeUpExecution(agentPubkey: string, conversationId: string): boolean {
1688
+ const ral = this.getState(agentPubkey, conversationId);
1689
+
1690
+ // No RAL = start new execution
1691
+ if (!ral) return true;
1692
+
1693
+ // If currently streaming (actively processing), don't wake up
1694
+ // The prepareStep callback will pick up the injected message
1695
+ if (ral.isStreaming) return false;
1696
+
1697
+ // If there are completed delegations waiting, wake up
1698
+ const completed = this.getConversationCompletedDelegations(
1699
+ agentPubkey, conversationId, ral.ralNumber
1700
+ );
1701
+ if (completed.length > 0) return true;
1702
+
1703
+ // If there are pending delegations, we're waiting - wake up to process new message
1704
+ const pending = this.getConversationPendingDelegations(
1705
+ agentPubkey, conversationId, ral.ralNumber
1706
+ );
1707
+ if (pending.length > 0) return true;
1708
+
1709
+ // RAL exists but not streaming and no delegations - it's finished
1710
+ // This shouldn't happen often (RAL should be cleared), but wake up to start fresh
1711
+ return true;
1712
+ }
1713
+
1714
+ /**
1715
+ * Get the most recent RAL number for a conversation
1716
+ */
1717
+ private getCurrentRalNumber(agentPubkey: string, conversationId: string): number | undefined {
1718
+ const ral = this.getState(agentPubkey, conversationId);
1719
+ return ral?.ralNumber;
1720
+ }
1721
+
1722
+ /**
1723
+ * Abort current tool on most recent RAL (convenience for tests)
1724
+ */
1725
+ abortCurrentTool(agentPubkey: string, conversationId: string): void {
1726
+ const ralNumber = this.getCurrentRalNumber(agentPubkey, conversationId);
1727
+ if (ralNumber !== undefined) {
1728
+ const key = this.makeKey(agentPubkey, conversationId);
1729
+ const abortKey = this.makeAbortKey(key, ralNumber);
1730
+ const controller = this.abortControllers.get(abortKey);
1731
+ if (controller) {
1732
+ controller.abort();
1733
+ this.abortControllers.delete(abortKey);
1734
+ trace.getActiveSpan()?.addEvent("ral.tool_aborted", {
1735
+ "ral.number": ralNumber,
1736
+ });
1737
+ }
1738
+ }
1739
+ }
1740
+
1741
+ /**
1742
+ * Abort all running RALs for an agent in a conversation.
1743
+ * This is used when a stop signal is received to immediately terminate all executions.
1744
+ */
1745
+ abortAllForAgent(agentPubkey: string, conversationId: string): number {
1746
+ const key = this.makeKey(agentPubkey, conversationId);
1747
+ const rals = this.states.get(key);
1748
+ if (!rals) return 0;
1749
+
1750
+ let abortedCount = 0;
1751
+
1752
+ for (const [ralNumber] of rals) {
1753
+ const abortKey = this.makeAbortKey(key, ralNumber);
1754
+ const controller = this.abortControllers.get(abortKey);
1755
+ if (controller && !controller.signal.aborted) {
1756
+ controller.abort();
1757
+ abortedCount++;
1758
+ trace.getActiveSpan()?.addEvent("ral.aborted_by_stop_signal", {
1759
+ "ral.number": ralNumber,
1760
+ "agent.pubkey": agentPubkey.substring(0, 8),
1761
+ "conversation.id": shortenConversationId(conversationId),
1762
+ });
1763
+ }
1764
+ }
1765
+
1766
+ // Clear all state for this agent+conversation
1767
+ this.clear(agentPubkey, conversationId);
1768
+
1769
+ return abortedCount;
1770
+ }
1771
+
1772
+ /**
1773
+ * Abort an agent in a conversation with cascading support.
1774
+ * If the conversation has nested delegations (found via delegation chain),
1775
+ * this will recursively abort all descendant agents in their respective conversations.
1776
+ *
1777
+ * @param agentPubkey - The agent's pubkey
1778
+ * @param conversationId - The conversation ID
1779
+ * @param projectId - The project ID (for cooldown isolation)
1780
+ * @param reason - Optional reason for the abort
1781
+ * @param cooldownRegistry - Optional cooldown registry to track aborted tuples
1782
+ * @returns An object with abortedCount and descendantConversations array
1783
+ */
1784
+ async abortWithCascade(
1785
+ agentPubkey: string,
1786
+ conversationId: string,
1787
+ projectId: string,
1788
+ reason?: string,
1789
+ cooldownRegistry?: { add: (projectId: string, convId: string, agentPubkey: string, reason?: string) => void }
1790
+ ): Promise<{ abortedCount: number; descendantConversations: Array<{ conversationId: string; agentPubkey: string }> }> {
1791
+ const abortedTuples: Array<{ conversationId: string; agentPubkey: string }> = [];
1792
+
1793
+ // CRITICAL: Capture pending delegations AND conversation delegations BEFORE aborting/clearing
1794
+ // The abort operation will clear conversation delegations, so we must snapshot them first
1795
+ const pendingDelegations = this.getConversationPendingDelegations(agentPubkey, conversationId);
1796
+ const key = this.makeKey(agentPubkey, conversationId);
1797
+ const convDelegations = this.conversationDelegations.get(key);
1798
+
1799
+ // RACE CONDITION FIX: Mark all pending delegations as killed BEFORE aborting.
1800
+ // This prevents the race where a delegation completes between the time we abort
1801
+ // and the time we process the cascade. The killed flag ensures that any
1802
+ // completion events arriving for these delegations will be ignored.
1803
+ const killedDelegationCount = this.markAllDelegationsKilled(agentPubkey, conversationId);
1804
+ if (killedDelegationCount > 0) {
1805
+ trace.getActiveSpan()?.addEvent("ral.delegations_marked_killed_before_abort", {
1806
+ "cascade.agent_pubkey": agentPubkey.substring(0, 12),
1807
+ "cascade.conversation_id": shortenConversationId(conversationId),
1808
+ "cascade.killed_delegation_count": killedDelegationCount,
1809
+ });
1810
+ }
1811
+
1812
+ // Abort the target agent's RAL controllers and LLM stream
1813
+ const directAbortCount = this.abortAllForAgent(agentPubkey, conversationId);
1814
+ const llmAborted = llmOpsRegistry.stopByAgentAndConversation(agentPubkey, conversationId, reason);
1815
+
1816
+ // Always mark killed + block — we were asked to stop this agent.
1817
+ // Unconditional to handle the timing window where an LLM op exists but RAL controller doesn't yet.
1818
+ this.markAgentConversationKilled(agentPubkey, conversationId);
1819
+ const conversation = ConversationStore.get(conversationId);
1820
+ if (conversation) {
1821
+ conversation.blockAgent(agentPubkey);
1822
+ }
1823
+
1824
+ if (directAbortCount > 0 || llmAborted) {
1825
+ abortedTuples.push({ conversationId, agentPubkey });
1826
+
1827
+ // Update parent's delegation state when killing a child conversation.
1828
+ this.markParentDelegationKilled(conversationId);
1829
+
1830
+ // Add to cooldown registry if provided
1831
+ if (cooldownRegistry) {
1832
+ cooldownRegistry.add(projectId, conversationId, agentPubkey, reason);
1833
+ }
1834
+
1835
+ // Persist abort message in conversation store
1836
+ if (conversation) {
1837
+ const abortMessage = `This conversation was aborted at ${new Date().toISOString()}. Reason: ${reason ?? "manual abort"}`;
1838
+ conversation.addMessage({
1839
+ pubkey: "system",
1840
+ content: abortMessage,
1841
+ messageType: "text",
1842
+ timestamp: Math.floor(Date.now() / 1000),
1843
+ });
1844
+ await conversation.save();
1845
+ }
1846
+ }
1847
+
1848
+ trace.getActiveSpan()?.addEvent("ral.cascade_abort_started", {
1849
+ "cascade.root_conversation_id": shortenConversationId(conversationId),
1850
+ "cascade.root_agent_pubkey": agentPubkey.substring(0, 12),
1851
+ "cascade.pending_delegations": pendingDelegations.length,
1852
+ "cascade.reason": reason ?? "unknown",
1853
+ });
1854
+
1855
+ // Recursively abort each pending delegation
1856
+ for (const delegation of pendingDelegations) {
1857
+ const descendantConvId = delegation.delegationConversationId;
1858
+ const descendantAgentPubkey = delegation.recipientPubkey;
1859
+
1860
+ // Get the projectId for the descendant conversation
1861
+ const descendantConversation = ConversationStore.get(descendantConvId);
1862
+ const descendantProjectId = descendantConversation?.getProjectId() ?? projectId;
1863
+
1864
+ logger.info("[RALRegistry] Cascading abort to nested delegation", {
1865
+ parentConversation: shortenConversationId(conversationId),
1866
+ parentAgent: agentPubkey.substring(0, 12),
1867
+ childConversation: descendantConvId.substring(0, 12),
1868
+ childAgent: descendantAgentPubkey.substring(0, 12),
1869
+ projectId: descendantProjectId?.substring(0, 12),
1870
+ });
1871
+
1872
+ // Recursively abort the descendant
1873
+ const descendantResult = await this.abortWithCascade(
1874
+ descendantAgentPubkey,
1875
+ descendantConvId,
1876
+ descendantProjectId,
1877
+ `cascaded from ${shortenConversationId(conversationId)}`,
1878
+ cooldownRegistry
1879
+ );
1880
+
1881
+ // Collect all aborted tuples from descendants
1882
+ abortedTuples.push(...descendantResult.descendantConversations);
1883
+
1884
+ // Mark this delegation as aborted by moving it from pending to completed with abort status
1885
+ // Use the captured convDelegations from before the abort
1886
+ if (convDelegations) {
1887
+ // Remove from pending
1888
+ const pendingDelegation = convDelegations.pending.get(descendantConvId);
1889
+ if (pendingDelegation) {
1890
+ convDelegations.pending.delete(descendantConvId);
1891
+
1892
+ // Load partial transcript from the aborted conversation
1893
+ const abortedConv = ConversationStore.get(descendantConvId);
1894
+ const partialTranscript: DelegationMessage[] = [];
1895
+ if (abortedConv) {
1896
+ const messages = abortedConv.getAllMessages();
1897
+ for (const msg of messages) {
1898
+ if (msg.messageType === "text" && msg.targetedPubkeys && msg.targetedPubkeys.length > 0) {
1899
+ partialTranscript.push({
1900
+ senderPubkey: msg.pubkey,
1901
+ recipientPubkey: msg.targetedPubkeys[0],
1902
+ content: msg.content,
1903
+ timestamp: msg.timestamp ?? Date.now(),
1904
+ });
1905
+ }
1906
+ }
1907
+ }
1908
+
1909
+ // Add to completed with "aborted" status
1910
+ convDelegations.completed.set(descendantConvId, {
1911
+ delegationConversationId: descendantConvId,
1912
+ recipientPubkey: descendantAgentPubkey,
1913
+ senderPubkey: pendingDelegation.senderPubkey,
1914
+ ralNumber: pendingDelegation.ralNumber,
1915
+ transcript: partialTranscript,
1916
+ completedAt: Date.now(),
1917
+ status: "aborted",
1918
+ abortReason: `cascaded from ${shortenConversationId(conversationId)}`,
1919
+ });
1920
+
1921
+ trace.getActiveSpan()?.addEvent("ral.delegation_marked_aborted", {
1922
+ "delegation.conversation_id": shortenConversationId(descendantConvId),
1923
+ "delegation.transcript_length": partialTranscript.length,
1924
+ });
1925
+ }
1926
+ }
1927
+ }
1928
+
1929
+ // Re-insert the modified convDelegations back into the Map so it's queryable later
1930
+ // This is critical because abortAllForAgent() deleted the Map entry, but we've
1931
+ // now added aborted completions to the captured reference
1932
+ if (convDelegations && (convDelegations.pending.size > 0 || convDelegations.completed.size > 0)) {
1933
+ this.conversationDelegations.set(key, convDelegations);
1934
+ }
1935
+
1936
+ trace.getActiveSpan()?.addEvent("ral.cascade_abort_completed", {
1937
+ "cascade.root_conversation_id": shortenConversationId(conversationId),
1938
+ "cascade.root_agent_pubkey": agentPubkey.substring(0, 12),
1939
+ "cascade.total_aborted": abortedTuples.length,
1940
+ });
1941
+
1942
+ return {
1943
+ abortedCount: directAbortCount + (llmAborted ? 1 : 0),
1944
+ descendantConversations: abortedTuples,
1945
+ };
1946
+ }
1947
+
1948
+ /**
1949
+ * Clear all state (for testing)
1950
+ */
1951
+ clearAll(): void {
1952
+ this.states.clear();
1953
+ this.nextRalNumber.clear();
1954
+ this.delegationToRal.clear();
1955
+ this.ralIdToLocation.clear();
1956
+ this.abortControllers.clear();
1957
+ this.conversationDelegations.clear();
1958
+ this.followupToCanonical.clear();
1959
+ this.killedAgentConversations.clear();
1960
+ }
1961
+
1962
+ // ============================================================================
1963
+ // Killed Delegation Methods (Race Condition Prevention)
1964
+ // ============================================================================
1965
+
1966
+ /**
1967
+ * Mark a pending delegation as killed.
1968
+ * This prevents the race condition where a delegation completes after being
1969
+ * killed but before the abort fully propagates. The killed flag ensures that
1970
+ * completion events for killed delegations are ignored.
1971
+ *
1972
+ * @param delegationConversationId - The delegation conversation ID to mark as killed
1973
+ * @returns true if the delegation was found and marked, false otherwise
1974
+ */
1975
+ markDelegationKilled(delegationConversationId: string): boolean {
1976
+ // Look up the delegation's location
1977
+ const location = this.delegationToRal.get(delegationConversationId);
1978
+ if (!location) {
1979
+ logger.debug("[RALRegistry.markDelegationKilled] No delegation found for ID", {
1980
+ delegationConversationId: delegationConversationId.substring(0, 12),
1981
+ });
1982
+ return false;
1983
+ }
1984
+
1985
+ const convDelegations = this.conversationDelegations.get(location.key);
1986
+ if (!convDelegations) {
1987
+ return false;
1988
+ }
1989
+
1990
+ // Resolve followup event ID to canonical delegation conversation ID if needed
1991
+ const canonicalId = this.followupToCanonical.get(delegationConversationId)
1992
+ ?? delegationConversationId;
1993
+
1994
+ const pendingDelegation = convDelegations.pending.get(canonicalId);
1995
+ if (!pendingDelegation) {
1996
+ logger.debug("[RALRegistry.markDelegationKilled] No pending delegation found", {
1997
+ delegationConversationId: canonicalId.substring(0, 12),
1998
+ });
1999
+ return false;
2000
+ }
2001
+
2002
+ // Idempotent: preserve original kill time for audit trail
2003
+ if (pendingDelegation.killed) {
2004
+ logger.debug("[RALRegistry.markDelegationKilled] Delegation already killed", {
2005
+ delegationConversationId: canonicalId.substring(0, 12),
2006
+ killedAt: pendingDelegation.killedAt,
2007
+ });
2008
+ return true; // Already killed, but return true to indicate it's in killed state
2009
+ }
2010
+
2011
+ // Mark the delegation as killed
2012
+ pendingDelegation.killed = true;
2013
+ pendingDelegation.killedAt = Date.now();
2014
+
2015
+ trace.getActiveSpan()?.addEvent("ral.delegation_marked_killed", {
2016
+ "delegation.conversation_id": shortenConversationId(canonicalId),
2017
+ "delegation.recipient_pubkey": pendingDelegation.recipientPubkey.substring(0, 12),
2018
+ });
2019
+
2020
+ logger.info("[RALRegistry.markDelegationKilled] Delegation marked as killed", {
2021
+ delegationConversationId: canonicalId.substring(0, 12),
2022
+ recipientPubkey: pendingDelegation.recipientPubkey.substring(0, 12),
2023
+ });
2024
+
2025
+ return true;
2026
+ }
2027
+
2028
+ /**
2029
+ * Check if a delegation has been marked as killed.
2030
+ * Used by completion handlers to skip processing killed delegations.
2031
+ *
2032
+ * @param delegationConversationId - The delegation conversation ID to check
2033
+ * @returns true if the delegation is killed, false if not found or not killed
2034
+ */
2035
+ isDelegationKilled(delegationConversationId: string): boolean {
2036
+ const location = this.delegationToRal.get(delegationConversationId);
2037
+ if (!location) {
2038
+ return false;
2039
+ }
2040
+
2041
+ const convDelegations = this.conversationDelegations.get(location.key);
2042
+ if (!convDelegations) {
2043
+ return false;
2044
+ }
2045
+
2046
+ // Resolve followup event ID to canonical delegation conversation ID if needed
2047
+ const canonicalId = this.followupToCanonical.get(delegationConversationId)
2048
+ ?? delegationConversationId;
2049
+
2050
+ const pendingDelegation = convDelegations.pending.get(canonicalId);
2051
+ return pendingDelegation?.killed === true;
2052
+ }
2053
+
2054
+ /**
2055
+ * Mark all pending delegations for an agent+conversation as killed.
2056
+ * Used when killing an agent to prevent any of its delegations from completing.
2057
+ *
2058
+ * @returns The number of delegations marked as killed
2059
+ */
2060
+ markAllDelegationsKilled(agentPubkey: string, conversationId: string): number {
2061
+ const key = this.makeKey(agentPubkey, conversationId);
2062
+ const convDelegations = this.conversationDelegations.get(key);
2063
+ if (!convDelegations) {
2064
+ return 0;
2065
+ }
2066
+
2067
+ let killedCount = 0;
2068
+ const now = Date.now();
2069
+
2070
+ for (const [delegationId, pendingDelegation] of convDelegations.pending) {
2071
+ if (!pendingDelegation.killed) {
2072
+ pendingDelegation.killed = true;
2073
+ pendingDelegation.killedAt = now;
2074
+ killedCount++;
2075
+
2076
+ trace.getActiveSpan()?.addEvent("ral.delegation_marked_killed_bulk", {
2077
+ "delegation.conversation_id": shortenConversationId(delegationId),
2078
+ "delegation.recipient_pubkey": pendingDelegation.recipientPubkey.substring(0, 12),
2079
+ });
2080
+ }
2081
+ }
2082
+
2083
+ if (killedCount > 0) {
2084
+ logger.info("[RALRegistry.markAllDelegationsKilled] Marked delegations as killed", {
2085
+ agentPubkey: agentPubkey.substring(0, 12),
2086
+ conversationId: conversationId.substring(0, 12),
2087
+ killedCount,
2088
+ });
2089
+ }
2090
+
2091
+ return killedCount;
2092
+ }
2093
+
2094
+ /**
2095
+ * Mark an agent+conversation as killed.
2096
+ * Used to prevent killed agents from publishing completion events.
2097
+ * This addresses the race condition where an agent continues running
2098
+ * (e.g., in a long tool execution) after being killed.
2099
+ *
2100
+ * ISSUE 3 FIX: Scoped to agentPubkey:conversationId to ensure killing one
2101
+ * agent doesn't suppress completions for other agents in the same conversation.
2102
+ *
2103
+ * @param agentPubkey - The agent's pubkey
2104
+ * @param conversationId - The conversation ID
2105
+ */
2106
+ markAgentConversationKilled(agentPubkey: string, conversationId: string): void {
2107
+ const key = this.makeKey(agentPubkey, conversationId);
2108
+ this.killedAgentConversations.add(key);
2109
+
2110
+ trace.getActiveSpan()?.addEvent("ral.agent_conversation_marked_killed", {
2111
+ "agent.pubkey": agentPubkey.substring(0, 12),
2112
+ "conversation.id": shortenConversationId(conversationId),
2113
+ });
2114
+
2115
+ logger.info("[RALRegistry.markAgentConversationKilled] Agent+conversation marked as killed", {
2116
+ agentPubkey: agentPubkey.substring(0, 12),
2117
+ conversationId: conversationId.substring(0, 12),
2118
+ });
2119
+ }
2120
+
2121
+ /**
2122
+ * Check if an agent+conversation has been killed.
2123
+ * Used by AgentPublisher to skip completion events for killed agents.
2124
+ *
2125
+ * ISSUE 3 FIX: Scoped to agentPubkey:conversationId to ensure killing one
2126
+ * agent doesn't suppress completions for other agents in the same conversation.
2127
+ *
2128
+ * @param agentPubkey - The agent's pubkey
2129
+ * @param conversationId - The conversation ID
2130
+ * @returns true if the agent+conversation has been killed
2131
+ */
2132
+ isAgentConversationKilled(agentPubkey: string, conversationId: string): boolean {
2133
+ const key = this.makeKey(agentPubkey, conversationId);
2134
+ return this.killedAgentConversations.has(key);
2135
+ }
2136
+
2137
+ /**
2138
+ * Look up the recipient agent pubkey for a PENDING delegation conversation.
2139
+ * Uses the delegationToRal map to find the parent, then looks up the pending delegation
2140
+ * to find the recipient.
2141
+ *
2142
+ * IMPORTANT: This method ONLY returns pubkeys for pending delegations. Completed
2143
+ * delegations are intentionally excluded to prevent pre-emptive kill from succeeding
2144
+ * on already-completed delegations (which would be a no-op that adds unnecessary
2145
+ * cooldown entries and misleading success messages).
2146
+ *
2147
+ * @param delegationConversationId - The delegation conversation ID
2148
+ * @returns The recipient agent pubkey if delegation is PENDING, or null if not found or completed
2149
+ */
2150
+ getDelegationRecipientPubkey(delegationConversationId: string): string | null {
2151
+ // Look up which parent owns this delegation
2152
+ const location = this.delegationToRal.get(delegationConversationId);
2153
+ if (!location) {
2154
+ return null;
2155
+ }
2156
+
2157
+ // Get the parent's conversation delegations
2158
+ const convDelegations = this.conversationDelegations.get(location.key);
2159
+ if (!convDelegations) {
2160
+ return null;
2161
+ }
2162
+
2163
+ // Resolve followup event ID to canonical delegation conversation ID if needed
2164
+ const canonicalId = this.followupToCanonical.get(delegationConversationId)
2165
+ ?? delegationConversationId;
2166
+
2167
+ // ONLY return for pending delegations - completed delegations are excluded
2168
+ // to prevent pre-emptive kill on already-finished work
2169
+ const pendingDelegation = convDelegations.pending.get(canonicalId);
2170
+ if (pendingDelegation) {
2171
+ return pendingDelegation.recipientPubkey;
2172
+ }
2173
+
2174
+ return null;
2175
+ }
2176
+
2177
+ /**
2178
+ * Mark the parent's pending delegation as killed when killing a child conversation.
2179
+ * Uses the delegationToRal map to find the parent that owns this delegation.
2180
+ *
2181
+ * This fixes the bug where Agent0's pending_delegations count remains at 1
2182
+ * after killing a delegation, because the parent's state wasn't updated.
2183
+ *
2184
+ * @param delegationConversationId - The delegation conversation ID being killed
2185
+ * @returns true if parent delegation was found and marked, false otherwise
2186
+ */
2187
+ markParentDelegationKilled(delegationConversationId: string): boolean {
2188
+ // Look up which parent owns this delegation
2189
+ const location = this.delegationToRal.get(delegationConversationId);
2190
+ if (!location) {
2191
+ logger.debug("[RALRegistry.markParentDelegationKilled] No parent found for delegation", {
2192
+ delegationConversationId: delegationConversationId.substring(0, 12),
2193
+ });
2194
+ return false;
2195
+ }
2196
+
2197
+ // Get the parent's conversation delegations
2198
+ const convDelegations = this.conversationDelegations.get(location.key);
2199
+ if (!convDelegations) {
2200
+ logger.debug("[RALRegistry.markParentDelegationKilled] No delegations found for parent", {
2201
+ parentKey: location.key.substring(0, 20),
2202
+ });
2203
+ return false;
2204
+ }
2205
+
2206
+ // Resolve followup event ID to canonical delegation conversation ID if needed
2207
+ const canonicalId = this.followupToCanonical.get(delegationConversationId)
2208
+ ?? delegationConversationId;
2209
+
2210
+ const pendingDelegation = convDelegations.pending.get(canonicalId);
2211
+ if (!pendingDelegation) {
2212
+ logger.debug("[RALRegistry.markParentDelegationKilled] No pending delegation found", {
2213
+ delegationConversationId: canonicalId.substring(0, 12),
2214
+ });
2215
+ return false;
2216
+ }
2217
+
2218
+ // Mark the delegation as killed (idempotent)
2219
+ if (!pendingDelegation.killed) {
2220
+ pendingDelegation.killed = true;
2221
+ pendingDelegation.killedAt = Date.now();
2222
+
2223
+ trace.getActiveSpan()?.addEvent("ral.parent_delegation_marked_killed", {
2224
+ "parent.key": location.key.substring(0, 20),
2225
+ "delegation.conversation_id": shortenConversationId(canonicalId),
2226
+ "delegation.recipient_pubkey": pendingDelegation.recipientPubkey.substring(0, 12),
2227
+ });
2228
+
2229
+ logger.info("[RALRegistry.markParentDelegationKilled] Parent delegation marked as killed", {
2230
+ parentKey: location.key.substring(0, 20),
2231
+ delegationConversationId: canonicalId.substring(0, 12),
2232
+ recipientPubkey: pendingDelegation.recipientPubkey.substring(0, 12),
2233
+ });
2234
+ }
2235
+
2236
+ // Remove from pending
2237
+ convDelegations.pending.delete(canonicalId);
2238
+
2239
+ // ISSUE 2 FIX: Check if there's an existing completion entry (from followup scenario).
2240
+ // If so, preserve the existing transcript when creating the abort entry.
2241
+ const existingCompletion = convDelegations.completed.get(canonicalId);
2242
+ if (existingCompletion) {
2243
+ // Replace existing completion with aborted entry, but PRESERVE the transcript
2244
+ // The discriminated union type requires replacing the entire object to change status
2245
+ convDelegations.completed.set(canonicalId, {
2246
+ delegationConversationId: canonicalId,
2247
+ recipientPubkey: existingCompletion.recipientPubkey,
2248
+ senderPubkey: existingCompletion.senderPubkey,
2249
+ ralNumber: existingCompletion.ralNumber,
2250
+ transcript: existingCompletion.transcript, // PRESERVE original transcript
2251
+ completedAt: Date.now(),
2252
+ status: "aborted",
2253
+ abortReason: "killed via kill tool (after partial completion)",
2254
+ });
2255
+
2256
+ trace.getActiveSpan()?.addEvent("ral.parent_delegation_updated_as_aborted", {
2257
+ "delegation.conversation_id": shortenConversationId(canonicalId),
2258
+ "delegation.status": "aborted",
2259
+ "delegation.transcript_preserved": existingCompletion.transcript.length,
2260
+ });
2261
+ } else {
2262
+ // No existing completion - create new aborted entry
2263
+ convDelegations.completed.set(canonicalId, {
2264
+ delegationConversationId: canonicalId,
2265
+ recipientPubkey: pendingDelegation.recipientPubkey,
2266
+ senderPubkey: pendingDelegation.senderPubkey,
2267
+ ralNumber: pendingDelegation.ralNumber,
2268
+ transcript: [], // Empty transcript since it was killed before completion
2269
+ completedAt: Date.now(),
2270
+ status: "aborted",
2271
+ abortReason: "killed via kill tool",
2272
+ });
2273
+
2274
+ trace.getActiveSpan()?.addEvent("ral.parent_delegation_moved_to_completed", {
2275
+ "delegation.conversation_id": shortenConversationId(canonicalId),
2276
+ "delegation.status": "aborted",
2277
+ });
2278
+ }
2279
+
2280
+ // Decrement the O(1) delegation counter
2281
+ this.decrementDelegationCounter(
2282
+ location.key.split(":")[0], // agentPubkey
2283
+ location.key.split(":")[1], // conversationId
2284
+ pendingDelegation.ralNumber
2285
+ );
2286
+
2287
+ return true;
2288
+ }
2289
+
2290
+ // ============================================================================
2291
+ // Heuristic Violation Methods (Namespaced State)
2292
+ // ============================================================================
2293
+
2294
+ /**
2295
+ * Add heuristic violations to pending queue for a RAL.
2296
+ * These will be injected as system messages in the next LLM step.
2297
+ *
2298
+ * @param violations - Array of violations to add
2299
+ */
2300
+ addHeuristicViolations(
2301
+ agentPubkey: string,
2302
+ conversationId: string,
2303
+ ralNumber: number,
2304
+ violations: Array<{
2305
+ id: string;
2306
+ title: string;
2307
+ message: string;
2308
+ severity: "warning" | "error";
2309
+ timestamp: number;
2310
+ heuristicId: string;
2311
+ }>
2312
+ ): void {
2313
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
2314
+ if (!ral) {
2315
+ logger.warn("[RALRegistry] Cannot add heuristic violations - no RAL state", {
2316
+ agentPubkey: agentPubkey.substring(0, 8),
2317
+ conversationId: conversationId.substring(0, 8),
2318
+ ralNumber,
2319
+ });
2320
+ return;
2321
+ }
2322
+
2323
+ // Initialize heuristics state if needed
2324
+ if (!ral.heuristics) {
2325
+ ral.heuristics = {
2326
+ pendingViolations: [],
2327
+ shownViolationIds: new Set(),
2328
+ };
2329
+ }
2330
+
2331
+ // Filter out duplicates (already shown)
2332
+ const newViolations = violations.filter(
2333
+ (v) => !ral.heuristics!.shownViolationIds.has(v.id)
2334
+ );
2335
+
2336
+ if (newViolations.length === 0) {
2337
+ return; // All violations already shown
2338
+ }
2339
+
2340
+ // Add to pending queue
2341
+ ral.heuristics.pendingViolations.push(...newViolations);
2342
+ ral.lastActivityAt = Date.now();
2343
+
2344
+ trace.getActiveSpan()?.addEvent("ral.heuristic_violations_added", {
2345
+ "ral.number": ralNumber,
2346
+ "heuristic.violation_count": newViolations.length,
2347
+ "heuristic.pending_count": ral.heuristics.pendingViolations.length,
2348
+ });
2349
+ }
2350
+
2351
+ /**
2352
+ * Get and consume pending heuristic violations for injection.
2353
+ * Atomically reads and clears the pending queue, marks violations as shown.
2354
+ *
2355
+ * @returns Array of pending violations (empty if none)
2356
+ */
2357
+ getAndConsumeHeuristicViolations(
2358
+ agentPubkey: string,
2359
+ conversationId: string,
2360
+ ralNumber: number
2361
+ ): Array<{
2362
+ id: string;
2363
+ title: string;
2364
+ message: string;
2365
+ severity: "warning" | "error";
2366
+ timestamp: number;
2367
+ heuristicId: string;
2368
+ }> {
2369
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
2370
+ if (!ral || !ral.heuristics || ral.heuristics.pendingViolations.length === 0) {
2371
+ return [];
2372
+ }
2373
+
2374
+ // Atomic read+clear
2375
+ const violations = [...ral.heuristics.pendingViolations];
2376
+ ral.heuristics.pendingViolations = [];
2377
+
2378
+ // Mark as shown (for deduplication)
2379
+ for (const v of violations) {
2380
+ ral.heuristics.shownViolationIds.add(v.id);
2381
+ }
2382
+
2383
+ trace.getActiveSpan()?.addEvent("ral.heuristic_violations_consumed", {
2384
+ "ral.number": ralNumber,
2385
+ "heuristic.violation_count": violations.length,
2386
+ });
2387
+
2388
+ return violations;
2389
+ }
2390
+
2391
+ /**
2392
+ * Check if there are pending heuristic violations for a RAL.
2393
+ */
2394
+ hasPendingHeuristicViolations(
2395
+ agentPubkey: string,
2396
+ conversationId: string,
2397
+ ralNumber: number
2398
+ ): boolean {
2399
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
2400
+ return (ral?.heuristics?.pendingViolations?.length ?? 0) > 0;
2401
+ }
2402
+
2403
+ // ============================================================================
2404
+ // Heuristic Helper Methods (BLOCKER 1 & 2 Fixes)
2405
+ // ============================================================================
2406
+
2407
+ /**
2408
+ * Initialize heuristic state for a RAL entry (DRY helper).
2409
+ */
2410
+ private initializeHeuristicState(): NonNullable<RALRegistryEntry["heuristics"]> {
2411
+ return {
2412
+ pendingViolations: [],
2413
+ shownViolationIds: new Set(),
2414
+ summary: {
2415
+ recentTools: [],
2416
+ flags: {
2417
+ hasTodoWrite: false,
2418
+ hasDelegation: false,
2419
+ hasVerification: false,
2420
+ hasGitAgentCommit: false,
2421
+ },
2422
+ pendingDelegationCount: 0,
2423
+ },
2424
+ toolArgs: new Map(),
2425
+ };
2426
+ }
2427
+
2428
+ /**
2429
+ * Store tool arguments by toolCallId for later retrieval by heuristics.
2430
+ * BLOCKER 2 FIX: Enables passing real args to heuristics, not result.
2431
+ */
2432
+ storeToolArgs(
2433
+ agentPubkey: string,
2434
+ conversationId: string,
2435
+ ralNumber: number,
2436
+ toolCallId: string,
2437
+ args: unknown
2438
+ ): void {
2439
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
2440
+ if (!ral) return;
2441
+
2442
+ if (!ral.heuristics) {
2443
+ ral.heuristics = this.initializeHeuristicState();
2444
+ }
2445
+
2446
+ if (!ral.heuristics.toolArgs) {
2447
+ ral.heuristics.toolArgs = new Map();
2448
+ }
2449
+
2450
+ ral.heuristics.toolArgs.set(toolCallId, args);
2451
+ }
2452
+
2453
+ /**
2454
+ * Retrieve stored tool arguments by toolCallId.
2455
+ * BLOCKER 2 FIX: Returns real args stored at tool-will-execute.
2456
+ */
2457
+ getToolArgs(
2458
+ agentPubkey: string,
2459
+ conversationId: string,
2460
+ ralNumber: number,
2461
+ toolCallId: string
2462
+ ): unknown | undefined {
2463
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
2464
+ return ral?.heuristics?.toolArgs?.get(toolCallId);
2465
+ }
2466
+
2467
+ /**
2468
+ * Clear stored tool args for a specific toolCallId after evaluation.
2469
+ * Prevents memory leak by cleaning up after heuristic evaluation.
2470
+ */
2471
+ clearToolArgs(
2472
+ agentPubkey: string,
2473
+ conversationId: string,
2474
+ ralNumber: number,
2475
+ toolCallId: string
2476
+ ): void {
2477
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
2478
+ if (!ral?.heuristics?.toolArgs) return;
2479
+ ral.heuristics.toolArgs.delete(toolCallId);
2480
+ }
2481
+
2482
+ /**
2483
+ * Update O(1) precomputed summary for heuristic evaluation.
2484
+ * BLOCKER 1 FIX: Maintains O(1) context building with bounded history.
2485
+ *
2486
+ * @param maxRecentTools - Maximum recent tools to track (default: 10)
2487
+ */
2488
+ updateHeuristicSummary(
2489
+ agentPubkey: string,
2490
+ conversationId: string,
2491
+ ralNumber: number,
2492
+ toolName: string,
2493
+ toolArgs: unknown,
2494
+ maxRecentTools = 10
2495
+ ): void {
2496
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
2497
+ if (!ral) return;
2498
+
2499
+ if (!ral.heuristics) {
2500
+ ral.heuristics = this.initializeHeuristicState();
2501
+ }
2502
+
2503
+ if (!ral.heuristics.summary) {
2504
+ ral.heuristics.summary = {
2505
+ recentTools: [],
2506
+ flags: {
2507
+ hasTodoWrite: false,
2508
+ hasDelegation: false,
2509
+ hasVerification: false,
2510
+ hasGitAgentCommit: false,
2511
+ },
2512
+ pendingDelegationCount: 0,
2513
+ };
2514
+ }
2515
+
2516
+ const summary = ral.heuristics.summary;
2517
+
2518
+ // Add to recent tools (bounded)
2519
+ summary.recentTools.push({ name: toolName, timestamp: Date.now() });
2520
+ if (summary.recentTools.length > maxRecentTools) {
2521
+ summary.recentTools.shift(); // Remove oldest
2522
+ }
2523
+
2524
+ // Update flags based on tool name
2525
+ // Include all variants: todo_write (actual ToolName), TodoWrite (legacy), mcp__tenex__todo_write (MCP)
2526
+ if (toolName === "todo_write" || toolName === "TodoWrite" || toolName === "mcp__tenex__todo_write") {
2527
+ summary.flags.hasTodoWrite = true;
2528
+ }
2529
+
2530
+ if (toolName === "mcp__tenex__delegate" || toolName === "mcp__tenex__delegate_crossproject") {
2531
+ summary.flags.hasDelegation = true;
2532
+
2533
+ // Check if delegation to git-agent
2534
+ const args = toolArgs as { delegations?: Array<{ recipient?: string }> };
2535
+ if (args?.delegations?.some((d) => d.recipient === "git-agent")) {
2536
+ summary.flags.hasGitAgentCommit = true;
2537
+ }
2538
+ }
2539
+
2540
+ if (toolName === "Bash") {
2541
+ const args = toolArgs as { command?: string };
2542
+ const command = args?.command?.toLowerCase() || "";
2543
+
2544
+ // Check for verification commands
2545
+ if (
2546
+ command.includes("test") ||
2547
+ command.includes("build") ||
2548
+ command.includes("lint") ||
2549
+ command.includes("jest") ||
2550
+ command.includes("vitest")
2551
+ ) {
2552
+ summary.flags.hasVerification = true;
2553
+ }
2554
+ }
2555
+ }
2556
+
2557
+ /**
2558
+ * Increment the pending delegation counter for a RAL.
2559
+ * Used when a new delegation is added in mergePendingDelegations.
2560
+ */
2561
+ private incrementDelegationCounter(
2562
+ agentPubkey: string,
2563
+ conversationId: string,
2564
+ ralNumber: number
2565
+ ): void {
2566
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
2567
+ if (!ral) return;
2568
+
2569
+ if (!ral.heuristics) {
2570
+ ral.heuristics = this.initializeHeuristicState();
2571
+ }
2572
+
2573
+ if (!ral.heuristics.summary) {
2574
+ ral.heuristics.summary = {
2575
+ recentTools: [],
2576
+ flags: {
2577
+ hasTodoWrite: false,
2578
+ hasDelegation: false,
2579
+ hasVerification: false,
2580
+ hasGitAgentCommit: false,
2581
+ },
2582
+ pendingDelegationCount: 0,
2583
+ };
2584
+ }
2585
+
2586
+ ral.heuristics.summary.pendingDelegationCount++;
2587
+ }
2588
+
2589
+ /**
2590
+ * Decrement the pending delegation counter for a RAL.
2591
+ * Used when a delegation is completed.
2592
+ */
2593
+ private decrementDelegationCounter(
2594
+ agentPubkey: string,
2595
+ conversationId: string,
2596
+ ralNumber: number
2597
+ ): void {
2598
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
2599
+ if (!ral?.heuristics?.summary) return;
2600
+
2601
+ ral.heuristics.summary.pendingDelegationCount = Math.max(
2602
+ 0,
2603
+ ral.heuristics.summary.pendingDelegationCount - 1
2604
+ );
2605
+ }
2606
+
2607
+ /**
2608
+ * Get the O(1) precomputed summary for heuristic evaluation.
2609
+ * BLOCKER 1 FIX: Provides O(1) access to RAL state without scans.
2610
+ */
2611
+ getHeuristicSummary(
2612
+ agentPubkey: string,
2613
+ conversationId: string,
2614
+ ralNumber: number
2615
+ ):
2616
+ | {
2617
+ recentTools: Array<{ name: string; timestamp: number }>;
2618
+ flags: {
2619
+ hasTodoWrite: boolean;
2620
+ hasDelegation: boolean;
2621
+ hasVerification: boolean;
2622
+ hasGitAgentCommit: boolean;
2623
+ };
2624
+ pendingDelegationCount: number;
2625
+ }
2626
+ | undefined {
2627
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
2628
+ return ral?.heuristics?.summary;
2629
+ }
2630
+
2631
+ // ============================================================================
2632
+ // Outstanding Work Detection (Race Condition Prevention)
2633
+ // ============================================================================
2634
+
2635
+ /**
2636
+ * Check if there's any outstanding work for a conversation that would prevent finalization.
2637
+ *
2638
+ * This method consolidates checking for both:
2639
+ * 1. Queued injections (messages waiting to be processed in the next LLM step)
2640
+ * 2. Pending delegations (delegations that haven't completed yet)
2641
+ *
2642
+ * This is the key guard against the race condition where delegation results arrive
2643
+ * (via debounce) after the last prepareStep but before the executor finalizes.
2644
+ * By checking this before publishing status:completed, we ensure no work is orphaned.
2645
+ *
2646
+ * @param agentPubkey - The agent's pubkey
2647
+ * @param conversationId - The conversation ID
2648
+ * @param ralNumber - The RAL number to check
2649
+ * @returns Object indicating if there's outstanding work and details about it
2650
+ */
2651
+ hasOutstandingWork(
2652
+ agentPubkey: string,
2653
+ conversationId: string,
2654
+ ralNumber: number
2655
+ ): {
2656
+ hasWork: boolean;
2657
+ details: {
2658
+ queuedInjections: number;
2659
+ pendingDelegations: number;
2660
+ };
2661
+ } {
2662
+ const ral = this.getRAL(agentPubkey, conversationId, ralNumber);
2663
+
2664
+ // Count pending delegations from conversation storage (independent of RAL existence)
2665
+ // Pending delegations persist in conversationDelegations map which is separate from RAL state
2666
+ const pendingDelegations = this.getConversationPendingDelegations(
2667
+ agentPubkey,
2668
+ conversationId,
2669
+ ralNumber
2670
+ ).length;
2671
+
2672
+ // If RAL doesn't exist, we can't have queued injections but may still have pending delegations
2673
+ if (!ral) {
2674
+ const hasWork = pendingDelegations > 0;
2675
+ if (hasWork) {
2676
+ trace.getActiveSpan()?.addEvent("ral.outstanding_work_no_ral", {
2677
+ "ral.number": ralNumber,
2678
+ "outstanding.pending_delegations": pendingDelegations,
2679
+ "agent.pubkey": agentPubkey.substring(0, 12),
2680
+ "conversation.id": shortenConversationId(conversationId),
2681
+ });
2682
+ }
2683
+ return {
2684
+ hasWork,
2685
+ details: {
2686
+ queuedInjections: 0,
2687
+ pendingDelegations,
2688
+ },
2689
+ };
2690
+ }
2691
+
2692
+ // Count queued injections from the RAL entry
2693
+ const queuedInjections = ral.queuedInjections.length;
2694
+
2695
+ const hasWork = queuedInjections > 0 || pendingDelegations > 0;
2696
+
2697
+ // Add telemetry for debugging race conditions
2698
+ if (hasWork) {
2699
+ trace.getActiveSpan()?.addEvent("ral.outstanding_work_detected", {
2700
+ "ral.number": ralNumber,
2701
+ "outstanding.queued_injections": queuedInjections,
2702
+ "outstanding.pending_delegations": pendingDelegations,
2703
+ "agent.pubkey": agentPubkey.substring(0, 12),
2704
+ "conversation.id": shortenConversationId(conversationId),
2705
+ });
2706
+ }
2707
+
2708
+ return {
2709
+ hasWork,
2710
+ details: {
2711
+ queuedInjections,
2712
+ pendingDelegations,
2713
+ },
2714
+ };
2715
+ }
2716
+
2717
+ // ============================================================================
2718
+ // Graceful Restart Support
2719
+ // ============================================================================
2720
+
2721
+ /**
2722
+ * Get the total count of active RALs across all conversations.
2723
+ * Used by the daemon to determine when it's safe to perform a graceful restart.
2724
+ *
2725
+ * A RAL is considered "active" if it exists in the states map, meaning:
2726
+ * - An agent execution is in progress (streaming, tool execution, etc.)
2727
+ * - An agent is waiting on pending delegations
2728
+ *
2729
+ * @returns Total count of active RALs
2730
+ */
2731
+ getTotalActiveCount(): number {
2732
+ let totalCount = 0;
2733
+ for (const rals of this.states.values()) {
2734
+ totalCount += rals.size;
2735
+ }
2736
+ return totalCount;
2737
+ }
2738
+
2739
+ /**
2740
+ * Get all active RALs for a specific conversation (across all agents).
2741
+ * Used by the kill tool to find active agents in a conversation.
2742
+ *
2743
+ * @param conversationId - The conversation ID to search for
2744
+ * @returns Array of RAL entries with their agent pubkeys
2745
+ */
2746
+ getActiveRalsForConversation(conversationId: string): Array<{ agentPubkey: string; ralNumber: number }> {
2747
+ const results: Array<{ agentPubkey: string; ralNumber: number }> = [];
2748
+
2749
+ for (const [key, rals] of this.states) {
2750
+ // Key format: "agentPubkey:conversationId"
2751
+ const [agentPubkey, convId] = key.split(":");
2752
+ if (convId === conversationId) {
2753
+ for (const ralNumber of rals.keys()) {
2754
+ results.push({ agentPubkey, ralNumber });
2755
+ }
2756
+ }
2757
+ }
2758
+
2759
+ return results;
2760
+ }
2761
+
2762
+ /**
2763
+ * Get all active RAL entries for a specific project.
2764
+ * Used by the active-conversations prompt fragment to show concurrent activity.
2765
+ *
2766
+ * @param projectId - The project ID to filter by
2767
+ * @returns Array of active RAL entries for the project
2768
+ */
2769
+ getActiveEntriesForProject(projectId: string): RALRegistryEntry[] {
2770
+ const results: RALRegistryEntry[] = [];
2771
+
2772
+ for (const rals of this.states.values()) {
2773
+ for (const ral of rals.values()) {
2774
+ if (ral.projectId === projectId) {
2775
+ results.push(ral);
2776
+ }
2777
+ }
2778
+ }
2779
+
2780
+ return results;
2781
+ }
2782
+ }