@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.
- package/README.md +194 -0
- package/dist/backend-wrapper.cjs +3 -0
- package/dist/src/index.js +331928 -0
- package/package.json +103 -0
- package/src/agents/AgentRegistry.ts +418 -0
- package/src/agents/AgentStorage.ts +1133 -0
- package/src/agents/ConfigResolver.ts +229 -0
- package/src/agents/agent-installer.ts +236 -0
- package/src/agents/agent-loader.ts +241 -0
- package/src/agents/constants.ts +82 -0
- package/src/agents/errors.ts +48 -0
- package/src/agents/execution/AgentExecutor.ts +561 -0
- package/src/agents/execution/ExecutionContextFactory.ts +112 -0
- package/src/agents/execution/MessageCompiler.ts +597 -0
- package/src/agents/execution/MessageSyncer.ts +100 -0
- package/src/agents/execution/PostCompletionChecker.ts +278 -0
- package/src/agents/execution/ProgressMonitor.ts +50 -0
- package/src/agents/execution/RALResolver.ts +177 -0
- package/src/agents/execution/SessionManager.ts +181 -0
- package/src/agents/execution/StreamCallbacks.ts +312 -0
- package/src/agents/execution/StreamExecutionHandler.ts +579 -0
- package/src/agents/execution/StreamSetup.ts +313 -0
- package/src/agents/execution/ToolEventHandlers.ts +239 -0
- package/src/agents/execution/ToolExecutionTracker.ts +498 -0
- package/src/agents/execution/ToolResultUtils.ts +97 -0
- package/src/agents/execution/ToolSupervisionWrapper.ts +174 -0
- package/src/agents/execution/constants.ts +16 -0
- package/src/agents/execution/index.ts +3 -0
- package/src/agents/execution/types.ts +96 -0
- package/src/agents/execution/utils.ts +26 -0
- package/src/agents/index.ts +4 -0
- package/src/agents/script-installer.ts +266 -0
- package/src/agents/supervision/SupervisorLLMService.ts +253 -0
- package/src/agents/supervision/SupervisorOrchestrator.ts +471 -0
- package/src/agents/supervision/heuristics/ConsecutiveToolsWithoutTodoHeuristic.ts +73 -0
- package/src/agents/supervision/heuristics/DelegationClaimHeuristic.ts +80 -0
- package/src/agents/supervision/heuristics/HeuristicRegistry.ts +114 -0
- package/src/agents/supervision/heuristics/PendingTodosHeuristic.ts +93 -0
- package/src/agents/supervision/heuristics/SilentAgentHeuristic.ts +54 -0
- package/src/agents/supervision/heuristics/index.ts +5 -0
- package/src/agents/supervision/index.ts +28 -0
- package/src/agents/supervision/registerHeuristics.ts +110 -0
- package/src/agents/supervision/supervisionHealthCheck.ts +123 -0
- package/src/agents/supervision/types.ts +171 -0
- package/src/agents/tool-names.ts +46 -0
- package/src/agents/tool-normalization.ts +184 -0
- package/src/agents/types/index.ts +2 -0
- package/src/agents/types/runtime.ts +74 -0
- package/src/agents/types/storage.ts +145 -0
- package/src/commands/agent/import/index.ts +6 -0
- package/src/commands/agent/import/openclaw-distiller.ts +57 -0
- package/src/commands/agent/import/openclaw-reader.ts +141 -0
- package/src/commands/agent/import/openclaw.ts +154 -0
- package/src/commands/agent/index.ts +6 -0
- package/src/commands/agent.ts +215 -0
- package/src/commands/daemon.ts +198 -0
- package/src/commands/doctor.ts +134 -0
- package/src/commands/setup/embed.ts +228 -0
- package/src/commands/setup/global-system-prompt.ts +223 -0
- package/src/commands/setup/image.ts +179 -0
- package/src/commands/setup/index.ts +16 -0
- package/src/commands/setup/interactive.ts +95 -0
- package/src/commands/setup/llm.ts +38 -0
- package/src/commands/setup/onboarding.ts +294 -0
- package/src/commands/setup/providers.ts +27 -0
- package/src/constants.ts +34 -0
- package/src/conversations/ConversationDiskReader.ts +148 -0
- package/src/conversations/ConversationRegistry.ts +728 -0
- package/src/conversations/ConversationStore.ts +868 -0
- package/src/conversations/MessageBuilder.ts +866 -0
- package/src/conversations/executionTime.ts +62 -0
- package/src/conversations/formatters/DelegationXmlFormatter.ts +64 -0
- package/src/conversations/formatters/ThreadedConversationFormatter.ts +303 -0
- package/src/conversations/formatters/index.ts +9 -0
- package/src/conversations/formatters/utils/MessageFormatter.ts +46 -0
- package/src/conversations/formatters/utils/TimestampFormatter.ts +56 -0
- package/src/conversations/formatters/utils/TreeBuilder.ts +131 -0
- package/src/conversations/formatters/utils/TreeRenderer.ts +49 -0
- package/src/conversations/index.ts +2 -0
- package/src/conversations/persistence/ToolMessageStorage.ts +143 -0
- package/src/conversations/search/ConversationIndexManager.ts +393 -0
- package/src/conversations/search/QueryParser.ts +114 -0
- package/src/conversations/search/SearchEngine.ts +175 -0
- package/src/conversations/search/SnippetExtractor.ts +345 -0
- package/src/conversations/search/embeddings/ConversationEmbeddingService.ts +484 -0
- package/src/conversations/search/embeddings/ConversationIndexingJob.ts +320 -0
- package/src/conversations/search/embeddings/IndexingStateManager.ts +338 -0
- package/src/conversations/search/embeddings/index.ts +18 -0
- package/src/conversations/search/index.ts +49 -0
- package/src/conversations/search/types.ts +124 -0
- package/src/conversations/services/CategoryManager.ts +160 -0
- package/src/conversations/services/ConversationResolver.ts +296 -0
- package/src/conversations/services/ConversationSummarizer.ts +234 -0
- package/src/conversations/services/MetadataDebounceManager.ts +188 -0
- package/src/conversations/services/index.ts +2 -0
- package/src/conversations/types.ts +148 -0
- package/src/conversations/utils/content-utils.ts +69 -0
- package/src/conversations/utils/image-placeholder.ts +281 -0
- package/src/conversations/utils/image-url-utils.ts +171 -0
- package/src/conversations/utils/multimodal-content.ts +90 -0
- package/src/conversations/utils/tool-result-truncator.ts +159 -0
- package/src/daemon/Daemon.ts +1883 -0
- package/src/daemon/ProjectRuntime.ts +657 -0
- package/src/daemon/RestartState.ts +152 -0
- package/src/daemon/RuntimeLifecycle.ts +268 -0
- package/src/daemon/SubscriptionManager.ts +305 -0
- package/src/daemon/UnixSocketTransport.ts +318 -0
- package/src/daemon/filters/SubscriptionFilterBuilder.ts +119 -0
- package/src/daemon/index.ts +9 -0
- package/src/daemon/routing/DaemonRouter.ts +491 -0
- package/src/daemon/types.ts +150 -0
- package/src/daemon/utils/routing-log.ts +76 -0
- package/src/daemon/utils/telemetry.ts +173 -0
- package/src/event-handler/agentDeletion.ts +383 -0
- package/src/event-handler/index.ts +749 -0
- package/src/event-handler/newConversation.ts +165 -0
- package/src/event-handler/project.ts +166 -0
- package/src/event-handler/reply.ts +18 -0
- package/src/events/NDKAgentDefinition.ts +292 -0
- package/src/events/NDKAgentLesson.ts +106 -0
- package/src/events/NDKEventMetadata.ts +34 -0
- package/src/events/NDKMCPTool.ts +60 -0
- package/src/events/NDKProjectStatus.ts +384 -0
- package/src/events/index.ts +4 -0
- package/src/index.ts +126 -0
- package/src/lib/agent-home.ts +334 -0
- package/src/lib/error-formatter.ts +200 -0
- package/src/lib/fs/filesystem.ts +128 -0
- package/src/lib/fs/index.ts +1 -0
- package/src/lib/json-parser.ts +30 -0
- package/src/lib/string.ts +15 -0
- package/src/lib/time.ts +74 -0
- package/src/llm/ChunkHandler.ts +277 -0
- package/src/llm/FinishHandler.ts +250 -0
- package/src/llm/LLMConfigEditor.ts +154 -0
- package/src/llm/LLMServiceFactory.ts +230 -0
- package/src/llm/MessageProcessor.ts +90 -0
- package/src/llm/RecordingState.ts +37 -0
- package/src/llm/StreamPublisher.ts +40 -0
- package/src/llm/TracingUtils.ts +77 -0
- package/src/llm/chunk-validators.ts +57 -0
- package/src/llm/constants.ts +6 -0
- package/src/llm/index.ts +12 -0
- package/src/llm/meta/MetaModelResolver.ts +352 -0
- package/src/llm/meta/index.ts +11 -0
- package/src/llm/middleware/flight-recorder.ts +188 -0
- package/src/llm/providers/MockProvider.ts +332 -0
- package/src/llm/providers/agent/ClaudeCodeProvider.ts +343 -0
- package/src/llm/providers/agent/ClaudeCodeToolsAdapter.ts +203 -0
- package/src/llm/providers/agent/CodexAppServerProvider.ts +214 -0
- package/src/llm/providers/agent/CodexAppServerToolsAdapter.ts +91 -0
- package/src/llm/providers/agent/index.ts +10 -0
- package/src/llm/providers/base/AgentProvider.ts +107 -0
- package/src/llm/providers/base/BaseProvider.ts +114 -0
- package/src/llm/providers/base/StandardProvider.ts +38 -0
- package/src/llm/providers/base/index.ts +9 -0
- package/src/llm/providers/index.ts +106 -0
- package/src/llm/providers/key-manager.ts +238 -0
- package/src/llm/providers/ollama-models.ts +105 -0
- package/src/llm/providers/openrouter-models.ts +102 -0
- package/src/llm/providers/provider-ids.ts +18 -0
- package/src/llm/providers/registry/ProviderRegistry.ts +414 -0
- package/src/llm/providers/registry/index.ts +7 -0
- package/src/llm/providers/standard/AnthropicProvider.ts +71 -0
- package/src/llm/providers/standard/OllamaProvider.ts +59 -0
- package/src/llm/providers/standard/OpenAIProvider.ts +44 -0
- package/src/llm/providers/standard/OpenRouterProvider.ts +103 -0
- package/src/llm/providers/standard/index.ts +10 -0
- package/src/llm/providers/types.ts +194 -0
- package/src/llm/providers/usage-metadata.ts +78 -0
- package/src/llm/service.ts +713 -0
- package/src/llm/types.ts +167 -0
- package/src/llm/utils/ConfigurationManager.ts +650 -0
- package/src/llm/utils/ConfigurationTester.ts +229 -0
- package/src/llm/utils/ModelSelector.ts +212 -0
- package/src/llm/utils/ProviderConfigUI.ts +177 -0
- package/src/llm/utils/claudeCodePromptCompiler.ts +141 -0
- package/src/llm/utils/codex-models.ts +53 -0
- package/src/llm/utils/context-window-cache.ts +30 -0
- package/src/llm/utils/models-dev-cache.ts +267 -0
- package/src/llm/utils/provider-setup.ts +50 -0
- package/src/llm/utils/tool-errors.ts +78 -0
- package/src/llm/utils/usage.ts +74 -0
- package/src/logging/EventRoutingLogger.ts +205 -0
- package/src/nostr/AgentEventDecoder.ts +357 -0
- package/src/nostr/AgentEventEncoder.ts +677 -0
- package/src/nostr/AgentProfilePublisher.ts +657 -0
- package/src/nostr/AgentPublisher.ts +437 -0
- package/src/nostr/BlossomService.ts +226 -0
- package/src/nostr/InterventionPublisher.ts +132 -0
- package/src/nostr/TagExtractor.ts +228 -0
- package/src/nostr/collectEvents.ts +83 -0
- package/src/nostr/constants.ts +38 -0
- package/src/nostr/encryption.ts +26 -0
- package/src/nostr/index.ts +31 -0
- package/src/nostr/keys.ts +17 -0
- package/src/nostr/kinds.ts +37 -0
- package/src/nostr/ndkClient.ts +72 -0
- package/src/nostr/relays.ts +43 -0
- package/src/nostr/trace-context.ts +39 -0
- package/src/nostr/types.ts +227 -0
- package/src/nostr/utils.ts +84 -0
- package/src/prompts/core/FragmentRegistry.ts +30 -0
- package/src/prompts/core/PromptBuilder.ts +98 -0
- package/src/prompts/core/index.ts +3 -0
- package/src/prompts/core/types.ts +13 -0
- package/src/prompts/fragments/00-global-system-prompt.ts +44 -0
- package/src/prompts/fragments/01-agent-identity.ts +69 -0
- package/src/prompts/fragments/02-agent-home-directory.ts +114 -0
- package/src/prompts/fragments/03-system-reminders-explanation.ts +14 -0
- package/src/prompts/fragments/04-relay-configuration.ts +38 -0
- package/src/prompts/fragments/05-delegation-chain.ts +45 -0
- package/src/prompts/fragments/06-agent-todos.ts +74 -0
- package/src/prompts/fragments/06-todo-usage-guidance.ts +34 -0
- package/src/prompts/fragments/07-meta-project-context.ts +234 -0
- package/src/prompts/fragments/08-active-conversations.ts +382 -0
- package/src/prompts/fragments/09-recent-conversations.ts +153 -0
- package/src/prompts/fragments/10-referenced-article.ts +21 -0
- package/src/prompts/fragments/11-nudges.ts +134 -0
- package/src/prompts/fragments/12-skills.ts +127 -0
- package/src/prompts/fragments/13-available-nudges.ts +122 -0
- package/src/prompts/fragments/15-available-agents.ts +53 -0
- package/src/prompts/fragments/16-stay-in-your-lane.ts +41 -0
- package/src/prompts/fragments/17-todo-before-delegation.ts +39 -0
- package/src/prompts/fragments/20-voice-mode.ts +62 -0
- package/src/prompts/fragments/22-scheduled-tasks.ts +175 -0
- package/src/prompts/fragments/24-retrieved-lessons.ts +26 -0
- package/src/prompts/fragments/25-rag-instructions.ts +333 -0
- package/src/prompts/fragments/26-mcp-resources.ts +237 -0
- package/src/prompts/fragments/27-memorized-reports.ts +77 -0
- package/src/prompts/fragments/28-agent-directed-monitoring.ts +32 -0
- package/src/prompts/fragments/29-rag-collections.ts +50 -0
- package/src/prompts/fragments/30-worktree-context.ts +98 -0
- package/src/prompts/fragments/31-agents-md-guidance.ts +96 -0
- package/src/prompts/fragments/32-process-metrics.ts +72 -0
- package/src/prompts/fragments/debug-mode.ts +48 -0
- package/src/prompts/fragments/delegation-completion.ts +44 -0
- package/src/prompts/fragments/index.ts +91 -0
- package/src/prompts/index.ts +21 -0
- package/src/prompts/utils/systemPromptBuilder.ts +777 -0
- package/src/scripts/migrate-prefix-index.ts +157 -0
- package/src/services/AgentDefinitionMonitor.ts +701 -0
- package/src/services/ConfigService.ts +723 -0
- package/src/services/CooldownRegistry.ts +199 -0
- package/src/services/LLMOperationsRegistry.ts +424 -0
- package/src/services/OwnerAgentListService.ts +354 -0
- package/src/services/PubkeyService.ts +308 -0
- package/src/services/agents/AgentMetadataStore.ts +72 -0
- package/src/services/agents/AgentResolution.ts +59 -0
- package/src/services/agents/EscalationService.ts +281 -0
- package/src/services/agents/NDKAgentDiscovery.ts +95 -0
- package/src/services/agents/index.ts +7 -0
- package/src/services/agents-md/AgentsMdService.ts +184 -0
- package/src/services/agents-md/SystemReminderInjector.ts +238 -0
- package/src/services/agents-md/index.ts +11 -0
- package/src/services/apns/APNsClient.ts +203 -0
- package/src/services/apns/APNsService.ts +358 -0
- package/src/services/apns/index.ts +11 -0
- package/src/services/apns/types.ts +80 -0
- package/src/services/compression/CompressionService.ts +445 -0
- package/src/services/compression/compression-schema.ts +28 -0
- package/src/services/compression/compression-types.ts +74 -0
- package/src/services/compression/compression-utils.ts +587 -0
- package/src/services/config/types.ts +394 -0
- package/src/services/dispatch/AgentDispatchService.ts +937 -0
- package/src/services/dispatch/AgentRouter.ts +181 -0
- package/src/services/dispatch/DelegationCompletionHandler.ts +232 -0
- package/src/services/embedding/EmbeddingProvider.ts +188 -0
- package/src/services/embedding/index.ts +5 -0
- package/src/services/event-context/EventContextService.ts +108 -0
- package/src/services/event-context/index.ts +2 -0
- package/src/services/heuristics/ContextBuilder.ts +106 -0
- package/src/services/heuristics/HeuristicEngine.ts +200 -0
- package/src/services/heuristics/formatters.ts +58 -0
- package/src/services/heuristics/index.ts +12 -0
- package/src/services/heuristics/rules/index.ts +25 -0
- package/src/services/heuristics/rules/todoBeforeDelegation.ts +69 -0
- package/src/services/heuristics/rules/todoReminderOnToolUse.ts +63 -0
- package/src/services/heuristics/types.ts +144 -0
- package/src/services/image/ImageGenerationService.ts +389 -0
- package/src/services/image/index.ts +12 -0
- package/src/services/intervention/InterventionService.ts +1352 -0
- package/src/services/intervention/index.ts +7 -0
- package/src/services/mcp/MCPManager.ts +683 -0
- package/src/services/mcp/McpNotificationDelivery.ts +139 -0
- package/src/services/mcp/McpSubscriptionService.ts +653 -0
- package/src/services/mcp/mcpInstaller.ts +130 -0
- package/src/services/nip46/Nip46SigningLog.ts +81 -0
- package/src/services/nip46/Nip46SigningService.ts +467 -0
- package/src/services/nip46/index.ts +4 -0
- package/src/services/nudge/NudgeService.ts +224 -0
- package/src/services/nudge/NudgeWhitelistService.ts +382 -0
- package/src/services/nudge/index.ts +5 -0
- package/src/services/nudge/types.ts +83 -0
- package/src/services/projects/ProjectContext.ts +672 -0
- package/src/services/projects/ProjectContextStore.ts +102 -0
- package/src/services/projects/index.ts +6 -0
- package/src/services/prompt-compiler/index.ts +15 -0
- package/src/services/prompt-compiler/prompt-compiler-service.ts +1143 -0
- package/src/services/pubkey-gate/PubkeyGateService.ts +93 -0
- package/src/services/pubkey-gate/index.ts +1 -0
- package/src/services/rag/EmbeddingProviderFactory.ts +292 -0
- package/src/services/rag/LanceDBMaintenanceService.ts +211 -0
- package/src/services/rag/RAGDatabaseService.ts +173 -0
- package/src/services/rag/RAGOperations.ts +682 -0
- package/src/services/rag/RAGService.ts +240 -0
- package/src/services/rag/RagSubscriptionService.ts +618 -0
- package/src/services/rag/rag-utils.ts +174 -0
- package/src/services/ral/PendingDelegationsRegistry.ts +168 -0
- package/src/services/ral/RALRegistry.ts +2782 -0
- package/src/services/ral/index.ts +4 -0
- package/src/services/ral/types.ts +292 -0
- package/src/services/reports/LocalReportStore.ts +380 -0
- package/src/services/reports/ReportEmbeddingService.ts +430 -0
- package/src/services/reports/ReportService.ts +440 -0
- package/src/services/reports/articleUtils.ts +52 -0
- package/src/services/reports/index.ts +7 -0
- package/src/services/scheduling/SchedulerService.ts +1057 -0
- package/src/services/scheduling/errors.ts +14 -0
- package/src/services/scheduling/index.ts +7 -0
- package/src/services/scheduling/utils.ts +77 -0
- package/src/services/search/SearchProviderRegistry.ts +78 -0
- package/src/services/search/UnifiedSearchService.ts +218 -0
- package/src/services/search/index.ts +47 -0
- package/src/services/search/projectFilter.ts +22 -0
- package/src/services/search/providers/ConversationSearchProvider.ts +48 -0
- package/src/services/search/providers/LessonSearchProvider.ts +75 -0
- package/src/services/search/providers/ReportSearchProvider.ts +49 -0
- package/src/services/search/types.ts +144 -0
- package/src/services/skill/SkillService.ts +482 -0
- package/src/services/skill/index.ts +2 -0
- package/src/services/skill/types.ts +70 -0
- package/src/services/status/OperationsStatusService.ts +276 -0
- package/src/services/status/ProjectStatusService.ts +522 -0
- package/src/services/status/index.ts +11 -0
- package/src/services/storage/PrefixKVStore.ts +242 -0
- package/src/services/storage/index.ts +1 -0
- package/src/services/system-reminder/SystemReminderUtils.ts +96 -0
- package/src/services/system-reminder/index.ts +7 -0
- package/src/services/trust-pubkeys/TrustPubkeyService.ts +325 -0
- package/src/services/trust-pubkeys/index.ts +2 -0
- package/src/telemetry/ConversationSpanManager.ts +111 -0
- package/src/telemetry/EventLoopMonitor.ts +206 -0
- package/src/telemetry/LLMSpanRegistry.ts +20 -0
- package/src/telemetry/NostrSpanProcessor.ts +89 -0
- package/src/telemetry/ToolCallSpanProcessor.ts +66 -0
- package/src/telemetry/diagnostics.ts +27 -0
- package/src/telemetry/setup.ts +120 -0
- package/src/tools/implementations/agents_discover.ts +121 -0
- package/src/tools/implementations/agents_hire.ts +127 -0
- package/src/tools/implementations/agents_list.ts +96 -0
- package/src/tools/implementations/agents_publish.ts +611 -0
- package/src/tools/implementations/agents_read.ts +173 -0
- package/src/tools/implementations/agents_write.ts +200 -0
- package/src/tools/implementations/ask.ts +411 -0
- package/src/tools/implementations/change_model.ts +141 -0
- package/src/tools/implementations/conversation_get.ts +661 -0
- package/src/tools/implementations/conversation_list.ts +377 -0
- package/src/tools/implementations/conversation_search.ts +370 -0
- package/src/tools/implementations/delegate.ts +327 -0
- package/src/tools/implementations/delegate_crossproject.ts +209 -0
- package/src/tools/implementations/delegate_followup.ts +300 -0
- package/src/tools/implementations/fs_edit.ts +162 -0
- package/src/tools/implementations/fs_glob.ts +182 -0
- package/src/tools/implementations/fs_grep.ts +513 -0
- package/src/tools/implementations/fs_read.ts +332 -0
- package/src/tools/implementations/fs_write.ts +113 -0
- package/src/tools/implementations/generate_image.ts +259 -0
- package/src/tools/implementations/home_fs.ts +515 -0
- package/src/tools/implementations/kill.ts +651 -0
- package/src/tools/implementations/learn.ts +166 -0
- package/src/tools/implementations/lesson-formatter.ts +38 -0
- package/src/tools/implementations/lesson_delete.ts +164 -0
- package/src/tools/implementations/lesson_get.ts +105 -0
- package/src/tools/implementations/lessons_list.ts +153 -0
- package/src/tools/implementations/mcp_resource_read.ts +161 -0
- package/src/tools/implementations/mcp_subscribe.ts +158 -0
- package/src/tools/implementations/mcp_subscription_stop.ts +85 -0
- package/src/tools/implementations/nostr_fetch.ts +149 -0
- package/src/tools/implementations/nostr_publish_as_user.ts +353 -0
- package/src/tools/implementations/project_list.ts +146 -0
- package/src/tools/implementations/rag_add_documents.ts +573 -0
- package/src/tools/implementations/rag_create_collection.ts +65 -0
- package/src/tools/implementations/rag_delete_collection.ts +68 -0
- package/src/tools/implementations/rag_list_collections.ts +77 -0
- package/src/tools/implementations/rag_query.ts +107 -0
- package/src/tools/implementations/rag_subscription_create.ts +105 -0
- package/src/tools/implementations/rag_subscription_delete.ts +80 -0
- package/src/tools/implementations/rag_subscription_get.ts +123 -0
- package/src/tools/implementations/rag_subscription_list.ts +128 -0
- package/src/tools/implementations/report_delete.ts +79 -0
- package/src/tools/implementations/report_read.ts +160 -0
- package/src/tools/implementations/report_write.ts +278 -0
- package/src/tools/implementations/reports_list.ts +77 -0
- package/src/tools/implementations/schedule_task.ts +104 -0
- package/src/tools/implementations/schedule_task_cancel.ts +62 -0
- package/src/tools/implementations/schedule_task_once.ts +128 -0
- package/src/tools/implementations/schedule_tasks_list.ts +79 -0
- package/src/tools/implementations/search.ts +160 -0
- package/src/tools/implementations/shell.ts +553 -0
- package/src/tools/implementations/todo.ts +260 -0
- package/src/tools/implementations/upload_blob.ts +381 -0
- package/src/tools/implementations/web_fetch.ts +153 -0
- package/src/tools/implementations/web_search.ts +250 -0
- package/src/tools/registry.ts +670 -0
- package/src/tools/types.ts +177 -0
- package/src/tools/utils.ts +256 -0
- package/src/types/event-ids.ts +320 -0
- package/src/types/index.ts +46 -0
- package/src/utils/agentFetcher.ts +107 -0
- package/src/utils/cli-error.ts +29 -0
- package/src/utils/conversation-id.ts +27 -0
- package/src/utils/conversation-utils.ts +1 -0
- package/src/utils/delegation-chain.ts +357 -0
- package/src/utils/error-handler.ts +42 -0
- package/src/utils/git/gitignore.ts +69 -0
- package/src/utils/git/index.ts +2 -0
- package/src/utils/git/initializeGitRepo.ts +204 -0
- package/src/utils/git/worktree.ts +260 -0
- package/src/utils/lessonFormatter.ts +70 -0
- package/src/utils/lessonTrust.ts +24 -0
- package/src/utils/lockfile.ts +123 -0
- package/src/utils/logger.ts +149 -0
- package/src/utils/nostr-entity-parser.ts +365 -0
- package/src/utils/process.ts +49 -0
- package/src/wrapper.ts +262 -0
- 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
|
+
}
|