@xenosystem/agent-interface-host 0.1.3
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/LICENSE +67 -0
- package/dist/acp/AcpAgentRegistryService.d.ts +77 -0
- package/dist/acp/AcpAgentRegistryService.d.ts.map +1 -0
- package/dist/acp/AcpAgentRegistryService.js +754 -0
- package/dist/acp/AcpAgentRegistryService.js.map +1 -0
- package/dist/acp/AcpAgentTurnExecutionAdapter.d.ts +56 -0
- package/dist/acp/AcpAgentTurnExecutionAdapter.d.ts.map +1 -0
- package/dist/acp/AcpAgentTurnExecutionAdapter.js +323 -0
- package/dist/acp/AcpAgentTurnExecutionAdapter.js.map +1 -0
- package/dist/acp/AcpApprovalPortAdapter.d.ts +41 -0
- package/dist/acp/AcpApprovalPortAdapter.d.ts.map +1 -0
- package/dist/acp/AcpApprovalPortAdapter.js +82 -0
- package/dist/acp/AcpApprovalPortAdapter.js.map +1 -0
- package/dist/acp/AcpCoordinationBridge.d.ts +13 -0
- package/dist/acp/AcpCoordinationBridge.d.ts.map +1 -0
- package/dist/acp/AcpCoordinationBridge.js +13 -0
- package/dist/acp/AcpCoordinationBridge.js.map +1 -0
- package/dist/acp/AcpDiagnosticsService.d.ts +61 -0
- package/dist/acp/AcpDiagnosticsService.d.ts.map +1 -0
- package/dist/acp/AcpDiagnosticsService.js +203 -0
- package/dist/acp/AcpDiagnosticsService.js.map +1 -0
- package/dist/acp/AcpExternalCertificationHarness.d.ts +47 -0
- package/dist/acp/AcpExternalCertificationHarness.d.ts.map +1 -0
- package/dist/acp/AcpExternalCertificationHarness.js +176 -0
- package/dist/acp/AcpExternalCertificationHarness.js.map +1 -0
- package/dist/acp/AcpHostBridge.d.ts +81 -0
- package/dist/acp/AcpHostBridge.d.ts.map +1 -0
- package/dist/acp/AcpHostBridge.js +101 -0
- package/dist/acp/AcpHostBridge.js.map +1 -0
- package/dist/acp/AcpInteractivePermissionBridge.d.ts +77 -0
- package/dist/acp/AcpInteractivePermissionBridge.d.ts.map +1 -0
- package/dist/acp/AcpInteractivePermissionBridge.js +127 -0
- package/dist/acp/AcpInteractivePermissionBridge.js.map +1 -0
- package/dist/acp/AcpPermissionBridge.d.ts +19 -0
- package/dist/acp/AcpPermissionBridge.d.ts.map +1 -0
- package/dist/acp/AcpPermissionBridge.js +110 -0
- package/dist/acp/AcpPermissionBridge.js.map +1 -0
- package/dist/acp/AcpPermissionPolicy.d.ts +111 -0
- package/dist/acp/AcpPermissionPolicy.d.ts.map +1 -0
- package/dist/acp/AcpPermissionPolicy.js +167 -0
- package/dist/acp/AcpPermissionPolicy.js.map +1 -0
- package/dist/acp/AcpProcessSupervisor.d.ts +134 -0
- package/dist/acp/AcpProcessSupervisor.d.ts.map +1 -0
- package/dist/acp/AcpProcessSupervisor.js +674 -0
- package/dist/acp/AcpProcessSupervisor.js.map +1 -0
- package/dist/acp/AcpProviderApprovalService.d.ts +27 -0
- package/dist/acp/AcpProviderApprovalService.d.ts.map +1 -0
- package/dist/acp/AcpProviderApprovalService.js +128 -0
- package/dist/acp/AcpProviderApprovalService.js.map +1 -0
- package/dist/acp/AcpProviderCatalogService.d.ts +69 -0
- package/dist/acp/AcpProviderCatalogService.d.ts.map +1 -0
- package/dist/acp/AcpProviderCatalogService.js +707 -0
- package/dist/acp/AcpProviderCatalogService.js.map +1 -0
- package/dist/acp/AcpProviderModelService.d.ts +6 -0
- package/dist/acp/AcpProviderModelService.d.ts.map +1 -0
- package/dist/acp/AcpProviderModelService.js +274 -0
- package/dist/acp/AcpProviderModelService.js.map +1 -0
- package/dist/acp/AcpProviderVersionReadiness.d.ts +6 -0
- package/dist/acp/AcpProviderVersionReadiness.d.ts.map +1 -0
- package/dist/acp/AcpProviderVersionReadiness.js +100 -0
- package/dist/acp/AcpProviderVersionReadiness.js.map +1 -0
- package/dist/acp/AcpRuntimeEventMapper.d.ts +16 -0
- package/dist/acp/AcpRuntimeEventMapper.d.ts.map +1 -0
- package/dist/acp/AcpRuntimeEventMapper.js +326 -0
- package/dist/acp/AcpRuntimeEventMapper.js.map +1 -0
- package/dist/acp/AcpSessionService.d.ts +65 -0
- package/dist/acp/AcpSessionService.d.ts.map +1 -0
- package/dist/acp/AcpSessionService.js +931 -0
- package/dist/acp/AcpSessionService.js.map +1 -0
- package/dist/acp/DurableAcpPermissionPolicyStore.d.ts +25 -0
- package/dist/acp/DurableAcpPermissionPolicyStore.d.ts.map +1 -0
- package/dist/acp/DurableAcpPermissionPolicyStore.js +100 -0
- package/dist/acp/DurableAcpPermissionPolicyStore.js.map +1 -0
- package/dist/acp/acpCommandSafety.d.ts +63 -0
- package/dist/acp/acpCommandSafety.d.ts.map +1 -0
- package/dist/acp/acpCommandSafety.js +90 -0
- package/dist/acp/acpCommandSafety.js.map +1 -0
- package/dist/acp/acpErrors.d.ts +47 -0
- package/dist/acp/acpErrors.d.ts.map +1 -0
- package/dist/acp/acpErrors.js +93 -0
- package/dist/acp/acpErrors.js.map +1 -0
- package/dist/acp/acpRedaction.d.ts +9 -0
- package/dist/acp/acpRedaction.d.ts.map +1 -0
- package/dist/acp/acpRedaction.js +69 -0
- package/dist/acp/acpRedaction.js.map +1 -0
- package/dist/acp/acpSessionResume.d.ts +8 -0
- package/dist/acp/acpSessionResume.d.ts.map +1 -0
- package/dist/acp/acpSessionResume.js +31 -0
- package/dist/acp/acpSessionResume.js.map +1 -0
- package/dist/acp/acpTypes.d.ts +467 -0
- package/dist/acp/acpTypes.d.ts.map +1 -0
- package/dist/acp/acpTypes.js +2 -0
- package/dist/acp/acpTypes.js.map +1 -0
- package/dist/agent/chooseDirectoryBridge.d.ts +29 -0
- package/dist/agent/chooseDirectoryBridge.d.ts.map +1 -0
- package/dist/agent/chooseDirectoryBridge.js +116 -0
- package/dist/agent/chooseDirectoryBridge.js.map +1 -0
- package/dist/agent/permissionProfiles.d.ts +29 -0
- package/dist/agent/permissionProfiles.d.ts.map +1 -0
- package/dist/agent/permissionProfiles.js +107 -0
- package/dist/agent/permissionProfiles.js.map +1 -0
- package/dist/agent/runtimeAuthorityPrompt.d.ts +13 -0
- package/dist/agent/runtimeAuthorityPrompt.d.ts.map +1 -0
- package/dist/agent/runtimeAuthorityPrompt.js +45 -0
- package/dist/agent/runtimeAuthorityPrompt.js.map +1 -0
- package/dist/agent/runtimeModeOutputGuard.d.ts +6 -0
- package/dist/agent/runtimeModeOutputGuard.d.ts.map +1 -0
- package/dist/agent/runtimeModeOutputGuard.js +40 -0
- package/dist/agent/runtimeModeOutputGuard.js.map +1 -0
- package/dist/agent/workspacePlacementBridge.d.ts +29 -0
- package/dist/agent/workspacePlacementBridge.d.ts.map +1 -0
- package/dist/agent/workspacePlacementBridge.js +143 -0
- package/dist/agent/workspacePlacementBridge.js.map +1 -0
- package/dist/agentSearch/AgentSearchService.d.ts +81 -0
- package/dist/agentSearch/AgentSearchService.d.ts.map +1 -0
- package/dist/agentSearch/AgentSearchService.js +1038 -0
- package/dist/agentSearch/AgentSearchService.js.map +1 -0
- package/dist/agentSearch/JsonInvertedIndexAdapter.d.ts +44 -0
- package/dist/agentSearch/JsonInvertedIndexAdapter.d.ts.map +1 -0
- package/dist/agentSearch/JsonInvertedIndexAdapter.js +307 -0
- package/dist/agentSearch/JsonInvertedIndexAdapter.js.map +1 -0
- package/dist/agentSearch/SearchIndexAdapter.d.ts +33 -0
- package/dist/agentSearch/SearchIndexAdapter.d.ts.map +1 -0
- package/dist/agentSearch/SearchIndexAdapter.js +2 -0
- package/dist/agentSearch/SearchIndexAdapter.js.map +1 -0
- package/dist/agentSearch/SearchSourceWatcher.d.ts +29 -0
- package/dist/agentSearch/SearchSourceWatcher.d.ts.map +1 -0
- package/dist/agentSearch/SearchSourceWatcher.js +150 -0
- package/dist/agentSearch/SearchSourceWatcher.js.map +1 -0
- package/dist/agentSearch/SqliteFtsIndexAdapter.d.ts +58 -0
- package/dist/agentSearch/SqliteFtsIndexAdapter.d.ts.map +1 -0
- package/dist/agentSearch/SqliteFtsIndexAdapter.js +420 -0
- package/dist/agentSearch/SqliteFtsIndexAdapter.js.map +1 -0
- package/dist/agentSearch/conversationIndexer.d.ts +69 -0
- package/dist/agentSearch/conversationIndexer.d.ts.map +1 -0
- package/dist/agentSearch/conversationIndexer.js +313 -0
- package/dist/agentSearch/conversationIndexer.js.map +1 -0
- package/dist/agentSearch/searchTypes.d.ts +264 -0
- package/dist/agentSearch/searchTypes.d.ts.map +1 -0
- package/dist/agentSearch/searchTypes.js +2 -0
- package/dist/agentSearch/searchTypes.js.map +1 -0
- package/dist/agentSearch/searchUtils.d.ts +12 -0
- package/dist/agentSearch/searchUtils.d.ts.map +1 -0
- package/dist/agentSearch/searchUtils.js +125 -0
- package/dist/agentSearch/searchUtils.js.map +1 -0
- package/dist/agentSearch/workspaceMetadataIndexer.d.ts +3 -0
- package/dist/agentSearch/workspaceMetadataIndexer.d.ts.map +1 -0
- package/dist/agentSearch/workspaceMetadataIndexer.js +125 -0
- package/dist/agentSearch/workspaceMetadataIndexer.js.map +1 -0
- package/dist/cli/CliControlPlaneService.d.ts +269 -0
- package/dist/cli/CliControlPlaneService.d.ts.map +1 -0
- package/dist/cli/CliControlPlaneService.js +1874 -0
- package/dist/cli/CliControlPlaneService.js.map +1 -0
- package/dist/cloud/XenoCloudAgentTurnExecutionAdapter.d.ts +91 -0
- package/dist/cloud/XenoCloudAgentTurnExecutionAdapter.d.ts.map +1 -0
- package/dist/cloud/XenoCloudAgentTurnExecutionAdapter.js +379 -0
- package/dist/cloud/XenoCloudAgentTurnExecutionAdapter.js.map +1 -0
- package/dist/codebase/CodebaseIntelligenceService.d.ts +68 -0
- package/dist/codebase/CodebaseIntelligenceService.d.ts.map +1 -0
- package/dist/codebase/CodebaseIntelligenceService.js +1717 -0
- package/dist/codebase/CodebaseIntelligenceService.js.map +1 -0
- package/dist/codebase/LanguageServerProcessManager.d.ts +85 -0
- package/dist/codebase/LanguageServerProcessManager.d.ts.map +1 -0
- package/dist/codebase/LanguageServerProcessManager.js +607 -0
- package/dist/codebase/LanguageServerProcessManager.js.map +1 -0
- package/dist/codebase/codeTypes.d.ts +485 -0
- package/dist/codebase/codeTypes.d.ts.map +1 -0
- package/dist/codebase/codeTypes.js +2 -0
- package/dist/codebase/codeTypes.js.map +1 -0
- package/dist/codebase/codeUtils.d.ts +24 -0
- package/dist/codebase/codeUtils.d.ts.map +1 -0
- package/dist/codebase/codeUtils.js +177 -0
- package/dist/codebase/codeUtils.js.map +1 -0
- package/dist/codebase/contextPacks.d.ts +18 -0
- package/dist/codebase/contextPacks.d.ts.map +1 -0
- package/dist/codebase/contextPacks.js +180 -0
- package/dist/codebase/contextPacks.js.map +1 -0
- package/dist/codebase/dependencyGraph.d.ts +3 -0
- package/dist/codebase/dependencyGraph.d.ts.map +1 -0
- package/dist/codebase/dependencyGraph.js +209 -0
- package/dist/codebase/dependencyGraph.js.map +1 -0
- package/dist/codebase/fileIndexer.d.ts +27 -0
- package/dist/codebase/fileIndexer.d.ts.map +1 -0
- package/dist/codebase/fileIndexer.js +167 -0
- package/dist/codebase/fileIndexer.js.map +1 -0
- package/dist/codebase/symbolIndexer.d.ts +3 -0
- package/dist/codebase/symbolIndexer.d.ts.map +1 -0
- package/dist/codebase/symbolIndexer.js +184 -0
- package/dist/codebase/symbolIndexer.js.map +1 -0
- package/dist/codebase/whatChanged.d.ts +4 -0
- package/dist/codebase/whatChanged.d.ts.map +1 -0
- package/dist/codebase/whatChanged.js +138 -0
- package/dist/codebase/whatChanged.js.map +1 -0
- package/dist/coordination/CoordinationService.d.ts +166 -0
- package/dist/coordination/CoordinationService.d.ts.map +1 -0
- package/dist/coordination/CoordinationService.js +247 -0
- package/dist/coordination/CoordinationService.js.map +1 -0
- package/dist/coordination/claims.d.ts +122 -0
- package/dist/coordination/claims.d.ts.map +1 -0
- package/dist/coordination/claims.js +196 -0
- package/dist/coordination/claims.js.map +1 -0
- package/dist/coordination/coordinationTools.d.ts +40 -0
- package/dist/coordination/coordinationTools.d.ts.map +1 -0
- package/dist/coordination/coordinationTools.js +92 -0
- package/dist/coordination/coordinationTools.js.map +1 -0
- package/dist/coordination/laneBridge.d.ts +84 -0
- package/dist/coordination/laneBridge.d.ts.map +1 -0
- package/dist/coordination/laneBridge.js +86 -0
- package/dist/coordination/laneBridge.js.map +1 -0
- package/dist/coordination/messages.d.ts +225 -0
- package/dist/coordination/messages.d.ts.map +1 -0
- package/dist/coordination/messages.js +335 -0
- package/dist/coordination/messages.js.map +1 -0
- package/dist/coordination/permissionWaitRegistry.d.ts +32 -0
- package/dist/coordination/permissionWaitRegistry.d.ts.map +1 -0
- package/dist/coordination/permissionWaitRegistry.js +59 -0
- package/dist/coordination/permissionWaitRegistry.js.map +1 -0
- package/dist/coordination/query.d.ts +187 -0
- package/dist/coordination/query.d.ts.map +1 -0
- package/dist/coordination/query.js +138 -0
- package/dist/coordination/query.js.map +1 -0
- package/dist/coordination/renderInbox.d.ts +15 -0
- package/dist/coordination/renderInbox.d.ts.map +1 -0
- package/dist/coordination/renderInbox.js +19 -0
- package/dist/coordination/renderInbox.js.map +1 -0
- package/dist/coordination/runtimeEvents.d.ts +53 -0
- package/dist/coordination/runtimeEvents.d.ts.map +1 -0
- package/dist/coordination/runtimeEvents.js +112 -0
- package/dist/coordination/runtimeEvents.js.map +1 -0
- package/dist/detachedHostService.d.ts +77 -0
- package/dist/detachedHostService.d.ts.map +1 -0
- package/dist/detachedHostService.js +66 -0
- package/dist/detachedHostService.js.map +1 -0
- package/dist/detachedHostSupervisor.d.ts +102 -0
- package/dist/detachedHostSupervisor.d.ts.map +1 -0
- package/dist/detachedHostSupervisor.js +168 -0
- package/dist/detachedHostSupervisor.js.map +1 -0
- package/dist/durableRuntimeEventStore.d.ts +49 -0
- package/dist/durableRuntimeEventStore.d.ts.map +1 -0
- package/dist/durableRuntimeEventStore.js +364 -0
- package/dist/durableRuntimeEventStore.js.map +1 -0
- package/dist/engineering/EngineeringWorkbenchService.d.ts +142 -0
- package/dist/engineering/EngineeringWorkbenchService.d.ts.map +1 -0
- package/dist/engineering/EngineeringWorkbenchService.js +2301 -0
- package/dist/engineering/EngineeringWorkbenchService.js.map +1 -0
- package/dist/engineering/LandingService.d.ts +112 -0
- package/dist/engineering/LandingService.d.ts.map +1 -0
- package/dist/engineering/LandingService.js +239 -0
- package/dist/engineering/LandingService.js.map +1 -0
- package/dist/engineering/engineeringTypes.d.ts +2 -0
- package/dist/engineering/engineeringTypes.d.ts.map +1 -0
- package/dist/engineering/engineeringTypes.js +2 -0
- package/dist/engineering/engineeringTypes.js.map +1 -0
- package/dist/engineering/patchReview.d.ts +11 -0
- package/dist/engineering/patchReview.d.ts.map +1 -0
- package/dist/engineering/patchReview.js +174 -0
- package/dist/engineering/patchReview.js.map +1 -0
- package/dist/index.d.ts +834 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3242 -0
- package/dist/index.js.map +1 -0
- package/dist/intake/IntakeService.d.ts +84 -0
- package/dist/intake/IntakeService.d.ts.map +1 -0
- package/dist/intake/IntakeService.js +117 -0
- package/dist/intake/IntakeService.js.map +1 -0
- package/dist/intake/RepositoryTodoIntakeSource.d.ts +72 -0
- package/dist/intake/RepositoryTodoIntakeSource.d.ts.map +1 -0
- package/dist/intake/RepositoryTodoIntakeSource.js +168 -0
- package/dist/intake/RepositoryTodoIntakeSource.js.map +1 -0
- package/dist/intake/WorkspaceTodoIntakeSource.d.ts +53 -0
- package/dist/intake/WorkspaceTodoIntakeSource.d.ts.map +1 -0
- package/dist/intake/WorkspaceTodoIntakeSource.js +81 -0
- package/dist/intake/WorkspaceTodoIntakeSource.js.map +1 -0
- package/dist/isolation/CommandExecution.d.ts +132 -0
- package/dist/isolation/CommandExecution.d.ts.map +1 -0
- package/dist/isolation/CommandExecution.js +48 -0
- package/dist/isolation/CommandExecution.js.map +1 -0
- package/dist/isolation/LocalCommandExecutor.d.ts +27 -0
- package/dist/isolation/LocalCommandExecutor.d.ts.map +1 -0
- package/dist/isolation/LocalCommandExecutor.js +97 -0
- package/dist/isolation/LocalCommandExecutor.js.map +1 -0
- package/dist/isolation/RunExecutionEnvironment.d.ts +135 -0
- package/dist/isolation/RunExecutionEnvironment.d.ts.map +1 -0
- package/dist/isolation/RunExecutionEnvironment.js +206 -0
- package/dist/isolation/RunExecutionEnvironment.js.map +1 -0
- package/dist/isolation/RunIsolationService.d.ts +90 -0
- package/dist/isolation/RunIsolationService.d.ts.map +1 -0
- package/dist/isolation/RunIsolationService.js +86 -0
- package/dist/isolation/RunIsolationService.js.map +1 -0
- package/dist/isolation/SandboxCommandExecutor.d.ts +93 -0
- package/dist/isolation/SandboxCommandExecutor.d.ts.map +1 -0
- package/dist/isolation/SandboxCommandExecutor.js +189 -0
- package/dist/isolation/SandboxCommandExecutor.js.map +1 -0
- package/dist/knowledge/WorkspaceKnowledgeService.d.ts +71 -0
- package/dist/knowledge/WorkspaceKnowledgeService.d.ts.map +1 -0
- package/dist/knowledge/WorkspaceKnowledgeService.js +103 -0
- package/dist/knowledge/WorkspaceKnowledgeService.js.map +1 -0
- package/dist/localHostAuthority.d.ts +24 -0
- package/dist/localHostAuthority.d.ts.map +1 -0
- package/dist/localHostAuthority.js +87 -0
- package/dist/localHostAuthority.js.map +1 -0
- package/dist/localHostLease.d.ts +51 -0
- package/dist/localHostLease.d.ts.map +1 -0
- package/dist/localHostLease.js +167 -0
- package/dist/localHostLease.js.map +1 -0
- package/dist/localHostPaths.d.ts +30 -0
- package/dist/localHostPaths.d.ts.map +1 -0
- package/dist/localHostPaths.js +60 -0
- package/dist/localHostPaths.js.map +1 -0
- package/dist/localRpcTransport.d.ts +21 -0
- package/dist/localRpcTransport.d.ts.map +1 -0
- package/dist/localRpcTransport.js +423 -0
- package/dist/localRpcTransport.js.map +1 -0
- package/dist/node.d.ts +104 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +103 -0
- package/dist/node.js.map +1 -0
- package/dist/observation/DirectoryBaselineScanner.d.ts +44 -0
- package/dist/observation/DirectoryBaselineScanner.d.ts.map +1 -0
- package/dist/observation/DirectoryBaselineScanner.js +112 -0
- package/dist/observation/DirectoryBaselineScanner.js.map +1 -0
- package/dist/observation/MountContentReader.d.ts +63 -0
- package/dist/observation/MountContentReader.d.ts.map +1 -0
- package/dist/observation/MountContentReader.js +158 -0
- package/dist/observation/MountContentReader.js.map +1 -0
- package/dist/observation/RunObservationService.d.ts +229 -0
- package/dist/observation/RunObservationService.d.ts.map +1 -0
- package/dist/observation/RunObservationService.js +248 -0
- package/dist/observation/RunObservationService.js.map +1 -0
- package/dist/observation/XenoUseObservationSource.d.ts +42 -0
- package/dist/observation/XenoUseObservationSource.d.ts.map +1 -0
- package/dist/observation/XenoUseObservationSource.js +104 -0
- package/dist/observation/XenoUseObservationSource.js.map +1 -0
- package/dist/observation/XenoUseRunObserver.d.ts +140 -0
- package/dist/observation/XenoUseRunObserver.d.ts.map +1 -0
- package/dist/observation/XenoUseRunObserver.js +256 -0
- package/dist/observation/XenoUseRunObserver.js.map +1 -0
- package/dist/pluginMcp/PluginMcpManagerService.d.ts +141 -0
- package/dist/pluginMcp/PluginMcpManagerService.d.ts.map +1 -0
- package/dist/pluginMcp/PluginMcpManagerService.js +1821 -0
- package/dist/pluginMcp/PluginMcpManagerService.js.map +1 -0
- package/dist/pluginMcp/pluginMcpTypes.d.ts +349 -0
- package/dist/pluginMcp/pluginMcpTypes.d.ts.map +1 -0
- package/dist/pluginMcp/pluginMcpTypes.js +2 -0
- package/dist/pluginMcp/pluginMcpTypes.js.map +1 -0
- package/dist/policy/WorkspacePolicyService.d.ts +83 -0
- package/dist/policy/WorkspacePolicyService.d.ts.map +1 -0
- package/dist/policy/WorkspacePolicyService.js +809 -0
- package/dist/policy/WorkspacePolicyService.js.map +1 -0
- package/dist/policy/policyGuard.d.ts +27 -0
- package/dist/policy/policyGuard.d.ts.map +1 -0
- package/dist/policy/policyGuard.js +143 -0
- package/dist/policy/policyGuard.js.map +1 -0
- package/dist/policy/policyTypes.d.ts +190 -0
- package/dist/policy/policyTypes.d.ts.map +1 -0
- package/dist/policy/policyTypes.js +2 -0
- package/dist/policy/policyTypes.js.map +1 -0
- package/dist/runtime/RuntimeEventStore.d.ts +42 -0
- package/dist/runtime/RuntimeEventStore.d.ts.map +1 -0
- package/dist/runtime/RuntimeEventStore.js +412 -0
- package/dist/runtime/RuntimeEventStore.js.map +1 -0
- package/dist/runtime/RuntimeEventStoreRepositoryAdapter.d.ts +21 -0
- package/dist/runtime/RuntimeEventStoreRepositoryAdapter.d.ts.map +1 -0
- package/dist/runtime/RuntimeEventStoreRepositoryAdapter.js +41 -0
- package/dist/runtime/RuntimeEventStoreRepositoryAdapter.js.map +1 -0
- package/dist/runtime/RuntimeTimelineProjection.d.ts +5 -0
- package/dist/runtime/RuntimeTimelineProjection.d.ts.map +1 -0
- package/dist/runtime/RuntimeTimelineProjection.js +705 -0
- package/dist/runtime/RuntimeTimelineProjection.js.map +1 -0
- package/dist/runtime/runtimeEventTypes.d.ts +73 -0
- package/dist/runtime/runtimeEventTypes.d.ts.map +1 -0
- package/dist/runtime/runtimeEventTypes.js +2 -0
- package/dist/runtime/runtimeEventTypes.js.map +1 -0
- package/dist/sdk/SdkNativeAgentTurnExecutionAdapter.d.ts +117 -0
- package/dist/sdk/SdkNativeAgentTurnExecutionAdapter.d.ts.map +1 -0
- package/dist/sdk/SdkNativeAgentTurnExecutionAdapter.js +214 -0
- package/dist/sdk/SdkNativeAgentTurnExecutionAdapter.js.map +1 -0
- package/dist/session/AgentSessionService.d.ts +285 -0
- package/dist/session/AgentSessionService.d.ts.map +1 -0
- package/dist/session/AgentSessionService.js +444 -0
- package/dist/session/AgentSessionService.js.map +1 -0
- package/dist/session/bufferedSessionPty.d.ts +27 -0
- package/dist/session/bufferedSessionPty.d.ts.map +1 -0
- package/dist/session/bufferedSessionPty.js +49 -0
- package/dist/session/bufferedSessionPty.js.map +1 -0
- package/dist/session/renderSupport.d.ts +53 -0
- package/dist/session/renderSupport.d.ts.map +1 -0
- package/dist/session/renderSupport.js +41 -0
- package/dist/session/renderSupport.js.map +1 -0
- package/dist/session/runtimeEventChannel.d.ts +56 -0
- package/dist/session/runtimeEventChannel.d.ts.map +1 -0
- package/dist/session/runtimeEventChannel.js +53 -0
- package/dist/session/runtimeEventChannel.js.map +1 -0
- package/dist/state/JsonAgentStateRepository.d.ts +29 -0
- package/dist/state/JsonAgentStateRepository.d.ts.map +1 -0
- package/dist/state/JsonAgentStateRepository.js +165 -0
- package/dist/state/JsonAgentStateRepository.js.map +1 -0
- package/dist/state/SqliteAgentStateRepository.d.ts +58 -0
- package/dist/state/SqliteAgentStateRepository.d.ts.map +1 -0
- package/dist/state/SqliteAgentStateRepository.js +777 -0
- package/dist/state/SqliteAgentStateRepository.js.map +1 -0
- package/dist/state/createAgentStateRepository.d.ts +4 -0
- package/dist/state/createAgentStateRepository.d.ts.map +1 -0
- package/dist/state/createAgentStateRepository.js +12 -0
- package/dist/state/createAgentStateRepository.js.map +1 -0
- package/dist/state/stateSanitizers.d.ts +7 -0
- package/dist/state/stateSanitizers.d.ts.map +1 -0
- package/dist/state/stateSanitizers.js +87 -0
- package/dist/state/stateSanitizers.js.map +1 -0
- package/dist/subagents/AcpSubagentExecutionAdapter.d.ts +12 -0
- package/dist/subagents/AcpSubagentExecutionAdapter.d.ts.map +1 -0
- package/dist/subagents/AcpSubagentExecutionAdapter.js +119 -0
- package/dist/subagents/AcpSubagentExecutionAdapter.js.map +1 -0
- package/dist/subagents/CliSubagentExecutionAdapter.d.ts +13 -0
- package/dist/subagents/CliSubagentExecutionAdapter.d.ts.map +1 -0
- package/dist/subagents/CliSubagentExecutionAdapter.js +217 -0
- package/dist/subagents/CliSubagentExecutionAdapter.js.map +1 -0
- package/dist/subagents/HostTurnSubagentExecutionAdapter.d.ts +78 -0
- package/dist/subagents/HostTurnSubagentExecutionAdapter.d.ts.map +1 -0
- package/dist/subagents/HostTurnSubagentExecutionAdapter.js +160 -0
- package/dist/subagents/HostTurnSubagentExecutionAdapter.js.map +1 -0
- package/dist/subagents/SubagentRunService.d.ts +103 -0
- package/dist/subagents/SubagentRunService.d.ts.map +1 -0
- package/dist/subagents/SubagentRunService.js +1183 -0
- package/dist/subagents/SubagentRunService.js.map +1 -0
- package/dist/subagents/SubagentScheduler.d.ts +167 -0
- package/dist/subagents/SubagentScheduler.d.ts.map +1 -0
- package/dist/subagents/SubagentScheduler.js +530 -0
- package/dist/subagents/SubagentScheduler.js.map +1 -0
- package/dist/subagents/TeamAgentRouter.d.ts +104 -0
- package/dist/subagents/TeamAgentRouter.d.ts.map +1 -0
- package/dist/subagents/TeamAgentRouter.js +435 -0
- package/dist/subagents/TeamAgentRouter.js.map +1 -0
- package/dist/subagents/capabilityMatcher.d.ts +3 -0
- package/dist/subagents/capabilityMatcher.d.ts.map +1 -0
- package/dist/subagents/capabilityMatcher.js +127 -0
- package/dist/subagents/capabilityMatcher.js.map +1 -0
- package/dist/subagents/subagentRunPort.d.ts +43 -0
- package/dist/subagents/subagentRunPort.d.ts.map +1 -0
- package/dist/subagents/subagentRunPort.js +24 -0
- package/dist/subagents/subagentRunPort.js.map +1 -0
- package/dist/subagents/subagentTypes.d.ts +330 -0
- package/dist/subagents/subagentTypes.d.ts.map +1 -0
- package/dist/subagents/subagentTypes.js +2 -0
- package/dist/subagents/subagentTypes.js.map +1 -0
- package/dist/subagents/subagentWorkspaceAuthority.d.ts +29 -0
- package/dist/subagents/subagentWorkspaceAuthority.d.ts.map +1 -0
- package/dist/subagents/subagentWorkspaceAuthority.js +173 -0
- package/dist/subagents/subagentWorkspaceAuthority.js.map +1 -0
- package/dist/support/SupportDiagnosticsService.d.ts +87 -0
- package/dist/support/SupportDiagnosticsService.d.ts.map +1 -0
- package/dist/support/SupportDiagnosticsService.js +1199 -0
- package/dist/support/SupportDiagnosticsService.js.map +1 -0
- package/dist/turns/DurableInterruptedTurnStore.d.ts +14 -0
- package/dist/turns/DurableInterruptedTurnStore.d.ts.map +1 -0
- package/dist/turns/DurableInterruptedTurnStore.js +63 -0
- package/dist/turns/DurableInterruptedTurnStore.js.map +1 -0
- package/dist/turns/interruptedTurns.d.ts +50 -0
- package/dist/turns/interruptedTurns.d.ts.map +1 -0
- package/dist/turns/interruptedTurns.js +54 -0
- package/dist/turns/interruptedTurns.js.map +1 -0
- package/dist/turns/turnAdmission.d.ts +113 -0
- package/dist/turns/turnAdmission.d.ts.map +1 -0
- package/dist/turns/turnAdmission.js +136 -0
- package/dist/turns/turnAdmission.js.map +1 -0
- package/dist/workspace/WorkspaceHostService.d.ts +126 -0
- package/dist/workspace/WorkspaceHostService.d.ts.map +1 -0
- package/dist/workspace/WorkspaceHostService.js +841 -0
- package/dist/workspace/WorkspaceHostService.js.map +1 -0
- package/dist/workspace/WorkspaceShellService.d.ts +71 -0
- package/dist/workspace/WorkspaceShellService.d.ts.map +1 -0
- package/dist/workspace/WorkspaceShellService.js +273 -0
- package/dist/workspace/WorkspaceShellService.js.map +1 -0
- package/dist/workspace/fileAssociations.d.ts +12 -0
- package/dist/workspace/fileAssociations.d.ts.map +1 -0
- package/dist/workspace/fileAssociations.js +202 -0
- package/dist/workspace/fileAssociations.js.map +1 -0
- package/dist/workspace/scrollbackBuffer.d.ts +48 -0
- package/dist/workspace/scrollbackBuffer.d.ts.map +1 -0
- package/dist/workspace/scrollbackBuffer.js +94 -0
- package/dist/workspace/scrollbackBuffer.js.map +1 -0
- package/package.json +42 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3242 @@
|
|
|
1
|
+
import { AgentHostProtocolError, XENO_AGENT_HOST_MIN_PROTOCOL_VERSION, XENO_AGENT_HOST_PROTOCOL_VERSION, isAgentHostClientIdentity, isAgentProviderListResult, defaultRunIdOf, isAgentRuntimeEventEnvelope, } from '@xenosystem/agent-interface-contract';
|
|
2
|
+
/**
|
|
3
|
+
* Bounds on the instruction queue. Unbounded queues are how a stuck loop turns
|
|
4
|
+
* into unbounded memory; the coordination spec applies the same discipline to
|
|
5
|
+
* inter-run messages.
|
|
6
|
+
*/
|
|
7
|
+
/** A refusal is cheap to produce and a host can live for days. */
|
|
8
|
+
const MAX_FLEET_COLLISIONS = 500;
|
|
9
|
+
const MAX_QUEUED_INSTRUCTIONS = 50;
|
|
10
|
+
const MAX_QUEUED_INSTRUCTION_LENGTH = 20_000;
|
|
11
|
+
import { cloneProvider, isDevelopmentProviderReady, missingDevelopmentCapabilities, resolveAgentRuntimeSelection, renderWorkspaceKnowledge, selectAvailableProviders, withHostBackedCapabilities, planFanOut, routeByCapability, } from '@xenosystem/agent-interface-core';
|
|
12
|
+
import { InMemoryInterruptedTurnStore, } from './turns/interruptedTurns.js';
|
|
13
|
+
import { evaluateTurnAdmission, resolveTurnAdmissionPolicy, } from './turns/turnAdmission.js';
|
|
14
|
+
import { boundSubagentWorkspace } from './subagents/subagentWorkspaceAuthority.js';
|
|
15
|
+
import { summariseSubagentRun } from './subagents/subagentRunPort.js';
|
|
16
|
+
import { describeClaimScope } from './coordination/claims.js';
|
|
17
|
+
import { CoordinationService } from './coordination/CoordinationService.js';
|
|
18
|
+
import { renderInbox } from './coordination/renderInbox.js';
|
|
19
|
+
import { coordinationRuntimeEventDraft } from './coordination/runtimeEvents.js';
|
|
20
|
+
import { RunObservationService } from './observation/RunObservationService.js';
|
|
21
|
+
import { RunIsolationService } from './isolation/RunIsolationService.js';
|
|
22
|
+
import { IntakeService } from './intake/IntakeService.js';
|
|
23
|
+
import { WorkspaceKnowledgeService } from './knowledge/WorkspaceKnowledgeService.js';
|
|
24
|
+
import { AgentSessionService } from './session/AgentSessionService.js';
|
|
25
|
+
import { createBufferedSessionPty } from './session/bufferedSessionPty.js';
|
|
26
|
+
import { renderSupportFor } from './session/renderSupport.js';
|
|
27
|
+
import { createRuntimeEventSessionChannel } from './session/runtimeEventChannel.js';
|
|
28
|
+
import { answerChooseDirectoryRequest } from './agent/chooseDirectoryBridge.js';
|
|
29
|
+
import { answerWorkspacePlacementRequest } from './agent/workspacePlacementBridge.js';
|
|
30
|
+
/**
|
|
31
|
+
* How long the host waits for an adapter to notice an abort before it stops counting the turn.
|
|
32
|
+
*
|
|
33
|
+
* 🔴 This exists because the host used to wait FOREVER. `activeTurns` is cleared in the
|
|
34
|
+
* `.finally()` of the adapter's execute promise, so an adapter that ignores `signal` — or simply
|
|
35
|
+
* hangs — kept its entry indefinitely. Measured 2026-08-17: `turn.cancel` returned
|
|
36
|
+
* `success: true` while the turn stayed active and stayed counted, and with turn admission in
|
|
37
|
+
* place the slot never came back. Sixteen such turns wedge a default host, recoverable only by
|
|
38
|
+
* restarting it.
|
|
39
|
+
*
|
|
40
|
+
* Generous, because a slow-but-honest adapter finishing its cleanup is the common case and
|
|
41
|
+
* abandoning it early would be worse than waiting. Bounded, because "wait for cooperation that
|
|
42
|
+
* may never come" is not a policy.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_TURN_ABANDON_GRACE_MS = 5_000;
|
|
45
|
+
export class AgentHostCoordinator {
|
|
46
|
+
options;
|
|
47
|
+
listeners = new Set();
|
|
48
|
+
providers = new Map();
|
|
49
|
+
providerCatalogLoader;
|
|
50
|
+
runtimeEvents = new Map();
|
|
51
|
+
runtimeEventRepository;
|
|
52
|
+
stateRepository;
|
|
53
|
+
workspaceService;
|
|
54
|
+
interactionService;
|
|
55
|
+
permissionPolicyService;
|
|
56
|
+
interruptedTurnStore;
|
|
57
|
+
sdkControlService;
|
|
58
|
+
engineeringControlService;
|
|
59
|
+
coordinationService;
|
|
60
|
+
/**
|
|
61
|
+
* Run id -> the conversation that run belongs to.
|
|
62
|
+
*
|
|
63
|
+
* Gate 8 needs coordination on the RUN TIMELINE, and a timeline entry is
|
|
64
|
+
* addressed by conversation and request. Only the host knows that mapping;
|
|
65
|
+
* the coordination service deals in run ids alone, which is what keeps it
|
|
66
|
+
* testable without a store.
|
|
67
|
+
*/
|
|
68
|
+
runContexts = new Map();
|
|
69
|
+
coordinationEventSequence = 0;
|
|
70
|
+
/** Collisions seen since this host started — see the sink above for why this
|
|
71
|
+
* is the only place they exist. */
|
|
72
|
+
fleetCollisions = [];
|
|
73
|
+
runObservationService;
|
|
74
|
+
runIsolationService;
|
|
75
|
+
runObservationBinder;
|
|
76
|
+
sessionService;
|
|
77
|
+
intakeService;
|
|
78
|
+
knowledgeService;
|
|
79
|
+
acpApprovalService;
|
|
80
|
+
/**
|
|
81
|
+
* Subagent runs — ADE §5.2. A PORT spanning `SubagentRunService` (create/list) and
|
|
82
|
+
* `SubagentScheduler` (cancel), because cancelling a launched run must reach the adapter.
|
|
83
|
+
*/
|
|
84
|
+
subagentRuns;
|
|
85
|
+
resolveRunId;
|
|
86
|
+
connectedClients = new Map();
|
|
87
|
+
/** What each connected client declared it can do, keyed the same way. */
|
|
88
|
+
clientCapabilities = new Map();
|
|
89
|
+
turnExecutionAdapters = new Map();
|
|
90
|
+
turnAdmissionPolicy;
|
|
91
|
+
turnAbandonGraceMs;
|
|
92
|
+
/**
|
|
93
|
+
* Timers owned by a live turn: its deadline, and any abandonment grace already started.
|
|
94
|
+
* Cleared when the turn settles, so a completed turn cannot be abandoned retroactively.
|
|
95
|
+
*/
|
|
96
|
+
turnTimers = new Map();
|
|
97
|
+
activeTurns = new Map();
|
|
98
|
+
commandResults = new Map();
|
|
99
|
+
/**
|
|
100
|
+
* Instructions stacked while a turn is running, per conversation.
|
|
101
|
+
*
|
|
102
|
+
* Held by the HOST, not by an adapter: that is what makes queueing work at
|
|
103
|
+
* every structure tier (ADE spec §2.9), including a third-party agent running
|
|
104
|
+
* its own TUI where there is no protocol to hand a steer to.
|
|
105
|
+
*
|
|
106
|
+
* The host does NOT auto-start the queued turn. It holds the instruction and
|
|
107
|
+
* signals when the active turn ends; the client starts the next turn, because
|
|
108
|
+
* the client is where the user's permission context lives. Auto-drain would
|
|
109
|
+
* convert a queue into unattended execution, which is a different decision.
|
|
110
|
+
*/
|
|
111
|
+
queuedInstructions = new Map();
|
|
112
|
+
queuedInstructionSequence = 0;
|
|
113
|
+
sequence = 0;
|
|
114
|
+
catalogRevision = 0;
|
|
115
|
+
providerCatalogFingerprint = '';
|
|
116
|
+
providerCatalogRefresh = null;
|
|
117
|
+
constructor(options) {
|
|
118
|
+
const { providers, providerCatalogLoader, runtimeEvents, runtimeEventRepository, stateRepository, workspaceService, interactionService, permissionPolicyService, interruptedTurnStore, turnAdmissionPolicy, turnAbandonGraceMs, sdkControlService, engineeringControlService, turnExecutionAdapters, coordinationService, runObservationService, runIsolationService, runObservationBinder, sessionService, intakeService, knowledgeService, acpApprovalService, subagentRuns, resolveRunId, ...hostOptions } = options;
|
|
119
|
+
if (runtimeEventRepository && runtimeEvents && runtimeEvents.length > 0) {
|
|
120
|
+
throw new Error('Seeded runtimeEvents cannot be combined with a durable runtimeEventRepository.');
|
|
121
|
+
}
|
|
122
|
+
this.options = { ...hostOptions };
|
|
123
|
+
this.runtimeEventRepository = runtimeEventRepository;
|
|
124
|
+
this.providerCatalogLoader = providerCatalogLoader;
|
|
125
|
+
this.stateRepository = stateRepository;
|
|
126
|
+
this.workspaceService = workspaceService;
|
|
127
|
+
this.interactionService = interactionService;
|
|
128
|
+
this.permissionPolicyService = permissionPolicyService;
|
|
129
|
+
this.interruptedTurnStore = interruptedTurnStore || new InMemoryInterruptedTurnStore();
|
|
130
|
+
this.turnAdmissionPolicy = turnAdmissionPolicy ?? resolveTurnAdmissionPolicy();
|
|
131
|
+
this.turnAbandonGraceMs = turnAbandonGraceMs ?? DEFAULT_TURN_ABANDON_GRACE_MS;
|
|
132
|
+
this.sdkControlService = sdkControlService;
|
|
133
|
+
this.engineeringControlService = engineeringControlService;
|
|
134
|
+
this.coordinationService = coordinationService;
|
|
135
|
+
// A turn's run identity, if the request carries one. `subagentRunId` first:
|
|
136
|
+
// a subagent IS a distinct run with its own claims, so folding it into its
|
|
137
|
+
// parent conversation would let two runs share one lock holder.
|
|
138
|
+
// 🔴 `defaultRunIdOf`, not a hand-written `??` — a session filters runtime
|
|
139
|
+
// EVENTS by the same rule, and the two must not drift. See its doc.
|
|
140
|
+
this.resolveRunId = resolveRunId ?? ((request) => defaultRunIdOf(request));
|
|
141
|
+
this.workspaceService?.setEventSink((type, payload) => this.publish(type, payload));
|
|
142
|
+
// Coordination reaches surfaces through the event feed and nothing else.
|
|
143
|
+
// Published as `redactionState: 'safe'` like every host event — which holds
|
|
144
|
+
// because a coordination event carries ids, scopes and outcomes, never
|
|
145
|
+
// message TEXT. That is a property of the CoordinationEvent union, so it is
|
|
146
|
+
// asserted in a test rather than trusted to stay true.
|
|
147
|
+
this.coordinationService?.setEventSink((event) => {
|
|
148
|
+
// 🔴 Recorded HERE, not projected from the runtime-event cache.
|
|
149
|
+
//
|
|
150
|
+
// The first version of `fleet.view` projected collisions from
|
|
151
|
+
// `this.runtimeEvents`, which looked right and was empty in the app:
|
|
152
|
+
// `publishPersistedRuntimeEvent` publishes a persisted event to
|
|
153
|
+
// subscribers and does NOT add it to that map, so with a repository
|
|
154
|
+
// configured — which is every real composition — coordination events
|
|
155
|
+
// never reached it. The view would have reported "no collisions" forever.
|
|
156
|
+
//
|
|
157
|
+
// The claim registry keeps no record of a refusal (it refuses and moves
|
|
158
|
+
// on), so this sink is the only place a collision exists at all. Bounded,
|
|
159
|
+
// because a long-lived host must not accumulate one entry per refusal
|
|
160
|
+
// without limit.
|
|
161
|
+
if (event.type === 'coordination.claim.refused') {
|
|
162
|
+
this.fleetCollisions.push({
|
|
163
|
+
// `describeClaimScope`, because a scope is a structured value and the fleet view
|
|
164
|
+
// reports the unit of work a person recognises — a path as itself, a branch permit
|
|
165
|
+
// as `branch:<name>` so the two axes stay distinguishable in one timeline.
|
|
166
|
+
scope: describeClaimScope(event.scope),
|
|
167
|
+
wantedByRunId: event.runId,
|
|
168
|
+
heldByRunId: event.heldByRunId,
|
|
169
|
+
at: new Date().toISOString(),
|
|
170
|
+
});
|
|
171
|
+
if (this.fleetCollisions.length > MAX_FLEET_COLLISIONS)
|
|
172
|
+
this.fleetCollisions.shift();
|
|
173
|
+
}
|
|
174
|
+
this.publish('coordination.event', event);
|
|
175
|
+
});
|
|
176
|
+
// The OTHER half of gate 8: the durable record, which carries the message
|
|
177
|
+
// text the live feed deliberately does not.
|
|
178
|
+
this.coordinationService?.setTimelineSink((event, text) => {
|
|
179
|
+
void this.recordCoordinationEvent(event, text);
|
|
180
|
+
});
|
|
181
|
+
this.runObservationService = runObservationService;
|
|
182
|
+
// Always present, never optional. A host that could not answer "is this
|
|
183
|
+
// contained?" would leave the surface guessing, and the honest answer with
|
|
184
|
+
// no source is `none` rather than silence.
|
|
185
|
+
this.runIsolationService = runIsolationService ?? new RunIsolationService();
|
|
186
|
+
this.runObservationBinder = runObservationBinder;
|
|
187
|
+
// Counts and completeness only — see the contract's `run.observation`.
|
|
188
|
+
this.runObservationService?.setEventSink((summary) => this.publish('run.observation', summary));
|
|
189
|
+
this.sessionService = sessionService;
|
|
190
|
+
this.intakeService = intakeService;
|
|
191
|
+
this.knowledgeService = knowledgeService;
|
|
192
|
+
// Session lifecycle on the feed: ids, modes and viewer counts — never PTY
|
|
193
|
+
// BYTES. Terminal output reaches a surface by attaching to the session,
|
|
194
|
+
// which is a subscription per viewer, not a broadcast to every client.
|
|
195
|
+
this.sessionService?.setEventSink((event) => this.publish('session.event', event));
|
|
196
|
+
this.acpApprovalService = acpApprovalService;
|
|
197
|
+
this.subagentRuns = subagentRuns;
|
|
198
|
+
for (const provider of providers ?? []) {
|
|
199
|
+
this.setProvider(provider, false);
|
|
200
|
+
}
|
|
201
|
+
this.providerCatalogFingerprint = fingerprintProviders([...this.providers.values()]);
|
|
202
|
+
for (const event of runtimeEvents ?? []) {
|
|
203
|
+
this.appendRuntimeEvent(event, false);
|
|
204
|
+
}
|
|
205
|
+
for (const adapter of turnExecutionAdapters ?? []) {
|
|
206
|
+
this.registerTurnExecutionAdapter(adapter);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The mounted coordination service, or `undefined` when the host has none.
|
|
211
|
+
*
|
|
212
|
+
* Deliberately NOT reachable through `request()`. A run coordinates with
|
|
213
|
+
* another run; a client surface watches. Exposing send/claim as host methods
|
|
214
|
+
* would let whatever is holding a transport act *as* a run, which is exactly
|
|
215
|
+
* the boundary coordination spec §4 draws — and `AgentHostTransport` is a
|
|
216
|
+
* protected cross-repo interface whose consumers (Hub, Shell) would all need
|
|
217
|
+
* coordinating for a capability neither of them is asking for.
|
|
218
|
+
*/
|
|
219
|
+
get coordination() {
|
|
220
|
+
return this.coordinationService;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Puts what the workspace knows into the turn — ADE §5.8's gate.
|
|
224
|
+
*
|
|
225
|
+
* 🔴 **In the coordinator, before the adapter is invoked, so it cannot be
|
|
226
|
+
* per-provider.** The gate is that a fact learned by one provider reaches a
|
|
227
|
+
* later run on a DIFFERENT one; injecting inside an adapter would make that a
|
|
228
|
+
* property each lane had to remember, and the lane that forgot would fail
|
|
229
|
+
* silently — the prompt would simply be missing knowledge, which looks exactly
|
|
230
|
+
* like an agent that never learned anything.
|
|
231
|
+
*
|
|
232
|
+
* 🔴 **Prepended to `systemPrompt`, never replacing it.** Standing context
|
|
233
|
+
* about the codebase is what a system prompt is for, and every lane already
|
|
234
|
+
* carries one — so this reaches all of them without touching three adapters.
|
|
235
|
+
* A caller's own system prompt is preserved below it.
|
|
236
|
+
*
|
|
237
|
+
* A failure here does NOT fail the turn. Knowledge is an enrichment; the run
|
|
238
|
+
* is the user's actual work, and a broken store must not be able to stop it.
|
|
239
|
+
* The turn simply proceeds without the block, which is the same behaviour as a
|
|
240
|
+
* workspace that has learned nothing yet.
|
|
241
|
+
*/
|
|
242
|
+
async injectWorkspaceKnowledge(request) {
|
|
243
|
+
const service = this.knowledgeService;
|
|
244
|
+
if (!service || !request.workspaceId)
|
|
245
|
+
return;
|
|
246
|
+
try {
|
|
247
|
+
const selection = await service.context(request.workspaceId);
|
|
248
|
+
const block = renderWorkspaceKnowledge(selection);
|
|
249
|
+
// An empty block is not injected at all — not as an empty string, not as a
|
|
250
|
+
// stray blank line. Prompt space spent saying nothing is prompt space
|
|
251
|
+
// taken from something.
|
|
252
|
+
if (!block)
|
|
253
|
+
return;
|
|
254
|
+
const heading = 'What this workspace knows:';
|
|
255
|
+
request.systemPrompt = request.systemPrompt
|
|
256
|
+
? `${heading}\n${block}\n\n${request.systemPrompt}`
|
|
257
|
+
: `${heading}\n${block}`;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Deliberately silent to the TURN. The knowledge service reports its own
|
|
261
|
+
// problems through `knowledge.context`, which a surface can show.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Workspace knowledge — ADE §5.8. Thin: the rules live in `core`. */
|
|
265
|
+
async listKnowledge(input) {
|
|
266
|
+
const service = this.knowledgeService;
|
|
267
|
+
if (!service) {
|
|
268
|
+
return {
|
|
269
|
+
available: false,
|
|
270
|
+
entries: [],
|
|
271
|
+
durable: false,
|
|
272
|
+
reason: 'This host is not keeping workspace knowledge.',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
available: true,
|
|
277
|
+
entries: (await service.list(input.workspaceId)).map(summariseKnowledge),
|
|
278
|
+
durable: service.durable,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
async recordKnowledge(input) {
|
|
282
|
+
const service = this.knowledgeService;
|
|
283
|
+
if (!service)
|
|
284
|
+
return { ok: false, reason: 'This host is not keeping workspace knowledge.' };
|
|
285
|
+
const subject = String(input.subject ?? '').trim();
|
|
286
|
+
const fact = String(input.fact ?? '').trim();
|
|
287
|
+
if (!subject || !fact) {
|
|
288
|
+
// Refused rather than stored empty: a blank fact occupies prompt budget
|
|
289
|
+
// and tells an agent nothing.
|
|
290
|
+
return { ok: false, reason: 'A fact needs both a subject and a statement.' };
|
|
291
|
+
}
|
|
292
|
+
const entry = await service.record({
|
|
293
|
+
workspaceId: input.workspaceId,
|
|
294
|
+
subject,
|
|
295
|
+
fact,
|
|
296
|
+
...(input.pinned ? { pinned: true } : {}),
|
|
297
|
+
runId: input.runId,
|
|
298
|
+
...(input.providerId ? { providerId: input.providerId } : {}),
|
|
299
|
+
});
|
|
300
|
+
return { ok: true, entry: summariseKnowledge(entry) };
|
|
301
|
+
}
|
|
302
|
+
async forgetKnowledge(input) {
|
|
303
|
+
const service = this.knowledgeService;
|
|
304
|
+
if (!service)
|
|
305
|
+
return { ok: false, reason: 'This host is not keeping workspace knowledge.' };
|
|
306
|
+
const forgotten = await service.forget(input.workspaceId, input.entryId);
|
|
307
|
+
return forgotten ? { ok: true } : { ok: false, reason: 'No such knowledge entry.' };
|
|
308
|
+
}
|
|
309
|
+
async knowledgeContext(input) {
|
|
310
|
+
const service = this.knowledgeService;
|
|
311
|
+
if (!service) {
|
|
312
|
+
return {
|
|
313
|
+
available: false,
|
|
314
|
+
text: '',
|
|
315
|
+
entries: [],
|
|
316
|
+
omitted: [],
|
|
317
|
+
// NOT complete. Nothing was read, and a caller treating this as a
|
|
318
|
+
// complete empty context would inject nothing and believe it had
|
|
319
|
+
// everything.
|
|
320
|
+
complete: false,
|
|
321
|
+
reason: 'This host is not keeping workspace knowledge.',
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
const selection = await service.context(input.workspaceId, input.maxChars === undefined ? {} : { maxChars: input.maxChars });
|
|
325
|
+
return {
|
|
326
|
+
available: true,
|
|
327
|
+
text: renderWorkspaceKnowledge(selection),
|
|
328
|
+
entries: selection.entries.map(summariseKnowledge),
|
|
329
|
+
omitted: selection.omitted,
|
|
330
|
+
complete: selection.complete,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
/** Intake — ADE §5.1. Thin: every rule lives in `core/intakeClaim`. */
|
|
334
|
+
listIntakeSources() {
|
|
335
|
+
const service = this.intakeService;
|
|
336
|
+
if (!service) {
|
|
337
|
+
return { available: false, sources: [], reason: 'This host has no intake sources configured.' };
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
available: true,
|
|
341
|
+
sources: service.sources().map((source) => ({
|
|
342
|
+
sourceId: source.sourceId,
|
|
343
|
+
label: source.label,
|
|
344
|
+
healthy: source.healthy,
|
|
345
|
+
...(source.problem ? { problem: source.problem } : {}),
|
|
346
|
+
})),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
async listIntakeItems(input) {
|
|
350
|
+
const service = this.intakeService;
|
|
351
|
+
if (!service) {
|
|
352
|
+
return {
|
|
353
|
+
available: false,
|
|
354
|
+
items: [],
|
|
355
|
+
// NOT `complete: true`. Nothing was read, so nothing is known — and an
|
|
356
|
+
// unreadable inbox reporting completeness reads as "no work".
|
|
357
|
+
complete: false,
|
|
358
|
+
failedSources: [],
|
|
359
|
+
reason: 'This host has no intake sources configured.',
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const inbox = await service.inbox(input.sourceId ? { sourceId: input.sourceId } : {});
|
|
363
|
+
return {
|
|
364
|
+
available: true,
|
|
365
|
+
items: inbox.items.map((item) => ({
|
|
366
|
+
id: item.id,
|
|
367
|
+
sourceId: item.source.sourceId,
|
|
368
|
+
externalId: item.source.externalId,
|
|
369
|
+
title: item.title,
|
|
370
|
+
...(item.detail === undefined ? {} : { detail: item.detail }),
|
|
371
|
+
...(item.createdAt === undefined ? {} : { createdAt: item.createdAt }),
|
|
372
|
+
...(item.url === undefined ? {} : { url: item.url }),
|
|
373
|
+
claimState: item.claim.state,
|
|
374
|
+
...(item.claim.state === 'unclaimed' ? {} : { claimedBy: item.claim.byClientId }),
|
|
375
|
+
...(item.claim.state === 'dispatched' ? { runId: item.claim.runId } : {}),
|
|
376
|
+
})),
|
|
377
|
+
complete: inbox.complete,
|
|
378
|
+
failedSources: inbox.failedSources,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
claimIntakeItem(input) {
|
|
382
|
+
const service = this.intakeService;
|
|
383
|
+
if (!service)
|
|
384
|
+
return { ok: false, reason: 'This host has no intake sources configured.' };
|
|
385
|
+
const result = service.claim(input.itemId, input.clientId);
|
|
386
|
+
return intakeClaimResponse(result, service.claimState(input.itemId).state);
|
|
387
|
+
}
|
|
388
|
+
releaseIntakeItem(input) {
|
|
389
|
+
const service = this.intakeService;
|
|
390
|
+
if (!service)
|
|
391
|
+
return { ok: false, reason: 'This host has no intake sources configured.' };
|
|
392
|
+
const released = service.release(input.itemId, input.clientId);
|
|
393
|
+
if (released)
|
|
394
|
+
return { ok: true, claimState: 'unclaimed' };
|
|
395
|
+
// Named rather than a bare false: "you do not hold this" and "nobody does"
|
|
396
|
+
// call for different next moves.
|
|
397
|
+
const state = service.claimState(input.itemId);
|
|
398
|
+
return {
|
|
399
|
+
ok: false,
|
|
400
|
+
claimState: state.state,
|
|
401
|
+
...(state.state === 'unclaimed'
|
|
402
|
+
? { reason: 'Nobody holds this item.' }
|
|
403
|
+
: { heldBy: state.byClientId, reason: `${state.byClientId} holds this item.` }),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
dispatchIntakeItem(input) {
|
|
407
|
+
const service = this.intakeService;
|
|
408
|
+
if (!service)
|
|
409
|
+
return { ok: false, reason: 'This host has no intake sources configured.' };
|
|
410
|
+
const result = service.dispatch(input.itemId, input.clientId, input.runId);
|
|
411
|
+
return intakeClaimResponse(result, service.claimState(input.itemId).state);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Sessions over the protocol — ADE §5.5, build-order step 4.
|
|
415
|
+
*
|
|
416
|
+
* The substrate has had attach, takeover and input routing all along; none of
|
|
417
|
+
* it was reachable from a surface, which is the same "built, tested,
|
|
418
|
+
* unreachable" shape this repo has shipped repeatedly. These methods are thin
|
|
419
|
+
* on purpose: every rule worth trusting lives in `AgentSessionService` and
|
|
420
|
+
* `writeLease`, where it is tested without a host.
|
|
421
|
+
*/
|
|
422
|
+
listSessions(input) {
|
|
423
|
+
const service = this.sessionService;
|
|
424
|
+
if (!service) {
|
|
425
|
+
// Distinct from "no sessions". A surface that cannot tell them apart
|
|
426
|
+
// waits for sessions that can never appear.
|
|
427
|
+
return { available: false, sessions: [], reason: 'This host has no session substrate mounted.' };
|
|
428
|
+
}
|
|
429
|
+
const sessions = service.sessions()
|
|
430
|
+
.filter((session) => !input.runId || session.runId === input.runId)
|
|
431
|
+
.map((session) => summariseSession(session));
|
|
432
|
+
return { available: true, sessions };
|
|
433
|
+
}
|
|
434
|
+
attachSession(input) {
|
|
435
|
+
const service = this.sessionService;
|
|
436
|
+
if (!service)
|
|
437
|
+
return { ok: false, reason: 'This host has no session substrate mounted.' };
|
|
438
|
+
/**
|
|
439
|
+
* 🔴 The `onData` sink is deliberately EMPTY.
|
|
440
|
+
*
|
|
441
|
+
* Attaching registers a viewer and makes the session's viewer count true;
|
|
442
|
+
* it does not stream bytes to this caller, because the host protocol is
|
|
443
|
+
* request/response and its event feed is a broadcast that must never carry
|
|
444
|
+
* PTY output. Output is read with `session.snapshot`.
|
|
445
|
+
*
|
|
446
|
+
* The retention buffer is subscribed at OPEN, not here, so a viewer that
|
|
447
|
+
* attaches late still sees what happened while nobody was watching.
|
|
448
|
+
*/
|
|
449
|
+
const ok = service.attach(input.sessionId, input.viewerId, () => { });
|
|
450
|
+
if (!ok) {
|
|
451
|
+
return {
|
|
452
|
+
ok: false,
|
|
453
|
+
reason: service.session(input.sessionId)
|
|
454
|
+
? 'That viewer is already attached to this session.'
|
|
455
|
+
: 'No such session.',
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const session = service.session(input.sessionId);
|
|
459
|
+
return { ok: true, ...(session ? { session: summariseSession(session) } : {}) };
|
|
460
|
+
}
|
|
461
|
+
detachSession(input) {
|
|
462
|
+
const service = this.sessionService;
|
|
463
|
+
if (!service)
|
|
464
|
+
return { ok: false, reason: 'This host has no session substrate mounted.' };
|
|
465
|
+
const ok = service.detach(input.sessionId, input.viewerId);
|
|
466
|
+
// A session outlives its viewers — detaching is not closing.
|
|
467
|
+
if (!ok)
|
|
468
|
+
return { ok: false, reason: 'That viewer is not attached to this session.' };
|
|
469
|
+
const session = service.session(input.sessionId);
|
|
470
|
+
return { ok: true, ...(session ? { session: summariseSession(session) } : {}) };
|
|
471
|
+
}
|
|
472
|
+
snapshotSession(input) {
|
|
473
|
+
const service = this.sessionService;
|
|
474
|
+
const snapshot = service?.snapshot(input.sessionId);
|
|
475
|
+
if (!snapshot) {
|
|
476
|
+
return {
|
|
477
|
+
available: false,
|
|
478
|
+
output: '',
|
|
479
|
+
// NOT `truncated: false`. Nothing was read, so nothing is known about
|
|
480
|
+
// whether output was dropped, and a false here would be a claim.
|
|
481
|
+
truncated: false,
|
|
482
|
+
reason: service ? 'No such session.' : 'This host has no session substrate mounted.',
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
return { available: true, output: snapshot.output, truncated: snapshot.truncated };
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Hands a run what its peers said — ADE §5.2b's `message` primitive.
|
|
489
|
+
*
|
|
490
|
+
* 🔴 Nothing called `deliverTo`. Messages were sent, journalled, reported
|
|
491
|
+
* `delivered` to the sender — and sat in the bus forever, because the only
|
|
492
|
+
* thing that empties an inbox had no caller anywhere in the repo. The
|
|
493
|
+
* primitive was half a pipe: every agent could send and no agent could
|
|
494
|
+
* receive, while the sender was told it had been delivered.
|
|
495
|
+
*
|
|
496
|
+
* At TURN START and in the coordinator, for the same reason §5.8's knowledge
|
|
497
|
+
* injection is: a push that each lane had to remember would fail silently in
|
|
498
|
+
* the lane that forgot, and a prompt merely missing its messages looks exactly
|
|
499
|
+
* like a run nobody wrote to.
|
|
500
|
+
*
|
|
501
|
+
* Prepended to `systemPrompt` alongside knowledge rather than appended to the
|
|
502
|
+
* user's prompt — what a peer said is standing context for this turn, not
|
|
503
|
+
* something the user typed, and putting it in their words would misattribute
|
|
504
|
+
* it.
|
|
505
|
+
*
|
|
506
|
+
* ⚠️ Draining also sets the run's CAUSE, which is what makes gate 8
|
|
507
|
+
* attribution and §8 loop detection work at all: a message this run sends
|
|
508
|
+
* while handling another inherits the chain. Both were inert for the same
|
|
509
|
+
* reason.
|
|
510
|
+
*/
|
|
511
|
+
deliverCoordinationMessages(request) {
|
|
512
|
+
const service = this.coordinationService;
|
|
513
|
+
const runId = this.resolveRunId(request);
|
|
514
|
+
if (!service || !runId)
|
|
515
|
+
return;
|
|
516
|
+
try {
|
|
517
|
+
const block = renderInbox(service.deliverTo(runId));
|
|
518
|
+
// An empty inbox injects NOTHING — not a heading, not a blank line. Prompt
|
|
519
|
+
// space spent saying "no messages" is prompt space taken from something.
|
|
520
|
+
if (!block)
|
|
521
|
+
return;
|
|
522
|
+
const heading = 'Messages from other runs in this fleet:';
|
|
523
|
+
request.systemPrompt = request.systemPrompt
|
|
524
|
+
? `${heading}\n${block}\n\n${request.systemPrompt}`
|
|
525
|
+
: `${heading}\n${block}`;
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
// A failure here does NOT fail the turn, matching the knowledge
|
|
529
|
+
// injection: the run is the user's actual work. But note the asymmetry —
|
|
530
|
+
// a dropped message is a LOSS, not a missing enrichment, and the drain
|
|
531
|
+
// already published `coordination.message.delivered` to the timeline, so
|
|
532
|
+
// a reader can still see what arrived.
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* A run's activity changed — §7's run state and last decision.
|
|
537
|
+
*
|
|
538
|
+
* 🔴 `awaiting-approval` had no producer at all, and the gap is not
|
|
539
|
+
* cosmetic: a peer deciding whether to wait for a run or take the work itself
|
|
540
|
+
* needs "blocked on a human" to look different from "busy". A run reported
|
|
541
|
+
* `running` while it sits on a permission prompt is the one case where
|
|
542
|
+
* waiting is guaranteed to be wrong.
|
|
543
|
+
*
|
|
544
|
+
* Public because the signal originates in a turn adapter, which the
|
|
545
|
+
* composition builds BEFORE this host exists — the same ordering
|
|
546
|
+
* `setEventSink` already solves for coordination events.
|
|
547
|
+
*/
|
|
548
|
+
reportRunActivity(signal) {
|
|
549
|
+
this.registerCoordinationRun(signal.runId, signal.state, signal.lastDecision);
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Puts a run on the coordination roster — ADE §5.2b.
|
|
553
|
+
*
|
|
554
|
+
* 🔴 Nothing called `registerRun`. The primitives were reachable from both
|
|
555
|
+
* lanes and INERT: `peers()` returns an empty list for a caller it has never
|
|
556
|
+
* heard of, so every agent asking who else was working got the same answer as
|
|
557
|
+
* an agent genuinely alone. The build was complete and the feature did
|
|
558
|
+
* nothing — the shape this repo keeps finding one level further in.
|
|
559
|
+
*
|
|
560
|
+
* An UPSERT, called at both ends of a turn, because a run spans many turns and
|
|
561
|
+
* its state changes: `running` while a turn is in flight, `idle` between them.
|
|
562
|
+
* A run is never removed here; `endRun` belongs to whoever created the run,
|
|
563
|
+
* and claims have lease expiry as the backstop for a host that never got the
|
|
564
|
+
* news.
|
|
565
|
+
*
|
|
566
|
+
* ⚠️ `complete: false`, always, and honestly. This host observes FILES — the
|
|
567
|
+
* process sensor lives behind `xeno-use` and only the file-change port is
|
|
568
|
+
* consumed here — so `commandsRun` is empty because commands are UNOBSERVED,
|
|
569
|
+
* not because none ran. A peer must be able to tell those apart.
|
|
570
|
+
*/
|
|
571
|
+
registerCoordinationRun(runId, state, lastDecision) {
|
|
572
|
+
const service = this.coordinationService;
|
|
573
|
+
// \u{1F534} Read from `runContexts`, not from a request handed in. A state change
|
|
574
|
+
// arrives mid-turn from the permission bridge, which has no turn request —
|
|
575
|
+
// and a version of this that needed one could only ever be called at the
|
|
576
|
+
// two moments a turn begins and ends, which is exactly the granularity that
|
|
577
|
+
// made `awaiting-approval` unreachable.
|
|
578
|
+
const context = this.runContexts.get(runId);
|
|
579
|
+
if (!service || !context)
|
|
580
|
+
return;
|
|
581
|
+
// Live, from tier-1 observation, rather than a list this method maintains:
|
|
582
|
+
// a second copy would disagree with the diff a reviewer is looking at.
|
|
583
|
+
const observed = this.runObservationService?.snapshot(runId);
|
|
584
|
+
// Carried forward when this update does not bring a new one: `undefined`
|
|
585
|
+
// means "no change", and overwriting a real decision with it would erase
|
|
586
|
+
// §7's fourth field every time the run merely went idle.
|
|
587
|
+
const previous = service.runSummary(runId, runId);
|
|
588
|
+
const decision = lastDecision ?? previous?.lastDecision;
|
|
589
|
+
service.registerRun({
|
|
590
|
+
runId,
|
|
591
|
+
// §3: human-readable, defaulting from the workspace. The run id is the
|
|
592
|
+
// honest fallback — it is at least the thing a peer would address.
|
|
593
|
+
name: context.workspaceId || runId,
|
|
594
|
+
state,
|
|
595
|
+
// '' when the request names no workspace, which `canSee` treats as
|
|
596
|
+
// matching NOTHING rather than matching every other unnamed run.
|
|
597
|
+
workspace: context.workspaceId ?? '',
|
|
598
|
+
filesTouched: (observed?.changes ?? []).map((change) => change.path),
|
|
599
|
+
commandsRun: [],
|
|
600
|
+
complete: false,
|
|
601
|
+
...(decision === undefined ? {} : { lastDecision: decision }),
|
|
602
|
+
// §6: the input to `deriveInboundPolicy`.
|
|
603
|
+
//
|
|
604
|
+
// Per-TURN, and that is correct rather than incidental: `upsertRun`
|
|
605
|
+
// REPLACES the record, and `runContexts` is rewritten at each turn start,
|
|
606
|
+
// so a turn that declares no capabilities leaves the posture unknown again
|
|
607
|
+
// instead of inheriting the last turn's. A posture is "what this run is
|
|
608
|
+
// executing with *now*" — carrying a stale one forward would judge a
|
|
609
|
+
// message against authority the run no longer has.
|
|
610
|
+
//
|
|
611
|
+
// Spread-omitted rather than set to `undefined` because the field is
|
|
612
|
+
// declared optional under `exactOptionalPropertyTypes`.
|
|
613
|
+
...(context.posture === undefined ? {} : { posture: context.posture }),
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Gives a run a session — ADE §2.8, and the step §9 has been stuck on.
|
|
618
|
+
*
|
|
619
|
+
* Opened at TURN START rather than on demand, because a session is where a
|
|
620
|
+
* person looks to see what the agent is doing, and one that only exists after
|
|
621
|
+
* someone asks for it is not there during the minute they most want it.
|
|
622
|
+
*
|
|
623
|
+
* 🔴 Every ingredient is derived, none is asserted:
|
|
624
|
+
*
|
|
625
|
+
* - the render support comes from the ADAPTERS (`renderSupportFor`), so a
|
|
626
|
+
* provider nothing can execute is refused by name instead of opening a
|
|
627
|
+
* session that would render nothing;
|
|
628
|
+
* - the protocol channel is the run's OWN runtime-event stream, already
|
|
629
|
+
* redacted and already durable, so the terminal is a rendering of the run
|
|
630
|
+
* rather than a second opinion about it;
|
|
631
|
+
* - the PTY has no process behind it, because in `acp` there is nothing to
|
|
632
|
+
* attach to — and `createBufferedSessionPty` throws rather than silently
|
|
633
|
+
* swallowing a write, so a broken mode rule surfaces instead of losing
|
|
634
|
+
* what someone typed.
|
|
635
|
+
*
|
|
636
|
+
* Failures are contained for the same reason the observation binder's are: a
|
|
637
|
+
* turn is the user's actual work, and a session is an observability surface
|
|
638
|
+
* over it. A run without a session is honest — `session.list` simply will not
|
|
639
|
+
* show it — whereas a turn that failed to start because a terminal could not
|
|
640
|
+
* be opened would be absurd.
|
|
641
|
+
*
|
|
642
|
+
* ⚠️ One session per RUN, not per turn. A second turn on the same run finds
|
|
643
|
+
* the session already open and leaves it alone; re-opening would discard the
|
|
644
|
+
* output a viewer is reading mid-conversation.
|
|
645
|
+
*/
|
|
646
|
+
openSessionForRun(runId, provider) {
|
|
647
|
+
const service = this.sessionService;
|
|
648
|
+
// No "already open?" check: `open` REFUSES `session_exists`, and a second
|
|
649
|
+
// statement of that rule here would be free to drift from it. Removing this
|
|
650
|
+
// guard changed no test, which is how it was found.
|
|
651
|
+
if (!service)
|
|
652
|
+
return;
|
|
653
|
+
try {
|
|
654
|
+
const support = renderSupportFor(provider, { adapters: [...this.turnExecutionAdapters.values()] });
|
|
655
|
+
const protocol = createRuntimeEventSessionChannel({
|
|
656
|
+
runId,
|
|
657
|
+
subscribe: (listener) => this.subscribe((event) => {
|
|
658
|
+
if (event.type !== 'runtime.event.appended')
|
|
659
|
+
return;
|
|
660
|
+
const payload = event.payload;
|
|
661
|
+
if (isAgentRuntimeEventEnvelope(payload?.event))
|
|
662
|
+
listener(payload.event);
|
|
663
|
+
}),
|
|
664
|
+
});
|
|
665
|
+
const opened = service.open({
|
|
666
|
+
sessionId: runId,
|
|
667
|
+
runId,
|
|
668
|
+
support,
|
|
669
|
+
pty: createBufferedSessionPty(),
|
|
670
|
+
protocol,
|
|
671
|
+
});
|
|
672
|
+
// A refusal is the substrate doing its job — an unexecutable provider has
|
|
673
|
+
// nothing to render. Close the channel we built for it rather than leaving
|
|
674
|
+
// a subscription attached to a session that never existed.
|
|
675
|
+
if (!opened.ok)
|
|
676
|
+
protocol.close();
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
// Contained: see above. The run proceeds without a session.
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
async routeSessionInput(input) {
|
|
683
|
+
const service = this.sessionService;
|
|
684
|
+
if (!service)
|
|
685
|
+
return { ok: false, reason: 'This host has no session substrate mounted.' };
|
|
686
|
+
// \u{1F534} DERIVED here, never accepted from the caller. Both facts are the
|
|
687
|
+
// host's: it holds the active turns, and it knows whether the run's adapter
|
|
688
|
+
// can steer — it already refuses `steer_unsupported` on that same basis.
|
|
689
|
+
const runId = service.session(input.sessionId)?.runId;
|
|
690
|
+
const active = runId ? this.activeTurnForRun(runId) : undefined;
|
|
691
|
+
const result = service.routeInput(input.sessionId, input.viewerId, input.data, {
|
|
692
|
+
turnInFlight: active !== undefined,
|
|
693
|
+
acceptsMidTurnSteering: active?.adapter.steer !== undefined,
|
|
694
|
+
});
|
|
695
|
+
if (!result.ok) {
|
|
696
|
+
// Who holds it, by name. "Someone else is typing" leaves the reader with
|
|
697
|
+
// no next action; naming the surface tells them whom to ask.
|
|
698
|
+
return result.code === 'lease_held'
|
|
699
|
+
? {
|
|
700
|
+
ok: false,
|
|
701
|
+
code: 'lease_held',
|
|
702
|
+
reason: `${result.holder} has taken over this session.`,
|
|
703
|
+
}
|
|
704
|
+
: { ok: false, code: 'no_such_session', reason: 'No such session.' };
|
|
705
|
+
}
|
|
706
|
+
const owed = await this.deliverOwedSessionInput(input.sessionId, input.command, input.data, result.delivery);
|
|
707
|
+
return {
|
|
708
|
+
ok: true,
|
|
709
|
+
disposition: result.routing.disposition,
|
|
710
|
+
delivered: owed.delivered,
|
|
711
|
+
sink: result.delivery.sink,
|
|
712
|
+
...(owed.queued === undefined ? {} : { queued: owed.queued }),
|
|
713
|
+
...(owed.reason === undefined ? {} : { reason: owed.reason }),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* The turn a run is executing right now, if any.
|
|
718
|
+
*
|
|
719
|
+
* §2.8's routing needs two facts — is a turn in flight, and can this agent
|
|
720
|
+
* take a correction mid-turn — and both live here. `activeTurns` is keyed by
|
|
721
|
+
* conversation and request, so finding a RUN's turn means asking
|
|
722
|
+
* `resolveRunId` about each one, the same derivation everything else uses.
|
|
723
|
+
*/
|
|
724
|
+
activeTurnForRun(runId) {
|
|
725
|
+
for (const active of this.activeTurns.values()) {
|
|
726
|
+
if (this.resolveRunId(active.request) === runId)
|
|
727
|
+
return active;
|
|
728
|
+
}
|
|
729
|
+
return undefined;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Delivers what the substrate could not, through the ONE path that can.
|
|
733
|
+
*
|
|
734
|
+
* §2.8 promises typing never falls into a void, and `AgentSessionService`
|
|
735
|
+
* keeps its half by refusing to write into a PTY with no process behind it. It
|
|
736
|
+
* deliberately stops there: an `acp` steer travels `turn.steer`, where
|
|
737
|
+
* acknowledgement, idempotency and the named refusal already live, and a
|
|
738
|
+
* second delivery path from inside the substrate would be the unacknowledged
|
|
739
|
+
* one §5.5 measured to be wrong.
|
|
740
|
+
*
|
|
741
|
+
* So the host — which owns execution — closes the loop here, for BOTH input
|
|
742
|
+
* doors. Splitting it would leave the promise held at one entrance again.
|
|
743
|
+
*
|
|
744
|
+
* A failure is REPORTED, never swallowed: `turn_not_active` in particular is
|
|
745
|
+
* an ordinary outcome (someone typed between turns) and its message already
|
|
746
|
+
* tells the reader to queue instead. Reporting `delivered: true` there would
|
|
747
|
+
* be the precise lie this whole chain exists to prevent.
|
|
748
|
+
*/
|
|
749
|
+
async deliverOwedSessionInput(sessionId, command, data, delivery) {
|
|
750
|
+
if (delivery.delivered || delivery.sink === 'pty')
|
|
751
|
+
return { delivered: delivery.delivered };
|
|
752
|
+
const runId = this.sessionService?.session(sessionId)?.runId;
|
|
753
|
+
const context = runId ? this.runContexts.get(runId) : undefined;
|
|
754
|
+
if (!context) {
|
|
755
|
+
return {
|
|
756
|
+
delivered: false,
|
|
757
|
+
reason: delivery.sink === 'turn.queue'
|
|
758
|
+
? 'That session has no run to queue against. Start a turn so the instruction has somewhere to go.'
|
|
759
|
+
: 'That session has no run to steer. Start a turn so the instruction has somewhere to go.',
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* 🔴 The `turn.queue` sink, SERVICED.
|
|
764
|
+
*
|
|
765
|
+
* `routeHumanInput` returns `disposition: 'queue'` when a turn is in flight
|
|
766
|
+
* and the agent does not take mid-turn steering, and its own reason string
|
|
767
|
+
* promises the input is "delivered as the next turn". Nothing kept that
|
|
768
|
+
* promise: this method returned early for every sink but `turn.steer`, so
|
|
769
|
+
* the keystrokes were dropped — the void `AgentSessionInputResult` says
|
|
770
|
+
* deliberately does not exist ("there is deliberately no `drop`: typing
|
|
771
|
+
* never falls into a void").
|
|
772
|
+
*
|
|
773
|
+
* It also left the host's whole turn queue with no writer, which is why
|
|
774
|
+
* `turn.queue` / `.list` / `.drop` were built, tested and reachable by
|
|
775
|
+
* nothing.
|
|
776
|
+
*
|
|
777
|
+
* `delivered` stays FALSE on purpose: the agent has not seen this. Queued
|
|
778
|
+
* is not delivered, and the id is what says the queue took it.
|
|
779
|
+
*/
|
|
780
|
+
if (delivery.sink === 'turn.queue') {
|
|
781
|
+
const queued = this.queueTurnInstruction({
|
|
782
|
+
command,
|
|
783
|
+
conversationId: context.conversationId,
|
|
784
|
+
instruction: data,
|
|
785
|
+
});
|
|
786
|
+
if (!queued.success)
|
|
787
|
+
return { delivered: false, reason: queued.safeError };
|
|
788
|
+
const entry = queued.queue[queued.queue.length - 1];
|
|
789
|
+
return {
|
|
790
|
+
delivered: false,
|
|
791
|
+
// The conversation rides along because that is what `turn.queue.drop`
|
|
792
|
+
// is keyed by, and the session only knows its run. Without it the id is
|
|
793
|
+
// evidence a surface can render and not act on.
|
|
794
|
+
...(entry
|
|
795
|
+
? { queued: { instructionId: entry.id, conversationId: context.conversationId } }
|
|
796
|
+
: {}),
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
const steer = await this.steerTurn({
|
|
800
|
+
command,
|
|
801
|
+
conversationId: context.conversationId,
|
|
802
|
+
requestId: context.requestId,
|
|
803
|
+
instruction: data,
|
|
804
|
+
});
|
|
805
|
+
return steer.success ? { delivered: true } : { delivered: false, reason: steer.safeError };
|
|
806
|
+
}
|
|
807
|
+
async takeOverSession(input) {
|
|
808
|
+
const service = this.sessionService;
|
|
809
|
+
if (!service)
|
|
810
|
+
return { ok: false, reason: 'This host has no session substrate mounted.' };
|
|
811
|
+
if (!service.session(input.sessionId))
|
|
812
|
+
return { ok: false, reason: 'No such session.' };
|
|
813
|
+
const outcome = service.takeOver(input.sessionId, input.viewerId, input.data);
|
|
814
|
+
if (!outcome) {
|
|
815
|
+
/**
|
|
816
|
+
* 🔴 The session EXISTS — checked immediately above — so `undefined` here
|
|
817
|
+
* means REFUSED, not missing.
|
|
818
|
+
*
|
|
819
|
+
* `takeOver` returns `undefined` for both, which is fine inside the
|
|
820
|
+
* substrate (the caller there has the session in hand) and is exactly the
|
|
821
|
+
* conflation a protocol surface must not pass on: "no such session" sends
|
|
822
|
+
* someone to re-open a terminal, while "someone else is typing" tells them
|
|
823
|
+
* to go talk to a colleague. The pre-check is what keeps them apart.
|
|
824
|
+
*/
|
|
825
|
+
const current = service.session(input.sessionId)?.lease;
|
|
826
|
+
const holder = current && current.holder.kind === 'human' ? current.holder.viewerId : null;
|
|
827
|
+
return {
|
|
828
|
+
ok: false,
|
|
829
|
+
...(current ? { lease: summariseLease(current) } : {}),
|
|
830
|
+
reason: holder
|
|
831
|
+
? `Another person is typing in this session (${holder}).`
|
|
832
|
+
: 'The write lease could not be taken.',
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
const owed = await this.deliverOwedSessionInput(input.sessionId, input.command, input.data, outcome.delivery);
|
|
836
|
+
return {
|
|
837
|
+
ok: true,
|
|
838
|
+
lease: summariseLease(outcome.lease),
|
|
839
|
+
delivered: owed.delivered,
|
|
840
|
+
sink: outcome.delivery.sink,
|
|
841
|
+
...(owed.reason === undefined ? {} : { reason: owed.reason }),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
releaseSession(input) {
|
|
845
|
+
const service = this.sessionService;
|
|
846
|
+
if (!service)
|
|
847
|
+
return { ok: false, reason: 'This host has no session substrate mounted.' };
|
|
848
|
+
const ok = service.releaseTakeOver(input.sessionId, input.viewerId);
|
|
849
|
+
if (!ok)
|
|
850
|
+
return { ok: false, reason: 'That viewer does not hold the write lease.' };
|
|
851
|
+
const session = service.session(input.sessionId);
|
|
852
|
+
return { ok: true, ...(session ? { session: summariseSession(session) } : {}) };
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Every run this host knows about — ADE §9 step 6.
|
|
856
|
+
*
|
|
857
|
+
* Composed from three subsystems rather than a fourth record, because a
|
|
858
|
+
* separate fleet ledger would be a second copy of facts that already exist and
|
|
859
|
+
* would disagree with them the first time one was updated and the other was
|
|
860
|
+
* not.
|
|
861
|
+
*
|
|
862
|
+
* 🔴 The completeness flags are the load-bearing part. A fleet view showing
|
|
863
|
+
* three collisions when five happened, or four changed files when a drain was
|
|
864
|
+
* dropped, is worse than no fleet view: it is the shape a person uses to
|
|
865
|
+
* decide the work is safe to land.
|
|
866
|
+
*/
|
|
867
|
+
/**
|
|
868
|
+
* Plans a fan-out WITHOUT starting anything — ADE §5.2's scheduler.
|
|
869
|
+
*
|
|
870
|
+
* Deliberately separate from dispatch. A plan is the reviewable artifact: it
|
|
871
|
+
* shows which agent gets which task, which tasks nothing can run and why, and
|
|
872
|
+
* how the load actually spread — all before a single agent touches a
|
|
873
|
+
* repository. Fusing plan and execute would remove the only moment at which
|
|
874
|
+
* any of that can be checked.
|
|
875
|
+
*/
|
|
876
|
+
planFleetFanOut(input) {
|
|
877
|
+
const plan = planFanOut([...this.providers.values()], (input.tasks ?? []).map((task) => ({
|
|
878
|
+
taskId: task.taskId,
|
|
879
|
+
...(task.requiredCapabilities === undefined
|
|
880
|
+
? {}
|
|
881
|
+
: { requiredCapabilities: task.requiredCapabilities }),
|
|
882
|
+
})));
|
|
883
|
+
return {
|
|
884
|
+
assignments: plan.assignments.map((assignment) => ({
|
|
885
|
+
taskId: assignment.taskId,
|
|
886
|
+
providerId: assignment.providerId,
|
|
887
|
+
degraded: [...assignment.degraded],
|
|
888
|
+
})),
|
|
889
|
+
refusals: plan.refusals.map((refusal) => ({ ...refusal })),
|
|
890
|
+
loadByProvider: { ...plan.loadByProvider },
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Which agents could run a task requiring these capabilities — ADE §5.2.
|
|
895
|
+
*
|
|
896
|
+
* A pure QUERY over the live provider catalog: it starts nothing, so a
|
|
897
|
+
* surface or a scheduler can ask before committing to anything.
|
|
898
|
+
*
|
|
899
|
+
* 🔴 The refusal REASON is the deliverable, not the boolean. §5.2's gate is
|
|
900
|
+
* that a task requiring an undeclared capability is "refused with a named
|
|
901
|
+
* reason" — so this reports which capability nobody declares, and
|
|
902
|
+
* distinguishes that from every capable agent merely being down. Those two
|
|
903
|
+
* send a user to completely different places.
|
|
904
|
+
*
|
|
905
|
+
* The matching rules live in `routeByCapability` (provider-neutral, in
|
|
906
|
+
* `core`) rather than here: this method's job is to supply the catalog and
|
|
907
|
+
* shape the answer for the wire.
|
|
908
|
+
*/
|
|
909
|
+
routeByCapabilities(input) {
|
|
910
|
+
const decision = routeByCapability([...this.providers.values()], input.requiredCapabilities ?? []);
|
|
911
|
+
if (decision.routable) {
|
|
912
|
+
return {
|
|
913
|
+
routable: true,
|
|
914
|
+
candidates: decision.matches.map((match) => ({
|
|
915
|
+
providerId: match.providerId,
|
|
916
|
+
degraded: [...match.degraded],
|
|
917
|
+
})),
|
|
918
|
+
rejected: decision.rejected.map((entry) => ({
|
|
919
|
+
providerId: entry.providerId,
|
|
920
|
+
...(entry.capability === undefined ? {} : { capability: entry.capability }),
|
|
921
|
+
reason: entry.reason,
|
|
922
|
+
})),
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
return {
|
|
926
|
+
routable: false,
|
|
927
|
+
candidates: [],
|
|
928
|
+
rejected: decision.rejected.map((entry) => ({
|
|
929
|
+
providerId: entry.providerId,
|
|
930
|
+
...(entry.capability === undefined ? {} : { capability: entry.capability }),
|
|
931
|
+
reason: entry.reason,
|
|
932
|
+
})),
|
|
933
|
+
reason: decision.reason,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
readFleetView(input) {
|
|
937
|
+
const problems = [];
|
|
938
|
+
// 🔴 Claims come from the SERVICE and collisions from the timeline, and the
|
|
939
|
+
// split is not an inconsistency — the two answer different questions.
|
|
940
|
+
//
|
|
941
|
+
// A claim is current state: the registry knows what is held right now,
|
|
942
|
+
// including leases that have since expired and runs that never started a
|
|
943
|
+
// turn. Projecting claims from events instead would MISS a run that claimed
|
|
944
|
+
// a path without a conversation — which is exactly the run worth seeing,
|
|
945
|
+
// because it has locked something and is about to collide with someone.
|
|
946
|
+
//
|
|
947
|
+
// A collision is history: the registry refuses the second claimant and keeps
|
|
948
|
+
// no record of the refusal, so the timeline is the only place it survives.
|
|
949
|
+
// `fleetProjection` retains them deliberately — "nobody collided" and "a
|
|
950
|
+
// collision happened and was handled" are different histories.
|
|
951
|
+
const liveClaims = this.coordinationService?.claims() ?? [];
|
|
952
|
+
const heldScopes = new Set(liveClaims.map((claim) => describeClaimScope(claim.scope)));
|
|
953
|
+
// 🔴 The collision record is NEVER complete today, and this says so.
|
|
954
|
+
//
|
|
955
|
+
// The first version of this line read `this.runtimeEventRepository ===
|
|
956
|
+
// undefined`, which was exactly backwards: with no repository the host
|
|
957
|
+
// records no coordination timeline at all, so collisions are always empty —
|
|
958
|
+
// and it reported that as COMPLETE. An empty list plus a complete flag is
|
|
959
|
+
// read as "nobody collided", which is the false-clean failure this whole
|
|
960
|
+
// family of flags exists to prevent. Found by running the test, not by
|
|
961
|
+
// reading the code.
|
|
962
|
+
//
|
|
963
|
+
// With a repository it is still partial: the collisions are projected from
|
|
964
|
+
// an in-memory cache of what this host has seen since it started, not from
|
|
965
|
+
// the durable timeline. Both cases are false; they differ in WHY, and the
|
|
966
|
+
// reason is what a reader can act on.
|
|
967
|
+
const coordinationComplete = false;
|
|
968
|
+
problems.push('Collisions cover what this host has seen since it started; earlier ones are not held anywhere.');
|
|
969
|
+
const claimsByRun = new Map();
|
|
970
|
+
for (const claim of liveClaims) {
|
|
971
|
+
claimsByRun.set(claim.runId, [...(claimsByRun.get(claim.runId) ?? []), describeClaimScope(claim.scope)]);
|
|
972
|
+
}
|
|
973
|
+
// §5.1 provenance, read per RUN. Absent when the host has no intake mounted
|
|
974
|
+
// OR when this run was not dispatched from an item — both mean "no intake
|
|
975
|
+
// origin to show", and neither is a claim that one is unknown.
|
|
976
|
+
const intakeOriginFor = (runId) => this.intakeService?.originFor(runId);
|
|
977
|
+
const overlaps = this.runObservationService?.landingOverlaps(input.claimedPathsByRun ?? {});
|
|
978
|
+
const contestedByRun = new Map();
|
|
979
|
+
for (const overlap of overlaps?.overlaps ?? []) {
|
|
980
|
+
for (const runId of overlap.runIds) {
|
|
981
|
+
contestedByRun.set(runId, [...(contestedByRun.get(runId) ?? []), overlap.path]);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
// Every run anything knows about, not just the observed ones: a run with a
|
|
985
|
+
// claim and no observation is exactly the run a person needs to see.
|
|
986
|
+
const runIds = new Set([
|
|
987
|
+
...this.runContexts.keys(),
|
|
988
|
+
...claimsByRun.keys(),
|
|
989
|
+
...this.fleetCollisions.flatMap((collision) => [collision.wantedByRunId, collision.heldByRunId]),
|
|
990
|
+
...(this.runObservationService?.observedRuns() ?? []),
|
|
991
|
+
]);
|
|
992
|
+
// One lookup built once: `runSummary(runId, runId)` is how a run reads its
|
|
993
|
+
// OWN record, which is the only view guaranteed visible regardless of
|
|
994
|
+
// workspace.
|
|
995
|
+
const service = this.coordinationService;
|
|
996
|
+
const rosterState = service
|
|
997
|
+
? (runId) => service.runSummary(runId, runId)?.state
|
|
998
|
+
: undefined;
|
|
999
|
+
const workingRuns = new Set();
|
|
1000
|
+
for (const active of this.activeTurns.values()) {
|
|
1001
|
+
const runId = this.resolveRunId(active.request);
|
|
1002
|
+
if (runId)
|
|
1003
|
+
workingRuns.add(runId);
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* 🔴 Starts FALSE when nothing is observing.
|
|
1007
|
+
*
|
|
1008
|
+
* It used to start `true` unconditionally and only go false when a snapshot
|
|
1009
|
+
* existed and reported a hole — so a host with no observation substrate,
|
|
1010
|
+
* where `snapshot` is always undefined, announced that every diff was the
|
|
1011
|
+
* whole story. Exactly the shape already fixed once in
|
|
1012
|
+
* `coordinationComplete`, which is why the fix is the same: say why.
|
|
1013
|
+
*/
|
|
1014
|
+
let observationComplete = this.runObservationService !== undefined;
|
|
1015
|
+
if (!observationComplete) {
|
|
1016
|
+
problems.push('No run observation is configured, so no run\'s changes are known.');
|
|
1017
|
+
}
|
|
1018
|
+
const runs = [...runIds].sort().map((runId) => {
|
|
1019
|
+
const context = this.runContexts.get(runId);
|
|
1020
|
+
const snapshot = this.runObservationService?.snapshot(runId);
|
|
1021
|
+
const pinnedInboundPolicy = service?.inboundPolicyFor(runId).explicit;
|
|
1022
|
+
const isolation = this.runIsolationService.report(runId);
|
|
1023
|
+
// A run nobody observed is not a complete picture either. Absent and
|
|
1024
|
+
// incomplete are different facts about the RUN — see `diffComplete` —
|
|
1025
|
+
// but for the FLEET both mean the same thing: this view cannot be read as
|
|
1026
|
+
// the whole story.
|
|
1027
|
+
if (!snapshot && this.runObservationService) {
|
|
1028
|
+
observationComplete = false;
|
|
1029
|
+
problems.push(`${runId}: not observed, so its changes are unknown.`);
|
|
1030
|
+
}
|
|
1031
|
+
if (snapshot && !snapshot.complete) {
|
|
1032
|
+
observationComplete = false;
|
|
1033
|
+
for (const problem of snapshot.problems)
|
|
1034
|
+
problems.push(`${runId}: ${problem}`);
|
|
1035
|
+
}
|
|
1036
|
+
return {
|
|
1037
|
+
runId,
|
|
1038
|
+
...(context?.conversationId ? { conversationId: context.conversationId } : {}),
|
|
1039
|
+
...(context?.workspaceId ? { workspaceId: context.workspaceId } : {}),
|
|
1040
|
+
...(context?.agentId ? { agentId: context.agentId } : {}),
|
|
1041
|
+
working: workingRuns.has(runId),
|
|
1042
|
+
claims: (claimsByRun.get(runId) ?? []).sort(),
|
|
1043
|
+
...(snapshot
|
|
1044
|
+
? {
|
|
1045
|
+
changes: countChangeKinds(snapshot.changes),
|
|
1046
|
+
// Present ONLY when observed. Absent and `false` are different
|
|
1047
|
+
// facts: "nobody watched" versus "watched, with a hole".
|
|
1048
|
+
diffComplete: snapshot.complete,
|
|
1049
|
+
}
|
|
1050
|
+
: {}),
|
|
1051
|
+
...(intakeOriginFor(runId) ? { intakeItemId: intakeOriginFor(runId) } : {}),
|
|
1052
|
+
contestedPaths: (contestedByRun.get(runId) ?? []).sort(),
|
|
1053
|
+
// From the ROSTER, the same state a peer sees. Absent without a
|
|
1054
|
+
// coordination service: no roster means no answer, and `false` would
|
|
1055
|
+
// assert the run is not waiting.
|
|
1056
|
+
...(rosterState
|
|
1057
|
+
? { awaitingApproval: rosterState(runId) === 'awaiting-approval' }
|
|
1058
|
+
: {}),
|
|
1059
|
+
// §6 `hold`, as a COUNT. Absent without a coordination service for the
|
|
1060
|
+
// same reason as the line above: no roster, no answer.
|
|
1061
|
+
...(service ? { heldMessages: service.heldFor(runId).length } : {}),
|
|
1062
|
+
// §6's EXPLICIT override only — absent when the derivation decides.
|
|
1063
|
+
//
|
|
1064
|
+
// Deliberately not the effective policy: §6 derives comparatively, so
|
|
1065
|
+
// "what happens to a message" depends on who sends it and has no single
|
|
1066
|
+
// value to show on a row. Reporting one would invent an answer for a
|
|
1067
|
+
// question the row cannot ask. What a human needs here is whether THEY
|
|
1068
|
+
// pinned something, because that is the only part they can undo.
|
|
1069
|
+
// Read ONCE into a local: two calls cannot be narrowed by the compiler,
|
|
1070
|
+
// and a value read twice is a value that can disagree with itself.
|
|
1071
|
+
...(pinnedInboundPolicy === undefined ? {} : { inboundPolicy: pinnedInboundPolicy }),
|
|
1072
|
+
// 🔴 ALWAYS present, unlike every other optional field on this row.
|
|
1073
|
+
// §5.3 requires the user to always know whether the thing running is
|
|
1074
|
+
// contained, and "always" is the word that forbids omitting it when the
|
|
1075
|
+
// answer is unflattering. `none` IS the answer today.
|
|
1076
|
+
isolation: isolation.isolation,
|
|
1077
|
+
...(isolation.mechanism === undefined ? {} : { isolationMechanism: isolation.mechanism }),
|
|
1078
|
+
...(isolation.reason === undefined ? {} : { isolationReason: isolation.reason }),
|
|
1079
|
+
};
|
|
1080
|
+
});
|
|
1081
|
+
return {
|
|
1082
|
+
runs,
|
|
1083
|
+
collisions: this.fleetCollisions.map((collision) => ({
|
|
1084
|
+
...collision,
|
|
1085
|
+
// Recomputed against the CURRENT claim map rather than stored: a
|
|
1086
|
+
// collision whose holder has since released is history, not something a
|
|
1087
|
+
// person still has to act on, and only the live registry knows which.
|
|
1088
|
+
stillHeld: heldScopes.has(collision.scope),
|
|
1089
|
+
})),
|
|
1090
|
+
coordinationComplete,
|
|
1091
|
+
observationComplete,
|
|
1092
|
+
problems,
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* A run's observed diff, for review.
|
|
1097
|
+
*
|
|
1098
|
+
* 🔴 Unavailable is reported as `available: false` with a reason, never as an
|
|
1099
|
+
* empty change list. They are different facts — "this run changed nothing" and
|
|
1100
|
+
* "nobody was watching" — and a surface that cannot tell them apart will show
|
|
1101
|
+
* a clean review for an unobserved run.
|
|
1102
|
+
*/
|
|
1103
|
+
/**
|
|
1104
|
+
* What a run is not being told yet — coordination §6 `hold`.
|
|
1105
|
+
*
|
|
1106
|
+
* 🔴 `available: false` when this host has no coordination service, so a
|
|
1107
|
+
* surface can tell "this host does not coordinate" from "nothing is
|
|
1108
|
+
* held". Reporting an empty list for both would make an unmounted service
|
|
1109
|
+
* look like a quiet one.
|
|
1110
|
+
*/
|
|
1111
|
+
listHeldMessages(input) {
|
|
1112
|
+
const service = this.coordinationService;
|
|
1113
|
+
if (!service) {
|
|
1114
|
+
return {
|
|
1115
|
+
available: false,
|
|
1116
|
+
runId: input.runId,
|
|
1117
|
+
held: [],
|
|
1118
|
+
reason: 'This host is not coordinating runs.',
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
return {
|
|
1122
|
+
available: true,
|
|
1123
|
+
runId: input.runId,
|
|
1124
|
+
held: service.heldFor(input.runId).map((message) => ({
|
|
1125
|
+
id: message.id,
|
|
1126
|
+
fromRunId: message.fromRunId,
|
|
1127
|
+
text: message.text,
|
|
1128
|
+
sentAt: message.sentAt,
|
|
1129
|
+
})),
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
/** A human approves one held message. §6's approval step, made reachable. */
|
|
1133
|
+
releaseHeldMessage(input) {
|
|
1134
|
+
const service = this.coordinationService;
|
|
1135
|
+
if (!service) {
|
|
1136
|
+
return {
|
|
1137
|
+
status: 'expired',
|
|
1138
|
+
messageId: input.messageId,
|
|
1139
|
+
reason: 'This host is not coordinating runs.',
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
const outcome = service.releaseHeld(input.runId, input.messageId);
|
|
1143
|
+
return {
|
|
1144
|
+
status: outcome.status,
|
|
1145
|
+
messageId: outcome.messageId,
|
|
1146
|
+
...('reason' in outcome && outcome.reason ? { reason: outcome.reason } : {}),
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* A human pins or releases a run's inbound policy — §6's override, made
|
|
1151
|
+
* reachable.
|
|
1152
|
+
*
|
|
1153
|
+
* 🔴 Deliberately a HOST method and not a lane call. `CoordinationLanePort`
|
|
1154
|
+
* exposes exactly four calls and this is not one of them, because an agent
|
|
1155
|
+
* that could set its receiver's policy could accept its own messages past the
|
|
1156
|
+
* rule that a message never moves trust upward.
|
|
1157
|
+
*/
|
|
1158
|
+
setCoordinationInboundPolicy(input) {
|
|
1159
|
+
const service = this.coordinationService;
|
|
1160
|
+
if (!service) {
|
|
1161
|
+
return { ok: false, reason: 'This host is not coordinating runs.' };
|
|
1162
|
+
}
|
|
1163
|
+
if (input.policy === null)
|
|
1164
|
+
service.clearInboundPolicy(input.runId);
|
|
1165
|
+
else
|
|
1166
|
+
service.setInboundPolicy(input.runId, input.policy);
|
|
1167
|
+
// Read back rather than echo the request: the surface must render what the
|
|
1168
|
+
// host now holds, and a result that restated the input would look identical
|
|
1169
|
+
// whether or not the write landed.
|
|
1170
|
+
const { explicit } = service.inboundPolicyFor(input.runId);
|
|
1171
|
+
return { ok: true, ...(explicit === undefined ? {} : { explicit }) };
|
|
1172
|
+
}
|
|
1173
|
+
async readRunObservation(input) {
|
|
1174
|
+
const unavailable = (reason) => ({
|
|
1175
|
+
available: false,
|
|
1176
|
+
runId: input.runId,
|
|
1177
|
+
changes: [],
|
|
1178
|
+
// `complete: false` on the unavailable path, deliberately. An unobserved
|
|
1179
|
+
// run's diff is not complete; it does not exist. Reporting `true` here
|
|
1180
|
+
// would let a landing gate that only reads completeness clear it.
|
|
1181
|
+
complete: false,
|
|
1182
|
+
droppedEvents: 0,
|
|
1183
|
+
problems: [],
|
|
1184
|
+
reason,
|
|
1185
|
+
});
|
|
1186
|
+
const service = this.runObservationService;
|
|
1187
|
+
if (!service)
|
|
1188
|
+
return unavailable('This host is not observing runs.');
|
|
1189
|
+
const snapshot = input.poll === true
|
|
1190
|
+
? await service.poll(input.runId)
|
|
1191
|
+
: service.snapshot(input.runId);
|
|
1192
|
+
if (!snapshot)
|
|
1193
|
+
return unavailable('This run is not being observed.');
|
|
1194
|
+
return {
|
|
1195
|
+
available: true,
|
|
1196
|
+
runId: snapshot.runId,
|
|
1197
|
+
changes: snapshot.changes.map((change) => ({ path: change.path, kind: change.kind })),
|
|
1198
|
+
complete: snapshot.complete,
|
|
1199
|
+
droppedEvents: snapshot.droppedEvents,
|
|
1200
|
+
problems: snapshot.problems,
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Content for one changed path — §2.9 hunk-level review.
|
|
1205
|
+
*
|
|
1206
|
+
* The service refuses a path the run did not change, so this cannot be used to
|
|
1207
|
+
* browse the sandbox: that is a different capability with a different consent
|
|
1208
|
+
* story, and a review pane is not a licence to read arbitrary files.
|
|
1209
|
+
*/
|
|
1210
|
+
async readRunObservationChange(input) {
|
|
1211
|
+
const service = this.runObservationService;
|
|
1212
|
+
const content = service ? await service.readChange(input.runId, input.path) : undefined;
|
|
1213
|
+
if (!content) {
|
|
1214
|
+
return {
|
|
1215
|
+
available: false,
|
|
1216
|
+
path: input.path,
|
|
1217
|
+
binary: false,
|
|
1218
|
+
truncated: false,
|
|
1219
|
+
refusal: service
|
|
1220
|
+
? 'This run has no observed change at that path, or no content reader is configured.'
|
|
1221
|
+
: 'This host is not observing runs.',
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
return {
|
|
1225
|
+
available: true,
|
|
1226
|
+
path: content.path,
|
|
1227
|
+
...(content.before === undefined ? {} : { before: content.before }),
|
|
1228
|
+
...(content.after === undefined ? {} : { after: content.after }),
|
|
1229
|
+
binary: content.binary,
|
|
1230
|
+
truncated: content.truncated,
|
|
1231
|
+
...(content.refusal === undefined ? {} : { refusal: content.refusal }),
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
/** Where the observed runs collide — §5.7, while both are still alive. */
|
|
1235
|
+
readRunObservationOverlaps(input) {
|
|
1236
|
+
const service = this.runObservationService;
|
|
1237
|
+
if (!service) {
|
|
1238
|
+
return {
|
|
1239
|
+
available: false,
|
|
1240
|
+
overlaps: [],
|
|
1241
|
+
incompleteRuns: [],
|
|
1242
|
+
// Not complete: nothing was measured. An `available: false` result that
|
|
1243
|
+
// claimed completeness would read as "checked, nothing found".
|
|
1244
|
+
complete: false,
|
|
1245
|
+
reason: 'This host is not observing runs, so overlaps cannot be detected.',
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* The host tells the service which runs it knows about; the service owns the
|
|
1250
|
+
* rule that an unobserved one makes the report incomplete.
|
|
1251
|
+
*
|
|
1252
|
+
* 🔴 That rule used to live HERE, and the shipping landing gate bypassed
|
|
1253
|
+
* it by calling `landingOverlaps` directly. A check that only one caller
|
|
1254
|
+
* performs is a check the other caller does not have.
|
|
1255
|
+
*/
|
|
1256
|
+
const report = service.landingOverlaps(input.claimedPathsByRun ?? {}, [
|
|
1257
|
+
...this.runContexts.keys(),
|
|
1258
|
+
...(this.coordinationService?.claims() ?? []).map((claim) => claim.runId),
|
|
1259
|
+
]);
|
|
1260
|
+
return {
|
|
1261
|
+
available: true,
|
|
1262
|
+
overlaps: report.overlaps.map((overlap) => ({
|
|
1263
|
+
path: overlap.path,
|
|
1264
|
+
runIds: overlap.runIds,
|
|
1265
|
+
kind: overlap.kind,
|
|
1266
|
+
...(overlap.unclaimed === undefined ? {} : { unclaimed: overlap.unclaimed }),
|
|
1267
|
+
})),
|
|
1268
|
+
incompleteRuns: report.incompleteRuns,
|
|
1269
|
+
complete: report.complete,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* How many turns are executing right now.
|
|
1274
|
+
*
|
|
1275
|
+
* Exposed so a composition can answer "would quitting destroy work?" — which
|
|
1276
|
+
* it cannot ask any other way, and which is the difference between an app that
|
|
1277
|
+
* closes cleanly and one that silently ends a run the user is waiting on.
|
|
1278
|
+
*
|
|
1279
|
+
* A COUNT, not the turns themselves: a caller deciding whether to quit has no
|
|
1280
|
+
* business with the requests, and handing them out would make an internal map
|
|
1281
|
+
* part of the surface.
|
|
1282
|
+
*/
|
|
1283
|
+
get activeTurnCount() {
|
|
1284
|
+
return this.activeTurns.size;
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* The mounted run-observation service, or `undefined` when unmounted.
|
|
1288
|
+
*
|
|
1289
|
+
* The full change set is pulled through here rather than pushed on the feed:
|
|
1290
|
+
* a diff is large and mostly unchanged between polls, while the notification
|
|
1291
|
+
* that it moved is small. Same reason it is not on `AgentHostTransport` —
|
|
1292
|
+
* that is a protected cross-repo interface (root ABSOLUTE RULE §4).
|
|
1293
|
+
*/
|
|
1294
|
+
get runObservation() {
|
|
1295
|
+
return this.runObservationService;
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* The mounted session substrate, or `undefined` when unmounted.
|
|
1299
|
+
*
|
|
1300
|
+
* Reached by composition, not through `request()`: attaching to a session
|
|
1301
|
+
* means subscribing to a byte stream, which is not a request/response shape —
|
|
1302
|
+
* and `AgentHostTransport` is protected (root ABSOLUTE RULE §4).
|
|
1303
|
+
*/
|
|
1304
|
+
get sessions() {
|
|
1305
|
+
return this.sessionService;
|
|
1306
|
+
}
|
|
1307
|
+
async request(method, input) {
|
|
1308
|
+
if (method === 'host.handshake') {
|
|
1309
|
+
return this.handshake(input);
|
|
1310
|
+
}
|
|
1311
|
+
if (method === 'providers.list') {
|
|
1312
|
+
await this.refreshProviderCatalog();
|
|
1313
|
+
return this.listProviders(input);
|
|
1314
|
+
}
|
|
1315
|
+
if (method === 'providers.refresh') {
|
|
1316
|
+
await this.refreshProviderCatalog();
|
|
1317
|
+
return this.listProviders(input);
|
|
1318
|
+
}
|
|
1319
|
+
if (method === 'runtime.events.list') {
|
|
1320
|
+
return await this.listRuntimeEvents(input);
|
|
1321
|
+
}
|
|
1322
|
+
if (method === 'state.workspaces.load') {
|
|
1323
|
+
return await this.requireStateRepository().loadWorkspaces();
|
|
1324
|
+
}
|
|
1325
|
+
if (method === 'state.workspaces.save') {
|
|
1326
|
+
return await this.requireStateRepository().saveWorkspaces(input);
|
|
1327
|
+
}
|
|
1328
|
+
if (method === 'state.conversations.load') {
|
|
1329
|
+
return await this.requireStateRepository().loadConversations();
|
|
1330
|
+
}
|
|
1331
|
+
if (method === 'state.conversations.save') {
|
|
1332
|
+
return await this.requireStateRepository().saveConversation(input);
|
|
1333
|
+
}
|
|
1334
|
+
if (method === 'state.conversations.delete') {
|
|
1335
|
+
return await this.requireStateRepository().deleteConversation(input);
|
|
1336
|
+
}
|
|
1337
|
+
if (method === 'workspace.access.grant') {
|
|
1338
|
+
return await this.requireWorkspaceService().grantDirectory(input);
|
|
1339
|
+
}
|
|
1340
|
+
if (method === 'coordination.held.list') {
|
|
1341
|
+
return this.listHeldMessages(input);
|
|
1342
|
+
}
|
|
1343
|
+
if (method === 'coordination.held.release') {
|
|
1344
|
+
return this.releaseHeldMessage(input);
|
|
1345
|
+
}
|
|
1346
|
+
if (method === 'coordination.policy.set') {
|
|
1347
|
+
return this.setCoordinationInboundPolicy(input);
|
|
1348
|
+
}
|
|
1349
|
+
if (method === 'workspace.application.grant') {
|
|
1350
|
+
// Deliberately NOT behind `assertWorkspaceClient`, matching
|
|
1351
|
+
// `workspace.access.grant` beside it: granting is how a client comes to
|
|
1352
|
+
// have access at all, so requiring existing access to ask for it would
|
|
1353
|
+
// make the first grant impossible.
|
|
1354
|
+
return await this.requireWorkspaceService().grantApplication(input);
|
|
1355
|
+
}
|
|
1356
|
+
if (method === 'workspace.directory.list') {
|
|
1357
|
+
this.assertWorkspaceClient(input);
|
|
1358
|
+
return await this.requireWorkspaceService().listDirectory(input);
|
|
1359
|
+
}
|
|
1360
|
+
if (method === 'workspace.file.read') {
|
|
1361
|
+
this.assertWorkspaceClient(input);
|
|
1362
|
+
return await this.requireWorkspaceService().readFile(input);
|
|
1363
|
+
}
|
|
1364
|
+
if (method === 'workspace.file.write') {
|
|
1365
|
+
this.assertWorkspaceClient(input);
|
|
1366
|
+
return await this.requireWorkspaceService().writeFile(input);
|
|
1367
|
+
}
|
|
1368
|
+
if (method === 'workspace.command.run') {
|
|
1369
|
+
this.assertWorkspaceClient(input);
|
|
1370
|
+
return await this.requireWorkspaceService().runCommand(input);
|
|
1371
|
+
}
|
|
1372
|
+
if (method === 'workspace.terminal.start') {
|
|
1373
|
+
this.assertWorkspaceClient(input);
|
|
1374
|
+
return await this.requireWorkspaceService().startTerminal(input);
|
|
1375
|
+
}
|
|
1376
|
+
if (method === 'workspace.terminal.stop') {
|
|
1377
|
+
this.assertWorkspaceClient(input);
|
|
1378
|
+
return await this.requireWorkspaceService().stopTerminal(input);
|
|
1379
|
+
}
|
|
1380
|
+
if (method === 'workspace.pty.start') {
|
|
1381
|
+
this.assertWorkspaceClient(input);
|
|
1382
|
+
return await this.requireWorkspaceService().startPty(input);
|
|
1383
|
+
}
|
|
1384
|
+
if (method === 'workspace.pty.snapshot') {
|
|
1385
|
+
this.assertWorkspaceClient(input);
|
|
1386
|
+
return await this.requireWorkspaceService().snapshotPty(input);
|
|
1387
|
+
}
|
|
1388
|
+
if (method === 'workspace.pty.input') {
|
|
1389
|
+
this.assertWorkspaceClient(input);
|
|
1390
|
+
return await this.requireWorkspaceService().inputPty(input);
|
|
1391
|
+
}
|
|
1392
|
+
if (method === 'workspace.pty.resize') {
|
|
1393
|
+
this.assertWorkspaceClient(input);
|
|
1394
|
+
return await this.requireWorkspaceService().resizePty(input);
|
|
1395
|
+
}
|
|
1396
|
+
if (method === 'workspace.pty.clear') {
|
|
1397
|
+
this.assertWorkspaceClient(input);
|
|
1398
|
+
return await this.requireWorkspaceService().clearPty(input);
|
|
1399
|
+
}
|
|
1400
|
+
if (method === 'workspace.pty.stop') {
|
|
1401
|
+
this.assertWorkspaceClient(input);
|
|
1402
|
+
return await this.requireWorkspaceService().stopPty(input);
|
|
1403
|
+
}
|
|
1404
|
+
if (method === 'turn.start') {
|
|
1405
|
+
return await this.startTurn(input);
|
|
1406
|
+
}
|
|
1407
|
+
if (method === 'acp.provider.approve') {
|
|
1408
|
+
return await this.approveAcpProvider(input);
|
|
1409
|
+
}
|
|
1410
|
+
if (method === 'turn.resume') {
|
|
1411
|
+
return await this.resumeTurn(input);
|
|
1412
|
+
}
|
|
1413
|
+
if (method === 'turn.cancel') {
|
|
1414
|
+
return await this.cancelTurn(input);
|
|
1415
|
+
}
|
|
1416
|
+
if (method === 'turn.steer') {
|
|
1417
|
+
return await this.steerTurn(input);
|
|
1418
|
+
}
|
|
1419
|
+
if (method === 'turn.queue') {
|
|
1420
|
+
return this.queueTurnInstruction(input);
|
|
1421
|
+
}
|
|
1422
|
+
if (method === 'turn.queue.list') {
|
|
1423
|
+
return this.listQueuedInstructions(input);
|
|
1424
|
+
}
|
|
1425
|
+
if (method === 'run.observation.snapshot') {
|
|
1426
|
+
return await this.readRunObservation(input);
|
|
1427
|
+
}
|
|
1428
|
+
if (method === 'run.observation.change') {
|
|
1429
|
+
return await this.readRunObservationChange(input);
|
|
1430
|
+
}
|
|
1431
|
+
if (method === 'run.observation.overlaps') {
|
|
1432
|
+
return this.readRunObservationOverlaps(input);
|
|
1433
|
+
}
|
|
1434
|
+
if (method === 'subagent.run.create') {
|
|
1435
|
+
return this.createSubagentRun(input);
|
|
1436
|
+
}
|
|
1437
|
+
if (method === 'subagent.run.list') {
|
|
1438
|
+
return this.listSubagentRuns(input);
|
|
1439
|
+
}
|
|
1440
|
+
if (method === 'subagent.run.cancel') {
|
|
1441
|
+
return this.cancelSubagentRun(input);
|
|
1442
|
+
}
|
|
1443
|
+
if (method === 'fleet.view') {
|
|
1444
|
+
return this.readFleetView(input);
|
|
1445
|
+
}
|
|
1446
|
+
if (method === 'fleet.route') {
|
|
1447
|
+
return this.routeByCapabilities(input);
|
|
1448
|
+
}
|
|
1449
|
+
if (method === 'fleet.plan') {
|
|
1450
|
+
return this.planFleetFanOut(input);
|
|
1451
|
+
}
|
|
1452
|
+
if (method === 'session.list') {
|
|
1453
|
+
return this.listSessions(input);
|
|
1454
|
+
}
|
|
1455
|
+
if (method === 'session.attach') {
|
|
1456
|
+
return this.attachSession(input);
|
|
1457
|
+
}
|
|
1458
|
+
if (method === 'session.detach') {
|
|
1459
|
+
return this.detachSession(input);
|
|
1460
|
+
}
|
|
1461
|
+
if (method === 'session.snapshot') {
|
|
1462
|
+
return this.snapshotSession(input);
|
|
1463
|
+
}
|
|
1464
|
+
if (method === 'session.input') {
|
|
1465
|
+
return await this.routeSessionInput(input);
|
|
1466
|
+
}
|
|
1467
|
+
if (method === 'session.takeover') {
|
|
1468
|
+
return await this.takeOverSession(input);
|
|
1469
|
+
}
|
|
1470
|
+
if (method === 'session.release') {
|
|
1471
|
+
return this.releaseSession(input);
|
|
1472
|
+
}
|
|
1473
|
+
if (method === 'intake.sources.list') {
|
|
1474
|
+
return this.listIntakeSources();
|
|
1475
|
+
}
|
|
1476
|
+
if (method === 'intake.items.list') {
|
|
1477
|
+
return await this.listIntakeItems(input);
|
|
1478
|
+
}
|
|
1479
|
+
if (method === 'intake.item.claim') {
|
|
1480
|
+
return this.claimIntakeItem(input);
|
|
1481
|
+
}
|
|
1482
|
+
if (method === 'intake.item.release') {
|
|
1483
|
+
return this.releaseIntakeItem(input);
|
|
1484
|
+
}
|
|
1485
|
+
if (method === 'intake.item.dispatch') {
|
|
1486
|
+
return this.dispatchIntakeItem(input);
|
|
1487
|
+
}
|
|
1488
|
+
if (method === 'knowledge.list') {
|
|
1489
|
+
return await this.listKnowledge(input);
|
|
1490
|
+
}
|
|
1491
|
+
if (method === 'knowledge.record') {
|
|
1492
|
+
return await this.recordKnowledge(input);
|
|
1493
|
+
}
|
|
1494
|
+
if (method === 'knowledge.forget') {
|
|
1495
|
+
return await this.forgetKnowledge(input);
|
|
1496
|
+
}
|
|
1497
|
+
if (method === 'knowledge.context') {
|
|
1498
|
+
return await this.knowledgeContext(input);
|
|
1499
|
+
}
|
|
1500
|
+
if (method === 'turn.queue.drop') {
|
|
1501
|
+
return this.dropQueuedInstruction(input);
|
|
1502
|
+
}
|
|
1503
|
+
if (method === 'elicitation.choose-directory.answer') {
|
|
1504
|
+
return answerChooseDirectoryRequest(input);
|
|
1505
|
+
}
|
|
1506
|
+
if (method === 'elicitation.workspace-placement.answer') {
|
|
1507
|
+
return answerWorkspacePlacementRequest(input);
|
|
1508
|
+
}
|
|
1509
|
+
if (method === 'elicitation.permission.answer') {
|
|
1510
|
+
return await this.requireInteractionService().answerPermission(input);
|
|
1511
|
+
}
|
|
1512
|
+
if (method === 'permissions.rules.list') {
|
|
1513
|
+
return await this.requirePermissionPolicyService().listRules();
|
|
1514
|
+
}
|
|
1515
|
+
if (method === 'permissions.rules.revoke') {
|
|
1516
|
+
return await this.requirePermissionPolicyService().revokeRule(input);
|
|
1517
|
+
}
|
|
1518
|
+
if (method === 'elicitation.ask-user.answer') {
|
|
1519
|
+
return await this.requireInteractionService().answerAskUser(input);
|
|
1520
|
+
}
|
|
1521
|
+
if (method === 'sdk.sessions.list') {
|
|
1522
|
+
this.assertSdkControlClient(input);
|
|
1523
|
+
return await this.requireSdkControlService().listSessions(input);
|
|
1524
|
+
}
|
|
1525
|
+
if (method === 'sdk.sessions.show') {
|
|
1526
|
+
this.assertSdkControlClient(input);
|
|
1527
|
+
return await this.requireSdkControlService().showSession(input);
|
|
1528
|
+
}
|
|
1529
|
+
if (method === 'sdk.sessions.attach') {
|
|
1530
|
+
this.assertSdkControlClient(input);
|
|
1531
|
+
return await this.requireSdkControlService().attachSession(input);
|
|
1532
|
+
}
|
|
1533
|
+
if (method === 'sdk.sessions.detach') {
|
|
1534
|
+
this.assertSdkControlClient(input);
|
|
1535
|
+
return await this.requireSdkControlService().detachSession(input);
|
|
1536
|
+
}
|
|
1537
|
+
if (method === 'sdk.sessions.delete') {
|
|
1538
|
+
this.assertSdkControlClient(input);
|
|
1539
|
+
return await this.requireSdkControlService().deleteSession(input);
|
|
1540
|
+
}
|
|
1541
|
+
if (method === 'sdk.sessions.clean') {
|
|
1542
|
+
this.assertSdkControlClient(input);
|
|
1543
|
+
return await this.requireSdkControlService().cleanSessions(input);
|
|
1544
|
+
}
|
|
1545
|
+
if (method === 'sdk.checkpoints.list') {
|
|
1546
|
+
this.assertSdkControlClient(input);
|
|
1547
|
+
return await this.requireSdkControlService().listCheckpoints(input);
|
|
1548
|
+
}
|
|
1549
|
+
if (method === 'sdk.checkpoints.restore') {
|
|
1550
|
+
this.assertSdkControlClient(input);
|
|
1551
|
+
return await this.requireSdkControlService().restoreCheckpoint(input);
|
|
1552
|
+
}
|
|
1553
|
+
if (method === 'sdk.checkpoints.delete') {
|
|
1554
|
+
this.assertSdkControlClient(input);
|
|
1555
|
+
return await this.requireSdkControlService().deleteCheckpoint(input);
|
|
1556
|
+
}
|
|
1557
|
+
if (method === 'sdk.tasks.output') {
|
|
1558
|
+
this.assertSdkControlClient(input);
|
|
1559
|
+
return await this.requireSdkControlService().readTaskOutput(input);
|
|
1560
|
+
}
|
|
1561
|
+
if (method === 'sdk.tasks.stop') {
|
|
1562
|
+
this.assertSdkControlClient(input);
|
|
1563
|
+
return await this.requireSdkControlService().stopTask(input);
|
|
1564
|
+
}
|
|
1565
|
+
if (String(method).startsWith('engineering.')) {
|
|
1566
|
+
this.assertEngineeringControlClient(input);
|
|
1567
|
+
const engineeringMethod = method;
|
|
1568
|
+
return await this.requireEngineeringControlService().request(engineeringMethod, input);
|
|
1569
|
+
}
|
|
1570
|
+
throw new AgentHostProtocolError('invalid_response', `Unsupported XENO Agent host method: ${String(method)}`);
|
|
1571
|
+
}
|
|
1572
|
+
subscribe(listener) {
|
|
1573
|
+
this.listeners.add(listener);
|
|
1574
|
+
return () => {
|
|
1575
|
+
this.listeners.delete(listener);
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Forget a client whose connection is gone.
|
|
1580
|
+
*
|
|
1581
|
+
* 🔴 Before this there was no `connectedClients.delete` anywhere except a wholesale clear, so
|
|
1582
|
+
* the set only ever grew within a session. That is invisible until something asks a question of
|
|
1583
|
+
* it — and `canPresentElicitations` is exactly such a question, asked immediately before the
|
|
1584
|
+
* host puts a prompt to a person and waits with no timeout.
|
|
1585
|
+
*/
|
|
1586
|
+
releaseClient(instanceId) {
|
|
1587
|
+
this.connectedClients.delete(instanceId);
|
|
1588
|
+
this.clientCapabilities.delete(instanceId);
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Is there a connected client that can put a question to a person and answer it?
|
|
1592
|
+
*
|
|
1593
|
+
* ⚠️ Subscribers are NOT the signal, and that is the trap this method exists to avoid: the RPC
|
|
1594
|
+
* server subscribes exactly ONE listener and fans out to whatever sockets it has, so a listener
|
|
1595
|
+
* count of 1 is equally consistent with ten attached clients and none. Only a completed
|
|
1596
|
+
* handshake says someone is there, and only a declared capability says they can answer.
|
|
1597
|
+
*/
|
|
1598
|
+
hasElicitationCapableClient() {
|
|
1599
|
+
for (const [instanceId, capabilities] of this.clientCapabilities) {
|
|
1600
|
+
if (!this.connectedClients.has(instanceId))
|
|
1601
|
+
continue;
|
|
1602
|
+
if (capabilities.canPresentElicitations === true)
|
|
1603
|
+
return true;
|
|
1604
|
+
}
|
|
1605
|
+
return false;
|
|
1606
|
+
}
|
|
1607
|
+
registerProvider(provider) {
|
|
1608
|
+
this.setProvider(provider, true);
|
|
1609
|
+
this.providerCatalogFingerprint = fingerprintProviders([...this.providers.values()]);
|
|
1610
|
+
}
|
|
1611
|
+
registerTurnExecutionAdapter(adapter) {
|
|
1612
|
+
if (!adapter.id.trim())
|
|
1613
|
+
throw new Error('Turn execution adapter id must not be empty.');
|
|
1614
|
+
if (this.turnExecutionAdapters.has(adapter.id)) {
|
|
1615
|
+
throw new Error(`Turn execution adapter ${adapter.id} is already registered.`);
|
|
1616
|
+
}
|
|
1617
|
+
this.turnExecutionAdapters.set(adapter.id, adapter);
|
|
1618
|
+
}
|
|
1619
|
+
async close() {
|
|
1620
|
+
for (const active of this.activeTurns.values()) {
|
|
1621
|
+
active.controller.abort('The XENO Agent host is shutting down.');
|
|
1622
|
+
}
|
|
1623
|
+
this.activeTurns.clear();
|
|
1624
|
+
await Promise.all([...this.turnExecutionAdapters.values()].map(async (adapter) => {
|
|
1625
|
+
await adapter.close?.();
|
|
1626
|
+
}));
|
|
1627
|
+
await this.sdkControlService?.close?.();
|
|
1628
|
+
await this.engineeringControlService?.close?.();
|
|
1629
|
+
await this.workspaceService?.close?.();
|
|
1630
|
+
await this.stateRepository?.close?.();
|
|
1631
|
+
// Before the listeners go: each session's protocol channel is subscribed to
|
|
1632
|
+
// this host's event feed, and closing the session is what releases it.
|
|
1633
|
+
for (const session of this.sessionService?.sessions() ?? []) {
|
|
1634
|
+
this.sessionService?.close(session.sessionId);
|
|
1635
|
+
}
|
|
1636
|
+
this.connectedClients.clear();
|
|
1637
|
+
// Cleared together, always: a capability outliving its client would answer "yes, someone can
|
|
1638
|
+
// be asked" on behalf of a connection that no longer exists.
|
|
1639
|
+
this.clientCapabilities.clear();
|
|
1640
|
+
this.listeners.clear();
|
|
1641
|
+
}
|
|
1642
|
+
removeProvider(providerId) {
|
|
1643
|
+
const removed = this.providers.delete(providerId);
|
|
1644
|
+
if (removed) {
|
|
1645
|
+
this.catalogRevision += 1;
|
|
1646
|
+
this.providerCatalogFingerprint = fingerprintProviders([...this.providers.values()]);
|
|
1647
|
+
this.publishCatalogChange();
|
|
1648
|
+
}
|
|
1649
|
+
return removed;
|
|
1650
|
+
}
|
|
1651
|
+
async recordRuntimeEvent(event) {
|
|
1652
|
+
assertRuntimeEventForHost(event);
|
|
1653
|
+
if (this.runtimeEventRepository) {
|
|
1654
|
+
const result = await this.runtimeEventRepository.append(structuredClone(event));
|
|
1655
|
+
if (result.inserted) {
|
|
1656
|
+
this.publish('runtime.event.appended', {
|
|
1657
|
+
requestId: result.event.requestId,
|
|
1658
|
+
event: structuredClone(result.event),
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
return result.inserted;
|
|
1662
|
+
}
|
|
1663
|
+
return this.appendRuntimeEvent(event, true);
|
|
1664
|
+
}
|
|
1665
|
+
publishPersistedRuntimeEvent(event) {
|
|
1666
|
+
assertRuntimeEventForHost(event);
|
|
1667
|
+
this.publish('runtime.event.appended', {
|
|
1668
|
+
requestId: event.requestId,
|
|
1669
|
+
event: structuredClone(event),
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
createElicitationSurfaceMessenger() {
|
|
1673
|
+
return {
|
|
1674
|
+
send: (channel, payload) => {
|
|
1675
|
+
if (channel === 'agent:choose-directory-request') {
|
|
1676
|
+
this.publish('elicitation.choose-directory.requested', structuredClone(payload));
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
if (channel === 'agent:workspace-placement-request') {
|
|
1680
|
+
this.publish('elicitation.workspace-placement.requested', structuredClone(payload));
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
if (channel === 'agent:permission-request') {
|
|
1684
|
+
this.publish('elicitation.permission.requested', structuredClone(payload));
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
if (channel === 'agent:ask-user-request') {
|
|
1688
|
+
this.publish('elicitation.ask-user.requested', structuredClone(payload));
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
if (channel === 'agent:task-event') {
|
|
1692
|
+
this.publish('task.changed', structuredClone(payload));
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
throw new AgentHostProtocolError('invalid_response', `Unsupported Agent elicitation channel: ${channel}`);
|
|
1696
|
+
},
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Enqueue a subagent run — ADE §5.2, the join between `fleet.plan` and `SubagentScheduler`.
|
|
1701
|
+
*
|
|
1702
|
+
* ⚠️ Reached through a PORT spanning two objects on purpose. `createRun`/`listRuns` live on
|
|
1703
|
+
* `SubagentRunService` and `cancelRun` on `SubagentScheduler` — and cancel must go through the
|
|
1704
|
+
* SCHEDULER, because cancelling a run whose adapter has already launched it has to tell the
|
|
1705
|
+
* adapter. Handing the host either object alone would push that distinction onto every caller.
|
|
1706
|
+
*
|
|
1707
|
+
* 🔴 Refuses with a NAMED reason when no scheduler is mounted, rather than pretending. A host
|
|
1708
|
+
* with no scheduler is a normal composition (the standalone app had none until this landed),
|
|
1709
|
+
* and a create that silently succeeded against nothing would queue work no one will ever run.
|
|
1710
|
+
*/
|
|
1711
|
+
createSubagentRun(input) {
|
|
1712
|
+
if (!this.isConnectedClient(input.command.client)) {
|
|
1713
|
+
return { success: false, safeError: 'The requesting surface must complete the host handshake first.' };
|
|
1714
|
+
}
|
|
1715
|
+
const commandKey = this.commandKey('subagent.run.create', input.command);
|
|
1716
|
+
const fingerprint = commandFingerprint(input);
|
|
1717
|
+
const prior = this.commandResults.get(commandKey);
|
|
1718
|
+
if (prior) {
|
|
1719
|
+
// 🔴 The reason this verb takes a command envelope at all: a client that retries an enqueue
|
|
1720
|
+
// it is unsure about must NOT get a second child doing identical work.
|
|
1721
|
+
if (prior.method !== 'subagent.run.create' || prior.fingerprint !== fingerprint) {
|
|
1722
|
+
return { success: false, safeError: 'This idempotency key was already used for a different command.' };
|
|
1723
|
+
}
|
|
1724
|
+
return structuredClone(prior.result);
|
|
1725
|
+
}
|
|
1726
|
+
if (!this.subagentRuns) {
|
|
1727
|
+
// Deliberately NOT remembered: a refusal caused by this host's shape must not freeze into
|
|
1728
|
+
// the command ledger, or mounting a scheduler later would replay the refusal forever.
|
|
1729
|
+
return { success: false, safeError: 'This Agent host has no subagent scheduler mounted.' };
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* 🔴 A CHILD MAY NOT OUTRANK ITS PARENT. The requested workspace is bounded by the workspace
|
|
1733
|
+
* of the turn that spawned this run, and a request that cannot be bounded is REFUSED rather
|
|
1734
|
+
* than quietly narrowed — a caller told it got a workspace it did not get would debug the
|
|
1735
|
+
* wrong thing.
|
|
1736
|
+
*
|
|
1737
|
+
* ⚠️ Refusals here are NOT remembered in the command ledger, for the same reason as the
|
|
1738
|
+
* missing-scheduler refusal above: the parent turn may simply not have started yet, and
|
|
1739
|
+
* freezing that answer would replay it forever against a retry that would now succeed.
|
|
1740
|
+
*/
|
|
1741
|
+
const bound = boundSubagentWorkspace({
|
|
1742
|
+
...(input.workspace === undefined ? {} : { requested: input.workspace }),
|
|
1743
|
+
...(this.parentTurnWorkspace(input) === undefined
|
|
1744
|
+
? {}
|
|
1745
|
+
: { parent: this.parentTurnWorkspace(input) }),
|
|
1746
|
+
});
|
|
1747
|
+
if (!bound.granted)
|
|
1748
|
+
return { success: false, safeError: bound.safeError };
|
|
1749
|
+
const created = this.subagentRuns.createRun({
|
|
1750
|
+
...(bound.workspace === undefined ? {} : { workspace: bound.workspace }),
|
|
1751
|
+
parentConversationId: input.parentConversationId,
|
|
1752
|
+
...(input.parentRequestId === undefined ? {} : { parentRequestId: input.parentRequestId }),
|
|
1753
|
+
parentAgentId: input.parentAgentId,
|
|
1754
|
+
childConversationId: input.childConversationId,
|
|
1755
|
+
childAgentId: input.childAgentId,
|
|
1756
|
+
workspaceId: input.workspaceId,
|
|
1757
|
+
teamId: input.teamId,
|
|
1758
|
+
objective: input.objective,
|
|
1759
|
+
...(input.role === undefined ? {} : { role: input.role }),
|
|
1760
|
+
...(input.requiredCapabilities === undefined
|
|
1761
|
+
? {}
|
|
1762
|
+
: { requiredCapabilities: [...input.requiredCapabilities] }),
|
|
1763
|
+
});
|
|
1764
|
+
const result = created.success && created.run
|
|
1765
|
+
? { success: true, run: summariseSubagentRun(created.run) }
|
|
1766
|
+
: { success: false, safeError: created.error ?? 'The subagent run could not be created.' };
|
|
1767
|
+
this.commandResults.set(commandKey, {
|
|
1768
|
+
method: 'subagent.run.create',
|
|
1769
|
+
fingerprint,
|
|
1770
|
+
result: structuredClone(result),
|
|
1771
|
+
});
|
|
1772
|
+
return result;
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* What the turn that spawned this run is allowed to reach.
|
|
1776
|
+
*
|
|
1777
|
+
* Resolved from `runContexts`, which the host populates at `turn.start` and keeps for the life
|
|
1778
|
+
* of the run. Absent means the host genuinely does not know — a run created outside a turn, or
|
|
1779
|
+
* by a surface that declared no workspace — and `boundSubagentWorkspace` treats that as
|
|
1780
|
+
* authorising nothing rather than as permission.
|
|
1781
|
+
*/
|
|
1782
|
+
parentTurnWorkspace(input) {
|
|
1783
|
+
for (const context of this.runContexts.values()) {
|
|
1784
|
+
if (context.conversationId !== input.parentConversationId)
|
|
1785
|
+
continue;
|
|
1786
|
+
if (input.parentRequestId !== undefined && context.requestId !== input.parentRequestId)
|
|
1787
|
+
continue;
|
|
1788
|
+
if (context.workspace)
|
|
1789
|
+
return context.workspace;
|
|
1790
|
+
}
|
|
1791
|
+
return undefined;
|
|
1792
|
+
}
|
|
1793
|
+
listSubagentRuns(input) {
|
|
1794
|
+
if (!this.subagentRuns) {
|
|
1795
|
+
return { success: false, runs: [], safeError: 'This Agent host has no subagent scheduler mounted.' };
|
|
1796
|
+
}
|
|
1797
|
+
const listed = this.subagentRuns.listRuns({
|
|
1798
|
+
...(input.parentConversationId === undefined ? {} : { parentConversationId: input.parentConversationId }),
|
|
1799
|
+
...(input.childAgentId === undefined ? {} : { childAgentId: input.childAgentId }),
|
|
1800
|
+
...(input.workspaceId === undefined ? {} : { workspaceId: input.workspaceId }),
|
|
1801
|
+
...(input.teamId === undefined ? {} : { teamId: input.teamId }),
|
|
1802
|
+
...(input.status === undefined ? {} : { status: input.status }),
|
|
1803
|
+
...(input.activeOnly === undefined ? {} : { activeOnly: input.activeOnly }),
|
|
1804
|
+
});
|
|
1805
|
+
return listed.success
|
|
1806
|
+
? { success: true, runs: listed.runs.map(summariseSubagentRun) }
|
|
1807
|
+
: { success: false, runs: [], safeError: listed.error ?? 'Subagent runs could not be listed.' };
|
|
1808
|
+
}
|
|
1809
|
+
cancelSubagentRun(input) {
|
|
1810
|
+
if (!this.isConnectedClient(input.command.client)) {
|
|
1811
|
+
return { success: false, safeError: 'The requesting surface must complete the host handshake first.' };
|
|
1812
|
+
}
|
|
1813
|
+
if (!this.subagentRuns) {
|
|
1814
|
+
return { success: false, safeError: 'This Agent host has no subagent scheduler mounted.' };
|
|
1815
|
+
}
|
|
1816
|
+
const cancelled = this.subagentRuns.cancelRun({
|
|
1817
|
+
id: input.runId,
|
|
1818
|
+
...(input.reason === undefined ? {} : { reason: input.reason }),
|
|
1819
|
+
});
|
|
1820
|
+
return cancelled.success && cancelled.run
|
|
1821
|
+
? { success: true, run: summariseSubagentRun(cancelled.run) }
|
|
1822
|
+
: { success: false, safeError: cancelled.error ?? 'The subagent run could not be cancelled.' };
|
|
1823
|
+
}
|
|
1824
|
+
/** Remember a timer owned by this turn, so settling can cancel it. */
|
|
1825
|
+
trackTurnTimer(activeKey, timer) {
|
|
1826
|
+
const timers = this.turnTimers.get(activeKey);
|
|
1827
|
+
if (timers)
|
|
1828
|
+
timers.push(timer);
|
|
1829
|
+
else
|
|
1830
|
+
this.turnTimers.set(activeKey, [timer]);
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Drop every timer for a turn. Called when it settles, so a turn that finished normally can
|
|
1834
|
+
* never be abandoned afterwards by a timer that was already in flight.
|
|
1835
|
+
*/
|
|
1836
|
+
clearTurnTimers(activeKey) {
|
|
1837
|
+
for (const timer of this.turnTimers.get(activeKey) ?? [])
|
|
1838
|
+
clearTimeout(timer);
|
|
1839
|
+
this.turnTimers.delete(activeKey);
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Abort a turn, then STOP COUNTING IT if the adapter does not settle in time.
|
|
1843
|
+
*
|
|
1844
|
+
* 🔴 The honesty this preserves is the whole point. The host cannot make a third-party adapter
|
|
1845
|
+
* stop — it can only ask, and then decide what to believe. Waiting forever means one
|
|
1846
|
+
* uncooperative adapter permanently consumes an admission slot; pretending the abort worked
|
|
1847
|
+
* means reporting a success we do not have, which this repo's own rules forbid. So the host
|
|
1848
|
+
* abandons ITS OWN record and says so: `turn.execution.abandoned` states that the host gave up
|
|
1849
|
+
* tracking, NOT that the work stopped.
|
|
1850
|
+
*/
|
|
1851
|
+
abortAndAbandon(activeKey, reason) {
|
|
1852
|
+
const active = this.activeTurns.get(activeKey);
|
|
1853
|
+
if (!active)
|
|
1854
|
+
return;
|
|
1855
|
+
const { controller, request, adapter } = active;
|
|
1856
|
+
controller.abort(reason);
|
|
1857
|
+
const timer = setTimeout(() => {
|
|
1858
|
+
const stillActive = this.activeTurns.get(activeKey);
|
|
1859
|
+
// Only abandon the turn we aborted: a newer turn may legitimately hold this key.
|
|
1860
|
+
if (!stillActive || stillActive.controller !== controller)
|
|
1861
|
+
return;
|
|
1862
|
+
this.activeTurns.delete(activeKey);
|
|
1863
|
+
this.clearTurnTimers(activeKey);
|
|
1864
|
+
this.publish('turn.execution.abandoned', {
|
|
1865
|
+
conversationId: request.conversationId,
|
|
1866
|
+
requestId: request.requestId,
|
|
1867
|
+
adapterId: adapter.id,
|
|
1868
|
+
graceMs: this.turnAbandonGraceMs,
|
|
1869
|
+
safeError: 'The runtime adapter did not stop within the grace period. The XENO Agent host has '
|
|
1870
|
+
+ 'stopped tracking this turn and released its capacity. The adapter may still be '
|
|
1871
|
+
+ 'running.',
|
|
1872
|
+
});
|
|
1873
|
+
}, this.turnAbandonGraceMs);
|
|
1874
|
+
timer.unref?.();
|
|
1875
|
+
this.trackTurnTimer(activeKey, timer);
|
|
1876
|
+
}
|
|
1877
|
+
async startTurn(input) {
|
|
1878
|
+
const invalidReason = validateTurnStartRequest(input);
|
|
1879
|
+
if (invalidReason) {
|
|
1880
|
+
return turnStartFailure('invalid_request', invalidReason, input);
|
|
1881
|
+
}
|
|
1882
|
+
if (!this.isConnectedClient(input.command.client)) {
|
|
1883
|
+
return turnStartFailure('client_not_connected', 'The requesting surface must complete the host handshake before starting a turn.', input);
|
|
1884
|
+
}
|
|
1885
|
+
const commandKey = this.commandKey('turn.start', input.command);
|
|
1886
|
+
const fingerprint = commandFingerprint(input);
|
|
1887
|
+
const prior = this.commandResults.get(commandKey);
|
|
1888
|
+
if (prior) {
|
|
1889
|
+
if (prior.method !== 'turn.start' || prior.fingerprint !== fingerprint) {
|
|
1890
|
+
return turnStartFailure('idempotency_conflict', 'This idempotency key was already used for a different command.', input);
|
|
1891
|
+
}
|
|
1892
|
+
const result = prior.result;
|
|
1893
|
+
return result.success
|
|
1894
|
+
? structuredClone({ ...result, status: 'replayed' })
|
|
1895
|
+
: structuredClone(result);
|
|
1896
|
+
}
|
|
1897
|
+
const provider = this.providers.get(input.selection.providerId);
|
|
1898
|
+
if (!provider) {
|
|
1899
|
+
return this.rememberTurnStartFailure(commandKey, fingerprint, turnStartFailure('provider_not_found', 'The selected runtime provider is no longer in the catalog.', input));
|
|
1900
|
+
}
|
|
1901
|
+
if (provider.catalogRevision !== input.selection.catalogRevision) {
|
|
1902
|
+
return this.rememberTurnStartFailure(commandKey, fingerprint, turnStartFailure('catalog_revision_mismatch', 'The provider catalog changed. Refresh the model selection before starting this turn.', input));
|
|
1903
|
+
}
|
|
1904
|
+
const resolved = resolveAgentRuntimeSelection([provider], {
|
|
1905
|
+
providerId: input.selection.providerId,
|
|
1906
|
+
...(input.selection.modelId ? { modelId: input.selection.modelId } : {}),
|
|
1907
|
+
...(input.selection.reasoningOptionId ? { reasoningOptionId: input.selection.reasoningOptionId } : {}),
|
|
1908
|
+
});
|
|
1909
|
+
if (!resolved.ok) {
|
|
1910
|
+
return this.rememberTurnStartFailure(commandKey, fingerprint, turnStartFailure(resolved.code, resolved.safeReason, input));
|
|
1911
|
+
}
|
|
1912
|
+
if (input.mode === 'development' && !isDevelopmentProviderReady(provider)) {
|
|
1913
|
+
const missing = missingDevelopmentCapabilities(provider);
|
|
1914
|
+
return this.rememberTurnStartFailure(commandKey, fingerprint, turnStartFailure('development_capability_missing', `The selected provider cannot run an engineering turn because it lacks: ${missing.join(', ')}.`, input));
|
|
1915
|
+
}
|
|
1916
|
+
if (input.execution?.toolExecution === 'disabled') {
|
|
1917
|
+
// 🔴 The declaration is what makes the request honest, so it is checked BEFORE the turn is
|
|
1918
|
+
// accepted rather than discovered at execution time. A lane that cannot hold an agent to a
|
|
1919
|
+
// tool-free turn must not be handed one: the request and the audit record would both read as
|
|
1920
|
+
// though the restriction had been honoured.
|
|
1921
|
+
//
|
|
1922
|
+
// ⚠️ `available` only. For most capabilities `degraded` means "works, with caveats"; a tool
|
|
1923
|
+
// policy that works with caveats is not a tool policy, and there is no partial credit for
|
|
1924
|
+
// mostly not executing tools.
|
|
1925
|
+
const declared = provider.capabilities?.['turn.tools-disabled'];
|
|
1926
|
+
if (declared?.status !== 'available') {
|
|
1927
|
+
return this.rememberTurnStartFailure(commandKey, fingerprint, turnStartFailure('development_capability_missing', declared?.reason
|
|
1928
|
+
?? 'The selected provider cannot guarantee a turn that executes no tools.', input));
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
const adapter = [...this.turnExecutionAdapters.values()].find((candidate) => {
|
|
1932
|
+
try {
|
|
1933
|
+
return candidate.supports(provider);
|
|
1934
|
+
}
|
|
1935
|
+
catch {
|
|
1936
|
+
return false;
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1939
|
+
if (!adapter) {
|
|
1940
|
+
return this.rememberTurnStartFailure(commandKey, fingerprint, turnStartFailure('adapter_unavailable', 'No runnable host adapter is registered for the selected provider.', input));
|
|
1941
|
+
}
|
|
1942
|
+
const activeKey = turnKey(input.conversationId, input.requestId);
|
|
1943
|
+
if (this.activeTurns.has(activeKey)) {
|
|
1944
|
+
return this.rememberTurnStartFailure(commandKey, fingerprint, turnStartFailure('request_already_active', 'A turn with this conversation and request ID is already active.', input));
|
|
1945
|
+
}
|
|
1946
|
+
// 🔴 ADMISSION — and deliberately the LAST gate.
|
|
1947
|
+
//
|
|
1948
|
+
// Everything above is a PERMANENT property of the request: malformed, unknown provider,
|
|
1949
|
+
// stale catalog, no adapter, already active. Those must be reported regardless of load,
|
|
1950
|
+
// because the caller has to change something. Capacity is TRANSIENT, so it is checked only
|
|
1951
|
+
// once the request is known to be otherwise runnable — telling someone they are over the
|
|
1952
|
+
// limit when their provider does not exist sends them to fix the wrong thing.
|
|
1953
|
+
//
|
|
1954
|
+
// Placed before `structuredClone` because nothing below is free of side effects: run
|
|
1955
|
+
// contexts, observation binding, sessions and the coordination roster all begin there.
|
|
1956
|
+
//
|
|
1957
|
+
// It READS `activeTurns` rather than keeping its own count. That map's cleanup runs in a
|
|
1958
|
+
// `finally` and is guarded against evicting a newer turn, so deriving the decision from it
|
|
1959
|
+
// makes a leaked slot unrepresentable — there is no second ledger to drift out of sync.
|
|
1960
|
+
const admission = evaluateTurnAdmission({
|
|
1961
|
+
surface: input.command.client.surface,
|
|
1962
|
+
providerId: input.selection.providerId,
|
|
1963
|
+
active: [...this.activeTurns.values()].map((turn) => ({
|
|
1964
|
+
surface: turn.request.command.client.surface,
|
|
1965
|
+
providerId: turn.request.selection.providerId,
|
|
1966
|
+
})),
|
|
1967
|
+
policy: this.turnAdmissionPolicy,
|
|
1968
|
+
});
|
|
1969
|
+
if (!admission.admitted) {
|
|
1970
|
+
// 🔴 NOT remembered, and that is the whole difference between this refusal and the ones
|
|
1971
|
+
// above it. `rememberTurnStartFailure` freezes a result into the command ledger so a
|
|
1972
|
+
// retried command replays it — correct for a PERMANENT fault, and wrong here. Capacity is
|
|
1973
|
+
// transient: caching it would mean a surface that waits and retries the same command gets
|
|
1974
|
+
// a stale "at capacity" forever, with no way back except inventing a new command id.
|
|
1975
|
+
//
|
|
1976
|
+
// Idempotency exists to stop duplicate EXECUTION. This refusal executed nothing, so there
|
|
1977
|
+
// is nothing to deduplicate — re-evaluating on retry is the correct behaviour.
|
|
1978
|
+
return turnStartFailure('concurrency_limit_reached', admission.safeError, input);
|
|
1979
|
+
}
|
|
1980
|
+
const request = structuredClone(input);
|
|
1981
|
+
// Recorded BEFORE execution, so a coordination event arriving in the first
|
|
1982
|
+
// moments of a turn still finds its conversation.
|
|
1983
|
+
const contextRunId = this.resolveRunId(request);
|
|
1984
|
+
if (contextRunId) {
|
|
1985
|
+
this.runContexts.set(contextRunId, {
|
|
1986
|
+
conversationId: request.conversationId,
|
|
1987
|
+
requestId: request.requestId,
|
|
1988
|
+
...(request.workspaceId === undefined ? {} : { workspaceId: request.workspaceId }),
|
|
1989
|
+
...(request.agentId === undefined ? {} : { agentId: request.agentId }),
|
|
1990
|
+
// §6's derivation input. Captured from the turn that is starting,
|
|
1991
|
+
// because "the capabilities this run is executing with" is a property of
|
|
1992
|
+
// the turn — and omitted entirely when the request declares none, so an
|
|
1993
|
+
// undeclared posture stays UNKNOWN rather than becoming an empty set
|
|
1994
|
+
// that would read as "maximally constrained".
|
|
1995
|
+
...(request.execution?.capabilities === undefined
|
|
1996
|
+
? {}
|
|
1997
|
+
: { posture: request.execution.capabilities }),
|
|
1998
|
+
// The bound for any subagent this turn spawns. Captured from the turn that is starting
|
|
1999
|
+
// for the same reason as `posture`: it is a property of the turn, and a child created
|
|
2000
|
+
// later must be measured against what its parent was actually granted — not against
|
|
2001
|
+
// whatever the child asks for.
|
|
2002
|
+
...(request.workspace === undefined ? {} : { workspace: structuredClone(request.workspace) }),
|
|
2003
|
+
});
|
|
2004
|
+
// Fire and forget, and guarded: attaching a sandbox is slow and the turn
|
|
2005
|
+
// is the user's actual work. A binder that throws must not be able to fail
|
|
2006
|
+
// a turn — the run simply goes unobserved, which every consumer of the
|
|
2007
|
+
// observation service already reports honestly.
|
|
2008
|
+
try {
|
|
2009
|
+
this.runObservationBinder?.begin({
|
|
2010
|
+
runId: contextRunId,
|
|
2011
|
+
conversationId: request.conversationId,
|
|
2012
|
+
...(request.workspace?.primaryDirectory === undefined
|
|
2013
|
+
? {}
|
|
2014
|
+
: { primaryDirectory: request.workspace.primaryDirectory }),
|
|
2015
|
+
...(request.workspaceId === undefined ? {} : { workspaceId: request.workspaceId }),
|
|
2016
|
+
...(request.agentId === undefined ? {} : { agentId: request.agentId }),
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
catch {
|
|
2020
|
+
// Nothing to report to the caller: observation is not part of this
|
|
2021
|
+
// command's contract, and the binder owns its own problem reporting.
|
|
2022
|
+
}
|
|
2023
|
+
this.openSessionForRun(contextRunId, provider);
|
|
2024
|
+
this.registerCoordinationRun(contextRunId, 'running');
|
|
2025
|
+
}
|
|
2026
|
+
const controller = new AbortController();
|
|
2027
|
+
this.activeTurns.set(activeKey, { adapter, controller, request });
|
|
2028
|
+
// 🔴 The host ENFORCES the deadline it already validates. `timeoutMs` was checked on the way
|
|
2029
|
+
// in (1..3_600_000) and forwarded to the adapter, but nothing here ever acted on it: the
|
|
2030
|
+
// controller was aborted only for shutdown or an explicit cancel. A turn whose adapter
|
|
2031
|
+
// ignores the timeout ran forever — and, since admission counts `activeTurns`, held its
|
|
2032
|
+
// capacity forever with it. Client sets the deadline, the AUTHORITY enforces it.
|
|
2033
|
+
if (request.timeoutMs !== undefined) {
|
|
2034
|
+
const deadline = setTimeout(() => {
|
|
2035
|
+
this.abortAndAbandon(activeKey, `Turn exceeded its ${request.timeoutMs}ms deadline.`);
|
|
2036
|
+
}, request.timeoutMs);
|
|
2037
|
+
deadline.unref?.();
|
|
2038
|
+
this.trackTurnTimer(activeKey, deadline);
|
|
2039
|
+
}
|
|
2040
|
+
// Written BEFORE execution begins: a record created afterwards would miss
|
|
2041
|
+
// exactly the turns that die early, which are the ones worth reporting.
|
|
2042
|
+
this.interruptedTurnStore.record({
|
|
2043
|
+
conversationId: request.conversationId,
|
|
2044
|
+
requestId: request.requestId,
|
|
2045
|
+
adapterId: adapter.id,
|
|
2046
|
+
startedAt: Date.now(),
|
|
2047
|
+
request,
|
|
2048
|
+
});
|
|
2049
|
+
const accepted = {
|
|
2050
|
+
success: true,
|
|
2051
|
+
status: 'accepted',
|
|
2052
|
+
conversationId: input.conversationId,
|
|
2053
|
+
requestId: input.requestId,
|
|
2054
|
+
adapterId: adapter.id,
|
|
2055
|
+
selection: { ...resolved.selection },
|
|
2056
|
+
};
|
|
2057
|
+
this.commandResults.set(commandKey, {
|
|
2058
|
+
method: 'turn.start',
|
|
2059
|
+
fingerprint,
|
|
2060
|
+
result: structuredClone(accepted),
|
|
2061
|
+
});
|
|
2062
|
+
void Promise.resolve()
|
|
2063
|
+
.then(() => this.recordContextCompaction(request, provider, resolved.selection))
|
|
2064
|
+
// §5.8's gate lives here. Everything else in that pillar — the store, the
|
|
2065
|
+
// selection, the cross-provider guarantee — is inert until a fact actually
|
|
2066
|
+
// reaches a prompt.
|
|
2067
|
+
.then(() => this.injectWorkspaceKnowledge(request))
|
|
2068
|
+
// §5.2b's `message` primitive is a PUSH, and this is the push. Without
|
|
2069
|
+
// it a run could send and no run could ever receive.
|
|
2070
|
+
.then(() => this.deliverCoordinationMessages(request))
|
|
2071
|
+
.then(() => adapter.execute(request, provider, resolved.selection, {
|
|
2072
|
+
signal: controller.signal,
|
|
2073
|
+
publishPersistedRuntimeEvent: (event) => this.publishPersistedRuntimeEvent(event),
|
|
2074
|
+
}))
|
|
2075
|
+
.then(() => {
|
|
2076
|
+
// The turn ended WELL. Published here rather than inferred by each surface from
|
|
2077
|
+
// provider-specific runtime event strings — see the note on the event type itself.
|
|
2078
|
+
//
|
|
2079
|
+
// 🔴 Guarded by controller identity, because "the adapter's promise resolved" and "this
|
|
2080
|
+
// turn is still the host's turn" are different facts. An abandoned turn has already been
|
|
2081
|
+
// declared over and its capacity handed to someone else; if its adapter finally returns
|
|
2082
|
+
// afterwards, reporting completion would give a turn TWO terminal events and contradict
|
|
2083
|
+
// what the host already told every surface. Late is not the same as successful.
|
|
2084
|
+
if (this.activeTurns.get(activeKey)?.controller !== controller)
|
|
2085
|
+
return;
|
|
2086
|
+
this.publish('turn.execution.completed', {
|
|
2087
|
+
conversationId: request.conversationId,
|
|
2088
|
+
requestId: request.requestId,
|
|
2089
|
+
adapterId: adapter.id,
|
|
2090
|
+
});
|
|
2091
|
+
})
|
|
2092
|
+
.catch(() => {
|
|
2093
|
+
// Same guard, same reason: an abandoned turn whose adapter later rejects must not add a
|
|
2094
|
+
// second terminal event either.
|
|
2095
|
+
if (this.activeTurns.get(activeKey)?.controller !== controller)
|
|
2096
|
+
return;
|
|
2097
|
+
this.publish('turn.execution.failed', {
|
|
2098
|
+
conversationId: request.conversationId,
|
|
2099
|
+
requestId: request.requestId,
|
|
2100
|
+
adapterId: adapter.id,
|
|
2101
|
+
safeError: 'The selected runtime adapter failed to execute the turn.',
|
|
2102
|
+
});
|
|
2103
|
+
})
|
|
2104
|
+
.finally(() => {
|
|
2105
|
+
const active = this.activeTurns.get(activeKey);
|
|
2106
|
+
if (active?.controller === controller)
|
|
2107
|
+
this.activeTurns.delete(activeKey);
|
|
2108
|
+
// Before anything else: a turn that settled must not be abandoned afterwards by a timer
|
|
2109
|
+
// that was already in flight.
|
|
2110
|
+
this.clearTurnTimers(activeKey);
|
|
2111
|
+
// Reaching here at all means the turn ended — well or badly. Either way
|
|
2112
|
+
// it was not interrupted by process death, which is the only thing this
|
|
2113
|
+
// record exists to describe.
|
|
2114
|
+
this.interruptedTurnStore.clear(request.conversationId, request.requestId);
|
|
2115
|
+
// Gate 8: a delivered message is the receiving run's cause "until its
|
|
2116
|
+
// turn ends", so this is where that ends. In `finally`, because a turn
|
|
2117
|
+
// that FAILED still ended — leaving the cause set would attribute the
|
|
2118
|
+
// run's next, unrelated work to a message it already finished acting on.
|
|
2119
|
+
//
|
|
2120
|
+
// `endTurn`, never `endRun`: see `resolveRunId`. Releasing claims here
|
|
2121
|
+
// would drop a live run's locks every time a turn completed.
|
|
2122
|
+
const runId = this.resolveRunId(request);
|
|
2123
|
+
if (runId) {
|
|
2124
|
+
this.coordinationService?.endTurn(runId);
|
|
2125
|
+
// \u{1F534} `idle`, not gone. A run outlives its turns — the same reason
|
|
2126
|
+
// `endTurn` is called here and `endRun` is not — and a peer that
|
|
2127
|
+
// vanished between turns would be unaddressable exactly when someone
|
|
2128
|
+
// wanted to hand it work.
|
|
2129
|
+
this.registerCoordinationRun(runId, 'idle');
|
|
2130
|
+
}
|
|
2131
|
+
});
|
|
2132
|
+
return structuredClone(accepted);
|
|
2133
|
+
}
|
|
2134
|
+
async recordContextCompaction(request, provider, selection) {
|
|
2135
|
+
const compaction = request.contextCompaction;
|
|
2136
|
+
if (!compaction || compaction.compactedMessageCount < 1)
|
|
2137
|
+
return;
|
|
2138
|
+
const draft = {
|
|
2139
|
+
conversationId: request.conversationId,
|
|
2140
|
+
requestId: request.requestId,
|
|
2141
|
+
...(request.workspaceId ? { workspaceId: request.workspaceId } : {}),
|
|
2142
|
+
...(request.teamId ? { teamId: request.teamId } : {}),
|
|
2143
|
+
...(request.agentId ? { agentId: request.agentId } : {}),
|
|
2144
|
+
...(request.subagentRunId ? { subagentRunId: request.subagentRunId } : {}),
|
|
2145
|
+
providerKind: provider.kind,
|
|
2146
|
+
providerId: provider.id,
|
|
2147
|
+
source: 'agent-host',
|
|
2148
|
+
schemaVersion: '1',
|
|
2149
|
+
redactionState: 'safe',
|
|
2150
|
+
event: {
|
|
2151
|
+
id: `context-compacted-${request.requestId}-${safeEventIdSegment(compaction.contextDigest)}`,
|
|
2152
|
+
type: 'context.compacted',
|
|
2153
|
+
timestamp: new Date().toISOString(),
|
|
2154
|
+
strategyVersion: compaction.strategyVersion,
|
|
2155
|
+
rawMessageCount: compaction.rawMessageCount,
|
|
2156
|
+
sentMessageCount: compaction.sentMessageCount,
|
|
2157
|
+
compactedMessageCount: compaction.compactedMessageCount,
|
|
2158
|
+
estimatedRawTokens: compaction.estimatedRawTokens,
|
|
2159
|
+
estimatedSentTokens: compaction.estimatedSentTokens,
|
|
2160
|
+
retainedAnchorCount: compaction.retainedAnchorCount,
|
|
2161
|
+
retainedArtifactCount: compaction.retainedArtifactCount,
|
|
2162
|
+
retainedGrantedDirectoryCount: compaction.retainedGrantedDirectoryCount,
|
|
2163
|
+
contextDigest: compaction.contextDigest,
|
|
2164
|
+
selection: {
|
|
2165
|
+
providerId: selection.providerId,
|
|
2166
|
+
catalogRevision: selection.catalogRevision,
|
|
2167
|
+
...(selection.modelId ? { modelId: selection.modelId } : {}),
|
|
2168
|
+
...(selection.reasoningOptionId ? { reasoningOptionId: selection.reasoningOptionId } : {}),
|
|
2169
|
+
...(selection.runtimeModelId ? { runtimeModelId: selection.runtimeModelId } : {}),
|
|
2170
|
+
},
|
|
2171
|
+
},
|
|
2172
|
+
};
|
|
2173
|
+
if (this.runtimeEventRepository) {
|
|
2174
|
+
const result = await this.runtimeEventRepository.appendGenerated(draft);
|
|
2175
|
+
if (result.inserted) {
|
|
2176
|
+
this.publish('runtime.event.appended', {
|
|
2177
|
+
requestId: result.event.requestId,
|
|
2178
|
+
event: structuredClone(result.event),
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
const sequence = [...this.runtimeEvents.values()]
|
|
2184
|
+
.filter((event) => event.conversationId === request.conversationId)
|
|
2185
|
+
.reduce((maximum, event) => Math.max(maximum, event.sequence), 0) + 1;
|
|
2186
|
+
this.appendRuntimeEvent({
|
|
2187
|
+
id: draft.event.id,
|
|
2188
|
+
type: draft.event.type,
|
|
2189
|
+
timestamp: draft.event.timestamp,
|
|
2190
|
+
sequence,
|
|
2191
|
+
conversationId: draft.conversationId,
|
|
2192
|
+
requestId: draft.requestId,
|
|
2193
|
+
...(draft.workspaceId ? { workspaceId: draft.workspaceId } : {}),
|
|
2194
|
+
...(draft.teamId ? { teamId: draft.teamId } : {}),
|
|
2195
|
+
...(draft.agentId ? { agentId: draft.agentId } : {}),
|
|
2196
|
+
...(draft.parentRunId ? { parentRunId: draft.parentRunId } : {}),
|
|
2197
|
+
...(draft.subagentRunId ? { subagentRunId: draft.subagentRunId } : {}),
|
|
2198
|
+
...(draft.providerKind ? { providerKind: draft.providerKind } : {}),
|
|
2199
|
+
...(draft.providerId ? { providerId: draft.providerId } : {}),
|
|
2200
|
+
source: draft.source || 'agent-host',
|
|
2201
|
+
schemaVersion: draft.schemaVersion || '1',
|
|
2202
|
+
redactionState: draft.redactionState || 'safe',
|
|
2203
|
+
event: structuredClone(draft.event),
|
|
2204
|
+
}, true);
|
|
2205
|
+
}
|
|
2206
|
+
async cancelTurn(input) {
|
|
2207
|
+
const invalidReason = validateTurnCancelRequest(input);
|
|
2208
|
+
if (invalidReason) {
|
|
2209
|
+
return turnCancelFailure('invalid_request', invalidReason, input);
|
|
2210
|
+
}
|
|
2211
|
+
if (!this.isConnectedClient(input.command.client)) {
|
|
2212
|
+
return turnCancelFailure('client_not_connected', 'The requesting surface must complete the host handshake before cancelling a turn.', input);
|
|
2213
|
+
}
|
|
2214
|
+
const commandKey = this.commandKey('turn.cancel', input.command);
|
|
2215
|
+
const fingerprint = commandFingerprint(input);
|
|
2216
|
+
const prior = this.commandResults.get(commandKey);
|
|
2217
|
+
if (prior) {
|
|
2218
|
+
if (prior.method !== 'turn.cancel' || prior.fingerprint !== fingerprint) {
|
|
2219
|
+
return turnCancelFailure('idempotency_conflict', 'This idempotency key was already used for a different command.', input);
|
|
2220
|
+
}
|
|
2221
|
+
const result = prior.result;
|
|
2222
|
+
return result.success
|
|
2223
|
+
? structuredClone({ ...result, status: 'replayed' })
|
|
2224
|
+
: structuredClone(result);
|
|
2225
|
+
}
|
|
2226
|
+
const active = this.activeTurns.get(turnKey(input.conversationId, input.requestId));
|
|
2227
|
+
if (!active) {
|
|
2228
|
+
const failure = turnCancelFailure('turn_not_active', 'The requested turn is not active on this host.', input);
|
|
2229
|
+
this.commandResults.set(commandKey, { method: 'turn.cancel', fingerprint, result: structuredClone(failure) });
|
|
2230
|
+
return failure;
|
|
2231
|
+
}
|
|
2232
|
+
// Abort, then stop counting it if the adapter does not comply. `turn.cancel` used to return
|
|
2233
|
+
// success while the turn stayed active and stayed counted against admission — reporting a
|
|
2234
|
+
// success the host did not have.
|
|
2235
|
+
this.abortAndAbandon(turnKey(input.conversationId, input.requestId), input.reason || 'Turn cancelled by the user.');
|
|
2236
|
+
if (active.adapter.cancel) {
|
|
2237
|
+
try {
|
|
2238
|
+
await active.adapter.cancel(structuredClone(input));
|
|
2239
|
+
}
|
|
2240
|
+
catch {
|
|
2241
|
+
// The shared abort signal is authoritative; provider-specific cancellation is best-effort.
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
const result = {
|
|
2245
|
+
success: true,
|
|
2246
|
+
status: 'cancel_requested',
|
|
2247
|
+
conversationId: input.conversationId,
|
|
2248
|
+
requestId: input.requestId,
|
|
2249
|
+
};
|
|
2250
|
+
this.commandResults.set(commandKey, { method: 'turn.cancel', fingerprint, result: structuredClone(result) });
|
|
2251
|
+
return result;
|
|
2252
|
+
}
|
|
2253
|
+
async steerTurn(input) {
|
|
2254
|
+
if (!this.isConnectedClient(input.command.client)) {
|
|
2255
|
+
return {
|
|
2256
|
+
success: false,
|
|
2257
|
+
code: 'client_not_connected',
|
|
2258
|
+
safeError: 'The requesting surface must complete the host handshake before steering a turn.',
|
|
2259
|
+
conversationId: input.conversationId,
|
|
2260
|
+
requestId: input.requestId,
|
|
2261
|
+
};
|
|
2262
|
+
}
|
|
2263
|
+
const instruction = typeof input.instruction === 'string' ? input.instruction.trim() : '';
|
|
2264
|
+
if (!input.conversationId || !input.requestId || !instruction) {
|
|
2265
|
+
return {
|
|
2266
|
+
success: false,
|
|
2267
|
+
code: 'invalid_request',
|
|
2268
|
+
safeError: 'Steering requires a conversation id, a request id and non-empty text.',
|
|
2269
|
+
conversationId: input.conversationId,
|
|
2270
|
+
requestId: input.requestId,
|
|
2271
|
+
};
|
|
2272
|
+
}
|
|
2273
|
+
const commandKey = this.commandKey('turn.steer', input.command);
|
|
2274
|
+
const fingerprint = commandFingerprint(input);
|
|
2275
|
+
const prior = this.commandResults.get(commandKey);
|
|
2276
|
+
if (prior) {
|
|
2277
|
+
if (prior.method !== 'turn.steer' || prior.fingerprint !== fingerprint) {
|
|
2278
|
+
return {
|
|
2279
|
+
success: false,
|
|
2280
|
+
code: 'idempotency_conflict',
|
|
2281
|
+
safeError: 'This idempotency key was already used for a different command.',
|
|
2282
|
+
conversationId: input.conversationId,
|
|
2283
|
+
requestId: input.requestId,
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
2286
|
+
const result = prior.result;
|
|
2287
|
+
return result.success
|
|
2288
|
+
? structuredClone({ ...result, status: 'replayed' })
|
|
2289
|
+
: structuredClone(result);
|
|
2290
|
+
}
|
|
2291
|
+
const active = this.activeTurns.get(turnKey(input.conversationId, input.requestId));
|
|
2292
|
+
if (!active) {
|
|
2293
|
+
// Not an error the user should be punished for: the turn most likely just
|
|
2294
|
+
// ended. `turn.queue` is the right destination, and the message says so.
|
|
2295
|
+
const failure = {
|
|
2296
|
+
success: false,
|
|
2297
|
+
code: 'turn_not_active',
|
|
2298
|
+
safeError: 'That turn is no longer running. Queue the instruction instead so it is delivered to the next turn.',
|
|
2299
|
+
conversationId: input.conversationId,
|
|
2300
|
+
requestId: input.requestId,
|
|
2301
|
+
};
|
|
2302
|
+
this.commandResults.set(commandKey, { method: 'turn.steer', fingerprint, result: structuredClone(failure) });
|
|
2303
|
+
return failure;
|
|
2304
|
+
}
|
|
2305
|
+
if (!active.adapter.steer) {
|
|
2306
|
+
/**
|
|
2307
|
+
* 🔴 THE REMEDY IS DERIVED, NOT NAMED BY HAND.
|
|
2308
|
+
*
|
|
2309
|
+
* This used to read "… or run the agent over ACP where steering is acknowledged", and it was
|
|
2310
|
+
* returned FOR AN ACP LANE — measured against a real host on 2026-08-18. The ACP adapter
|
|
2311
|
+
* implements no `steer`; the SDK-native one does. So the advice named the single lane that
|
|
2312
|
+
* cannot do it, to someone already on it.
|
|
2313
|
+
*
|
|
2314
|
+
* An error that misdirects is worse than a generic one: it spends the reader's time proving
|
|
2315
|
+
* the system wrong before they can start. Same shape as a package name retyped into a path —
|
|
2316
|
+
* a fact that already exists in the code, restated by hand, then drifting.
|
|
2317
|
+
*/
|
|
2318
|
+
const steerable = [...this.turnExecutionAdapters.values()]
|
|
2319
|
+
.filter((adapter) => typeof adapter.steer === 'function')
|
|
2320
|
+
.map((adapter) => adapter.id);
|
|
2321
|
+
const failure = {
|
|
2322
|
+
success: false,
|
|
2323
|
+
code: 'steer_unsupported',
|
|
2324
|
+
safeError: steerable.length > 0
|
|
2325
|
+
? `The '${active.adapter.id}' lane cannot accept a correction mid-turn. Queue the `
|
|
2326
|
+
+ `instruction so it reaches the next turn, or run this agent on a lane that can: `
|
|
2327
|
+
+ `${steerable.join(', ')}.`
|
|
2328
|
+
: `The '${active.adapter.id}' lane cannot accept a correction mid-turn, and no lane on `
|
|
2329
|
+
+ 'this host can. Queue the instruction so it reaches the next turn.',
|
|
2330
|
+
conversationId: input.conversationId,
|
|
2331
|
+
requestId: input.requestId,
|
|
2332
|
+
};
|
|
2333
|
+
this.commandResults.set(commandKey, { method: 'turn.steer', fingerprint, result: structuredClone(failure) });
|
|
2334
|
+
return failure;
|
|
2335
|
+
}
|
|
2336
|
+
try {
|
|
2337
|
+
await active.adapter.steer({ ...structuredClone(input), instruction });
|
|
2338
|
+
}
|
|
2339
|
+
catch (error) {
|
|
2340
|
+
// A failed steer is reported, never swallowed: the user must not believe
|
|
2341
|
+
// they corrected an agent that never heard them.
|
|
2342
|
+
const failure = {
|
|
2343
|
+
success: false,
|
|
2344
|
+
code: 'steer_unsupported',
|
|
2345
|
+
safeError: error instanceof Error && error.message
|
|
2346
|
+
? `The provider rejected the correction: ${error.message.slice(0, 300)}`
|
|
2347
|
+
: 'The provider rejected the correction.',
|
|
2348
|
+
conversationId: input.conversationId,
|
|
2349
|
+
requestId: input.requestId,
|
|
2350
|
+
};
|
|
2351
|
+
this.commandResults.set(commandKey, { method: 'turn.steer', fingerprint, result: structuredClone(failure) });
|
|
2352
|
+
return failure;
|
|
2353
|
+
}
|
|
2354
|
+
const result = {
|
|
2355
|
+
success: true,
|
|
2356
|
+
status: 'delivered',
|
|
2357
|
+
acknowledged: true,
|
|
2358
|
+
deliveredVia: 'adapter',
|
|
2359
|
+
conversationId: input.conversationId,
|
|
2360
|
+
requestId: input.requestId,
|
|
2361
|
+
};
|
|
2362
|
+
this.commandResults.set(commandKey, { method: 'turn.steer', fingerprint, result: structuredClone(result) });
|
|
2363
|
+
return result;
|
|
2364
|
+
}
|
|
2365
|
+
/** True while any turn is running for this conversation. */
|
|
2366
|
+
hasActiveTurn(conversationId) {
|
|
2367
|
+
for (const active of this.activeTurns.values()) {
|
|
2368
|
+
if (active.request.conversationId === conversationId)
|
|
2369
|
+
return true;
|
|
2370
|
+
}
|
|
2371
|
+
return false;
|
|
2372
|
+
}
|
|
2373
|
+
queueSnapshot(conversationId) {
|
|
2374
|
+
return structuredClone(this.queuedInstructions.get(conversationId) || []);
|
|
2375
|
+
}
|
|
2376
|
+
queueTurnInstruction(input) {
|
|
2377
|
+
if (!this.isConnectedClient(input.command.client)) {
|
|
2378
|
+
return {
|
|
2379
|
+
success: false,
|
|
2380
|
+
code: 'client_not_connected',
|
|
2381
|
+
safeError: 'The requesting surface must complete the host handshake before queueing an instruction.',
|
|
2382
|
+
conversationId: input.conversationId,
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
const instruction = typeof input.instruction === 'string' ? input.instruction.trim() : '';
|
|
2386
|
+
if (!input.conversationId || !instruction) {
|
|
2387
|
+
return {
|
|
2388
|
+
success: false,
|
|
2389
|
+
code: 'invalid_request',
|
|
2390
|
+
safeError: 'A queued instruction requires a conversation id and non-empty text.',
|
|
2391
|
+
conversationId: input.conversationId,
|
|
2392
|
+
};
|
|
2393
|
+
}
|
|
2394
|
+
const commandKey = this.commandKey('turn.queue', input.command);
|
|
2395
|
+
const fingerprint = commandFingerprint(input);
|
|
2396
|
+
const prior = this.commandResults.get(commandKey);
|
|
2397
|
+
if (prior) {
|
|
2398
|
+
if (prior.method !== 'turn.queue' || prior.fingerprint !== fingerprint) {
|
|
2399
|
+
return {
|
|
2400
|
+
success: false,
|
|
2401
|
+
code: 'idempotency_conflict',
|
|
2402
|
+
safeError: 'This idempotency key was already used for a different command.',
|
|
2403
|
+
conversationId: input.conversationId,
|
|
2404
|
+
};
|
|
2405
|
+
}
|
|
2406
|
+
const result = prior.result;
|
|
2407
|
+
return result.success
|
|
2408
|
+
? structuredClone({ ...result, status: 'replayed' })
|
|
2409
|
+
: structuredClone(result);
|
|
2410
|
+
}
|
|
2411
|
+
const existing = input.replace ? [] : (this.queuedInstructions.get(input.conversationId) || []);
|
|
2412
|
+
if (existing.length >= MAX_QUEUED_INSTRUCTIONS) {
|
|
2413
|
+
const failure = {
|
|
2414
|
+
success: false,
|
|
2415
|
+
code: 'queue_full',
|
|
2416
|
+
safeError: `At most ${MAX_QUEUED_INSTRUCTIONS} instructions may be queued for one conversation.`,
|
|
2417
|
+
conversationId: input.conversationId,
|
|
2418
|
+
};
|
|
2419
|
+
this.commandResults.set(commandKey, { method: 'turn.queue', fingerprint, result: structuredClone(failure) });
|
|
2420
|
+
return failure;
|
|
2421
|
+
}
|
|
2422
|
+
this.queuedInstructionSequence += 1;
|
|
2423
|
+
const queued = {
|
|
2424
|
+
id: `queued-${this.queuedInstructionSequence}`,
|
|
2425
|
+
instruction: instruction.slice(0, MAX_QUEUED_INSTRUCTION_LENGTH),
|
|
2426
|
+
queuedAt: new Date().toISOString(),
|
|
2427
|
+
queuedByInstanceId: input.command.client.instanceId,
|
|
2428
|
+
};
|
|
2429
|
+
this.queuedInstructions.set(input.conversationId, [...existing, queued]);
|
|
2430
|
+
const result = {
|
|
2431
|
+
success: true,
|
|
2432
|
+
status: 'queued',
|
|
2433
|
+
conversationId: input.conversationId,
|
|
2434
|
+
queue: this.queueSnapshot(input.conversationId),
|
|
2435
|
+
};
|
|
2436
|
+
this.commandResults.set(commandKey, { method: 'turn.queue', fingerprint, result: structuredClone(result) });
|
|
2437
|
+
return result;
|
|
2438
|
+
}
|
|
2439
|
+
listQueuedInstructions(input) {
|
|
2440
|
+
return {
|
|
2441
|
+
conversationId: input.conversationId,
|
|
2442
|
+
queue: this.queueSnapshot(input.conversationId),
|
|
2443
|
+
turnActive: this.hasActiveTurn(input.conversationId),
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
dropQueuedInstruction(input) {
|
|
2447
|
+
if (!this.isConnectedClient(input.command.client)) {
|
|
2448
|
+
return {
|
|
2449
|
+
success: false,
|
|
2450
|
+
code: 'client_not_connected',
|
|
2451
|
+
safeError: 'The requesting surface must complete the host handshake before changing the queue.',
|
|
2452
|
+
conversationId: input.conversationId,
|
|
2453
|
+
};
|
|
2454
|
+
}
|
|
2455
|
+
if (!input.conversationId) {
|
|
2456
|
+
return {
|
|
2457
|
+
success: false,
|
|
2458
|
+
code: 'invalid_request',
|
|
2459
|
+
safeError: 'A conversation id is required.',
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
const existing = this.queuedInstructions.get(input.conversationId) || [];
|
|
2463
|
+
if (input.instructionId === undefined) {
|
|
2464
|
+
this.queuedInstructions.delete(input.conversationId);
|
|
2465
|
+
return { success: true, conversationId: input.conversationId, queue: [] };
|
|
2466
|
+
}
|
|
2467
|
+
if (!existing.some((entry) => entry.id === input.instructionId)) {
|
|
2468
|
+
return {
|
|
2469
|
+
success: false,
|
|
2470
|
+
code: 'not_found',
|
|
2471
|
+
safeError: 'No queued instruction with that id exists for this conversation.',
|
|
2472
|
+
conversationId: input.conversationId,
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
this.queuedInstructions.set(input.conversationId, existing.filter((entry) => entry.id !== input.instructionId));
|
|
2476
|
+
return {
|
|
2477
|
+
success: true,
|
|
2478
|
+
conversationId: input.conversationId,
|
|
2479
|
+
queue: this.queueSnapshot(input.conversationId),
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
rememberTurnStartFailure(commandKey, fingerprint, result) {
|
|
2483
|
+
this.commandResults.set(commandKey, { method: 'turn.start', fingerprint, result: structuredClone(result) });
|
|
2484
|
+
return result;
|
|
2485
|
+
}
|
|
2486
|
+
commandKey(method, command) {
|
|
2487
|
+
return `${command.client.instanceId}:${method}:${command.idempotencyKey}`;
|
|
2488
|
+
}
|
|
2489
|
+
isConnectedClient(identity) {
|
|
2490
|
+
const connected = this.connectedClients.get(identity.instanceId);
|
|
2491
|
+
return !!connected
|
|
2492
|
+
&& connected.clientId === identity.clientId
|
|
2493
|
+
&& connected.clientVersion === identity.clientVersion
|
|
2494
|
+
&& connected.surface === identity.surface;
|
|
2495
|
+
}
|
|
2496
|
+
handshake(input) {
|
|
2497
|
+
if (!isAgentHostClientIdentity(input.identity)) {
|
|
2498
|
+
throw new AgentHostProtocolError('invalid_handshake', 'Agent host client identity is invalid.');
|
|
2499
|
+
}
|
|
2500
|
+
if (!isProtocolRange(input.supportedProtocol)) {
|
|
2501
|
+
throw new AgentHostProtocolError('invalid_handshake', 'Agent host client protocol range is invalid.');
|
|
2502
|
+
}
|
|
2503
|
+
const hostRange = {
|
|
2504
|
+
min: XENO_AGENT_HOST_MIN_PROTOCOL_VERSION,
|
|
2505
|
+
max: XENO_AGENT_HOST_PROTOCOL_VERSION,
|
|
2506
|
+
};
|
|
2507
|
+
if (!rangesOverlap(hostRange, input.supportedProtocol)) {
|
|
2508
|
+
throw new AgentHostProtocolError('protocol_mismatch', `Client protocol ${input.supportedProtocol.min}-${input.supportedProtocol.max} is incompatible with host ${hostRange.min}-${hostRange.max}.`);
|
|
2509
|
+
}
|
|
2510
|
+
this.connectedClients.set(input.identity.instanceId, { ...input.identity });
|
|
2511
|
+
// Recorded per connection, and re-recorded on every handshake so a reconnecting client
|
|
2512
|
+
// cannot inherit what a previous connection with the same instance id claimed.
|
|
2513
|
+
this.clientCapabilities.set(input.identity.instanceId, {
|
|
2514
|
+
canPresentElicitations: input.capabilities?.canPresentElicitations === true,
|
|
2515
|
+
});
|
|
2516
|
+
return {
|
|
2517
|
+
protocol: {
|
|
2518
|
+
current: XENO_AGENT_HOST_PROTOCOL_VERSION,
|
|
2519
|
+
supported: hostRange,
|
|
2520
|
+
},
|
|
2521
|
+
host: {
|
|
2522
|
+
instanceId: this.options.hostInstanceId,
|
|
2523
|
+
version: this.options.hostVersion,
|
|
2524
|
+
authority: 'primary',
|
|
2525
|
+
processBoundary: this.options.processBoundary,
|
|
2526
|
+
},
|
|
2527
|
+
storage: {
|
|
2528
|
+
owner: 'xeno-agent-host',
|
|
2529
|
+
schemaVersion: this.options.storageSchemaVersion,
|
|
2530
|
+
eventSchemaVersion: this.options.eventSchemaVersion,
|
|
2531
|
+
},
|
|
2532
|
+
surface: { ...input.identity },
|
|
2533
|
+
};
|
|
2534
|
+
}
|
|
2535
|
+
listProviders(input) {
|
|
2536
|
+
return {
|
|
2537
|
+
revision: String(this.catalogRevision),
|
|
2538
|
+
providers: selectAvailableProviders([...this.providers.values()], input.includeUnavailable === true),
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2541
|
+
async refreshProviderCatalog() {
|
|
2542
|
+
if (!this.providerCatalogLoader)
|
|
2543
|
+
return false;
|
|
2544
|
+
if (this.providerCatalogRefresh)
|
|
2545
|
+
return this.providerCatalogRefresh;
|
|
2546
|
+
this.providerCatalogRefresh = (async () => {
|
|
2547
|
+
const providers = await this.providerCatalogLoader();
|
|
2548
|
+
if (!isAgentProviderListResult({ revision: 'candidate', providers })) {
|
|
2549
|
+
throw new AgentHostProtocolError('invalid_response', 'Provider catalog source returned an invalid descriptor.');
|
|
2550
|
+
}
|
|
2551
|
+
const providerIds = new Set();
|
|
2552
|
+
for (const provider of providers) {
|
|
2553
|
+
if (providerIds.has(provider.id)) {
|
|
2554
|
+
throw new AgentHostProtocolError('invalid_response', `Provider catalog source returned duplicate id: ${provider.id}`);
|
|
2555
|
+
}
|
|
2556
|
+
providerIds.add(provider.id);
|
|
2557
|
+
}
|
|
2558
|
+
const nextFingerprint = fingerprintProviders(providers);
|
|
2559
|
+
if (nextFingerprint === this.providerCatalogFingerprint)
|
|
2560
|
+
return false;
|
|
2561
|
+
this.providers.clear();
|
|
2562
|
+
for (const provider of providers)
|
|
2563
|
+
this.providers.set(provider.id, cloneProvider(provider));
|
|
2564
|
+
this.providerCatalogFingerprint = nextFingerprint;
|
|
2565
|
+
this.catalogRevision += 1;
|
|
2566
|
+
this.publishCatalogChange();
|
|
2567
|
+
return true;
|
|
2568
|
+
})().finally(() => {
|
|
2569
|
+
this.providerCatalogRefresh = null;
|
|
2570
|
+
});
|
|
2571
|
+
return this.providerCatalogRefresh;
|
|
2572
|
+
}
|
|
2573
|
+
async listRuntimeEvents(input) {
|
|
2574
|
+
if (!input.conversationId.trim()) {
|
|
2575
|
+
throw new AgentHostProtocolError('invalid_response', 'conversationId is required to list runtime events.');
|
|
2576
|
+
}
|
|
2577
|
+
if (this.runtimeEventRepository) {
|
|
2578
|
+
return this.runtimeEventRepository.list(structuredClone(input));
|
|
2579
|
+
}
|
|
2580
|
+
const limit = normalizeEventLimit(input.limit);
|
|
2581
|
+
const events = [...this.runtimeEvents.values()]
|
|
2582
|
+
.filter((event) => event.conversationId === input.conversationId)
|
|
2583
|
+
.filter((event) => !input.requestId || event.requestId === input.requestId)
|
|
2584
|
+
.filter((event) => !input.subagentRunId || event.subagentRunId === input.subagentRunId)
|
|
2585
|
+
.filter((event) => !input.type || event.type === input.type)
|
|
2586
|
+
.filter((event) => input.afterSequence === undefined || event.sequence > input.afterSequence)
|
|
2587
|
+
.sort((left, right) => left.sequence - right.sequence || left.id.localeCompare(right.id))
|
|
2588
|
+
.slice(0, limit)
|
|
2589
|
+
.map((event) => structuredClone(event));
|
|
2590
|
+
return {
|
|
2591
|
+
events,
|
|
2592
|
+
nextSequence: events.length > 0 ? events[events.length - 1]?.sequence ?? null : null,
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
requireStateRepository() {
|
|
2596
|
+
if (!this.stateRepository) {
|
|
2597
|
+
throw new AgentHostProtocolError('invalid_response', 'Host-owned Agent state persistence is unavailable.');
|
|
2598
|
+
}
|
|
2599
|
+
return this.stateRepository;
|
|
2600
|
+
}
|
|
2601
|
+
requireWorkspaceService() {
|
|
2602
|
+
if (!this.workspaceService) {
|
|
2603
|
+
throw new AgentHostProtocolError('invalid_response', 'Host-owned workspace services are unavailable.');
|
|
2604
|
+
}
|
|
2605
|
+
return this.workspaceService;
|
|
2606
|
+
}
|
|
2607
|
+
requireInteractionService() {
|
|
2608
|
+
if (!this.interactionService) {
|
|
2609
|
+
throw new AgentHostProtocolError('invalid_response', 'Host-owned Agent interaction services are unavailable.');
|
|
2610
|
+
}
|
|
2611
|
+
return this.interactionService;
|
|
2612
|
+
}
|
|
2613
|
+
/**
|
|
2614
|
+
* Re-issues a turn that process death interrupted (§5.5).
|
|
2615
|
+
*
|
|
2616
|
+
* ⚠️ It RESTARTS rather than resumes, and says so in the result. A turn killed
|
|
2617
|
+
* with the process left no continuation point inside the agent — only the
|
|
2618
|
+
* request that produced it. Work already done is done again, and side effects
|
|
2619
|
+
* already performed have already happened. Where the request carries a
|
|
2620
|
+
* provider continuation the session is picked up, which narrows the gap
|
|
2621
|
+
* without closing it.
|
|
2622
|
+
*
|
|
2623
|
+
* Everything after the lookup goes through `startTurn`, deliberately: a
|
|
2624
|
+
* second execution path would be free to drift from the first on provider
|
|
2625
|
+
* validation, catalog revision, adapter selection and idempotency — and the
|
|
2626
|
+
* one that runs rarely is the one that would rot.
|
|
2627
|
+
*/
|
|
2628
|
+
/**
|
|
2629
|
+
* Approve or revoke an ACP provider's binary.
|
|
2630
|
+
*
|
|
2631
|
+
* Fails CLOSED and by name in every branch. The shipping app previously
|
|
2632
|
+
* rendered an enabled Approve button whose bridge answered "not available on
|
|
2633
|
+
* this host yet" — a message that was true of the bridge and false of the
|
|
2634
|
+
* host, which implements this. `unavailable` here means the host genuinely
|
|
2635
|
+
* has no approval service mounted, and says so.
|
|
2636
|
+
*/
|
|
2637
|
+
async approveAcpProvider(input) {
|
|
2638
|
+
const agentId = typeof input?.agentId === 'string' ? input.agentId.trim() : '';
|
|
2639
|
+
if (!input?.client || !this.isConnectedClient(input.client)) {
|
|
2640
|
+
return {
|
|
2641
|
+
success: false,
|
|
2642
|
+
status: 'failed',
|
|
2643
|
+
agentId,
|
|
2644
|
+
safeError: 'Complete the host handshake before approving a provider.',
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
if (!agentId) {
|
|
2648
|
+
return {
|
|
2649
|
+
success: false,
|
|
2650
|
+
status: 'failed',
|
|
2651
|
+
agentId,
|
|
2652
|
+
safeError: 'Provider approval needs the id of a configured ACP agent.',
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
if (input.action !== 'approve' && input.action !== 'revoke') {
|
|
2656
|
+
// Not defaulted to `approve`: guessing an unrecognised action would be
|
|
2657
|
+
// guessing at a trust decision.
|
|
2658
|
+
return {
|
|
2659
|
+
success: false,
|
|
2660
|
+
status: 'failed',
|
|
2661
|
+
agentId,
|
|
2662
|
+
safeError: 'Provider approval action must be approve or revoke.',
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
if (!this.acpApprovalService) {
|
|
2666
|
+
return {
|
|
2667
|
+
success: false,
|
|
2668
|
+
status: 'unavailable',
|
|
2669
|
+
agentId,
|
|
2670
|
+
safeError: 'This host has no ACP approval service mounted.',
|
|
2671
|
+
};
|
|
2672
|
+
}
|
|
2673
|
+
try {
|
|
2674
|
+
return await this.acpApprovalService.approveProvider({
|
|
2675
|
+
agentId,
|
|
2676
|
+
...(typeof input.providerId === 'string' && input.providerId
|
|
2677
|
+
? { providerId: input.providerId }
|
|
2678
|
+
: {}),
|
|
2679
|
+
action: input.action,
|
|
2680
|
+
});
|
|
2681
|
+
}
|
|
2682
|
+
catch {
|
|
2683
|
+
// The underlying service spawns a CLI. A thrown error carries a command
|
|
2684
|
+
// line and environment; neither belongs on a surface.
|
|
2685
|
+
return {
|
|
2686
|
+
success: false,
|
|
2687
|
+
status: 'failed',
|
|
2688
|
+
agentId,
|
|
2689
|
+
safeError: 'The provider approval command failed to run.',
|
|
2690
|
+
};
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
async resumeTurn(input) {
|
|
2694
|
+
if (!input?.command?.client || !this.isConnectedClient(input.command.client)) {
|
|
2695
|
+
return {
|
|
2696
|
+
success: false,
|
|
2697
|
+
code: 'client_not_connected',
|
|
2698
|
+
safeError: 'Complete the host handshake before resuming a turn.',
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
const conversationId = typeof input?.conversationId === 'string' ? input.conversationId.trim() : '';
|
|
2702
|
+
const requestId = typeof input?.requestId === 'string' ? input.requestId.trim() : '';
|
|
2703
|
+
if (!conversationId || !requestId) {
|
|
2704
|
+
return {
|
|
2705
|
+
success: false,
|
|
2706
|
+
code: 'invalid_request',
|
|
2707
|
+
safeError: 'A turn resume needs both a conversation id and a request id.',
|
|
2708
|
+
};
|
|
2709
|
+
}
|
|
2710
|
+
if (this.activeTurns.has(turnKey(conversationId, requestId))) {
|
|
2711
|
+
// Restarting a live turn would run it twice against the same workspace.
|
|
2712
|
+
return {
|
|
2713
|
+
success: false,
|
|
2714
|
+
code: 'turn_already_active',
|
|
2715
|
+
safeError: 'That turn is already running.',
|
|
2716
|
+
conversationId,
|
|
2717
|
+
requestId,
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
const record = this.interruptedTurnStore.list()
|
|
2721
|
+
.find((entry) => entry.conversationId === conversationId && entry.requestId === requestId);
|
|
2722
|
+
if (!record) {
|
|
2723
|
+
// Absence is reported by name rather than as a generic failure: "this turn
|
|
2724
|
+
// was never interrupted" and "resuming it failed" are different answers.
|
|
2725
|
+
return {
|
|
2726
|
+
success: false,
|
|
2727
|
+
code: 'not_interrupted',
|
|
2728
|
+
safeError: 'No interrupted turn is recorded for that request.',
|
|
2729
|
+
conversationId,
|
|
2730
|
+
requestId,
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
const started = await this.startTurn(record.request);
|
|
2734
|
+
if (!started.success) {
|
|
2735
|
+
return {
|
|
2736
|
+
success: false,
|
|
2737
|
+
code: 'resume_failed',
|
|
2738
|
+
safeError: started.safeError,
|
|
2739
|
+
conversationId,
|
|
2740
|
+
requestId,
|
|
2741
|
+
};
|
|
2742
|
+
}
|
|
2743
|
+
return {
|
|
2744
|
+
success: true,
|
|
2745
|
+
status: 'restarted',
|
|
2746
|
+
conversationId,
|
|
2747
|
+
requestId,
|
|
2748
|
+
adapterId: started.adapterId,
|
|
2749
|
+
};
|
|
2750
|
+
}
|
|
2751
|
+
/**
|
|
2752
|
+
* Reports turns that were running when the previous process ended (§5.5).
|
|
2753
|
+
*
|
|
2754
|
+
* Called by the composition after construction. Each surviving record is
|
|
2755
|
+
* journalled as a durable `turn.interrupted` runtime event, so the surface
|
|
2756
|
+
* shows "this turn was interrupted" instead of a turn that simply stops
|
|
2757
|
+
* mid-sentence and never says why. An absence indistinguishable from an
|
|
2758
|
+
* outcome is the failure this whole mechanism exists to remove.
|
|
2759
|
+
*
|
|
2760
|
+
* Records are NOT cleared: `turn.resume` needs them. They clear when the turn
|
|
2761
|
+
* reaches a terminal state, which a resumed turn eventually does.
|
|
2762
|
+
*/
|
|
2763
|
+
async reportInterruptedTurns() {
|
|
2764
|
+
const records = this.interruptedTurnStore.list();
|
|
2765
|
+
if (records.length === 0 || !this.runtimeEventRepository)
|
|
2766
|
+
return 0;
|
|
2767
|
+
let reported = 0;
|
|
2768
|
+
for (const entry of records) {
|
|
2769
|
+
try {
|
|
2770
|
+
const committed = await this.runtimeEventRepository.appendGenerated({
|
|
2771
|
+
conversationId: entry.conversationId,
|
|
2772
|
+
requestId: entry.requestId,
|
|
2773
|
+
source: 'host',
|
|
2774
|
+
event: {
|
|
2775
|
+
id: `${entry.requestId}:turn-interrupted`,
|
|
2776
|
+
type: 'turn.interrupted',
|
|
2777
|
+
timestamp: new Date().toISOString(),
|
|
2778
|
+
status: 'interrupted',
|
|
2779
|
+
adapterId: entry.adapterId,
|
|
2780
|
+
startedAt: entry.startedAt,
|
|
2781
|
+
resumable: true,
|
|
2782
|
+
},
|
|
2783
|
+
});
|
|
2784
|
+
this.publishPersistedRuntimeEvent(committed.event);
|
|
2785
|
+
reported += 1;
|
|
2786
|
+
}
|
|
2787
|
+
catch {
|
|
2788
|
+
// One unreportable turn must not hide the rest.
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
return reported;
|
|
2792
|
+
}
|
|
2793
|
+
requirePermissionPolicyService() {
|
|
2794
|
+
if (!this.permissionPolicyService) {
|
|
2795
|
+
throw new AgentHostProtocolError('invalid_response', 'Host-owned permission policy is unavailable.');
|
|
2796
|
+
}
|
|
2797
|
+
return this.permissionPolicyService;
|
|
2798
|
+
}
|
|
2799
|
+
requireSdkControlService() {
|
|
2800
|
+
if (!this.sdkControlService) {
|
|
2801
|
+
throw new AgentHostProtocolError('invalid_response', 'Host-owned SDK session controls are unavailable.');
|
|
2802
|
+
}
|
|
2803
|
+
return this.sdkControlService;
|
|
2804
|
+
}
|
|
2805
|
+
requireEngineeringControlService() {
|
|
2806
|
+
if (!this.engineeringControlService) {
|
|
2807
|
+
throw new AgentHostProtocolError('invalid_response', 'Host-owned engineering workbench controls are unavailable.');
|
|
2808
|
+
}
|
|
2809
|
+
return this.engineeringControlService;
|
|
2810
|
+
}
|
|
2811
|
+
assertWorkspaceClient(input) {
|
|
2812
|
+
if (!isRecord(input) || !isAgentHostClientIdentity(input.client) || !this.isConnectedClient(input.client)) {
|
|
2813
|
+
throw new AgentHostProtocolError('invalid_response', 'The requesting surface must complete the host handshake before using workspace services.');
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
assertSdkControlClient(input) {
|
|
2817
|
+
if (!isRecord(input) || !isAgentHostClientIdentity(input.client) || !this.isConnectedClient(input.client)) {
|
|
2818
|
+
throw new AgentHostProtocolError('invalid_response', 'The requesting surface must complete the host handshake before using SDK session controls.');
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
assertEngineeringControlClient(input) {
|
|
2822
|
+
if (!isRecord(input) || !isAgentHostClientIdentity(input.client) || !this.isConnectedClient(input.client)) {
|
|
2823
|
+
throw new AgentHostProtocolError('invalid_response', 'The requesting surface must complete the host handshake before using engineering workbench controls.');
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
setProvider(provider, publish) {
|
|
2827
|
+
if (!provider.id.trim())
|
|
2828
|
+
throw new Error('Provider id must not be empty.');
|
|
2829
|
+
/**
|
|
2830
|
+
* 🔴 A provider cannot advertise a capability the HOST does not have.
|
|
2831
|
+
*
|
|
2832
|
+
* Coordination is a property of this host — whether a service is mounted —
|
|
2833
|
+
* and no adapter can make it true by declaring it. Registration is the right
|
|
2834
|
+
* place because it is the only point every provider passes through,
|
|
2835
|
+
* including ones a composition adds later.
|
|
2836
|
+
*/
|
|
2837
|
+
const stored = cloneProvider(provider);
|
|
2838
|
+
stored.capabilities = withHostBackedCapabilities(stored.capabilities, {
|
|
2839
|
+
coordination: this.coordinationService !== undefined,
|
|
2840
|
+
});
|
|
2841
|
+
this.providers.set(provider.id, stored);
|
|
2842
|
+
this.catalogRevision += 1;
|
|
2843
|
+
if (publish)
|
|
2844
|
+
this.publishCatalogChange();
|
|
2845
|
+
}
|
|
2846
|
+
publishCatalogChange() {
|
|
2847
|
+
this.publish('provider.catalog.changed', {
|
|
2848
|
+
revision: String(this.catalogRevision),
|
|
2849
|
+
providerIds: [...this.providers.keys()].sort(),
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
appendRuntimeEvent(event, publish) {
|
|
2853
|
+
assertRuntimeEventForHost(event);
|
|
2854
|
+
if (this.runtimeEvents.has(event.id))
|
|
2855
|
+
return false;
|
|
2856
|
+
const copy = structuredClone(event);
|
|
2857
|
+
this.runtimeEvents.set(copy.id, copy);
|
|
2858
|
+
if (publish)
|
|
2859
|
+
this.publish('runtime.event.appended', { requestId: copy.requestId, event: copy });
|
|
2860
|
+
return true;
|
|
2861
|
+
}
|
|
2862
|
+
/**
|
|
2863
|
+
* Writes one coordination event to the run timeline.
|
|
2864
|
+
*
|
|
2865
|
+
* Silent when the run has no known conversation — a coordination event
|
|
2866
|
+
* addressed to a run this host never started a turn for has no timeline to
|
|
2867
|
+
* join, and inventing a conversation id would put the entry somewhere a
|
|
2868
|
+
* reviewer would never look. It stays on the live feed either way.
|
|
2869
|
+
*/
|
|
2870
|
+
async recordCoordinationEvent(event, text) {
|
|
2871
|
+
if (!this.runtimeEventRepository)
|
|
2872
|
+
return;
|
|
2873
|
+
const runId = coordinationSubjectRun(event);
|
|
2874
|
+
const context = this.runContexts.get(runId);
|
|
2875
|
+
if (!context)
|
|
2876
|
+
return;
|
|
2877
|
+
this.coordinationEventSequence += 1;
|
|
2878
|
+
const draft = coordinationRuntimeEventDraft(event, context, {
|
|
2879
|
+
id: `${this.options.hostInstanceId}:coordination:${this.coordinationEventSequence}`,
|
|
2880
|
+
timestamp: new Date().toISOString(),
|
|
2881
|
+
...(text === undefined ? {} : { text }),
|
|
2882
|
+
});
|
|
2883
|
+
try {
|
|
2884
|
+
const { inserted, event: envelope } = await this.runtimeEventRepository.appendGenerated(draft);
|
|
2885
|
+
if (inserted)
|
|
2886
|
+
this.publishPersistedRuntimeEvent(envelope);
|
|
2887
|
+
}
|
|
2888
|
+
catch {
|
|
2889
|
+
// A timeline write must not take the coordination call down with it: the
|
|
2890
|
+
// message was already sent and the claim already taken, so throwing here
|
|
2891
|
+
// would report a failure for work that succeeded.
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
publish(type, payload) {
|
|
2895
|
+
this.sequence += 1;
|
|
2896
|
+
const event = {
|
|
2897
|
+
id: `${this.options.hostInstanceId}:${this.sequence}`,
|
|
2898
|
+
sequence: this.sequence,
|
|
2899
|
+
timestamp: Date.now(),
|
|
2900
|
+
type,
|
|
2901
|
+
source: 'host',
|
|
2902
|
+
schemaVersion: 1,
|
|
2903
|
+
redactionState: 'safe',
|
|
2904
|
+
payload,
|
|
2905
|
+
};
|
|
2906
|
+
for (const listener of [...this.listeners])
|
|
2907
|
+
listener(event);
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
function fingerprintProviders(providers) {
|
|
2911
|
+
return JSON.stringify(providers
|
|
2912
|
+
.map((provider) => cloneProvider(provider))
|
|
2913
|
+
.sort((left, right) => left.id.localeCompare(right.id)));
|
|
2914
|
+
}
|
|
2915
|
+
function assertRuntimeEventForHost(value) {
|
|
2916
|
+
if (!isAgentRuntimeEventEnvelope(value)) {
|
|
2917
|
+
throw new AgentHostProtocolError('invalid_response', 'Runtime event does not satisfy the host contract.');
|
|
2918
|
+
}
|
|
2919
|
+
if (value.redactionState === 'unsafe') {
|
|
2920
|
+
throw new AgentHostProtocolError('invalid_response', 'Unsafe runtime events cannot enter host history.');
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
function rangesOverlap(left, right) {
|
|
2924
|
+
return left.min <= right.max && right.min <= left.max;
|
|
2925
|
+
}
|
|
2926
|
+
function isProtocolRange(value) {
|
|
2927
|
+
return Number.isInteger(value.min) && Number.isInteger(value.max) && value.min > 0 && value.max >= value.min;
|
|
2928
|
+
}
|
|
2929
|
+
function normalizeEventLimit(value) {
|
|
2930
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
2931
|
+
? Math.min(5_000, Math.max(1, Math.floor(value)))
|
|
2932
|
+
: 500;
|
|
2933
|
+
}
|
|
2934
|
+
function validateTurnStartRequest(value) {
|
|
2935
|
+
if (!isRecord(value))
|
|
2936
|
+
return 'Turn start input must be an object.';
|
|
2937
|
+
const commandError = validateHostCommand(value.command);
|
|
2938
|
+
if (commandError)
|
|
2939
|
+
return commandError;
|
|
2940
|
+
if (!isNonEmptyString(value.conversationId))
|
|
2941
|
+
return 'conversationId is required.';
|
|
2942
|
+
if (!isNonEmptyString(value.requestId))
|
|
2943
|
+
return 'requestId is required.';
|
|
2944
|
+
if (value.mode !== 'development' && value.mode !== 'chat')
|
|
2945
|
+
return 'mode must be development or chat.';
|
|
2946
|
+
if (!isRecord(value.selection))
|
|
2947
|
+
return 'selection is required.';
|
|
2948
|
+
if (!isNonEmptyString(value.selection.providerId))
|
|
2949
|
+
return 'selection.providerId is required.';
|
|
2950
|
+
if (!isNonEmptyString(value.selection.catalogRevision))
|
|
2951
|
+
return 'selection.catalogRevision is required.';
|
|
2952
|
+
if (!isNonEmptyString(value.prompt))
|
|
2953
|
+
return 'prompt is required.';
|
|
2954
|
+
if (value.history !== undefined) {
|
|
2955
|
+
if (!Array.isArray(value.history))
|
|
2956
|
+
return 'history must be an array.';
|
|
2957
|
+
for (const message of value.history) {
|
|
2958
|
+
if (!isRecord(message)
|
|
2959
|
+
|| (message.role !== 'user' && message.role !== 'assistant')
|
|
2960
|
+
|| typeof message.content !== 'string') {
|
|
2961
|
+
return 'history contains an invalid message.';
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
if (value.workspace !== undefined) {
|
|
2966
|
+
if (!isRecord(value.workspace))
|
|
2967
|
+
return 'workspace must be an object.';
|
|
2968
|
+
if (value.workspace.primaryDirectory !== undefined && !isNonEmptyString(value.workspace.primaryDirectory)) {
|
|
2969
|
+
return 'workspace.primaryDirectory must be a non-empty path.';
|
|
2970
|
+
}
|
|
2971
|
+
if (value.workspace.additionalDirectories !== undefined
|
|
2972
|
+
&& (!Array.isArray(value.workspace.additionalDirectories)
|
|
2973
|
+
|| value.workspace.additionalDirectories.some((entry) => !isNonEmptyString(entry)))) {
|
|
2974
|
+
return 'workspace.additionalDirectories must contain non-empty paths.';
|
|
2975
|
+
}
|
|
2976
|
+
if (value.workspace.filesystem !== undefined
|
|
2977
|
+
&& value.workspace.filesystem !== 'read-only'
|
|
2978
|
+
&& value.workspace.filesystem !== 'workspace-write') {
|
|
2979
|
+
return 'workspace.filesystem is invalid.';
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
if (value.execution !== undefined) {
|
|
2983
|
+
if (!isRecord(value.execution))
|
|
2984
|
+
return 'execution must be an object.';
|
|
2985
|
+
if (value.execution.maxIterations !== undefined
|
|
2986
|
+
&& (typeof value.execution.maxIterations !== 'number'
|
|
2987
|
+
|| !Number.isInteger(value.execution.maxIterations)
|
|
2988
|
+
|| value.execution.maxIterations < 1
|
|
2989
|
+
|| value.execution.maxIterations > 100)) {
|
|
2990
|
+
return 'execution.maxIterations must be an integer between 1 and 100.';
|
|
2991
|
+
}
|
|
2992
|
+
if (value.execution.enableThinking !== undefined
|
|
2993
|
+
&& typeof value.execution.enableThinking !== 'boolean') {
|
|
2994
|
+
return 'execution.enableThinking must be a boolean.';
|
|
2995
|
+
}
|
|
2996
|
+
if (value.execution.capabilities !== undefined) {
|
|
2997
|
+
if (!isRecord(value.execution.capabilities)
|
|
2998
|
+
|| Object.values(value.execution.capabilities).some((entry) => typeof entry !== 'boolean')) {
|
|
2999
|
+
return 'execution.capabilities must contain only boolean values.';
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
if (value.execution.approvalRules !== undefined
|
|
3003
|
+
&& (!Array.isArray(value.execution.approvalRules)
|
|
3004
|
+
|| value.execution.approvalRules.some((entry) => !isRecord(entry)))) {
|
|
3005
|
+
return 'execution.approvalRules must contain objects.';
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
if (value.contextCompaction !== undefined) {
|
|
3009
|
+
if (!isRecord(value.contextCompaction))
|
|
3010
|
+
return 'contextCompaction must be an object.';
|
|
3011
|
+
const integerFields = [
|
|
3012
|
+
'strategyVersion',
|
|
3013
|
+
'rawMessageCount',
|
|
3014
|
+
'sentMessageCount',
|
|
3015
|
+
'compactedMessageCount',
|
|
3016
|
+
'estimatedRawTokens',
|
|
3017
|
+
'estimatedSentTokens',
|
|
3018
|
+
'retainedAnchorCount',
|
|
3019
|
+
'retainedArtifactCount',
|
|
3020
|
+
'retainedGrantedDirectoryCount',
|
|
3021
|
+
];
|
|
3022
|
+
for (const field of integerFields) {
|
|
3023
|
+
const fieldValue = value.contextCompaction[field];
|
|
3024
|
+
if (typeof fieldValue !== 'number'
|
|
3025
|
+
|| !Number.isSafeInteger(fieldValue)
|
|
3026
|
+
|| fieldValue < (field === 'strategyVersion' ? 1 : 0)) {
|
|
3027
|
+
return `contextCompaction.${field} must be a non-negative safe integer.`;
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
if (!isNonEmptyString(value.contextCompaction.contextDigest)
|
|
3031
|
+
|| value.contextCompaction.contextDigest.length > 160
|
|
3032
|
+
|| !/^[a-z0-9]+:[a-f0-9]+$/i.test(value.contextCompaction.contextDigest)) {
|
|
3033
|
+
return 'contextCompaction.contextDigest is invalid.';
|
|
3034
|
+
}
|
|
3035
|
+
const rawMessageCount = Number(value.contextCompaction.rawMessageCount);
|
|
3036
|
+
const sentMessageCount = Number(value.contextCompaction.sentMessageCount);
|
|
3037
|
+
const compactedMessageCount = Number(value.contextCompaction.compactedMessageCount);
|
|
3038
|
+
if (sentMessageCount > rawMessageCount
|
|
3039
|
+
|| compactedMessageCount !== rawMessageCount - sentMessageCount) {
|
|
3040
|
+
return 'contextCompaction message counts are inconsistent.';
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
if (value.timeoutMs !== undefined
|
|
3044
|
+
&& (typeof value.timeoutMs !== 'number'
|
|
3045
|
+
|| !Number.isFinite(value.timeoutMs)
|
|
3046
|
+
|| value.timeoutMs < 1
|
|
3047
|
+
|| value.timeoutMs > 3_600_000)) {
|
|
3048
|
+
return 'timeoutMs must be between 1 and 3600000.';
|
|
3049
|
+
}
|
|
3050
|
+
return null;
|
|
3051
|
+
}
|
|
3052
|
+
function validateTurnCancelRequest(value) {
|
|
3053
|
+
if (!isRecord(value))
|
|
3054
|
+
return 'Turn cancel input must be an object.';
|
|
3055
|
+
const commandError = validateHostCommand(value.command);
|
|
3056
|
+
if (commandError)
|
|
3057
|
+
return commandError;
|
|
3058
|
+
if (!isNonEmptyString(value.conversationId))
|
|
3059
|
+
return 'conversationId is required.';
|
|
3060
|
+
if (!isNonEmptyString(value.requestId))
|
|
3061
|
+
return 'requestId is required.';
|
|
3062
|
+
if (value.reason !== undefined && typeof value.reason !== 'string')
|
|
3063
|
+
return 'reason must be a string.';
|
|
3064
|
+
return null;
|
|
3065
|
+
}
|
|
3066
|
+
function validateHostCommand(value) {
|
|
3067
|
+
if (!isRecord(value))
|
|
3068
|
+
return 'command context is required.';
|
|
3069
|
+
if (value.schemaVersion !== 1)
|
|
3070
|
+
return 'command.schemaVersion must be 1.';
|
|
3071
|
+
if (!isNonEmptyString(value.commandId))
|
|
3072
|
+
return 'command.commandId is required.';
|
|
3073
|
+
if (!isNonEmptyString(value.idempotencyKey))
|
|
3074
|
+
return 'command.idempotencyKey is required.';
|
|
3075
|
+
if (!isAgentHostClientIdentity(value.client))
|
|
3076
|
+
return 'command.client is invalid.';
|
|
3077
|
+
return null;
|
|
3078
|
+
}
|
|
3079
|
+
function commandFingerprint(
|
|
3080
|
+
// Every command that carries an idempotency key, not just turn commands: the fingerprint is
|
|
3081
|
+
// what distinguishes "the same request retried" from "a different request reusing a key".
|
|
3082
|
+
input) {
|
|
3083
|
+
const command = {
|
|
3084
|
+
...input.command,
|
|
3085
|
+
commandId: '',
|
|
3086
|
+
};
|
|
3087
|
+
return JSON.stringify({ ...input, command });
|
|
3088
|
+
}
|
|
3089
|
+
function turnKey(conversationId, requestId) {
|
|
3090
|
+
return `${conversationId}:${requestId}`;
|
|
3091
|
+
}
|
|
3092
|
+
function safeEventIdSegment(value) {
|
|
3093
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 120);
|
|
3094
|
+
}
|
|
3095
|
+
function turnStartFailure(code, safeError, input) {
|
|
3096
|
+
const record = isRecord(input) ? input : {};
|
|
3097
|
+
return {
|
|
3098
|
+
success: false,
|
|
3099
|
+
code,
|
|
3100
|
+
safeError,
|
|
3101
|
+
...(typeof record.conversationId === 'string' ? { conversationId: record.conversationId } : {}),
|
|
3102
|
+
...(typeof record.requestId === 'string' ? { requestId: record.requestId } : {}),
|
|
3103
|
+
};
|
|
3104
|
+
}
|
|
3105
|
+
function turnCancelFailure(code, safeError, input) {
|
|
3106
|
+
const record = isRecord(input) ? input : {};
|
|
3107
|
+
return {
|
|
3108
|
+
success: false,
|
|
3109
|
+
code,
|
|
3110
|
+
safeError,
|
|
3111
|
+
...(typeof record.conversationId === 'string' ? { conversationId: record.conversationId } : {}),
|
|
3112
|
+
...(typeof record.requestId === 'string' ? { requestId: record.requestId } : {}),
|
|
3113
|
+
};
|
|
3114
|
+
}
|
|
3115
|
+
function isNonEmptyString(value) {
|
|
3116
|
+
return typeof value === 'string' && value.trim().length > 0 && !value.includes('\0');
|
|
3117
|
+
}
|
|
3118
|
+
function isRecord(value) {
|
|
3119
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3120
|
+
}
|
|
3121
|
+
/**
|
|
3122
|
+
* Coordination — the team-agent primitives (`XENO_AGENT_COORDINATION_SPEC.md`).
|
|
3123
|
+
*
|
|
3124
|
+
* Exported from the PORTABLE entry rather than `./node`: these are
|
|
3125
|
+
* provider-neutral rules with no Node dependency, and the host is the only party
|
|
3126
|
+
* that can arbitrate them because it is the only party that sees every run.
|
|
3127
|
+
*/
|
|
3128
|
+
export * from './coordination/claims.js';
|
|
3129
|
+
export * from './coordination/messages.js';
|
|
3130
|
+
export * from './coordination/query.js';
|
|
3131
|
+
export * from './coordination/CoordinationService.js';
|
|
3132
|
+
export * from './coordination/laneBridge.js';
|
|
3133
|
+
export * from './coordination/coordinationTools.js';
|
|
3134
|
+
export * from './coordination/permissionWaitRegistry.js';
|
|
3135
|
+
export * from './coordination/renderInbox.js';
|
|
3136
|
+
export * from './coordination/runtimeEvents.js';
|
|
3137
|
+
export * from './knowledge/WorkspaceKnowledgeService.js';
|
|
3138
|
+
export * from './intake/IntakeService.js';
|
|
3139
|
+
// 🔴 The two TODO intake SOURCES are exported from `./node.js`, not here.
|
|
3140
|
+
// They import `node:fs/promises`, `node:path` and `node:crypto`; this barrel is
|
|
3141
|
+
// the browser-facing one, reached by any renderer that builds a fixture host,
|
|
3142
|
+
// and exporting them from here broke `apps/embed-preview`'s bundle outright
|
|
3143
|
+
// ("stat is not exported by __vite-browser-external"). `IntakeService` itself
|
|
3144
|
+
// stays here — it takes its sources by injection and touches no I/O.
|
|
3145
|
+
export * from './observation/RunObservationService.js';
|
|
3146
|
+
export * from './isolation/RunIsolationService.js';
|
|
3147
|
+
/**
|
|
3148
|
+
* The confining implementation — browser-safe, and that is not an accident.
|
|
3149
|
+
*
|
|
3150
|
+
* It reaches the substrate over `fetch` and imports nothing from `node:`, so it
|
|
3151
|
+
* belongs in THIS barrel while `RunExecutionEnvironment` does not: the
|
|
3152
|
+
* environment constructs a `LocalCommandExecutor`, which spawns processes.
|
|
3153
|
+
* Exporting the environment here made the browser barrel transitively reach
|
|
3154
|
+
* `node:child_process` — caught by `browserBarrelPurity.test.ts` rather than by
|
|
3155
|
+
* review, which is the whole reason that gate exists.
|
|
3156
|
+
*/
|
|
3157
|
+
export * from './isolation/SandboxCommandExecutor.js';
|
|
3158
|
+
export * from './observation/XenoUseObservationSource.js';
|
|
3159
|
+
export * from './observation/XenoUseRunObserver.js';
|
|
3160
|
+
export * from './session/AgentSessionService.js';
|
|
3161
|
+
export * from './session/runtimeEventChannel.js';
|
|
3162
|
+
export * from './session/renderSupport.js';
|
|
3163
|
+
/**
|
|
3164
|
+
* Which run a coordination event belongs to on a timeline.
|
|
3165
|
+
*
|
|
3166
|
+
* A message has two runs; the entry belongs to the one the event is ABOUT — the
|
|
3167
|
+
* sender for a send, the receiver for a delivery. Writing both would double
|
|
3168
|
+
* every message in the record and make a reviewer counting messages wrong.
|
|
3169
|
+
*/
|
|
3170
|
+
/** Counts by kind for the fleet view. Separate from the observation service's
|
|
3171
|
+
* `summarise`: that one shapes an EVENT payload and this one a query result,
|
|
3172
|
+
* and folding them together would couple two contracts that change for
|
|
3173
|
+
* different reasons. */
|
|
3174
|
+
/**
|
|
3175
|
+
* A session on the wire.
|
|
3176
|
+
*
|
|
3177
|
+
* The lease is FLATTENED: `holder` is a discriminated union internally, and a
|
|
3178
|
+
* surface that had to switch on `kind` to learn who is typing would reimplement
|
|
3179
|
+
* that logic in every client. `holder: null` means the agent, which is the
|
|
3180
|
+
* state a surface renders as "the agent is driving".
|
|
3181
|
+
*/
|
|
3182
|
+
/** Knowledge on the wire. Provenance is FLATTENED so a surface can render "run
|
|
3183
|
+
* r7, claude, two days ago" without walking a nested object. */
|
|
3184
|
+
function summariseKnowledge(entry) {
|
|
3185
|
+
return {
|
|
3186
|
+
id: entry.id,
|
|
3187
|
+
subject: entry.subject,
|
|
3188
|
+
fact: entry.fact,
|
|
3189
|
+
...(entry.pinned ? { pinned: true } : {}),
|
|
3190
|
+
runId: entry.provenance.runId,
|
|
3191
|
+
...(entry.provenance.providerId ? { providerId: entry.provenance.providerId } : {}),
|
|
3192
|
+
at: entry.provenance.at,
|
|
3193
|
+
};
|
|
3194
|
+
}
|
|
3195
|
+
/** One shape for every claim outcome, so a refusal always carries its reason. */
|
|
3196
|
+
function intakeClaimResponse(result, claimState) {
|
|
3197
|
+
return {
|
|
3198
|
+
ok: result.ok,
|
|
3199
|
+
claimState,
|
|
3200
|
+
...(result.reason ? { reason: result.reason } : {}),
|
|
3201
|
+
...(result.heldBy ? { heldBy: result.heldBy } : {}),
|
|
3202
|
+
...(result.runId ? { runId: result.runId } : {}),
|
|
3203
|
+
};
|
|
3204
|
+
}
|
|
3205
|
+
function summariseSession(session) {
|
|
3206
|
+
return {
|
|
3207
|
+
sessionId: session.sessionId,
|
|
3208
|
+
runId: session.runId,
|
|
3209
|
+
mode: session.mode,
|
|
3210
|
+
switchable: session.switchable,
|
|
3211
|
+
hasProtocolChannel: session.hasProtocolChannel,
|
|
3212
|
+
openedAt: session.openedAt,
|
|
3213
|
+
viewers: [...session.viewers],
|
|
3214
|
+
lease: summariseLease(session.lease),
|
|
3215
|
+
};
|
|
3216
|
+
}
|
|
3217
|
+
function summariseLease(lease) {
|
|
3218
|
+
const human = lease.holder.kind === 'human' ? lease.holder.viewerId ?? null : null;
|
|
3219
|
+
return {
|
|
3220
|
+
holder: human,
|
|
3221
|
+
// `heldSince` is null for the agent too: "since when" is a fact about a
|
|
3222
|
+
// human takeover, and reporting the session's open time there would read as
|
|
3223
|
+
// a takeover that never happened.
|
|
3224
|
+
heldSince: human ? lease.since : null,
|
|
3225
|
+
interventions: lease.interventions,
|
|
3226
|
+
};
|
|
3227
|
+
}
|
|
3228
|
+
function countChangeKinds(changes) {
|
|
3229
|
+
return {
|
|
3230
|
+
added: changes.filter((change) => change.kind === 'added').length,
|
|
3231
|
+
modified: changes.filter((change) => change.kind === 'modified').length,
|
|
3232
|
+
deleted: changes.filter((change) => change.kind === 'deleted').length,
|
|
3233
|
+
};
|
|
3234
|
+
}
|
|
3235
|
+
function coordinationSubjectRun(event) {
|
|
3236
|
+
if (event.type === 'coordination.message.sent')
|
|
3237
|
+
return event.fromRunId;
|
|
3238
|
+
if (event.type === 'coordination.message.delivered')
|
|
3239
|
+
return event.toRunId;
|
|
3240
|
+
return event.runId;
|
|
3241
|
+
}
|
|
3242
|
+
//# sourceMappingURL=index.js.map
|