From dd9deb5e7afe008a0506e00116fa934afe73dde1 Mon Sep 17 00:00:00 2001 From: rhdh-bot Date: Mon, 2 Nov 2026 06:01:37 +0000 Subject: [PATCH] chore: archive outdated workspaces (augment) --- .github/CODEOWNERS | 1 - .github/archived-plugins.json | 27 + .github/labeler.yml | 3 - .github/pr-labeler.yml | 4 - .../workspace/rhdh-augment-presets.json | 28 - .github/renovate.json | 1 - ARCHIVED_WORKSPACES.md | 3 + codecov.yml | 75 +- workspaces/augment/.changeset/.gitkeep | 0 workspaces/augment/.changeset/config.json | 14 - workspaces/augment/.dockerignore | 8 - workspaces/augment/.eslintignore | 1 - workspaces/augment/.eslintrc.js | 1 - workspaces/augment/.gitignore | 65 - workspaces/augment/.prettierignore | 9 - workspaces/augment/KAGENTI_FEATURES.md | 183 - workspaces/augment/README.md | 84 - workspaces/augment/app-config.yaml | 131 - workspaces/augment/backstage.json | 1 - workspaces/augment/bcp.json | 3 - workspaces/augment/catalog-info.yaml | 9 - workspaces/augment/docs/FEATURE_SPEC.md | 1033 - .../augment/docs/adk-web-analysis-summary.md | 144 - .../augment/docs/agent-lifecycle-research.md | 447 - .../augment/docs/augment-demo-walkthrough.md | 164 - .../docs/augment-outcomes-and-use-cases.md | 227 - workspaces/augment/docs/augment-platform.md | 427 - .../augment/docs/chat-ui-e2e-analysis.md | 189 - .../docs/cloud-concierge-executive-brief.md | 111 - workspaces/augment/docs/handover-reference.md | 466 - workspaces/augment/docs/handover-slides.md | 450 - .../kagenti-augment-integration-research.md | 478 - .../examples/templates/agent-template.yaml | 83 - .../templates/crewai-agent-template.yaml | 84 - .../templates/langgraph-agent-template.yaml | 84 - .../examples/templates/tool-template.yaml | 84 - workspaces/augment/package.json | 72 - workspaces/augment/packages/app/.eslintrc.js | 1 - .../augment/packages/app/knip-report.md | 13 - workspaces/augment/packages/app/package.json | 77 - .../app/public/android-chrome-192x192.png | Bin 13599 -> 0 bytes .../packages/app/public/apple-touch-icon.png | Bin 12619 -> 0 bytes .../packages/app/public/favicon-16x16.png | Bin 883 -> 0 bytes .../packages/app/public/favicon-32x32.png | Bin 1686 -> 0 bytes .../augment/packages/app/public/favicon.ico | Bin 15086 -> 0 bytes .../augment/packages/app/public/index.html | 60 - .../augment/packages/app/public/manifest.json | 15 - .../augment/packages/app/public/robots.txt | 2 - .../packages/app/public/safari-pinned-tab.svg | 1 - workspaces/augment/packages/app/src/App.tsx | 125 - workspaces/augment/packages/app/src/apis.ts | 34 - .../app/src/components/Root/LogoFull.tsx | 51 - .../app/src/components/Root/LogoIcon.tsx | 51 - .../packages/app/src/components/Root/Root.tsx | 109 - .../packages/app/src/components/Root/index.ts | 16 - .../app/src/components/catalog/EntityPage.tsx | 404 - .../app/src/components/search/SearchPage.tsx | 138 - workspaces/augment/packages/app/src/index.tsx | 20 - .../augment/packages/backend/.eslintrc.js | 1 - .../augment/packages/backend/knip-report.md | 14 - .../augment/packages/backend/package.json | 55 - .../augment/packages/backend/src/index.ts | 48 - workspaces/augment/plugins/.gitkeep | 0 .../plugins/augment-backend/.eslintrc.js | 2 - .../plugins/augment-backend/CHANGELOG.md | 47 - .../augment/plugins/augment-backend/README.md | 357 - .../plugins/augment-backend/config.d.ts | 1503 - .../plugins/augment-backend/dev/index.ts | 38 - .../plugins/augment-backend/knip-report.md | 9 - .../plugins/augment-backend/package.json | 85 - .../plugins/augment-backend/report.api.md | 1100 - .../plugins/augment-backend/src/constants.ts | 191 - .../plugins/augment-backend/src/extensions.ts | 78 - .../plugins/augment-backend/src/index.ts | 134 - .../src/middleware/csrf.test.ts | 99 - .../src/middleware/rateLimiter.ts | 72 - .../src/middleware/security.test.ts | 518 - .../src/middleware/security.ts | 199 - .../src/parsers/chatRequestParsers.test.ts | 332 - .../src/parsers/chatRequestParsers.ts | 188 - .../plugins/augment-backend/src/plugin.ts | 320 - .../src/providers/ProviderManager.test.ts | 151 - .../src/providers/ProviderManager.ts | 141 - .../src/providers/capabilityTypes.ts | 215 - .../augment-backend/src/providers/factory.ts | 140 - .../augment-backend/src/providers/index.ts | 47 - .../kagenti/KagentiAgentCardCache.ts | 107 - .../providers/kagenti/KagentiProvider.test.ts | 515 - .../src/providers/kagenti/KagentiProvider.ts | 808 - .../kagenti/client/KagentiAdminClient.test.ts | 162 - .../kagenti/client/KagentiAdminClient.ts | 148 - .../kagenti/client/KagentiApiClient.test.ts | 322 - .../kagenti/client/KagentiApiClient.ts | 512 - .../client/KagentiSandboxClient.test.ts | 179 - .../kagenti/client/KagentiSandboxClient.ts | 515 - .../client/KeycloakTokenManager.test.ts | 200 - .../kagenti/client/KeycloakTokenManager.ts | 223 - .../providers/kagenti/client/adminMethods.ts | 17 - .../providers/kagenti/client/agentMethods.ts | 17 - .../providers/kagenti/client/chatMethods.ts | 17 - .../providers/kagenti/client/requestCore.ts | 284 - .../kagenti/client/sandboxFileOps.ts | 76 - .../kagenti/client/sandboxSidecarOps.ts | 128 - .../providers/kagenti/client/toolMethods.ts | 17 - .../src/providers/kagenti/client/types.ts | 105 - .../kagenti/client/typesBuildTools.ts | 160 - .../src/providers/kagenti/client/typesCore.ts | 299 - .../providers/kagenti/client/typesSandbox.ts | 116 - .../src/providers/kagenti/client/utils.ts | 23 - .../config/KagentiConfigLoader.test.ts | 224 - .../kagenti/config/KagentiConfigLoader.ts | 251 - .../src/providers/kagenti/index.ts | 28 - .../providers/kagenti/kagentiAgentListing.ts | 17 - .../kagenti/kagentiApprovalHandler.ts | 162 - .../providers/kagenti/kagentiChatHandlers.ts | 17 - .../kagenti/kagentiConversationCapability.ts | 59 - .../src/providers/kagenti/kagentiHelpers.ts | 159 - .../kagenti/kagentiInitialization.ts | 17 - .../src/providers/kagenti/kagentiModelOps.ts | 17 - .../kagenti/kagentiNamespaceUtils.ts | 50 - .../providers/kagenti/kagentiRagCapability.ts | 240 - .../stream/KagentiStreamNormalizer.test.ts | 849 - .../kagenti/stream/KagentiStreamNormalizer.ts | 386 - .../stream/streamInteractiveHandler.ts | 134 - .../kagenti/stream/streamLegacyHandler.ts | 118 - .../providers/kagenti/stream/streamTypes.ts | 48 - .../kagenti/stream/streamUiExtensions.ts | 92 - .../llamastack/AgentGraphManager.test.ts | 281 - .../providers/llamastack/AgentGraphManager.ts | 207 - .../ApprovalContinuationExecutor.test.ts | 509 - .../ApprovalContinuationExecutor.ts | 18 - .../llamastack/ApprovalHandler.test.ts | 224 - .../providers/llamastack/ApprovalHandler.ts | 18 - .../llamastack/BackendApprovalHandler.test.ts | 230 - .../llamastack/BackendApprovalHandler.ts | 121 - .../llamastack/BackendApprovalStore.ts | 18 - .../llamastack/BackendToolEvents.test.ts | 102 - .../llamastack/BackendToolExecutor.ts | 18 - .../llamastack/BrandingConfigLoader.ts | 17 - .../llamastack/CapabilityBuilders.test.ts | 830 - .../llamastack/CapabilityBuilders.ts | 199 - .../llamastack/ChatDepsBuilder.test.ts | 245 - .../providers/llamastack/ChatDepsBuilder.ts | 127 - .../llamastack/ClientManager.test.ts | 221 - .../src/providers/llamastack/ClientManager.ts | 149 - .../providers/llamastack/ConfigLoader.test.ts | 413 - .../src/providers/llamastack/ConfigLoader.ts | 17 - .../ConfigResolutionService.test.ts | 206 - .../llamastack/ConfigResolutionService.ts | 17 - .../llamastack/ConversationFacade.test.ts | 426 - .../llamastack/ConversationFacade.ts | 16 - .../llamastack/ConversationHelpers.test.ts | 236 - .../llamastack/ConversationHelpers.ts | 16 - .../llamastack/ConversationRegistry.ts | 18 - .../llamastack/ConversationService.test.ts | 597 - .../llamastack/ConversationService.ts | 16 - .../llamastack/CoordinatorChatOps.ts | 150 - .../llamastack/CoordinatorLifecycleOps.ts | 170 - .../llamastack/DocumentLister.test.ts | 388 - .../providers/llamastack/DocumentLister.ts | 18 - .../llamastack/DocumentSyncService.test.ts | 661 - .../llamastack/DocumentSyncService.ts | 18 - .../llamastack/DocumentUploader.test.ts | 341 - .../providers/llamastack/DocumentUploader.ts | 18 - .../llamastack/EvaluationService.test.ts | 536 - .../providers/llamastack/EvaluationService.ts | 17 - .../llamastack/InputItemsNormalizer.test.ts | 101 - .../llamastack/InputItemsNormalizer.ts | 16 - .../llamastack/LlamaStackApiError.test.ts | 143 - .../llamastack/LlamaStackOrchestrator.test.ts | 203 - .../llamastack/LlamaStackOrchestrator.ts | 20 - .../llamastack/LlamaStackProvider.test.ts | 887 - .../llamastack/LlamaStackProvider.ts | 20 - .../llamastack/McpAuthService.test.ts | 405 - .../providers/llamastack/McpAuthService.ts | 17 - .../providers/llamastack/McpConfigLoader.ts | 17 - .../providers/llamastack/McpToolBuilder.ts | 18 - .../llamastack/MessageProcessor.test.ts | 619 - .../providers/llamastack/MessageProcessor.ts | 16 - .../providers/llamastack/ModerationService.ts | 141 - .../OrchestratorInitializer.test.ts | 307 - .../llamastack/OrchestratorInitializer.ts | 234 - .../providers/llamastack/ProviderModelOps.ts | 230 - .../llamastack/ResponseChainWalker.test.ts | 266 - .../llamastack/ResponseChainWalker.ts | 16 - .../llamastack/ResponsesApiClient.ts | 23 - .../ResponsesApiCoordinator.test.ts | 253 - .../llamastack/ResponsesApiCoordinator.ts | 355 - .../llamastack/ResponsesApiProvider.test.ts | 993 - .../llamastack/ResponsesApiProvider.ts | 335 - .../llamastack/ResponsesApiService.test.ts | 411 - .../llamastack/ResponsesApiService.ts | 21 - .../llamastack/SafetyService.test.ts | 595 - .../src/providers/llamastack/SafetyService.ts | 17 - .../llamastack/ServerCapabilities.ts | 17 - .../src/providers/llamastack/SessionStore.ts | 16 - .../llamastack/StatusAggregator.test.ts | 181 - .../providers/llamastack/StatusAggregator.ts | 17 - .../llamastack/StatusService.test.ts | 392 - .../src/providers/llamastack/StatusService.ts | 17 - .../llamastack/StreamEventNormalizer.test.ts | 527 - .../llamastack/StreamEventNormalizer.ts | 18 - .../llamastack/SyncCategorizer.test.ts | 141 - .../providers/llamastack/SyncCategorizer.ts | 18 - .../providers/llamastack/ToolGroupService.ts | 99 - .../providers/llamastack/ToolsBuilder.test.ts | 278 - .../src/providers/llamastack/ToolsBuilder.ts | 18 - .../llamastack/VectorStoreFacade.test.ts | 753 - .../providers/llamastack/VectorStoreFacade.ts | 356 - .../VectorStoreLifecycle.integration.test.ts | 223 - .../llamastack/VectorStoreQueryOps.ts | 276 - .../llamastack/VectorStoreService.test.ts | 371 - .../llamastack/VectorStoreService.ts | 18 - .../__tests__/multiAgent.e2e.test.ts | 1454 - .../llamastack/approvalContinuation.ts | 291 - .../llamastack/auth/McpAuthService.test.ts | 405 - .../llamastack/auth/McpAuthService.ts | 290 - .../llamastack/auth/mcpTokenFetchers.ts | 222 - .../src/providers/llamastack/chatUtils.ts | 18 - .../llamastack/config/AgentConfigParser.ts | 144 - .../llamastack/config/BrandingConfigLoader.ts | 69 - .../llamastack/config/ConfigLoader.test.ts | 505 - .../llamastack/config/ConfigLoader.ts | 329 - .../llamastack/config/ConfigParsers.ts | 178 - .../config/ConfigResolutionService.test.ts | 206 - .../config/ConfigResolutionService.ts | 178 - .../llamastack/config/McpConfigLoader.test.ts | 109 - .../llamastack/config/McpConfigLoader.ts | 265 - .../config/ServerCapabilities.test.ts | 164 - .../llamastack/config/ServerCapabilities.ts | 154 - .../llamastack/config/UiConfigLoader.ts | 104 - .../providers/llamastack/conversationTypes.ts | 16 - .../src/providers/llamastack/eventTypes.ts | 23 - .../src/providers/llamastack/index.ts | 118 - .../openai-agents-adapters/LlamaStackModel.ts | 270 - .../LlamaStackProvider.ts | 47 - .../OpenAIAgentsOrchestrator.ts | 270 - .../openai-agents-adapters/configMapper.ts | 186 - .../openai-agents-adapters/index.ts | 21 - .../orchestratorToolDiscovery.ts | 163 - .../openai-agents-adapters/responseMapper.ts | 147 - .../openai-agents-adapters/streamMapper.ts | 320 - .../llamastack/promptGeneration.test.ts | 186 - .../providers/llamastack/promptGeneration.ts | 18 - .../llamastack/ragCapabilityBuilder.ts | 191 - .../safety/EvaluationService.test.ts | 537 - .../llamastack/safety/EvaluationService.ts | 298 - .../llamastack/safety/SafetyService.test.ts | 596 - .../llamastack/safety/SafetyService.ts | 282 - .../safety/evaluationConfigLoader.ts | 89 - .../llamastack/safety/safetyConfigLoader.ts | 160 - .../status/StatusAggregator.test.ts | 181 - .../llamastack/status/StatusAggregator.ts | 165 - .../llamastack/status/StatusService.test.ts | 396 - .../llamastack/status/StatusService.ts | 276 - .../llamastack/workflow/WorkflowExecutor.ts | 27 - .../llamastack/workflow/WorkflowHydrator.ts | 251 - .../workflow/executor/EdgeResolver.ts | 89 - .../workflow/executor/McpExecutor.ts | 84 - .../workflow/executor/NodeExecutors.ts | 174 - .../workflow/executor/ResponseParser.ts | 54 - .../workflow/executor/StreamingExecutor.ts | 200 - .../workflow/executor/WorkflowExecutor.ts | 314 - .../llamastack/workflow/executor/index.ts | 23 - .../workflow/executor/llmNodeExecutors.ts | 226 - .../llamastack/workflow/executor/types.ts | 68 - .../providers/llamastack/workflow/index.ts | 18 - .../workflow/workflowExecutionPlan.ts | 168 - .../workflow/workflowToolResolver.ts | 161 - .../src/providers/providerInterface.ts | 136 - .../src/providers/registry.test.ts | 97 - .../augment-backend/src/providers/registry.ts | 227 - .../responses-api/agents/agentGraph.ts | 153 - .../chat/ResponsesApiService.test.ts | 558 - .../responses-api/chat/ResponsesApiService.ts | 320 - .../responses-api/chat/chatTurnBuilder.ts | 111 - .../providers/responses-api/chat/chatUtils.ts | 47 - .../responses-api/chat/continuationHandler.ts | 180 - .../chat/promptGeneration.test.ts | 186 - .../responses-api/chat/promptGeneration.ts | 187 - .../client/ResponsesApiClient.ts | 233 - .../client/ResponsesApiError.test.ts | 143 - .../responses-api/client/ResponsesApiError.ts | 90 - .../responses-api/client/streamRequest.ts | 142 - .../conversations/ConversationFacade.ts | 251 - .../conversations/ConversationHelpers.ts | 164 - .../conversations/ConversationRegistry.ts | 236 - .../conversations/ConversationService.ts | 296 - .../conversations/InputItemsNormalizer.ts | 44 - .../conversations/MessageProcessor.ts | 341 - .../conversations/ResponseChainWalker.ts | 169 - .../conversations/SessionStore.ts | 123 - .../conversations/conversationFetcher.ts | 299 - .../conversations/conversationTypes.ts | 220 - .../documents/DocumentHashCache.ts | 15 - .../documents/DocumentLister.test.ts | 388 - .../responses-api/documents/DocumentLister.ts | 197 - .../documents/DocumentSyncService.test.ts | 661 - .../documents/DocumentSyncService.ts | 230 - .../documents/DocumentUploader.test.ts | 341 - .../documents/DocumentUploader.ts | 290 - .../documents/SyncCategorizer.test.ts | 141 - .../documents/SyncCategorizer.ts | 77 - .../documents/VectorStoreService.test.ts | 371 - .../documents/VectorStoreService.ts | 322 - .../documents/documentSyncOps.ts | 315 - .../documents/vectorStoreLifecycle.ts | 184 - .../documents/vectorStoreMutations.ts | 134 - .../src/providers/responses-api/index.ts | 64 - .../stream/StreamEventNormalizer.test.ts | 560 - .../stream/StreamEventNormalizer.ts | 158 - .../responses-api/stream/eventTypes.ts | 109 - .../responses-api/stream/mcpErrorSanitizer.ts | 59 - .../stream/streamEventHandlers.ts | 329 - .../ApprovalContinuationExecutor.test.ts | 548 - .../tools/ApprovalContinuationExecutor.ts | 205 - .../tools/ApprovalHandler.test.ts | 265 - .../responses-api/tools/ApprovalHandler.ts | 234 - .../tools/BackendApprovalStore.test.ts | 122 - .../tools/BackendApprovalStore.ts | 98 - .../tools/BackendToolExecutor.test.ts | 1018 - .../tools/BackendToolExecutor.ts | 381 - .../responses-api/tools/McpToolBuilder.ts | 92 - .../responses-api/tools/ToolsBuilder.test.ts | 381 - .../responses-api/tools/ToolsBuilder.ts | 191 - .../responses-api/tools/toolClientOps.ts | 112 - .../tools/toolDiscoveryHelpers.ts | 175 - .../responses-api/tools/toolResolver.ts | 15 - .../src/providers/responses-api/types.ts | 84 - .../augment-backend/src/providers/types.ts | 104 - .../augment-backend/src/router.test.ts | 997 - .../plugins/augment-backend/src/router.ts | 440 - .../src/routes/adminConfigRoutes.ts | 402 - .../src/routes/adminDocumentRoutes.ts | 193 - .../src/routes/adminModelRoutes.ts | 189 - .../src/routes/adminRouteTypes.ts | 52 - .../src/routes/adminRoutes.test.ts | 1793 - .../augment-backend/src/routes/adminRoutes.ts | 309 - .../src/routes/adminSessionRoutes.ts | 143 - .../src/routes/adminVectorStoreRoutes.ts | 749 - .../src/routes/agentRoutes.test.ts | 1304 - .../augment-backend/src/routes/agentRoutes.ts | 1304 - .../augment-backend/src/routes/chatRoutes.ts | 978 - .../src/routes/configRoutes.ts | 241 - .../src/routes/conversationRoutes.ts | 326 - .../src/routes/devSpacesRoutes.ts | 200 - .../src/routes/documentRoutes.ts | 147 - .../src/routes/evaluationRoutes.ts | 778 - .../augment-backend/src/routes/index.ts | 31 - .../src/routes/k8sCredentials.ts | 51 - .../src/routes/kagentiAdminRoutes.test.ts | 234 - .../src/routes/kagentiAdminRoutes.ts | 264 - .../src/routes/kagentiAgentRoutes.ts | 454 - .../src/routes/kagentiConfigRoutes.ts | 93 - .../src/routes/kagentiRoutes.test.ts | 621 - .../src/routes/kagentiRoutes.ts | 124 - .../src/routes/kagentiSandboxRoutes.test.ts | 383 - .../src/routes/kagentiSandboxRoutes.ts | 121 - .../src/routes/kagentiToolRoutes.ts | 253 - .../src/routes/providerRoutes.ts | 150 - .../src/routes/resolveWorkflowConfig.ts | 49 - .../src/routes/routeWrapper.test.ts | 130 - .../src/routes/routeWrapper.ts | 64 - .../sandboxAgentDeployTelemetryRoutes.ts | 242 - .../src/routes/sandboxChatRoutes.ts | 86 - .../src/routes/sandboxFileRoutes.ts | 105 - .../src/routes/sandboxRouteContext.ts | 34 - .../src/routes/sandboxSessionRoutes.ts | 207 - .../src/routes/sandboxSidecarRoutes.ts | 176 - .../src/routes/sessionRoutes.ts | 425 - .../src/routes/skillChatProxy.ts | 190 - .../src/routes/skillsManifestBuilder.ts | 245 - .../src/routes/skillsRoutes.ts | 467 - .../src/routes/sseRouteHelpers.ts | 146 - .../src/routes/statusRoutes.ts | 129 - .../src/routes/toolLifecycleRoutes.ts | 432 - .../augment-backend/src/routes/types.ts | 106 - .../src/routes/validatePublicUrl.ts | 110 - .../src/routes/workflow/crud.ts | 134 - .../src/routes/workflow/execution.ts | 135 - .../src/routes/workflow/testSuites.ts | 87 - .../src/routes/workflow/versions.ts | 124 - .../src/routes/workflowRoutes.ts | 69 - .../AdminConfigService.scoped.test.ts | 150 - .../src/services/AdminConfigService.test.ts | 347 - .../src/services/AdminConfigService.ts | 481 - .../services/AgentApprovalWorkflowService.ts | 197 - .../src/services/AuditLogger.test.ts | 106 - .../src/services/AuditLogger.ts | 67 - .../src/services/ChatSessionService.test.ts | 402 - .../src/services/ChatSessionService.ts | 713 - .../src/services/DevSpacesService.ts | 560 - .../services/DocumentIngestionService.test.ts | 165 - .../src/services/DocumentIngestionService.ts | 432 - .../src/services/McpTestService.ts | 105 - .../src/services/ResponsesApiClient.ts | 22 - .../RuntimeConfigResolver.integration.test.ts | 488 - .../services/RuntimeConfigResolver.test.ts | 1089 - .../src/services/RuntimeConfigResolver.ts | 531 - .../src/services/VectorStoreService.ts | 20 - .../src/services/WorkflowConfigService.ts | 438 - .../src/services/WorkflowMigration.ts | 266 - .../augment-backend/src/services/index.ts | 47 - .../src/services/promptGeneration.ts | 24 - .../src/services/toolscope/HttpEmbedder.ts | 89 - .../services/toolscope/InMemoryToolIndex.ts | 106 - .../src/services/toolscope/TfIdfEmbedder.ts | 175 - .../src/services/toolscope/ToolIndex.ts | 525 - .../toolscope/ToolScopeFilterService.ts | 202 - .../toolscope/__tests__/HttpEmbedder.test.ts | 107 - .../__tests__/InMemoryToolIndex.test.ts | 218 - .../toolscope/__tests__/MemoryBackend.test.ts | 205 - .../toolscope/__tests__/TfIdfEmbedder.test.ts | 183 - .../toolscope/__tests__/ToolIndex.test.ts | 291 - .../__tests__/ToolScopeFilterService.test.ts | 294 - .../toolscope/__tests__/cache.test.ts | 59 - .../toolscope/__tests__/fingerprint.test.ts | 86 - .../toolscope/__tests__/normalize.test.ts | 178 - .../toolscope/__tests__/observability.test.ts | 57 - .../toolscope/__tests__/session.test.ts | 102 - .../services/toolscope/__tests__/text.test.ts | 122 - .../src/services/toolscope/backends/memory.ts | 162 - .../src/services/toolscope/cache.ts | 41 - .../src/services/toolscope/embeddingConfig.ts | 87 - .../src/services/toolscope/filter.ts | 93 - .../src/services/toolscope/fingerprint.ts | 60 - .../src/services/toolscope/index.ts | 97 - .../src/services/toolscope/normalize.ts | 220 - .../src/services/toolscope/observability.ts | 124 - .../src/services/toolscope/rerankers.ts | 62 - .../src/services/toolscope/session.ts | 119 - .../src/services/toolscope/text.ts | 123 - .../src/services/toolscope/types.ts | 124 - .../src/services/utils/GlobMatcher.test.ts | 96 - .../src/services/utils/GlobMatcher.ts | 61 - .../src/services/utils/SsrfGuard.test.ts | 188 - .../src/services/utils/SsrfGuard.ts | 114 - .../services/utils/configValidation.test.ts | 753 - .../src/services/utils/configValidation.ts | 671 - .../services/utils/documentAttributes.test.ts | 186 - .../src/services/utils/documentAttributes.ts | 92 - .../src/services/utils/errorSanitizer.test.ts | 81 - .../src/services/utils/errorSanitizer.ts | 79 - .../src/services/utils/errors.ts | 37 - .../src/services/utils/file-utils.ts | 47 - .../src/services/utils/http.ts | 93 - .../src/services/utils/index.ts | 29 - .../src/services/utils/mcpClient.ts | 114 - .../augment-backend/src/test-utils/index.ts | 16 - .../augment-backend/src/test-utils/mocks.ts | 60 - .../plugins/augment-backend/src/types/chat.ts | 39 - .../augment-backend/src/types/config.ts | 703 - .../augment-backend/src/types/conversation.ts | 184 - .../augment-backend/src/types/documents.ts | 139 - .../augment-backend/src/types/evaluation.ts | 92 - .../augment-backend/src/types/index.ts | 51 - .../augment-backend/src/types/responsesApi.ts | 359 - .../augment-backend/src/types/safety.ts | 97 - .../augment-backend/src/types/security.ts | 129 - .../plugins/augment-common/.eslintrc.js | 2 - .../plugins/augment-common/CHANGELOG.md | 16 - .../docs/BACKSTAGE_RBAC_SETUP.md | 411 - .../augment-common/docs/CONFIG_REFERENCE.md | 200 - .../augment-common/docs/KEYCLOAK_SETUP.md | 345 - .../augment-common/docs/MCP_SERVER_SETUP.md | 516 - .../plugins/augment-common/docs/README.md | 169 - .../plugins/augment-common/docs/SECURITY.md | 187 - .../augment-common/docs/SECURITY_MODES.md | 247 - .../augment-common/docs/TROUBLESHOOTING.md | 196 - .../plugins/augment-common/docs/VALIDATION.md | 602 - .../plugins/augment-common/knip-report.md | 2 - .../plugins/augment-common/package.json | 55 - .../plugins/augment-common/report.api.md | 2564 -- .../plugins/augment-common/src/index.ts | 215 - .../augment-common/src/permissions.test.ts | 55 - .../plugins/augment-common/src/permissions.ts | 72 - .../src/templates/workflowTemplates.ts | 380 - .../plugins/augment-common/src/types/admin.ts | 170 - .../augment-common/src/types/branding.test.ts | 67 - .../augment-common/src/types/branding.ts | 54 - .../plugins/augment-common/src/types/index.ts | 222 - .../augment-common/src/types/kagenti.ts | 559 - .../augment-common/src/types/provider.test.ts | 126 - .../augment-common/src/types/provider.ts | 217 - .../augment-common/src/types/shared.test.ts | 155 - .../augment-common/src/types/shared.ts | 828 - .../augment-common/src/types/streaming.ts | 268 - .../src/types/workflowBuilder.ts | 621 - .../augment/plugins/augment/.eslintrc.js | 5 - .../augment/plugins/augment/CHANGELOG.md | 40 - workspaces/augment/plugins/augment/README.md | 412 - .../augment/plugins/augment/config.d.ts | 82 - .../augment/plugins/augment/dev/index.tsx | 71 - .../augment/docs/images/chat-response.png | Bin 154610 -> 0 bytes .../docs/images/right-pane-expanded.png | Bin 202054 -> 0 bytes .../augment/docs/images/welcome-screen.png | Bin 145509 -> 0 bytes .../augment/plugins/augment/knip-report.md | 16 - .../augment/plugins/augment/package.json | 103 - .../augment/plugins/augment/report.api.md | 1137 - .../augment/src/api/AugmentApi.test.ts | 146 - .../plugins/augment/src/api/AugmentApi.ts | 1513 - .../augment/src/api/adminEndpoints.test.ts | 428 - .../plugins/augment/src/api/adminEndpoints.ts | 232 - .../augment/src/api/chatEndpoints.test.ts | 301 - .../plugins/augment/src/api/chatEndpoints.ts | 282 - .../src/api/conversationEndpoints.test.ts | 67 - .../augment/src/api/conversationEndpoints.ts | 42 - .../augment/src/api/documentEndpoints.test.ts | 368 - .../augment/src/api/documentEndpoints.ts | 202 - .../plugins/augment/src/api/fetchHelpers.ts | 35 - .../augment/plugins/augment/src/api/index.ts | 17 - .../augment/src/api/kagentiEndpoints.test.ts | 328 - .../api/kagentiEndpoints/adminEndpoints.ts | 161 - .../api/kagentiEndpoints/agentEndpoints.ts | 213 - .../api/kagentiEndpoints/configEndpoints.ts | 55 - .../kagentiEndpoints/devSpacesEndpoints.ts | 94 - .../augment/src/api/kagentiEndpoints/index.ts | 23 - .../api/kagentiEndpoints/sandboxEndpoints.ts | 485 - .../src/api/kagentiEndpoints/toolEndpoints.ts | 164 - .../augment/src/api/kagentiEndpoints/types.ts | 23 - .../augment/src/api/sessionEndpoints.test.ts | 232 - .../augment/src/api/sessionEndpoints.ts | 193 - .../plugins/augment/src/api/sseStreaming.ts | 140 - .../AgentRegistryPanel/AgentRegistryPanel.tsx | 2800 -- .../AdminPanels/AgentRegistryPanel/index.ts | 16 - .../AdminPanels/AgentsPanel/AdvancedTab.tsx | 287 - .../AgentsPanel/AgentEditorHeader.tsx | 139 - .../AgentsPanel/AgentEditorToolbar.tsx | 171 - .../AdminPanels/AgentsPanel/AgentListItem.tsx | 184 - .../AgentsPanel/AgentPreviewCard.tsx | 154 - .../AgentsPanel/AgentReviewStep.tsx | 220 - .../AdminPanels/AgentsPanel/AgentsPanel.tsx | 1776 - .../AgentsPanel/CapabilitiesTab.tsx | 382 - .../AgentsPanel/ConnectionsTab.tsx | 184 - .../AgentsPanel/CreateAgentModal.tsx | 128 - .../AgentsPanel/FieldHelpPopover.tsx | 144 - .../AgentsPanel/InstructionsTab.tsx | 303 - .../AgentsPanel/MultiAgentEditor.tsx | 460 - .../AgentsPanel/SingleAgentStepper.tsx | 313 - .../AdminPanels/AgentsPanel/TopologyGraph.tsx | 203 - .../AgentsPanel/agentTemplates.tsx | 73 - .../AgentsPanel/agentValidation.test.ts | 511 - .../AgentsPanel/agentValidation.ts | 376 - .../AdminPanels/AgentsPanel/index.ts | 24 - .../AgentsPanel/useAgentActions.ts | 364 - .../AgentsPanel/useAgentDerived.ts | 108 - .../AdminPanels/AgentsPanel/useAgentEditor.ts | 306 - .../AdminPanels/AppearanceSection.tsx | 253 - .../AdminPanels/BrandingPanel.test.tsx | 81 - .../components/AdminPanels/BrandingPanel.tsx | 54 - .../AdminPanels/BrandingSection.tsx | 163 - .../AdminPanels/ChatExperiencePanel.tsx | 605 - .../src/components/AdminPanels/ChunkCard.tsx | 236 - .../AdminPanels/CreateStoreForm.tsx | 159 - .../AdminPanels/DeleteStoreDialog.tsx | 81 - .../AdminPanels/DocsPanel/DocsPanel.tsx | 477 - .../AdminPanels/DocsPanel/docsContent.ts | 1168 - .../components/AdminPanels/DocsPanel/index.ts | 17 - .../components/AdminPanels/DocumentsTable.tsx | 147 - .../AdminPanels/GeneratedAnswerCard.tsx | 125 - .../AdminPanels/HybridSearchConfig.tsx | 74 - .../components/AdminPanels/IngestDropZone.tsx | 119 - .../AdminPanels/KBCreateStore.test.tsx | 275 - .../components/AdminPanels/KBCreateStore.tsx | 167 - .../components/AdminPanels/KBManageStores.tsx | 627 - .../src/components/AdminPanels/KBRagTest.tsx | 298 - .../components/AdminPanels/KBStoreDetail.tsx | 538 - .../KagentiPanels/AgentCardTab.tsx | 264 - .../KagentiPanels/AgentCatalogCard.tsx | 315 - .../KagentiPanels/AgentCreateIntentDialog.tsx | 868 - .../KagentiPanels/AgentDetailsTab.tsx | 317 - .../KagentiPanels/AgentLifecycleDetail.tsx | 454 - .../KagentiPanels/AgentResourceTab.tsx | 119 - .../KagentiPanels/AgentStatusTab.tsx | 471 - .../KagentiPanels/AgentTemplateBrowser.tsx | 510 - .../KagentiPanels/AgentWizardBasicsStep.tsx | 133 - .../KagentiPanels/AgentWizardBuildStep.tsx | 525 - .../KagentiPanels/AgentWizardDeployStep.tsx | 334 - .../KagentiPanels/AgentWizardRuntimeStep.tsx | 344 - .../KagentiPanels/CreateAgentWizard.test.tsx | 444 - .../KagentiPanels/CreateAgentWizard.tsx | 209 - .../KagentiPanels/CreateToolWizard.test.tsx | 305 - .../KagentiPanels/CreateToolWizard.tsx | 196 - .../DevSpacesFrameworkPicker.tsx | 231 - .../KagentiPanels/DevSpacesLaunchForm.tsx | 912 - .../KagentiPanels/IntentDialogParts.tsx | 203 - .../KagentiPanels/KagentiAdminPanel.test.tsx | 271 - .../KagentiPanels/KagentiAdminPanel.tsx | 1430 - .../KagentiAgentDetailView.test.tsx | 378 - .../KagentiPanels/KagentiAgentDetailView.tsx | 332 - .../KagentiPanels/KagentiAgentsPanel.test.tsx | 433 - .../KagentiPanels/KagentiAgentsPanel.tsx | 1052 - .../KagentiBuildPipelinePanel.tsx | 450 - .../KagentiPanels/KagentiDashboardLinks.tsx | 340 - .../KagentiPanels/KagentiHomeDashboard.tsx | 918 - .../KagentiPanels/KagentiPanelUtils.test.ts | 162 - .../KagentiPanels/KagentiPropertyRow.tsx | 42 - .../KagentiPanels/KagentiSandboxPanel.tsx | 1839 - .../KagentiToolDetailDrawer.test.tsx | 205 - .../KagentiPanels/KagentiToolDetailDrawer.tsx | 317 - .../KagentiPanels/KagentiToolsPanel.test.tsx | 182 - .../KagentiPanels/KagentiToolsPanel.tsx | 494 - .../KagentiPanels/LifecycleActionButtons.tsx | 196 - .../KagentiPanels/LifecycleDetailShell.tsx | 99 - .../KagentiPanels/McpToolCatalog.test.tsx | 102 - .../KagentiPanels/McpToolCatalog.tsx | 284 - .../KagentiPanels/NamespacePicker.tsx | 151 - .../KagentiPanels/OrchAgentDetailView.tsx | 478 - .../KagentiPanels/SchemaInvokeForm.test.tsx | 192 - .../KagentiPanels/SchemaInvokeForm.tsx | 524 - .../KagentiPanels/ToolBuildSection.tsx | 175 - .../KagentiPanels/ToolConnectDialog.tsx | 246 - .../KagentiPanels/ToolCreateIntentDialog.tsx | 234 - .../KagentiPanels/ToolInvokeDialog.tsx | 162 - .../KagentiPanels/ToolMcpSection.tsx | 236 - .../KagentiPanels/ToolRouteSection.tsx | 111 - .../KagentiPanels/ToolSpecSection.tsx | 208 - .../KagentiPanels/ToolWizardBasicsStep.tsx | 157 - .../KagentiPanels/ToolWizardDeployStep.tsx | 346 - .../KagentiPanels/ToolWizardRuntimeStep.tsx | 401 - .../AdminPanels/KagentiPanels/WizardShell.tsx | 222 - .../KagentiPanels/WorkflowAgentDetail.tsx | 791 - .../KagentiPanels/agentWizardTypes.test.ts | 41 - .../KagentiPanels/agentWizardTypes.ts | 112 - .../KagentiPanels/agentWizardUtils.test.ts | 490 - .../KagentiPanels/agentWizardUtils.ts | 144 - .../AdminPanels/KagentiPanels/index.ts | 53 - .../KagentiPanels/kagentiDisplayUtils.ts | 93 - .../KagentiPanels/kagentiTestFixtures.ts | 60 - .../KagentiPanels/lifecycleTransitions.ts | 82 - .../KagentiPanels/toolWizardTypes.test.ts | 47 - .../KagentiPanels/toolWizardTypes.ts | 88 - .../KagentiPanels/toolWizardUtils.test.ts | 316 - .../KagentiPanels/toolWizardUtils.ts | 176 - .../KagentiPanels/useAgentTemplates.ts | 103 - .../KagentiPanels/useAgentWizardForm.test.tsx | 448 - .../KagentiPanels/useAgentWizardForm.ts | 820 - .../KagentiPanels/useKagentiAgentDetail.ts | 138 - .../KagentiPanels/useKagentiToolDetail.ts | 209 - .../KagentiPanels/useKagentiToolsList.ts | 102 - .../KagentiPanels/useToolInvoke.ts | 197 - .../KagentiPanels/useToolWizardForm.test.ts | 512 - .../KagentiPanels/useToolWizardForm.ts | 831 - .../AdminPanels/KnowledgeBasePanel.tsx | 181 - .../ModelToolsPanel/AllowedToolsSelector.tsx | 158 - .../ModelToolsPanel/CapabilitiesSelector.tsx | 259 - .../ModelToolsPanel/GeneratePromptForm.tsx | 241 - .../KagentiConnectionSection.tsx | 134 - .../ModelToolsPanel/McpServerCard.tsx | 350 - .../ModelToolsPanel/McpServerFormDialog.tsx | 302 - .../McpServersSection.test.tsx | 296 - .../ModelToolsPanel/McpServersSection.tsx | 211 - .../ModelToolsPanel/McpToolsSection.tsx | 165 - .../ModelConnectionSection.tsx | 280 - .../ModelToolsPanel/ModelSelector.tsx | 123 - .../ModelToolsPanel/ModelToolsPanel.test.tsx | 152 - .../ModelToolsPanel/ModelToolsPanel.tsx | 256 - .../SystemPromptSection.test.tsx | 241 - .../ModelToolsPanel/SystemPromptSection.tsx | 229 - .../ModelToolsPanel/ToolsSection.tsx | 264 - .../ModelToolsPanel/mcpServerTypes.ts | 27 - .../ModelToolsPanel/useMcpServers.test.tsx | 725 - .../ModelToolsPanel/useMcpServers.ts | 386 - .../AdminPanels/PromptsPanel/CardEditor.tsx | 252 - .../PromptsPanel/ColorPicker.test.tsx | 58 - .../AdminPanels/PromptsPanel/ColorPicker.tsx | 98 - .../PromptsPanel/DeleteConfirmButton.test.tsx | 88 - .../PromptsPanel/DeleteConfirmButton.tsx | 87 - .../AdminPanels/PromptsPanel/GroupEditor.tsx | 368 - .../PromptsPanel/IconPicker.test.tsx | 62 - .../AdminPanels/PromptsPanel/IconPicker.tsx | 94 - .../AdminPanels/PromptsPanel/LivePreview.tsx | 85 - .../PromptsPanel/PromptsPanel.test.tsx | 241 - .../AdminPanels/PromptsPanel/PromptsPanel.tsx | 252 - .../PromptsPanel/PromptsPanelHeader.tsx | 119 - .../AdminPanels/PromptsPanel/index.ts | 16 - .../AdminPanels/ProviderPlaceholder.tsx | 71 - .../AdminPanels/ProviderSelector.tsx | 91 - .../components/AdminPanels/RagQueryForm.tsx | 288 - .../AdminPanels/RagResultsTable.tsx | 411 - .../SafetyEvalPanel/EvaluationSection.tsx | 247 - .../SafetyEvalPanel/SafetyEvalPanel.test.tsx | 111 - .../SafetyEvalPanel/SafetyEvalPanel.tsx | 117 - .../SafetyEvalPanel/SafetyPatternsSection.tsx | 105 - .../SafetyEvalPanel/SafetyShieldsSection.tsx | 253 - .../AdminPanels/SafetyPatternsSection.tsx | 135 - .../src/components/AdminPanels/ScoreBar.tsx | 75 - .../components/AdminPanels/SettingsPanel.tsx | 237 - .../AdminPanels/StoreConfigFields.tsx | 167 - .../components/AdminPanels/StoresTable.tsx | 493 - .../src/components/AdminPanels/index.ts | 18 - .../AdminPanels/ragResultsUtils.test.tsx | 166 - .../AdminPanels/ragResultsUtils.tsx | 68 - .../AdminPanels/shared/AdminSection.test.tsx | 102 - .../AdminPanels/shared/AdminSection.tsx | 179 - .../AdminPanels/shared/AnimatedList.tsx | 73 - .../AdminPanels/shared/CardGridSkeleton.tsx | 51 - .../AdminPanels/shared/ColorInput.tsx | 67 - .../AdminPanels/shared/ConfirmDialog.tsx | 76 - .../AdminPanels/shared/GlassCard.tsx | 75 - .../components/AdminPanels/shared/InfoTip.tsx | 34 - .../AdminPanels/shared/InlineAgentChat.tsx | 298 - .../AdminPanels/shared/PanelIntroBanner.tsx | 67 - .../shared/ServerItemPicker.test.tsx | 113 - .../AdminPanels/shared/ServerItemPicker.tsx | 252 - .../shared/StringListEditor.test.tsx | 107 - .../AdminPanels/shared/StringListEditor.tsx | 113 - .../AdminPanels/shared/TableSkeleton.tsx | 90 - .../AdminPanels/shared/ToggleSwitch.tsx | 155 - .../AdminPanels/shared/TourController.tsx | 191 - .../AdminPanels/shared/TourLauncherDialog.tsx | 645 - .../AdminPanels/shared/TourProvider.tsx | 481 - .../AdminPanels/shared/commandCenterStyles.ts | 116 - .../AdminPanels/shared/defaultTours.ts | 522 - .../AdminPanels/shared/selectMenuProps.ts | 43 - .../AdminPanels/shared/tourDefinitions.ts | 64 - .../AdminPanels/shared/tourStyles.css | 136 - .../AdminPanels/shared/useTourVoice.ts | 190 - .../AdminPanels/useIngestDropZone.test.tsx | 264 - .../AdminPanels/useIngestDropZone.ts | 99 - .../usePromptGroupsEditor.test.tsx | 605 - .../AdminPanels/usePromptGroupsEditor.ts | 390 - .../AdminPanels/useStoreDocuments.ts | 70 - .../AgentStatusPanel/AgentStatusPanel.tsx | 239 - .../src/components/AgentStatusPanel/index.ts | 16 - .../AgentStudio/AgentStudioPage.tsx | 178 - .../src/components/AgentStudio/index.ts | 17 - .../components/AugmentIcon/AugmentIcon.tsx | 25 - .../src/components/AugmentIcon/index.ts | 17 - .../components/AugmentPage/AdminLayout.tsx | 583 - .../AugmentPage/AdminOnboardingCard.tsx | 282 - .../src/components/AugmentPage/AppRouter.tsx | 220 - .../src/components/AugmentPage/AppShell.tsx | 124 - .../AugmentPage/AppStateProvider.tsx | 234 - .../AugmentPage/AugmentErrorBoundary.tsx | 84 - .../components/AugmentPage/AugmentPage.tsx | 36 - .../src/components/AugmentPage/ChatView.tsx | 679 - .../AugmentPage/CommandCenterHeader.tsx | 200 - .../components/AugmentPage/KagentiSidebar.tsx | 485 - .../AugmentPage/ProviderOfflineBanner.tsx | 64 - .../components/AugmentPage/SecurityGate.tsx | 155 - .../AugmentPage/SwitchSessionDialog.tsx | 63 - .../AugmentPage/augment-isolation.css | 144 - .../src/components/AugmentPage/index.ts | 17 - .../ChatContainer/ChatContainer.test.tsx | 248 - .../ChatContainer/ChatContainer.tsx | 693 - .../components/ChatContainer/ChatHeader.tsx | 339 - .../ChatContainer/ChatScrollArea.tsx | 98 - .../ChatContainer/KeyboardShortcutsDialog.tsx | 175 - .../ChatContainer/MessageInspectorPanel.tsx | 381 - .../ChatContainer/ThinkingIndicator.tsx | 90 - .../ChatContainer/VirtualizedMessageList.tsx | 242 - .../src/components/ChatContainer/index.ts | 17 - .../ChatContainer/useChatActions.test.tsx | 303 - .../ChatContainer/useChatActions.ts | 106 - .../src/components/ChatDrawer/ChatDrawer.tsx | 137 - .../src/components/ChatDrawer/index.ts | 18 - .../components/ChatDrawer/useChatDrawer.ts | 42 - .../ChatInput/ChatInput.kagenti.test.tsx | 104 - .../components/ChatInput/ChatInput.test.tsx | 224 - .../src/components/ChatInput/ChatInput.tsx | 325 - .../augment/src/components/ChatInput/index.ts | 18 - .../src/components/ChatInput/styles.ts | 212 - .../ChatMessage/ChatMessage.test.tsx | 469 - .../components/ChatMessage/ChatMessage.tsx | 561 - .../src/components/ChatMessage/CopyButton.tsx | 70 - .../src/components/ChatMessage/ErrorCard.tsx | 162 - .../ChatMessage/ExpandableOutput.tsx | 113 - .../ChatMessage/MessageActionButtons.tsx | 92 - .../ChatMessage/MessageFeedback.tsx | 280 - .../ChatMessage/RAGSourcesSection.tsx | 180 - .../ChatMessage/ReasoningSummary.test.tsx | 103 - .../ChatMessage/ReasoningSummary.tsx | 150 - .../ChatMessage/ToolCallSummary.tsx | 86 - .../ChatMessage/ToolCallsSection.tsx | 242 - .../src/components/ChatMessage/index.ts | 18 - .../src/components/ChatMessage/styles.ts | 289 - .../ChatMessage/useMessageEdit.test.tsx | 229 - .../components/ChatMessage/useMessageEdit.ts | 78 - .../src/components/CodeBlock/CodeBlock.tsx | 231 - .../augment/src/components/CodeBlock/index.ts | 16 - .../CollapsibleSection/CollapsibleSection.tsx | 94 - .../components/CollapsibleSection/index.ts | 16 - .../components/CommandCenter/ActivityFeed.tsx | 195 - .../components/CommandCenter/CommandBar.tsx | 216 - .../components/CommandCenter/HealthMosaic.tsx | 209 - .../components/CommandCenter/MetricTile.tsx | 186 - .../components/CommandCenter/OpsOverview.tsx | 284 - .../components/CommandCenter/OverviewHero.tsx | 198 - .../components/CommandCenter/ReviewQueue.tsx | 383 - .../components/CommandCenter/StatusBar.tsx | 122 - .../CommandCenter/ToolReviewQueue.tsx | 257 - .../CommandCenter/commandcenter.constants.ts | 49 - .../CommandCenter/commandcenter.styles.ts | 132 - .../src/components/CommandCenter/index.ts | 25 - .../ConversationHistory.test.tsx | 262 - .../ConversationHistory.tsx | 413 - .../ConversationSkeleton.tsx | 33 - .../EmptyConversationState.tsx | 63 - .../GroupedSessionList.tsx | 106 - .../ConversationHistory/SessionItem.tsx | 342 - .../ConversationHistory/SessionSearchBar.tsx | 134 - .../components/ConversationHistory/index.ts | 16 - .../components/ConversationHistory/styles.ts | 286 - .../components/Dashboard/DeveloperHome.tsx | 107 - .../src/components/Dashboard/OpsHome.tsx | 107 - .../src/components/Dashboard/QuickActions.tsx | 134 - .../src/components/Dashboard/StatCard.tsx | 138 - .../augment/src/components/Dashboard/index.ts | 20 - .../DocumentPanel/DocumentPanel.tsx | 237 - .../src/components/DocumentPanel/index.ts | 17 - .../ExecutionTracePanel.test.ts | 355 - .../ExecutionTrace/ExecutionTracePanel.tsx | 501 - .../src/components/ExecutionTrace/index.ts | 16 - .../HandoffDivider/HandoffDivider.tsx | 96 - .../src/components/HandoffDivider/index.ts | 16 - .../src/components/Marketplace/AgentGrid.tsx | 188 - .../Marketplace/CompactAgentCard.tsx | 209 - .../Marketplace/MarketplaceHero.tsx | 116 - .../Marketplace/MarketplacePage.tsx | 660 - .../Marketplace/MarketplaceSearch.tsx | 121 - .../src/components/Marketplace/index.ts | 21 - .../Marketplace/marketplace.constants.ts | 91 - .../Marketplace/marketplace.styles.ts | 245 - .../components/Navigation/PrimarySidebar.tsx | 421 - .../src/components/Navigation/index.ts | 22 - .../src/components/Observe/ObservePage.tsx | 109 - .../augment/src/components/Observe/index.ts | 17 - .../components/RightPane/AgentInfoSection.tsx | 622 - .../components/RightPane/CollapsedSidebar.tsx | 104 - .../src/components/RightPane/RightPane.tsx | 353 - .../components/RightPane/RightPaneHeader.tsx | 80 - .../src/components/RightPane/StatusDot.tsx | 50 - .../augment/src/components/RightPane/index.ts | 17 - .../src/components/RightPane/styles.ts | 24 - .../SessionStateInspector/JsonTreeViewer.tsx | 235 - .../SessionStateInspector.tsx | 308 - .../components/SessionStateInspector/index.ts | 16 - .../src/components/Settings/SettingsPage.tsx | 121 - .../augment/src/components/Settings/index.ts | 17 - .../SkillsAgentCreation/RuntimePicker.tsx | 163 - .../SkillAgentConfigForm.tsx | 228 - .../SkillsAgentCreation/SkillBrowser.tsx | 209 - .../components/SkillsAgentCreation/index.ts | 25 - .../components/StatusPanel/StatusPanel.tsx | 216 - .../src/components/StatusPanel/index.ts | 17 - .../StreamingMessage/ArtifactRenderer.tsx | 295 - .../AuthRequiredCard.test.tsx | 147 - .../StreamingMessage/AuthRequiredCard.tsx | 253 - .../StreamingMessage/CitationRenderer.tsx | 157 - .../StreamingMessage/FormRequestCard.test.tsx | 169 - .../StreamingMessage/FormRequestCard.tsx | 287 - .../KagentiStreamingIntegration.test.ts | 562 - .../ReasoningDisplay.test.tsx | 161 - .../StreamingMessage/ReasoningDisplay.tsx | 202 - .../StreamingMessage.constants.ts | 161 - .../StreamingMessage.reducer.test.ts | 854 - .../StreamingMessage.reducer.ts | 687 - .../StreamingMessage/StreamingMessage.tsx | 395 - .../StreamingMessage.types.ts | 192 - .../StreamingMessage/StreamingProgress.tsx | 89 - .../StreamingMessage/ToolCallDisplay.tsx | 388 - .../src/components/StreamingMessage/index.ts | 55 - .../src/components/StreamingMessage/styles.ts | 241 - .../TokenUsageBadge/TokenUsageBadge.test.tsx | 201 - .../TokenUsageBadge/TokenUsageBadge.tsx | 232 - .../src/components/TokenUsageBadge/index.ts | 17 - .../ApprovalActionButtons.tsx | 114 - .../ToolApprovalDialog.test.tsx | 399 - .../ToolApprovalDialog/ToolApprovalDialog.tsx | 351 - .../ToolArgumentsEditor.tsx | 191 - .../components/ToolApprovalDialog/index.ts | 18 - .../components/ToolApprovalDialog/styles.ts | 297 - .../useApprovalKeyboardShortcuts.test.ts | 123 - .../useApprovalKeyboardShortcuts.ts | 55 - .../ToolRegistry/ToolRegistryPage.tsx | 78 - .../src/components/ToolRegistry/index.ts | 17 - .../WelcomeScreen/AgentCard.test.tsx | 144 - .../components/WelcomeScreen/AgentCard.tsx | 288 - .../WelcomeScreen/AgentCatalogDialog.tsx | 1589 - .../WelcomeScreen/FeaturedAgents.tsx | 301 - .../WelcomeScreen/PromptCardItem.tsx | 106 - .../WelcomeScreen/PromptGroupRow.tsx | 108 - .../WelcomeScreen/WelcomeScreen.tsx | 932 - .../WelcomeScreen/agentUtils.test.ts | 174 - .../components/WelcomeScreen/agentUtils.ts | 216 - .../buildEffectivePromptGroups.test.ts | 306 - .../buildEffectivePromptGroups.ts | 109 - .../components/WelcomeScreen/iconUtils.tsx | 106 - .../src/components/WelcomeScreen/index.ts | 18 - .../components/WelcomeScreen/stripMarkdown.ts | 60 - .../src/components/WelcomeScreen/styles.ts | 214 - .../WelcomeScreen/useAgentGalleryData.ts | 100 - .../WelcomeScreen/usePinnedRecent.test.ts | 122 - .../WelcomeScreen/usePinnedRecent.ts | 57 - .../WorkflowBuilder/AgentListPanel.tsx | 236 - .../WorkflowBuilder/ConfigPanel.tsx | 234 - .../WorkflowBuilder/EditorCanvas.tsx | 406 - .../WorkflowBuilder/EditorToolbar.tsx | 418 - .../WorkflowBuilder/ExecutionTrace.tsx | 195 - .../WorkflowBuilder/PreviewChatPanel.tsx | 953 - .../WorkflowBuilder/PublishDialog.tsx | 216 - .../WorkflowBuilder/WorkflowCodeExport.tsx | 408 - .../WorkflowBuilder/WorkflowDashboard.tsx | 774 - .../WorkflowBuilder/WorkflowEditor.css | 65 - .../WorkflowBuilder/WorkflowEditor.tsx | 517 - .../WorkflowBuilder/WorkflowErrorBoundary.tsx | 90 - .../WorkflowBuilder/WorkflowEvaluation.tsx | 973 - .../WorkflowBuilder/WorkflowPreview.tsx | 283 - .../WorkflowSettingsDialog.tsx | 187 - .../WorkflowBuilder/WorkflowVersions.tsx | 199 - .../codegen/AgentDeclarations.ts | 60 - .../codegen/ClassifyDeclarations.ts | 76 - .../codegen/ExecutionEmitter.ts | 239 - .../codegen/PythonCodeGenerator.ts | 163 - .../codegen/WorkflowCodeGenerator.ts | 135 - .../WorkflowBuilder/codegen/utils.ts | 52 - .../config/AgentNodeConfig.tsx | 422 - .../config/ClassifyNodeConfig.tsx | 116 - .../config/FileSearchNodeConfig.tsx | 120 - .../config/GuardrailNodeConfig.tsx | 64 - .../config/LogicNodeConfig.tsx | 65 - .../WorkflowBuilder/config/McpNodeConfig.tsx | 122 - .../config/StartNodeConfig.tsx | 184 - .../WorkflowBuilder/config/ToolNodeConfig.tsx | 62 - .../WorkflowBuilder/config/index.ts | 24 - .../WorkflowBuilder/config/types.ts | 20 - .../src/components/WorkflowBuilder/index.ts | 34 - .../WorkflowBuilder/nodes/AgentNode.tsx | 40 - .../WorkflowBuilder/nodes/BaseNode.tsx | 136 - .../WorkflowBuilder/nodes/ClassifyNode.tsx | 37 - .../WorkflowBuilder/nodes/EndNode.tsx | 31 - .../WorkflowBuilder/nodes/FileSearchNode.tsx | 36 - .../WorkflowBuilder/nodes/GuardrailNode.tsx | 36 - .../WorkflowBuilder/nodes/LogicNode.tsx | 36 - .../WorkflowBuilder/nodes/McpNode.tsx | 33 - .../WorkflowBuilder/nodes/NoteNode.tsx | 35 - .../WorkflowBuilder/nodes/SetStateNode.tsx | 36 - .../WorkflowBuilder/nodes/StartNode.tsx | 31 - .../WorkflowBuilder/nodes/ToolNode.tsx | 33 - .../WorkflowBuilder/nodes/TransformNode.tsx | 36 - .../nodes/UserInteractionNode.tsx | 36 - .../WorkflowBuilder/theme/tokens.ts | 157 - .../components/WorkflowBuilder/utils/retry.ts | 86 - .../src/components/icons/BotAvatarIcon.tsx | 64 - .../augment/src/components/icons/McpIcon.tsx | 82 - .../augment/src/components/icons/index.ts | 17 - .../plugins/augment/src/hooks/index.ts | 85 - .../augment/src/hooks/useAbortController.ts | 48 - .../augment/src/hooks/useAdminConfig.test.tsx | 188 - .../augment/src/hooks/useAdminConfig.ts | 122 - .../plugins/augment/src/hooks/useAdminView.ts | 167 - .../src/hooks/useAgentCapabilities.test.ts | 146 - .../augment/src/hooks/useAgentCapabilities.ts | 172 - .../augment/src/hooks/useAgentSelection.ts | 147 - .../augment/src/hooks/useApiQuery.test.tsx | 110 - .../plugins/augment/src/hooks/useApiQuery.ts | 117 - .../augment/src/hooks/useBackendStatus.ts | 86 - .../augment/src/hooks/useBranding.test.tsx | 142 - .../plugins/augment/src/hooks/useBranding.ts | 70 - .../augment/src/hooks/useChatAgentConfig.ts | 36 - .../hooks/useChatKeyboardShortcuts.test.ts | 192 - .../src/hooks/useChatKeyboardShortcuts.ts | 150 - .../src/hooks/useChatSessions.test.tsx | 438 - .../augment/src/hooks/useChatSessions.ts | 315 - .../augment/src/hooks/useChatToolConfig.ts | 36 - .../src/hooks/useChatViewMode.test.tsx | 100 - .../augment/src/hooks/useChatViewMode.ts | 78 - .../augment/src/hooks/useConfigSync.test.tsx | 54 - .../augment/src/hooks/useConfigSync.ts | 55 - .../augment/src/hooks/useDocuments.test.tsx | 126 - .../plugins/augment/src/hooks/useDocuments.ts | 48 - .../src/hooks/useEffectiveConfig.test.tsx | 104 - .../augment/src/hooks/useEffectiveConfig.ts | 58 - .../augment/src/hooks/useEvaluationStatus.ts | 40 - .../augment/src/hooks/useFileUpload.ts | 123 - .../augment/src/hooks/useFormState.test.ts | 69 - .../plugins/augment/src/hooks/useFormState.ts | 62 - .../src/hooks/useGeneratePrompt.test.tsx | 168 - .../augment/src/hooks/useGeneratePrompt.ts | 77 - .../src/hooks/useInteractivePhases.test.tsx | 316 - .../augment/src/hooks/useInteractivePhases.ts | 227 - .../augment/src/hooks/useModels.test.tsx | 103 - .../plugins/augment/src/hooks/useModels.ts | 52 - .../plugins/augment/src/hooks/useProviders.ts | 64 - .../augment/src/hooks/useRagGenerate.ts | 106 - .../plugins/augment/src/hooks/useRagTest.ts | 106 - .../augment/src/hooks/useSafetyStatus.ts | 40 - .../src/hooks/useScrollToBottom.test.ts | 99 - .../augment/src/hooks/useScrollToBottom.ts | 82 - .../augment/src/hooks/useStatus.test.tsx | 124 - .../plugins/augment/src/hooks/useStatus.ts | 53 - .../src/hooks/useStreamingChat.test.tsx | 631 - .../augment/src/hooks/useStreamingChat.ts | 500 - .../src/hooks/useStreamingStateBatching.ts | 87 - .../augment/src/hooks/useToast.test.ts | 64 - .../plugins/augment/src/hooks/useToast.ts | 61 - .../src/hooks/useToolApproval.test.tsx | 442 - .../augment/src/hooks/useToolApproval.ts | 432 - .../plugins/augment/src/hooks/useTours.ts | 55 - .../augment/src/hooks/useTranslation.ts | 79 - .../augment/src/hooks/useVectorStoreConfig.ts | 174 - .../src/hooks/useVectorStores.test.tsx | 263 - .../augment/src/hooks/useVectorStores.ts | 127 - .../augment/src/hooks/useWelcomeData.test.tsx | 113 - .../augment/src/hooks/useWelcomeData.ts | 73 - .../augment/plugins/augment/src/index.ts | 33 - .../plugins/augment/src/plugin.test.ts | 31 - .../augment/plugins/augment/src/plugin.ts | 77 - .../augment/plugins/augment/src/routes.ts | 125 - .../augment/plugins/augment/src/setupTests.ts | 16 - .../augment/src/test-utils/factories.ts | 258 - .../plugins/augment/src/test-utils/index.ts | 26 - .../augment/src/test-utils/renderWithApi.tsx | 28 - .../plugins/augment/src/theme/branding.ts | 50 - .../plugins/augment/src/theme/components.ts | 166 - .../plugins/augment/src/theme/index.ts | 28 - .../plugins/augment/src/theme/markdown.ts | 199 - .../plugins/augment/src/theme/styles.ts | 28 - .../plugins/augment/src/theme/tokens.ts | 607 - .../plugins/augment/src/translations/de.ts | 338 - .../plugins/augment/src/translations/es.ts | 337 - .../plugins/augment/src/translations/fr.ts | 337 - .../plugins/augment/src/translations/index.ts | 35 - .../plugins/augment/src/translations/it.ts | 338 - .../plugins/augment/src/translations/ja.ts | 336 - .../plugins/augment/src/translations/ref.ts | 378 - .../augment/plugins/augment/src/types.ts | 355 - .../plugins/augment/src/utils/agentColors.ts | 71 - .../plugins/augment/src/utils/debug.ts | 141 - .../plugins/augment/src/utils/errors.test.ts | 68 - .../plugins/augment/src/utils/errors.ts | 25 - .../augment/src/utils/exportConversation.ts | 99 - .../augment/src/utils/formatResponse.test.ts | 221 - .../augment/src/utils/formatResponse.ts | 218 - .../augment/src/utils/formatTime.test.ts | 102 - .../plugins/augment/src/utils/formatTime.ts | 48 - .../augment/src/utils/getErrorMessage.ts | 21 - .../plugins/augment/src/utils/index.ts | 68 - .../plugins/augment/src/utils/isAbortError.ts | 31 - .../augment/src/utils/sanitize.test.ts | 236 - .../plugins/augment/src/utils/sanitize.ts | 115 - .../src/utils/streamErrorHandler.test.ts | 168 - .../augment/src/utils/streamErrorHandler.ts | 91 - .../src/utils/streamingHelpers.test.ts | 229 - .../augment/src/utils/streamingHelpers.ts | 293 - .../plugins/augment/src/utils/textPreview.ts | 25 - .../augment/src/utils/toolNameUtils.ts | 34 - .../augment/src/utils/toolSeverity.test.ts | 124 - .../plugins/augment/src/utils/toolSeverity.ts | 44 - .../augment/scripts/patch-agents-core.js | 56 - workspaces/augment/tsconfig.json | 23 - .../workflows/agent-approval/README.md | 77 - .../agent-approval/agent-approval.sw.yaml | 171 - .../agent-approval/application.properties | 22 - .../schemas/agent-approval-input.json | 35 - .../specs/augment-agent-lifecycle.yaml | 88 - workspaces/augment/yarn.lock | 36574 ---------------- 1058 files changed, 66 insertions(+), 266504 deletions(-) delete mode 100644 .github/renovate-presets/workspace/rhdh-augment-presets.json delete mode 100644 workspaces/augment/.changeset/.gitkeep delete mode 100644 workspaces/augment/.changeset/config.json delete mode 100644 workspaces/augment/.dockerignore delete mode 100644 workspaces/augment/.eslintignore delete mode 100644 workspaces/augment/.eslintrc.js delete mode 100644 workspaces/augment/.gitignore delete mode 100644 workspaces/augment/.prettierignore delete mode 100644 workspaces/augment/KAGENTI_FEATURES.md delete mode 100644 workspaces/augment/README.md delete mode 100644 workspaces/augment/app-config.yaml delete mode 100644 workspaces/augment/backstage.json delete mode 100644 workspaces/augment/bcp.json delete mode 100644 workspaces/augment/catalog-info.yaml delete mode 100644 workspaces/augment/docs/FEATURE_SPEC.md delete mode 100644 workspaces/augment/docs/adk-web-analysis-summary.md delete mode 100644 workspaces/augment/docs/agent-lifecycle-research.md delete mode 100644 workspaces/augment/docs/augment-demo-walkthrough.md delete mode 100644 workspaces/augment/docs/augment-outcomes-and-use-cases.md delete mode 100644 workspaces/augment/docs/augment-platform.md delete mode 100644 workspaces/augment/docs/chat-ui-e2e-analysis.md delete mode 100644 workspaces/augment/docs/cloud-concierge-executive-brief.md delete mode 100644 workspaces/augment/docs/handover-reference.md delete mode 100644 workspaces/augment/docs/handover-slides.md delete mode 100644 workspaces/augment/docs/kagenti-augment-integration-research.md delete mode 100644 workspaces/augment/examples/templates/agent-template.yaml delete mode 100644 workspaces/augment/examples/templates/crewai-agent-template.yaml delete mode 100644 workspaces/augment/examples/templates/langgraph-agent-template.yaml delete mode 100644 workspaces/augment/examples/templates/tool-template.yaml delete mode 100644 workspaces/augment/package.json delete mode 100644 workspaces/augment/packages/app/.eslintrc.js delete mode 100644 workspaces/augment/packages/app/knip-report.md delete mode 100644 workspaces/augment/packages/app/package.json delete mode 100644 workspaces/augment/packages/app/public/android-chrome-192x192.png delete mode 100644 workspaces/augment/packages/app/public/apple-touch-icon.png delete mode 100644 workspaces/augment/packages/app/public/favicon-16x16.png delete mode 100644 workspaces/augment/packages/app/public/favicon-32x32.png delete mode 100644 workspaces/augment/packages/app/public/favicon.ico delete mode 100644 workspaces/augment/packages/app/public/index.html delete mode 100644 workspaces/augment/packages/app/public/manifest.json delete mode 100644 workspaces/augment/packages/app/public/robots.txt delete mode 100644 workspaces/augment/packages/app/public/safari-pinned-tab.svg delete mode 100644 workspaces/augment/packages/app/src/App.tsx delete mode 100644 workspaces/augment/packages/app/src/apis.ts delete mode 100644 workspaces/augment/packages/app/src/components/Root/LogoFull.tsx delete mode 100644 workspaces/augment/packages/app/src/components/Root/LogoIcon.tsx delete mode 100644 workspaces/augment/packages/app/src/components/Root/Root.tsx delete mode 100644 workspaces/augment/packages/app/src/components/Root/index.ts delete mode 100644 workspaces/augment/packages/app/src/components/catalog/EntityPage.tsx delete mode 100644 workspaces/augment/packages/app/src/components/search/SearchPage.tsx delete mode 100644 workspaces/augment/packages/app/src/index.tsx delete mode 100644 workspaces/augment/packages/backend/.eslintrc.js delete mode 100644 workspaces/augment/packages/backend/knip-report.md delete mode 100644 workspaces/augment/packages/backend/package.json delete mode 100644 workspaces/augment/packages/backend/src/index.ts delete mode 100644 workspaces/augment/plugins/.gitkeep delete mode 100644 workspaces/augment/plugins/augment-backend/.eslintrc.js delete mode 100644 workspaces/augment/plugins/augment-backend/CHANGELOG.md delete mode 100644 workspaces/augment/plugins/augment-backend/README.md delete mode 100644 workspaces/augment/plugins/augment-backend/config.d.ts delete mode 100644 workspaces/augment/plugins/augment-backend/dev/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/knip-report.md delete mode 100644 workspaces/augment/plugins/augment-backend/package.json delete mode 100644 workspaces/augment/plugins/augment-backend/report.api.md delete mode 100644 workspaces/augment/plugins/augment-backend/src/constants.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/extensions.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/middleware/csrf.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/middleware/rateLimiter.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/middleware/security.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/middleware/security.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/parsers/chatRequestParsers.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/parsers/chatRequestParsers.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/plugin.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/ProviderManager.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/ProviderManager.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/capabilityTypes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/factory.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/KagentiAgentCardCache.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/KagentiProvider.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/KagentiProvider.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiAdminClient.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiAdminClient.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiApiClient.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiApiClient.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiSandboxClient.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiSandboxClient.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KeycloakTokenManager.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KeycloakTokenManager.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/adminMethods.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/agentMethods.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/chatMethods.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/requestCore.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/sandboxFileOps.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/sandboxSidecarOps.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/toolMethods.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/types.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/typesBuildTools.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/typesCore.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/typesSandbox.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/utils.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/config/KagentiConfigLoader.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/config/KagentiConfigLoader.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiAgentListing.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiApprovalHandler.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiChatHandlers.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiConversationCapability.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiHelpers.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiInitialization.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiModelOps.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiNamespaceUtils.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiRagCapability.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/KagentiStreamNormalizer.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/KagentiStreamNormalizer.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamInteractiveHandler.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamLegacyHandler.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamTypes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamUiExtensions.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/AgentGraphManager.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/AgentGraphManager.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalContinuationExecutor.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalContinuationExecutor.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalHandler.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalHandler.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendApprovalHandler.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendApprovalHandler.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendApprovalStore.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendToolEvents.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendToolExecutor.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/BrandingConfigLoader.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/CapabilityBuilders.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/CapabilityBuilders.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ChatDepsBuilder.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ChatDepsBuilder.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ClientManager.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ClientManager.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigLoader.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigLoader.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigResolutionService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigResolutionService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationFacade.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationFacade.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationHelpers.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationHelpers.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationRegistry.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/CoordinatorChatOps.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/CoordinatorLifecycleOps.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentLister.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentLister.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentSyncService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentSyncService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentUploader.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentUploader.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/EvaluationService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/EvaluationService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/InputItemsNormalizer.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/InputItemsNormalizer.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackApiError.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackOrchestrator.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackOrchestrator.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackProvider.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackProvider.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpAuthService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpAuthService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpConfigLoader.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpToolBuilder.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/MessageProcessor.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/MessageProcessor.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ModerationService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/OrchestratorInitializer.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/OrchestratorInitializer.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ProviderModelOps.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponseChainWalker.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponseChainWalker.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiClient.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiCoordinator.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiCoordinator.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiProvider.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiProvider.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/SafetyService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/SafetyService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ServerCapabilities.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/SessionStore.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusAggregator.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusAggregator.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/StreamEventNormalizer.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/StreamEventNormalizer.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/SyncCategorizer.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/SyncCategorizer.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ToolGroupService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ToolsBuilder.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ToolsBuilder.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreFacade.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreFacade.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreLifecycle.integration.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreQueryOps.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/__tests__/multiAgent.e2e.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/approvalContinuation.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/auth/McpAuthService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/auth/McpAuthService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/auth/mcpTokenFetchers.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/chatUtils.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/AgentConfigParser.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/BrandingConfigLoader.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigLoader.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigLoader.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigParsers.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigResolutionService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigResolutionService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/McpConfigLoader.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/McpConfigLoader.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ServerCapabilities.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ServerCapabilities.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/UiConfigLoader.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/conversationTypes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/eventTypes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/LlamaStackModel.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/LlamaStackProvider.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/OpenAIAgentsOrchestrator.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/configMapper.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/orchestratorToolDiscovery.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/responseMapper.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/streamMapper.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/promptGeneration.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/promptGeneration.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/ragCapabilityBuilder.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/EvaluationService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/EvaluationService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/SafetyService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/SafetyService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/evaluationConfigLoader.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/safetyConfigLoader.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusAggregator.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusAggregator.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/WorkflowExecutor.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/WorkflowHydrator.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/EdgeResolver.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/McpExecutor.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/NodeExecutors.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/ResponseParser.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/StreamingExecutor.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/WorkflowExecutor.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/llmNodeExecutors.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/types.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/workflowExecutionPlan.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/workflowToolResolver.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/providerInterface.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/registry.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/registry.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/agents/agentGraph.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/ResponsesApiService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/ResponsesApiService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/chatTurnBuilder.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/chatUtils.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/continuationHandler.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/promptGeneration.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/promptGeneration.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/ResponsesApiClient.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/ResponsesApiError.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/ResponsesApiError.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/streamRequest.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationFacade.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationHelpers.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationRegistry.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/InputItemsNormalizer.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/MessageProcessor.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ResponseChainWalker.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/SessionStore.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/conversationFetcher.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/conversationTypes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentHashCache.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentLister.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentLister.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentSyncService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentSyncService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentUploader.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentUploader.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/SyncCategorizer.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/SyncCategorizer.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/VectorStoreService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/VectorStoreService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/documentSyncOps.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/vectorStoreLifecycle.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/vectorStoreMutations.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/StreamEventNormalizer.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/StreamEventNormalizer.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/eventTypes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/mcpErrorSanitizer.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/streamEventHandlers.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalContinuationExecutor.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalContinuationExecutor.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalHandler.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalHandler.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendApprovalStore.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendApprovalStore.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendToolExecutor.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendToolExecutor.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/McpToolBuilder.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ToolsBuilder.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ToolsBuilder.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/toolClientOps.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/toolDiscoveryHelpers.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/toolResolver.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/responses-api/types.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/providers/types.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/router.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/router.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/adminConfigRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/adminDocumentRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/adminModelRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/adminRouteTypes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/adminRoutes.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/adminRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/adminSessionRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/adminVectorStoreRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/chatRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/configRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/conversationRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/devSpacesRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/documentRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/evaluationRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/k8sCredentials.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/kagentiAdminRoutes.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/kagentiAdminRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/kagentiAgentRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/kagentiConfigRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/kagentiRoutes.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/kagentiRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/kagentiSandboxRoutes.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/kagentiSandboxRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/kagentiToolRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/providerRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/resolveWorkflowConfig.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/routeWrapper.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/routeWrapper.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/sandboxAgentDeployTelemetryRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/sandboxChatRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/sandboxFileRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/sandboxRouteContext.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/sandboxSessionRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/sandboxSidecarRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/sessionRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/skillChatProxy.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/skillsManifestBuilder.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/skillsRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/sseRouteHelpers.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/statusRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/toolLifecycleRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/types.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/validatePublicUrl.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/workflow/crud.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/workflow/execution.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/workflow/testSuites.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/workflow/versions.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/routes/workflowRoutes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/AdminConfigService.scoped.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/AdminConfigService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/AdminConfigService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/AgentApprovalWorkflowService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/AuditLogger.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/AuditLogger.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/ChatSessionService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/ChatSessionService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/DevSpacesService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/DocumentIngestionService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/DocumentIngestionService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/McpTestService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/ResponsesApiClient.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/RuntimeConfigResolver.integration.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/RuntimeConfigResolver.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/RuntimeConfigResolver.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/VectorStoreService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/WorkflowConfigService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/WorkflowMigration.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/promptGeneration.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/HttpEmbedder.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/InMemoryToolIndex.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/TfIdfEmbedder.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/ToolIndex.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/ToolScopeFilterService.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/HttpEmbedder.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/InMemoryToolIndex.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/MemoryBackend.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/TfIdfEmbedder.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/ToolIndex.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/ToolScopeFilterService.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/cache.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/fingerprint.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/normalize.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/observability.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/session.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/text.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/backends/memory.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/cache.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/embeddingConfig.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/filter.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/fingerprint.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/normalize.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/observability.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/rerankers.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/session.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/text.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/toolscope/types.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/GlobMatcher.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/GlobMatcher.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/SsrfGuard.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/SsrfGuard.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/configValidation.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/configValidation.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/documentAttributes.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/documentAttributes.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/errorSanitizer.test.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/errorSanitizer.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/errors.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/file-utils.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/http.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/services/utils/mcpClient.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/test-utils/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/test-utils/mocks.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/types/chat.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/types/config.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/types/conversation.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/types/documents.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/types/evaluation.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/types/index.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/types/responsesApi.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/types/safety.ts delete mode 100644 workspaces/augment/plugins/augment-backend/src/types/security.ts delete mode 100644 workspaces/augment/plugins/augment-common/.eslintrc.js delete mode 100644 workspaces/augment/plugins/augment-common/CHANGELOG.md delete mode 100644 workspaces/augment/plugins/augment-common/docs/BACKSTAGE_RBAC_SETUP.md delete mode 100644 workspaces/augment/plugins/augment-common/docs/CONFIG_REFERENCE.md delete mode 100644 workspaces/augment/plugins/augment-common/docs/KEYCLOAK_SETUP.md delete mode 100644 workspaces/augment/plugins/augment-common/docs/MCP_SERVER_SETUP.md delete mode 100644 workspaces/augment/plugins/augment-common/docs/README.md delete mode 100644 workspaces/augment/plugins/augment-common/docs/SECURITY.md delete mode 100644 workspaces/augment/plugins/augment-common/docs/SECURITY_MODES.md delete mode 100644 workspaces/augment/plugins/augment-common/docs/TROUBLESHOOTING.md delete mode 100644 workspaces/augment/plugins/augment-common/docs/VALIDATION.md delete mode 100644 workspaces/augment/plugins/augment-common/knip-report.md delete mode 100644 workspaces/augment/plugins/augment-common/package.json delete mode 100644 workspaces/augment/plugins/augment-common/report.api.md delete mode 100644 workspaces/augment/plugins/augment-common/src/index.ts delete mode 100644 workspaces/augment/plugins/augment-common/src/permissions.test.ts delete mode 100644 workspaces/augment/plugins/augment-common/src/permissions.ts delete mode 100644 workspaces/augment/plugins/augment-common/src/templates/workflowTemplates.ts delete mode 100644 workspaces/augment/plugins/augment-common/src/types/admin.ts delete mode 100644 workspaces/augment/plugins/augment-common/src/types/branding.test.ts delete mode 100644 workspaces/augment/plugins/augment-common/src/types/branding.ts delete mode 100644 workspaces/augment/plugins/augment-common/src/types/index.ts delete mode 100644 workspaces/augment/plugins/augment-common/src/types/kagenti.ts delete mode 100644 workspaces/augment/plugins/augment-common/src/types/provider.test.ts delete mode 100644 workspaces/augment/plugins/augment-common/src/types/provider.ts delete mode 100644 workspaces/augment/plugins/augment-common/src/types/shared.test.ts delete mode 100644 workspaces/augment/plugins/augment-common/src/types/shared.ts delete mode 100644 workspaces/augment/plugins/augment-common/src/types/streaming.ts delete mode 100644 workspaces/augment/plugins/augment-common/src/types/workflowBuilder.ts delete mode 100644 workspaces/augment/plugins/augment/.eslintrc.js delete mode 100644 workspaces/augment/plugins/augment/CHANGELOG.md delete mode 100644 workspaces/augment/plugins/augment/README.md delete mode 100644 workspaces/augment/plugins/augment/config.d.ts delete mode 100644 workspaces/augment/plugins/augment/dev/index.tsx delete mode 100644 workspaces/augment/plugins/augment/docs/images/chat-response.png delete mode 100644 workspaces/augment/plugins/augment/docs/images/right-pane-expanded.png delete mode 100644 workspaces/augment/plugins/augment/docs/images/welcome-screen.png delete mode 100644 workspaces/augment/plugins/augment/knip-report.md delete mode 100644 workspaces/augment/plugins/augment/package.json delete mode 100644 workspaces/augment/plugins/augment/report.api.md delete mode 100644 workspaces/augment/plugins/augment/src/api/AugmentApi.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/AugmentApi.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/adminEndpoints.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/adminEndpoints.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/chatEndpoints.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/chatEndpoints.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/conversationEndpoints.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/conversationEndpoints.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/documentEndpoints.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/documentEndpoints.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/fetchHelpers.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/kagentiEndpoints.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/kagentiEndpoints/adminEndpoints.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/kagentiEndpoints/agentEndpoints.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/kagentiEndpoints/configEndpoints.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/kagentiEndpoints/devSpacesEndpoints.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/kagentiEndpoints/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/kagentiEndpoints/sandboxEndpoints.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/kagentiEndpoints/toolEndpoints.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/kagentiEndpoints/types.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/sessionEndpoints.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/sessionEndpoints.ts delete mode 100644 workspaces/augment/plugins/augment/src/api/sseStreaming.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentRegistryPanel/AgentRegistryPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentRegistryPanel/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AdvancedTab.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentEditorHeader.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentEditorToolbar.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentListItem.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentPreviewCard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentReviewStep.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentsPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/CapabilitiesTab.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/ConnectionsTab.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/CreateAgentModal.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/FieldHelpPopover.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/InstructionsTab.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/MultiAgentEditor.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/SingleAgentStepper.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/TopologyGraph.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/agentTemplates.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/agentValidation.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/agentValidation.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/useAgentActions.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/useAgentDerived.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/useAgentEditor.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/AppearanceSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/BrandingPanel.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/BrandingPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/BrandingSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ChatExperiencePanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ChunkCard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/CreateStoreForm.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/DeleteStoreDialog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/DocsPanel/DocsPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/DocsPanel/docsContent.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/DocsPanel/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/DocumentsTable.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/GeneratedAnswerCard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/HybridSearchConfig.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/IngestDropZone.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KBCreateStore.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KBCreateStore.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KBManageStores.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KBRagTest.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KBStoreDetail.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentCardTab.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentCatalogCard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentCreateIntentDialog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentDetailsTab.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentLifecycleDetail.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentResourceTab.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentStatusTab.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentTemplateBrowser.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardBasicsStep.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardBuildStep.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardDeployStep.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardRuntimeStep.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateAgentWizard.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateAgentWizard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateToolWizard.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateToolWizard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/DevSpacesFrameworkPicker.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/DevSpacesLaunchForm.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/IntentDialogParts.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAdminPanel.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAdminPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentDetailView.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentDetailView.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentsPanel.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentsPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiBuildPipelinePanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiDashboardLinks.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiHomeDashboard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiPanelUtils.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiPropertyRow.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiSandboxPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolDetailDrawer.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolDetailDrawer.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolsPanel.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolsPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/LifecycleActionButtons.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/LifecycleDetailShell.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/McpToolCatalog.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/McpToolCatalog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/NamespacePicker.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/OrchAgentDetailView.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/SchemaInvokeForm.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/SchemaInvokeForm.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolBuildSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolConnectDialog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolCreateIntentDialog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolInvokeDialog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolMcpSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolRouteSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolSpecSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolWizardBasicsStep.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolWizardDeployStep.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolWizardRuntimeStep.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/WizardShell.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/WorkflowAgentDetail.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardTypes.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardTypes.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardUtils.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardUtils.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/kagentiDisplayUtils.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/kagentiTestFixtures.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/lifecycleTransitions.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardTypes.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardTypes.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardUtils.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardUtils.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useAgentTemplates.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useAgentWizardForm.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useAgentWizardForm.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useKagentiAgentDetail.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useKagentiToolDetail.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useKagentiToolsList.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useToolInvoke.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useToolWizardForm.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useToolWizardForm.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/KnowledgeBasePanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/AllowedToolsSelector.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/CapabilitiesSelector.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/GeneratePromptForm.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/KagentiConnectionSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServerCard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServerFormDialog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServersSection.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServersSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpToolsSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelConnectionSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelSelector.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelToolsPanel.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelToolsPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/SystemPromptSection.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/SystemPromptSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ToolsSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/mcpServerTypes.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/useMcpServers.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/useMcpServers.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/CardEditor.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/ColorPicker.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/ColorPicker.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/DeleteConfirmButton.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/DeleteConfirmButton.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/GroupEditor.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/IconPicker.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/IconPicker.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/LivePreview.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/PromptsPanel.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/PromptsPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/PromptsPanelHeader.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ProviderPlaceholder.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ProviderSelector.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/RagQueryForm.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/RagResultsTable.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/EvaluationSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyEvalPanel.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyEvalPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyPatternsSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyShieldsSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyPatternsSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ScoreBar.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/SettingsPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/StoreConfigFields.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/StoresTable.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ragResultsUtils.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/ragResultsUtils.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/AdminSection.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/AdminSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/AnimatedList.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/CardGridSkeleton.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ColorInput.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ConfirmDialog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/GlassCard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/InfoTip.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/InlineAgentChat.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/PanelIntroBanner.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ServerItemPicker.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ServerItemPicker.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/StringListEditor.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/StringListEditor.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TableSkeleton.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ToggleSwitch.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TourController.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TourLauncherDialog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TourProvider.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/commandCenterStyles.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/defaultTours.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/selectMenuProps.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/tourDefinitions.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/tourStyles.css delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/shared/useTourVoice.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/useIngestDropZone.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/useIngestDropZone.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/usePromptGroupsEditor.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/usePromptGroupsEditor.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AdminPanels/useStoreDocuments.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AgentStatusPanel/AgentStatusPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AgentStatusPanel/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AgentStudio/AgentStudioPage.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AgentStudio/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentIcon/AugmentIcon.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentIcon/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/AdminLayout.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/AdminOnboardingCard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/AppRouter.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/AppShell.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/AppStateProvider.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/AugmentErrorBoundary.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/AugmentPage.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/ChatView.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/CommandCenterHeader.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/KagentiSidebar.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/ProviderOfflineBanner.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/SecurityGate.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/SwitchSessionDialog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/augment-isolation.css delete mode 100644 workspaces/augment/plugins/augment/src/components/AugmentPage/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatContainer/ChatContainer.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatContainer/ChatContainer.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatContainer/ChatHeader.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatContainer/ChatScrollArea.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatContainer/KeyboardShortcutsDialog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatContainer/MessageInspectorPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatContainer/ThinkingIndicator.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatContainer/VirtualizedMessageList.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatContainer/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatContainer/useChatActions.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatContainer/useChatActions.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatDrawer/ChatDrawer.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatDrawer/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatDrawer/useChatDrawer.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatInput/ChatInput.kagenti.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatInput/ChatInput.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatInput/ChatInput.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatInput/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatInput/styles.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/ChatMessage.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/ChatMessage.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/CopyButton.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/ErrorCard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/ExpandableOutput.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/MessageActionButtons.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/MessageFeedback.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/RAGSourcesSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/ReasoningSummary.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/ReasoningSummary.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/ToolCallSummary.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/ToolCallsSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/styles.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/useMessageEdit.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ChatMessage/useMessageEdit.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/CodeBlock/CodeBlock.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/CodeBlock/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/CollapsibleSection/CollapsibleSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/CollapsibleSection/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/CommandCenter/ActivityFeed.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/CommandCenter/CommandBar.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/CommandCenter/HealthMosaic.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/CommandCenter/MetricTile.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/CommandCenter/OpsOverview.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/CommandCenter/OverviewHero.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/CommandCenter/ReviewQueue.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/CommandCenter/StatusBar.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/CommandCenter/ToolReviewQueue.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/CommandCenter/commandcenter.constants.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/CommandCenter/commandcenter.styles.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/CommandCenter/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ConversationHistory/ConversationHistory.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ConversationHistory/ConversationHistory.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ConversationHistory/ConversationSkeleton.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ConversationHistory/EmptyConversationState.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ConversationHistory/GroupedSessionList.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ConversationHistory/SessionItem.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ConversationHistory/SessionSearchBar.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ConversationHistory/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ConversationHistory/styles.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/Dashboard/DeveloperHome.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/Dashboard/OpsHome.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/Dashboard/QuickActions.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/Dashboard/StatCard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/Dashboard/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/DocumentPanel/DocumentPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/DocumentPanel/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ExecutionTrace/ExecutionTracePanel.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ExecutionTrace/ExecutionTracePanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ExecutionTrace/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/HandoffDivider/HandoffDivider.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/HandoffDivider/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/Marketplace/AgentGrid.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/Marketplace/CompactAgentCard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/Marketplace/MarketplaceHero.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/Marketplace/MarketplacePage.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/Marketplace/MarketplaceSearch.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/Marketplace/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/Marketplace/marketplace.constants.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/Marketplace/marketplace.styles.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/Navigation/PrimarySidebar.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/Navigation/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/Observe/ObservePage.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/Observe/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/RightPane/AgentInfoSection.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/RightPane/CollapsedSidebar.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/RightPane/RightPane.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/RightPane/RightPaneHeader.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/RightPane/StatusDot.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/RightPane/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/RightPane/styles.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/SessionStateInspector/JsonTreeViewer.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/SessionStateInspector/SessionStateInspector.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/SessionStateInspector/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/Settings/SettingsPage.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/Settings/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/SkillsAgentCreation/RuntimePicker.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/SkillsAgentCreation/SkillAgentConfigForm.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/SkillsAgentCreation/SkillBrowser.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/SkillsAgentCreation/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/StatusPanel/StatusPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/StatusPanel/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/ArtifactRenderer.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/AuthRequiredCard.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/AuthRequiredCard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/CitationRenderer.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/FormRequestCard.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/FormRequestCard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/KagentiStreamingIntegration.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/ReasoningDisplay.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/ReasoningDisplay.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/StreamingMessage.constants.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/StreamingMessage.reducer.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/StreamingMessage.reducer.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/StreamingMessage.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/StreamingMessage.types.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/StreamingProgress.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/ToolCallDisplay.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/StreamingMessage/styles.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/TokenUsageBadge/TokenUsageBadge.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/TokenUsageBadge/TokenUsageBadge.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/TokenUsageBadge/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ToolApprovalDialog/ApprovalActionButtons.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ToolApprovalDialog/ToolApprovalDialog.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ToolApprovalDialog/ToolApprovalDialog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ToolApprovalDialog/ToolArgumentsEditor.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ToolApprovalDialog/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ToolApprovalDialog/styles.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ToolApprovalDialog/useApprovalKeyboardShortcuts.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ToolApprovalDialog/useApprovalKeyboardShortcuts.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/ToolRegistry/ToolRegistryPage.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/ToolRegistry/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/AgentCard.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/AgentCard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/AgentCatalogDialog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/FeaturedAgents.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/PromptCardItem.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/PromptGroupRow.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/WelcomeScreen.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/agentUtils.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/agentUtils.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/buildEffectivePromptGroups.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/buildEffectivePromptGroups.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/iconUtils.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/stripMarkdown.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/styles.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/useAgentGalleryData.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/usePinnedRecent.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WelcomeScreen/usePinnedRecent.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/AgentListPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/ConfigPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/EditorCanvas.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/EditorToolbar.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/ExecutionTrace.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/PreviewChatPanel.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/PublishDialog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/WorkflowCodeExport.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/WorkflowDashboard.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/WorkflowEditor.css delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/WorkflowEditor.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/WorkflowErrorBoundary.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/WorkflowEvaluation.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/WorkflowPreview.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/WorkflowSettingsDialog.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/WorkflowVersions.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/codegen/AgentDeclarations.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/codegen/ClassifyDeclarations.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/codegen/ExecutionEmitter.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/codegen/PythonCodeGenerator.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/codegen/WorkflowCodeGenerator.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/codegen/utils.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/config/AgentNodeConfig.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/config/ClassifyNodeConfig.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/config/FileSearchNodeConfig.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/config/GuardrailNodeConfig.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/config/LogicNodeConfig.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/config/McpNodeConfig.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/config/StartNodeConfig.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/config/ToolNodeConfig.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/config/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/config/types.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/nodes/AgentNode.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/nodes/BaseNode.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/nodes/ClassifyNode.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/nodes/EndNode.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/nodes/FileSearchNode.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/nodes/GuardrailNode.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/nodes/LogicNode.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/nodes/McpNode.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/nodes/NoteNode.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/nodes/SetStateNode.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/nodes/StartNode.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/nodes/ToolNode.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/nodes/TransformNode.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/nodes/UserInteractionNode.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/theme/tokens.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/WorkflowBuilder/utils/retry.ts delete mode 100644 workspaces/augment/plugins/augment/src/components/icons/BotAvatarIcon.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/icons/McpIcon.tsx delete mode 100644 workspaces/augment/plugins/augment/src/components/icons/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useAbortController.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useAdminConfig.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useAdminConfig.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useAdminView.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useAgentCapabilities.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useAgentCapabilities.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useAgentSelection.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useApiQuery.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useApiQuery.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useBackendStatus.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useBranding.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useBranding.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useChatAgentConfig.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useChatKeyboardShortcuts.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useChatKeyboardShortcuts.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useChatSessions.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useChatSessions.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useChatToolConfig.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useChatViewMode.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useChatViewMode.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useConfigSync.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useConfigSync.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useDocuments.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useDocuments.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useEffectiveConfig.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useEffectiveConfig.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useEvaluationStatus.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useFileUpload.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useFormState.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useFormState.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useGeneratePrompt.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useGeneratePrompt.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useInteractivePhases.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useInteractivePhases.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useModels.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useModels.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useProviders.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useRagGenerate.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useRagTest.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useSafetyStatus.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useScrollToBottom.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useScrollToBottom.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useStatus.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useStatus.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useStreamingChat.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useStreamingChat.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useStreamingStateBatching.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useToast.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useToast.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useToolApproval.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useToolApproval.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useTours.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useTranslation.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useVectorStoreConfig.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useVectorStores.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useVectorStores.ts delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useWelcomeData.test.tsx delete mode 100644 workspaces/augment/plugins/augment/src/hooks/useWelcomeData.ts delete mode 100644 workspaces/augment/plugins/augment/src/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/plugin.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/plugin.ts delete mode 100644 workspaces/augment/plugins/augment/src/routes.ts delete mode 100644 workspaces/augment/plugins/augment/src/setupTests.ts delete mode 100644 workspaces/augment/plugins/augment/src/test-utils/factories.ts delete mode 100644 workspaces/augment/plugins/augment/src/test-utils/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/test-utils/renderWithApi.tsx delete mode 100644 workspaces/augment/plugins/augment/src/theme/branding.ts delete mode 100644 workspaces/augment/plugins/augment/src/theme/components.ts delete mode 100644 workspaces/augment/plugins/augment/src/theme/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/theme/markdown.ts delete mode 100644 workspaces/augment/plugins/augment/src/theme/styles.ts delete mode 100644 workspaces/augment/plugins/augment/src/theme/tokens.ts delete mode 100644 workspaces/augment/plugins/augment/src/translations/de.ts delete mode 100644 workspaces/augment/plugins/augment/src/translations/es.ts delete mode 100644 workspaces/augment/plugins/augment/src/translations/fr.ts delete mode 100644 workspaces/augment/plugins/augment/src/translations/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/translations/it.ts delete mode 100644 workspaces/augment/plugins/augment/src/translations/ja.ts delete mode 100644 workspaces/augment/plugins/augment/src/translations/ref.ts delete mode 100644 workspaces/augment/plugins/augment/src/types.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/agentColors.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/debug.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/errors.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/errors.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/exportConversation.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/formatResponse.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/formatResponse.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/formatTime.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/formatTime.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/getErrorMessage.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/index.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/isAbortError.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/sanitize.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/sanitize.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/streamErrorHandler.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/streamErrorHandler.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/streamingHelpers.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/streamingHelpers.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/textPreview.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/toolNameUtils.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/toolSeverity.test.ts delete mode 100644 workspaces/augment/plugins/augment/src/utils/toolSeverity.ts delete mode 100644 workspaces/augment/scripts/patch-agents-core.js delete mode 100644 workspaces/augment/tsconfig.json delete mode 100644 workspaces/augment/workflows/agent-approval/README.md delete mode 100644 workspaces/augment/workflows/agent-approval/agent-approval.sw.yaml delete mode 100644 workspaces/augment/workflows/agent-approval/application.properties delete mode 100644 workspaces/augment/workflows/agent-approval/schemas/agent-approval-input.json delete mode 100644 workspaces/augment/workflows/agent-approval/specs/augment-agent-lifecycle.yaml delete mode 100644 workspaces/augment/yarn.lock diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1be2ab12998..b20424086ba 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -17,7 +17,6 @@ /workspaces/adoption-insights @redhat-developer/rhdh-plugins-maintainers @redhat-developer/rhdh-ui @rohitkrai03 @karthikjeeyar @eswaraiahsapram /workspaces/ai-integrations @redhat-developer/rhdh-plugins-maintainers @redhat-developer/rhdh-ui @redhat-developer/rhdh-ai @rohitkrai03 @karthikjeeyar @lucifergene @johnmcollier @gabemontero -/workspaces/augment @redhat-developer/rhdh-plugins-maintainers @pkliczewski @durandom @gabemontero @mareklibra @rohitkrai03 /workspaces/boost @redhat-developer/rhdh-plugins-maintainers @durandom @gabemontero @mareklibra @rohitkrai03 @johnmcollier @jordigilh /workspaces/app-defaults @redhat-developer/rhdh-plugins-maintainers @redhat-developer/rhdh-ui @rohitkrai03 @christoph-jerolimov /workspaces/bulk-import @redhat-developer/rhdh-plugins-maintainers @redhat-developer/rhdh-plugins @rm3l @redhat-developer/rhdh-ui @debsmita1 @its-mitesh-kumar diff --git a/.github/archived-plugins.json b/.github/archived-plugins.json index 3d0bdf08f4c..9caedec409b 100644 --- a/.github/archived-plugins.json +++ b/.github/archived-plugins.json @@ -17,6 +17,33 @@ "gitTag": "@red-hat-developer-hub/backstage-plugin-sandbox@0.1.0", "reason": "No longer maintained", "archivedDate": "2026-06-10" + }, + { + "pluginName": "@red-hat-developer-hub/backstage-plugin-augment", + "version": "0.3.0", + "workspace": "augment", + "plugin": "augment", + "gitTag": "@red-hat-developer-hub/backstage-plugin-augment@0.3.0", + "reason": "No longer maintained", + "archivedDate": "2026-11-02" + }, + { + "pluginName": "@red-hat-developer-hub/backstage-plugin-augment-backend", + "version": "0.3.0", + "workspace": "augment", + "plugin": "augment-backend", + "gitTag": "@red-hat-developer-hub/backstage-plugin-augment-backend@0.3.0", + "reason": "No longer maintained", + "archivedDate": "2026-11-02" + }, + { + "pluginName": "@red-hat-developer-hub/backstage-plugin-augment-common", + "version": "0.3.0", + "workspace": "augment", + "plugin": "augment-common", + "gitTag": "@red-hat-developer-hub/backstage-plugin-augment-common@0.3.0", + "reason": "No longer maintained", + "archivedDate": "2026-11-02" } ] } diff --git a/.github/labeler.yml b/.github/labeler.yml index 5f1cda3f520..33c2f373823 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -7,9 +7,6 @@ workspace/ai-integrations: workspace/app-defaults: - 'Workspace\\s*app-defaults' -workspace/augment: - - 'Workspace\\s*augment' - workspace/bulk-import: - 'Workspace\\s*bulk-import' diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml index a527ea43599..9d7e3322b32 100644 --- a/.github/pr-labeler.yml +++ b/.github/pr-labeler.yml @@ -10,10 +10,6 @@ workspace/app-defaults: - changed-files: - any-glob-to-any-file: ['workspaces/app-defaults/**'] -workspace/augment: - - changed-files: - - any-glob-to-any-file: ['workspaces/augment/**'] - workspace/bulk-import: - changed-files: - any-glob-to-any-file: ['workspaces/bulk-import/**'] diff --git a/.github/renovate-presets/workspace/rhdh-augment-presets.json b/.github/renovate-presets/workspace/rhdh-augment-presets.json deleted file mode 100644 index 354c79edade..00000000000 --- a/.github/renovate-presets/workspace/rhdh-augment-presets.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "packageRules": [ - { - "description": "all Augment minor updates", - "matchFileNames": ["workspaces/augment/**"], - "extends": [ - "github>redhat-developer/rhdh-plugins//.github/renovate-presets/base/rhdh-minor-presets(Augment)" - ], - "addLabels": ["augment"] - }, - { - "description": "all Augment patch updates", - "matchFileNames": ["workspaces/augment/**"], - "extends": [ - "github>redhat-developer/rhdh-plugins//.github/renovate-presets/base/rhdh-patch-presets(Augment)" - ], - "addLabels": ["augment"] - }, - { - "description": "all Augment dev dependency updates", - "matchFileNames": ["workspaces/augment/**"], - "extends": [ - "github>redhat-developer/rhdh-plugins//.github/renovate-presets/base/rhdh-devdependency-presets(Augment)" - ], - "addLabels": ["augment"] - } - ] -} diff --git a/.github/renovate.json b/.github/renovate.json index c546df829bc..4ae0866fbf6 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -85,7 +85,6 @@ "github>redhat-developer/rhdh-plugins//.github/renovate-presets/workspace/rhdh-konflux-presets", "github>redhat-developer/rhdh-plugins//.github/renovate-presets/workspace/rhdh-dcm-presets", "github>redhat-developer/rhdh-plugins//.github/renovate-presets/workspace/rhdh-app-defaults-presets", - "github>redhat-developer/rhdh-plugins//.github/renovate-presets/workspace/rhdh-augment-presets", "github>redhat-developer/rhdh-plugins//.github/renovate-presets/workspace/rhdh-boost-presets", "github>redhat-developer/rhdh-plugins//.github/renovate-presets/workspace/rhdh-install-dynamic-plugins-presets" ] diff --git a/ARCHIVED_WORKSPACES.md b/ARCHIVED_WORKSPACES.md index decf45880fe..7b0101dbce9 100644 --- a/ARCHIVED_WORKSPACES.md +++ b/ARCHIVED_WORKSPACES.md @@ -19,3 +19,6 @@ When a workspace or plugin is archived: | global-floating-action-button | `@red-hat-developer-hub/backstage-plugin-global-floating-action-button` | No longer maintained | [@red-hat-developer-hub/backstage-plugin-global-floating-action-button@1.9.3](https://github.com/redhat-developer/rhdh-plugins/tree/%40red-hat-developer-hub%2Fbackstage-plugin-global-floating-action-button%401.9.3/workspaces/global-floating-action-button) | | openshift-image-registry | `@red-hat-developer-hub/backstage-plugin-openshift-image-registry` | No longer maintained | [@red-hat-developer-hub/backstage-plugin-openshift-image-registry@1.18.0](https://github.com/redhat-developer/rhdh-plugins/tree/%40red-hat-developer-hub%2Fbackstage-plugin-openshift-image-registry%401.18.0/workspaces/openshift-image-registry) | | sandbox | `@red-hat-developer-hub/backstage-plugin-sandbox` | No longer maintained | [@red-hat-developer-hub/backstage-plugin-sandbox@0.1.0](https://github.com/redhat-developer/rhdh-plugins/tree/%40red-hat-developer-hub%2Fbackstage-plugin-sandbox%400.1.0/workspaces/sandbox) | +| augment | `@red-hat-developer-hub/backstage-plugin-augment` | No longer maintained | [@red-hat-developer-hub/backstage-plugin-augment@0.3.0](https://github.com/redhat-developer/rhdh-plugins/tree/%40red-hat-developer-hub%2Fbackstage-plugin-augment%400.3.0/workspaces/augment) | +| augment | `@red-hat-developer-hub/backstage-plugin-augment-backend` | No longer maintained | [@red-hat-developer-hub/backstage-plugin-augment-backend@0.3.0](https://github.com/redhat-developer/rhdh-plugins/tree/%40red-hat-developer-hub%2Fbackstage-plugin-augment-backend%400.3.0/workspaces/augment) | +| augment | `@red-hat-developer-hub/backstage-plugin-augment-common` | No longer maintained | [@red-hat-developer-hub/backstage-plugin-augment-common@0.3.0](https://github.com/redhat-developer/rhdh-plugins/tree/%40red-hat-developer-hub%2Fbackstage-plugin-augment-common%400.3.0/workspaces/augment) | diff --git a/codecov.yml b/codecov.yml index 8ae26b45dc5..aaa17167714 100644 --- a/codecov.yml +++ b/codecov.yml @@ -20,7 +20,7 @@ codecov: coverage: precision: 2 round: down - range: "40...80" + range: '40...80' status: project: @@ -30,62 +30,62 @@ coverage: informational: true only_pulls: false paths: - - "workspaces/*/plugins/*/src/**" + - 'workspaces/*/plugins/*/src/**' patch: default: target: auto threshold: 100% informational: true paths: - - "workspaces/*/plugins/*/src/**" + - 'workspaces/*/plugins/*/src/**' comment: - layout: "header, diff, flags, footer" + layout: 'header, diff, flags, footer' behavior: default require_changes: false show_carryforward_flags: true ignore: - - "**/*.test.ts" - - "**/*.test.tsx" - - "**/*.spec.ts" - - "**/*.spec.tsx" + - '**/*.test.ts' + - '**/*.test.tsx' + - '**/*.spec.ts' + - '**/*.spec.tsx' # Package entry points / NFS plugin wiring (createFrontendPlugin, blueprints). # Nested index.ts(x) under api/, components/, services/ often hold real logic # and must remain in the coverage denominator (hence the /src/ prefix). - - "**/src/index.ts" - - "**/src/index.tsx" - - "**/__fixtures__/**" - - "**/__tests__/**" - - "**/__mocks__/**" - - "**/dist/**" - - "**/build/**" - - "**/node_modules/**" - - "**/setupTests.*" - - "**/playwright.config.ts" - - "**/scripts/**" - - "**/docs/**" - - "workspaces/*/packages/app/**" - - "workspaces/*/packages/app-legacy/**" - - "workspaces/*/packages/backend/**" + - '**/src/index.ts' + - '**/src/index.tsx' + - '**/__fixtures__/**' + - '**/__tests__/**' + - '**/__mocks__/**' + - '**/dist/**' + - '**/build/**' + - '**/node_modules/**' + - '**/setupTests.*' + - '**/playwright.config.ts' + - '**/scripts/**' + - '**/docs/**' + - 'workspaces/*/packages/app/**' + - 'workspaces/*/packages/app-legacy/**' + - 'workspaces/*/packages/backend/**' # Test-helper plugins live under workspaces//plugins/*-test - - "workspaces/*/plugins/*-test/**" - - "**/generated/**" + - 'workspaces/*/plugins/*-test/**' + - '**/generated/**' # Locale catalogs, test helpers, and Backstage plugin wiring (no business logic). # Do not ignore src/alpha/** wholesale — those folders often contain real logic. - - "workspaces/*/plugins/*/src/translations/**" - - "workspaces/*/plugins/*/src/test-utils/**" - - "workspaces/*/plugins/*/src/plugin.ts" - - "workspaces/*/plugins/*/src/alpha.ts" - - "workspaces/*/plugins/*/src/alpha.tsx" - - "workspaces/*/plugins/*/src/alpha/index.ts" - - "workspaces/*/plugins/*/src/alpha/index.tsx" - - "workspaces/*/plugins/*/src/alpha/plugin.ts" + - 'workspaces/*/plugins/*/src/translations/**' + - 'workspaces/*/plugins/*/src/test-utils/**' + - 'workspaces/*/plugins/*/src/plugin.ts' + - 'workspaces/*/plugins/*/src/alpha.ts' + - 'workspaces/*/plugins/*/src/alpha.tsx' + - 'workspaces/*/plugins/*/src/alpha/index.ts' + - 'workspaces/*/plugins/*/src/alpha/index.tsx' + - 'workspaces/*/plugins/*/src/alpha/plugin.ts' # Route tables / i18n wrappers; page behavior is covered by e2e where needed. - - "workspaces/*/plugins/*/src/components/Router.tsx" - - "workspaces/*/plugins/*/src/components/Trans.tsx" + - 'workspaces/*/plugins/*/src/components/Router.tsx' + - 'workspaces/*/plugins/*/src/components/Trans.tsx' # Frontend string/number constants only (not *-common constants modules). - - "workspaces/orchestrator/plugins/orchestrator/src/constants.ts" + - 'workspaces/orchestrator/plugins/orchestrator/src/constants.ts' component_management: # GENERATED — do not edit by hand. @@ -146,9 +146,6 @@ flag_management: - name: app-defaults paths: - workspaces/app-defaults/ - - name: augment - paths: - - workspaces/augment/ - name: bulk-import paths: - workspaces/bulk-import/ diff --git a/workspaces/augment/.changeset/.gitkeep b/workspaces/augment/.changeset/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/workspaces/augment/.changeset/config.json b/workspaces/augment/.changeset/config.json deleted file mode 100644 index 8208df00f28..00000000000 --- a/workspaces/augment/.changeset/config.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [], - "linked": [], - "access": "public", - "baseBranch": "main", - "updateInternalDependencies": "patch", - "privatePackages": { - "tag": false, - "version": false - } -} diff --git a/workspaces/augment/.dockerignore b/workspaces/augment/.dockerignore deleted file mode 100644 index 05edb62650d..00000000000 --- a/workspaces/augment/.dockerignore +++ /dev/null @@ -1,8 +0,0 @@ -.git -.yarn/cache -.yarn/install-state.gz -node_modules -packages/*/src -packages/*/node_modules -plugins -*.local.yaml diff --git a/workspaces/augment/.eslintignore b/workspaces/augment/.eslintignore deleted file mode 100644 index e5b19947ff1..00000000000 --- a/workspaces/augment/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -playwright.config.ts diff --git a/workspaces/augment/.eslintrc.js b/workspaces/augment/.eslintrc.js deleted file mode 100644 index 59b86f84124..00000000000 --- a/workspaces/augment/.eslintrc.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('../../.eslintrc.cjs'); diff --git a/workspaces/augment/.gitignore b/workspaces/augment/.gitignore deleted file mode 100644 index e54c8fe34e2..00000000000 --- a/workspaces/augment/.gitignore +++ /dev/null @@ -1,65 +0,0 @@ -# macOS -.DS_Store - -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* - -# Coverage directory generated when running tests with coverage -coverage - -# Dependencies -node_modules/ - -# Yarn files -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - -# Node version directives -.nvmrc - -# dotenv environment variables file -.env -.env.* -.env.test - -# Build output -dist -dist-types - -# Temporary change files created by Vim -*.swp - -# MkDocs build output -site - -# Local configuration files -*.local.yaml - -# Sensitive credentials -*-credentials.yaml - -# vscode database functionality support files -*.session.sql - -# E2E test reports -e2e-test-report/ - -# Dev/ops helper scripts (keep local, not shipped with plugin) -plugins/augment-common/docs/scripts/ -plugins/augment-backend/scripts/ - -# Test scripts (not shipped) -scripts/test-openai-reference.ts - -# Recovery/session docs (local only) -docs/SESSION_WORK_LOG.md diff --git a/workspaces/augment/.prettierignore b/workspaces/augment/.prettierignore deleted file mode 100644 index 67d90410b7e..00000000000 --- a/workspaces/augment/.prettierignore +++ /dev/null @@ -1,9 +0,0 @@ -dist -dist-types -coverage -.vscode -.eslintrc.js - -generated -report.api.md -knip-report.md diff --git a/workspaces/augment/KAGENTI_FEATURES.md b/workspaces/augment/KAGENTI_FEATURES.md deleted file mode 100644 index d366812771c..00000000000 --- a/workspaces/augment/KAGENTI_FEATURES.md +++ /dev/null @@ -1,183 +0,0 @@ -# Kagenti Feature Catalog - -Complete inventory of every Kagenti feature available through the Augment RHDH plugin. - -**Legend:** - -- **Working** -- Backend route + frontend UI both exist and are wired -- **API-only** -- Backend route exists but no frontend UI -- **Untested** -- No automated tests cover this feature - ---- - -## Persona 1: Chat User - -Any authenticated user with `augment.access` permission when provider is `kagenti`. - -### Agent Discovery and Selection - -| # | Feature | Status | Conditions | Backend | Frontend | -| --- | ----------------------- | ------- | --------------------------- | -------------------------------- | ------------------- | -| 1 | Browse agent gallery | Working | Welcome screen, `isKagenti` | `GET /kagenti/agents` | `AgentGallery` | -| 2 | Search/filter agents | Working | Gallery open | Client-side | `AgentGallery` | -| 3 | Pin/unpin favorites | Working | Gallery open | localStorage | `usePinnedRecent` | -| 4 | Agent detail drawer | Working | Gallery open | `GET /kagenti/agents/:ns/:name` | `AgentDetailDrawer` | -| 5 | Select agent for chat | Working | Gallery open | Sets `model` state | `ChatContainer` | -| 6 | Agent chip in input | Working | Agent selected | N/A | `ChatInput` | -| 7 | Agent info sidebar | Working | Right pane open | `GET /kagenti/agents/:ns/:name` | `AgentInfoSection` | -| 8 | Dashboard links sidebar | Working | Config has URLs | `GET /kagenti/config/dashboards` | `AgentInfoSection` | - -### Chat and Streaming - -| # | Feature | Status | Conditions | Backend | Frontend | -| --- | ---------------------------- | ------- | --------------------- | -------------------------------------- | ---------------------------- | -| 9 | Send message (streaming) | Working | Session exists | `POST /chat/stream` | `useStreamingChat` | -| 10 | Send message (non-streaming) | Working | Fallback path | `POST /chat` | `useStreamingChat` | -| 11 | Session auto-creation | Working | First message | `POST /sessions` | `useStreamingChat` | -| 12 | Session persistence | Working | DB available | `GET /sessions` | `useChatSessions` | -| 13 | Stop/cancel generation | Working | During stream | AbortController | `ChatContainer` | -| 14 | Regenerate message | Working | Has messages | Re-streams | `useChatActions` | -| 15 | Edit previous message | Working | Has messages | Re-streams | `useChatActions` | -| 16 | View reasoning steps | Working | Agent sends them | Stream events | `ReasoningDisplay` | -| 17 | View RAG sources | Working | RAG enabled | Stream events | Stream state | -| 18 | View tool calls | Working | Agent uses tools | Stream events | `ToolCallDisplay` | -| 19 | View artifacts | Working | Agent sends them | Stream events | `ArtifactRenderer` | -| 20 | View citations | Working | Agent sends them | Stream events | `CitationRenderer` | -| 21 | Multi-agent handoffs | Working | Multi-agent setup | Stream events | `HandoffDivider` | -| 22 | Conversation history | Working | DB + Kagenti contexts | Kagenti `/api/v1/contexts/:id/history` | Via `ConversationCapability` | -| 23 | Provider offline banner | Working | Status polling | `GET /status` | `ProviderOfflineBanner` | - -### Human-in-the-Loop / A2A Interactive Flows - -| # | Feature | Status | Conditions | Backend | Frontend | -| --- | --------------------------- | ------- | ------------------------ | --------------------------------------- | ------------------------------ | -| 24 | Tool approval dialog | Working | Agent requests approval | `POST /chat/approve` | `ToolApprovalDialog` | -| 25 | Edit tool arguments | Working | Approval pending | Same | `ToolArgumentsEditor` | -| 26 | Reject with reason | Working | Approval pending | Same | `useToolApproval` | -| 27 | Form input card | Working | Agent sends form request | `POST /chat/approve` (form_response) | `FormRequestCard` | -| 28 | OAuth required card | Working | Agent requires OAuth | `POST /chat/approve` (oauth_confirm) | `AuthRequiredCard` | -| 29 | Secrets input card | Working | Agent requires secrets | `POST /chat/approve` (secrets_response) | `AuthRequiredCard` | -| 30 | Approval keyboard shortcuts | Working | Dialog open | N/A | `useApprovalKeyboardShortcuts` | -| 31 | Fallback on empty approval | Working | Approval succeeds | N/A | `ChatContainer` handlers | - -### Namespace Context - -| # | Feature | Status | Conditions | Backend | Frontend | -| --- | --------------------- | ------- | ------------------------- | ------------------------- | ----------------- | -| 32 | Namespace picker | Working | `isKagenti`, admin header | `GET /kagenti/namespaces` | `NamespacePicker` | -| 33 | Namespace persistence | Working | Always | sessionStorage | `AugmentPage` | -| 34 | Namespace allowlist | Working | Config `namespaces` set | `validateNamespaceParam` | Backend-only | - ---- - -## Persona 2: Admin -- Agents and Tools - -Requires `isAdmin` + admin view mode. Mutations gated by `requireAdminAccess`. - -### Agent Management - -| # | Feature | Status | Conditions | Backend | Frontend | -| --- | --------------------- | -------- | ----------------- | -------------------------------------------- | -------------------- | -| 35 | List agents | Working | Admin Kagenti tab | `GET /kagenti/agents` | `KagentiAgentsPanel` | -| 36 | Create agent | Working | Admin | `POST /kagenti/agents` | `KagentiAgentsPanel` | -| 37 | Delete agent | Working | Admin | `DELETE /kagenti/agents/:ns/:name` | `KagentiAgentsPanel` | -| 38 | View agent build info | Working | Admin | `GET /kagenti/agents/:ns/:name/build-info` | `KagentiAgentsPanel` | -| 39 | Trigger agent build | Working | Admin, Shipwright | `POST /kagenti/agents/:ns/:name/buildrun` | `KagentiAgentsPanel` | -| 40 | Agent migration | API-only | Admin | `POST /kagenti/agents/:ns/:name/migrate` | None | -| 41 | Bulk migration | API-only | Admin | `POST /kagenti/agents/migration/migrate-all` | None | - -### Tool Management - -| # | Feature | Status | Conditions | Backend | Frontend | -| --- | ------------------- | ------- | ----------------- | ---------------------------------------------- | ------------------------- | -| 42 | List tools | Working | Admin Kagenti tab | `GET /kagenti/tools` | `KagentiToolsPanel` | -| 43 | Create tool wizard | Working | Admin | `POST /kagenti/tools` | `CreateToolWizard` | -| 44 | Delete tool | Working | Admin | `DELETE /kagenti/tools/:ns/:name` | `KagentiToolsPanel` | -| 45 | Tool detail drawer | Working | Admin | `GET /kagenti/tools/:ns/:name` | `KagentiToolDetailDrawer` | -| 46 | Tool route status | Working | Admin | `GET /kagenti/tools/:ns/:name/route-status` | `KagentiToolDetailDrawer` | -| 47 | MCP tool discovery | Working | Admin | `POST /kagenti/tools/:ns/:name/connect` | `McpToolCatalog` | -| 48 | MCP tool invocation | Working | Admin | `POST /kagenti/tools/:ns/:name/invoke` | `McpToolCatalog` | -| 49 | Tool build info | Working | Admin | `GET /kagenti/tools/:ns/:name/build-info` | `KagentiToolDetailDrawer` | -| 50 | Trigger tool build | Working | Admin, Shipwright | `POST /kagenti/tools/:ns/:name/buildrun` | `KagentiToolDetailDrawer` | -| 51 | Finalize tool build | Working | Admin | `POST /kagenti/tools/:ns/:name/finalize-build` | `KagentiToolDetailDrawer` | - -### Shipwright Build Pipeline - -| # | Feature | Status | Conditions | Backend | Frontend | -| --- | ----------------------- | ------- | ---------- | ----------------------------------------- | --------------------------- | -| 52 | List build strategies | Working | Admin | `GET /kagenti/agents/build-strategies` | `KagentiBuildPipelinePanel` | -| 53 | List Shipwright builds | Working | Admin | `GET /kagenti/shipwright/builds` | `KagentiBuildPipelinePanel` | -| 54 | Trigger build from list | Working | Admin | `POST /kagenti/agents/:ns/:name/buildrun` | `KagentiBuildPipelinePanel` | - ---- - -## Persona 3: Admin -- Sandbox - -Requires `isAdmin` + `sandbox` feature flag. - -| # | Feature | Status | Conditions | Backend | Frontend | -| --- | ---------------------------- | -------- | --------------------- | --------------------------------------- | --------------------- | -| 55 | List sandbox sessions | Working | `sandbox` flag | `GET /kagenti/sandbox/:ns/sessions` | `KagentiSandboxPanel` | -| 56 | Session token usage | Working | `sandbox` flag | `GET .../token-usage/sessions/:id` | `KagentiSandboxPanel` | -| 57 | Rename session | Working | `sandbox` flag | `PUT .../sessions/:id/rename` | `KagentiSandboxPanel` | -| 58 | Kill session | Working | `sandbox` flag | `POST .../sessions/:id/kill` | `KagentiSandboxPanel` | -| 59 | Approve/deny session | Working | `sandbox` flag, admin | `POST .../approve`, `POST .../deny` | `KagentiSandboxPanel` | -| 60 | Set session visibility | Working | `sandbox` flag | `PUT .../visibility` | `KagentiSandboxPanel` | -| 61 | List agents + pod status | Working | `sandbox` flag | `GET .../agents`, `.../pod-status` | `KagentiSandboxPanel` | -| 62 | Sandbox chat (non-streaming) | API-only | `sandbox` flag | `POST /kagenti/sandbox/:ns/chat` | None | -| 63 | Sandbox streaming chat | API-only | `sandbox` flag | `POST /kagenti/sandbox/:ns/chat/stream` | None | -| 64 | Session subscribe (SSE) | API-only | `sandbox` flag | `GET .../sessions/:id/subscribe` | None | -| 65 | Create/delete sandbox | API-only | `sandbox` flag, admin | `POST/DELETE /kagenti/sandbox/:ns/...` | None | -| 66 | Browse sandbox files | API-only | `sandbox` flag | `GET /kagenti/sandbox/:ns/files/...` | None | -| 67 | Sidecar management | API-only | `sandbox` flag | `POST .../sidecars/...` | None | -| 68 | Session chain/history | API-only | `sandbox` flag | `GET .../chain`, `GET .../history` | None | -| 69 | Cleanup sessions (TTL) | API-only | `sandbox` flag, admin | `POST /kagenti/sandbox/:ns/cleanup` | None | - ---- - -## Persona 4: Admin -- Platform - -Requires `isAdmin` + specific feature flags. - -| # | Feature | Status | Flag | Backend | Frontend | -| --- | --------------------------- | -------- | -------------- | ----------------------------------- | ----------------------- | -| 70 | View LLM models | Working | `sandbox` | `GET /kagenti/models` | `KagentiAdminPanel` | -| 71 | Create/list LLM teams | Working | `sandbox` | `POST/GET /kagenti/llm/teams` | `KagentiAdminPanel` | -| 72 | Create/list/delete API keys | Working | `sandbox` | `POST/GET/DELETE /kagenti/llm/keys` | `KagentiAdminPanel` | -| 73 | List integrations | Working | `integrations` | `GET /kagenti/integrations` | `KagentiAdminPanel` | -| 74 | Create integration | Working | `integrations` | `POST /kagenti/integrations` | `KagentiAdminPanel` | -| 75 | Test integration | Working | `integrations` | `POST .../test` | `KagentiAdminPanel` | -| 76 | Delete integration | Working | `integrations` | `DELETE .../integrations/:ns/:name` | `KagentiAdminPanel` | -| 77 | Update integration | API-only | `integrations` | `PUT .../integrations/:ns/:name` | None | -| 78 | Create trigger | Working | `triggers` | `POST /kagenti/sandbox/trigger` | `KagentiAdminPanel` | -| 79 | Dashboard link cards | Working | None | `GET /kagenti/config/dashboards` | `KagentiDashboardLinks` | -| 80 | Feature flag display | Working | None | `GET /kagenti/config/features` | `KagentiAdminPanel` | - ---- - -## Test Coverage Summary - -| Layer | Coverage | Notes | -| -------------------------------- | ------------------- | -------------------------------------- | -| Stream Normalizer | Strong (40 tests) | Pure unit tests | -| HTTP Clients (API/Admin/Sandbox) | Good (36 tests) | Real/mocked HTTP | -| KagentiProvider | Moderate (18 tests) | Mocked clients | -| Config Loader | Good (7 tests) | Real config reader | -| Keycloak Token Manager | Good (6 tests) | Real mini HTTP server | -| Express Routes | Moderate (33 tests) | Supertest; many routes untested | -| Frontend Kagenti UI | None | No Kagenti-specific frontend tests | -| E2E / Contract | None | Playwright stubbed but not implemented | - -## Features Without UI (API-Only) - -The following 9 features have backend routes and client support but no frontend UI: - -1. **Agent migration** (#40) and **bulk migration** (#41) -2. **Sandbox chat** (#62), **streaming** (#63), and **session subscribe** (#64) -3. **Create/delete sandbox** (#65) -4. **Browse sandbox files** (#66) -5. **Sidecar management** (#67) -6. **Session chain/history** (#68) -7. **Update integration** (#77) - -These can be accessed programmatically via the Augment backend REST API but are not exposed in the Augment UI. diff --git a/workspaces/augment/README.md b/workspaces/augment/README.md deleted file mode 100644 index 798e0cab34b..00000000000 --- a/workspaces/augment/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# Augment Workspace - -This workspace contains the Augment plugin family for Red Hat Developer Hub. - -Augment is a config-driven AI assistant built on [Llama Stack's OpenAI-compatible Responses API](https://developers.redhat.com/articles/2025/08/20/your-agent-your-rules-deep-dive-responses-api-llama-stack). It provides RAG-powered document search, multi-agent orchestration, tool calling via MCP servers, and safety guardrails — all running against open-source models on your own infrastructure. - -## Architecture - -Augment follows the same agent architecture as the [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/multi_agent/): an agent is **not** a server-side resource — it is a client-side abstraction of `instructions + tools + model`, assembled into a `POST /v1/responses` call against Llama Stack. - -``` -┌──────────────────────────────────────────────────────────┐ -│ Backstage Frontend (augment plugin) │ -│ Chat UI · Handoff visualization · Agent status panel │ -└──────────────────────┬───────────────────────────────────┘ - │ SSE / REST -┌──────────────────────▼───────────────────────────────────┐ -│ Backstage Backend (augment-backend plugin) │ -│ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ ResponsesApiCoordinator (multi-agent orchestrator) │ │ -│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │ -│ │ │ Router │→ │Specialist│→ │ Specialist │ │ │ -│ │ │ Agent │ │ Agent A │ │ Agent B │ │ │ -│ │ │ │ │ │ │ │ │ │ -│ │ │ tools: │ │ tools: │ │ tools: │ │ │ -│ │ │ transfer │ │ MCP svrs │ │ file_search, MCP │ │ │ -│ │ │ _to_* │ │ │ │ │ │ │ -│ │ └──────────┘ └──────────┘ └──────────────────┘ │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ DocumentService · ConversationManager · SafetyService │ -└──────────────────────┬───────────────────────────────────┘ - │ OpenAI-compatible REST -┌──────────────────────▼───────────────────────────────────┐ -│ Llama Stack Server │ -│ /v1/responses · /v1/conversations · /v1/vector_stores │ -│ /v1/files · /v1/models · /v1/shields │ -└──────────────────────────────────────────────────────────┘ -``` - -### Multi-Agent Orchestration - -Each agent in the YAML config becomes a unique `POST /v1/responses` call with its own `instructions`, `tools`, `temperature`, and `toolChoice`. Multi-agent routing uses the [OpenAI Agents SDK handoff pattern](https://openai.github.io/openai-agents-js/guides/handoffs/): - -- **Handoffs** (`transfer_to_{agent}`): A router agent delegates to a specialist who takes over the conversation. -- **Agents-as-tools** (`call_{agent}`): A manager agent calls specialists as tools and retains control of the response. - -All agents share the same Llama Stack `conversation` ID for context continuity across handoffs. - -### Key APIs Used - -| Llama Stack API | Purpose | -| ---------------------------- | ------------------------------------------------------------------ | -| `POST /v1/responses` | All LLM inference — each agent config becomes a parameterized call | -| `POST /v1/conversations` | Persistent conversation state across agent handoffs | -| `POST /v1/vector_stores` | RAG document storage and retrieval | -| `POST /v1/files` | Document upload for ingestion | -| `GET /v1/models` | Model discovery and validation | -| `POST /v1/safety/run-shield` | Input/output safety guardrails | - -## Plugins - -| Plugin | Description | -| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| [@red-hat-developer-hub/backstage-plugin-augment](./plugins/augment/) | Frontend — chat UI, agent handoff visualization, admin panels | -| [@red-hat-developer-hub/backstage-plugin-augment-backend](./plugins/augment-backend/) | Backend — multi-agent orchestrator, RAG ingestion, MCP integration | -| [@red-hat-developer-hub/backstage-plugin-augment-common](./plugins/augment-common/) | Shared types, permissions, and security mode definitions | - -## Development - -```bash -# Install dependencies -yarn install - -# Start the dev server -yarn dev - -# Run tests -yarn test:all - -# Build all plugins -yarn build:all -``` diff --git a/workspaces/augment/app-config.yaml b/workspaces/augment/app-config.yaml deleted file mode 100644 index ed36316c0c3..00000000000 --- a/workspaces/augment/app-config.yaml +++ /dev/null @@ -1,131 +0,0 @@ -app: - title: Red Hat Developer Hub - baseUrl: http://localhost:3000 - -organization: - name: Red Hat - -backend: - baseUrl: http://localhost:7007 - listen: - port: 7007 - csp: - connect-src: ["'self'", 'http:', 'https:'] - cors: - origin: http://localhost:3000 - methods: [GET, HEAD, PATCH, POST, PUT, DELETE] - credentials: true - database: - client: better-sqlite3 - connection: ':memory:' - -auth: - environment: development - providers: - guest: {} - -catalog: - import: - entityFilename: catalog-info.yaml - pullRequestBranchName: backstage-integration - rules: - - allow: [Component, System, API, Resource, Location, Template] - locations: - - type: file - target: ../../examples/templates/agent-template.yaml - rules: - - allow: [Template] - - type: file - target: ../../examples/templates/langgraph-agent-template.yaml - rules: - - allow: [Template] - - type: file - target: ../../examples/templates/crewai-agent-template.yaml - rules: - - allow: [Template] - - type: file - target: ../../examples/templates/tool-template.yaml - rules: - - allow: [Template] - -augment: - provider: kagenti - devSpaces: - frameworks: - - id: google-adk - name: Google ADK - description: Build agents with Google's Agent Development Kit - starterRepo: https://github.com/rrbanda/devspaces-demo - tags: [python, google-adk, a2a] - badge: Popular - - id: langgraph - name: LangGraph - description: Build stateful agent graphs with LangChain - starterRepo: https://github.com/rrbanda/devspaces-demo - tags: [python, langgraph, langchain] - badge: Popular - - id: crewai - name: CrewAI - description: Build role-based multi-agent teams - starterRepo: https://github.com/rrbanda/devspaces-demo - tags: [python, crewai, multi-agent] - - id: openai-agents - name: OpenAI Agents - description: Build agents using the OpenAI Responses API - starterRepo: https://github.com/rrbanda/devspaces-demo - tags: [python, openai, responses-api] - - id: custom - name: DIY / Custom - description: Bring your own repository - security: - mode: 'none' - adminUsers: - - 'user:default/admin' - - 'user:default/guest' - agentApproval: - enabled: ${AGENT_APPROVAL_ENABLED:-false} - serviceUrl: ${SONATAFLOW_URL:-http://sonataflow.sonataflow-infra.svc:8080} - workflowId: agentApproval - llamaStack: - baseUrl: ${AUGMENT_LLAMA_STACK_URL:-http://localhost:8321} - model: ${AUGMENT_MODEL:-gemini/models/gemini-2.5-flash} - skipTlsVerify: ${AUGMENT_SKIP_TLS:-false} - kagenti: - baseUrl: ${KAGENTI_BASE_URL:-http://localhost:8080} - namespace: ${KAGENTI_NAMESPACE:-team1} - auth: - tokenEndpoint: ${KAGENTI_TOKEN_ENDPOINT:-http://localhost:8180/realms/kagenti/protocol/openid-connect/token} - clientId: ${KAGENTI_CLIENT_ID:-kagenti-api} - clientSecret: ${KAGENTI_CLIENT_SECRET} - showAllNamespaces: true - skipTlsVerify: false - verboseStreamLogging: true - skillsMarketplace: - baseUrl: ${SKILLS_MARKETPLACE_URL:-https://backstage-developer-hub-rhdh.apps.cluster-6crhb.6crhb.sandbox1011.opentlc.com/api/skill-marketplace/skills} - agentImage: ghcr.io/redhat-et/docsclaw:latest - advisorUrl: https://skill-advisor-smp-agents.apps.cluster-6crhb.6crhb.sandbox1011.opentlc.com - llmBaseUrl: ${LLM_BASE_URL:-https://llamastack-llamastack.apps.cluster-6crhb.6crhb.sandbox1011.opentlc.com/v1} - llmModel: ${LLM_MODEL:-gemini/models/gemini-2.5-pro} - runtimes: - - id: docsclaw - name: DocsClaw - description: ConfigMap-driven agentic runtime - image: ghcr.io/redhat-et/docsclaw:latest - language: Go - footprint: ~10 MiB per pod - features: [tool-use, a2a-protocol, skill-discovery, multi-provider] - status: available - - id: zeroclaw - name: ZeroClaw - description: Provider-agnostic agent runtime with 30+ channels - image: ghcr.io/pavelanni/zeroclaw:0.7.0-beta-minimal - language: Rust - footprint: ~5 MiB per pod - features: [multi-channel, a2a-protocol, rust-native] - status: available - - id: openfang - name: OpenFang - description: Agent operating system built in Rust - language: Rust - features: [agent-os, autonomous, rust-native] - status: coming-soon diff --git a/workspaces/augment/backstage.json b/workspaces/augment/backstage.json deleted file mode 100644 index 0539acb9dd0..00000000000 --- a/workspaces/augment/backstage.json +++ /dev/null @@ -1 +0,0 @@ -{ "version": "1.45.0" } diff --git a/workspaces/augment/bcp.json b/workspaces/augment/bcp.json deleted file mode 100644 index fdb3a953601..00000000000 --- a/workspaces/augment/bcp.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "knip-reports": true -} diff --git a/workspaces/augment/catalog-info.yaml b/workspaces/augment/catalog-info.yaml deleted file mode 100644 index cea5d674efb..00000000000 --- a/workspaces/augment/catalog-info.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: augment - description: AI assistant plugin for Backstage with RAG, tool calling, and safety guardrails. -spec: - type: website - owner: rrbanda - lifecycle: experimental diff --git a/workspaces/augment/docs/FEATURE_SPEC.md b/workspaces/augment/docs/FEATURE_SPEC.md deleted file mode 100644 index a64873150c2..00000000000 --- a/workspaces/augment/docs/FEATURE_SPEC.md +++ /dev/null @@ -1,1033 +0,0 @@ -# Augment Plugin: Complete Feature Spec - -Reference document for all features implemented between Thursday May 21 evening and Saturday May 23 end of day, when 35/35 E2E tests passed and skills agents were deploying to the OpenShift cluster. This is the target state to restore. - -Branch: `feat/augment-phase-c-approval` -Baseline: `b4184482` (PR #3228 merged -- Phases A, B, H done) - ---- - -## Table of Contents - -1. [Agent Creation UX](#1-agent-creation-ux) -2. [Skills-Based Agent Creation](#2-skills-based-agent-creation) -3. [Approval Workflow (Dual-Mode)](#3-approval-workflow-dual-mode) -4. [Agent Lifecycle](#4-agent-lifecycle) -5. [Chat and Streaming](#5-chat-and-streaming) -6. [DevSpaces Integration](#6-devspaces-integration) -7. [Kagenti Integration](#7-kagenti-integration) -8. [Terminology and Labeling](#8-terminology-and-labeling) -9. [Bug Fixes Catalog](#9-bug-fixes-catalog) -10. [35/35 Test Suite](#10-3535-test-suite) -11. [Design System](#11-design-system) -12. [Backend API Reference](#12-backend-api-reference) -13. [Remaining Gaps](#13-remaining-gaps) - ---- - -## 1. Agent Creation UX - -### AgentCreateIntentDialog - -**File:** `plugins/augment/src/components/AdminPanels/KagentiPanels/AgentCreateIntentDialog.tsx` - -**Dialog:** MUI Dialog, maxWidth="md", fullWidth, borderRadius: 3. - -**Top-level view -- "New Agent":** - -- Title: "New Agent" -- Subtitle: "Choose how you want to get started with your new agent." -- Layout: 2-column grid (repeat(2, 1fr), gap 2.5) - -| Card | Icon | Accent Color | Gradient | Subtitle | onClick | -| ------------ | ----------------------- | ---------------------------- | -------- | --------------------------------------------------------------------------------- | ----------------------- | -| Create Agent | AddCircleOutlineIcon | `theme.palette.primary.main` | Yes | "Build a new agent from scratch using skills, visual canvas, code, or templates." | Shows sub-options | -| BYO Agent | CloudUploadOutlinedIcon | `theme.palette.warning.dark` | No | "Import an existing agent from a container image or Git repository." | Opens CreateAgentWizard | - -**AccentCard component styling:** - -- Background: `glassSurface(theme, 6, isDark ? 0.5 : 0.75)` -- Animation: `fadeSlideIn` with `staggerDelay(animIndex, 60)` -- Padding: `p: 3` -- Border radius: `borderRadius.lg` (16px) -- Top accent: `borderTop: 3px solid ${accentColor}` -- Gradient variant: `linear-gradient(135deg, alpha(accent, 0.06-0.12) 0%, paper 60%)` -- Icon box: 48x48, borderRadius.md, bgcolor alpha(accent, 0.08-0.15), icon 26px -- Title: sectionTitle scale (1rem, weight 700) -- Subtitle: bodySmall scale, text.secondary -- Hover: `translateY(-3px) scale(1.01)`, accent box-shadow -- Focus: 2px solid accentColor outline -- Reduced motion: respects prefers-reduced-motion - -**Sub-options view -- "Create Agent":** - -- Title: "Create Agent", subtitle: "Pick a creation method." -- Back arrow returns to top-level -- 2x2 grid of AccentCards - -| Sub-option | Icon | Color | Subtitle | onClick | -| ------------------------- | ------------------------------ | ------------------------------------- | ----------------------------------------------- | ------------------------------------ | -| Create using Skills | ExtensionOutlinedIcon | `theme.palette.success.main` (green) | "Compose an agent from reusable skills" | Opens 3-step skills wizard | -| Create with Visual Canvas | DashboardCustomizeOutlinedIcon | `theme.palette.info.dark` (purple) | "Design with the no-code workflow builder" | Closes dialog, opens WorkflowBuilder | -| Code Your Agent | CodeIcon | `theme.palette.primary.dark` (blue) | "Scaffold from a template or cloud workspace" | Shows DevSpacesLaunchForm | -| Create from Template | DescriptionOutlinedIcon | `theme.palette.warning.main` (orange) | "Start from a pre-configured software template" | Shows AgentTemplateBrowser | - -**No "Recommended" badge on any option.** - -### Creation Path Journeys - -**All paths must end on My Agents tab with a success toast.** - -**Path 1 -- Create using Skills (3-step wizard inside dialog):** - -1. RuntimePicker (choose runtime) -> 2. SkillBrowser (select skills) -> 3. SkillAgentConfigForm (name, prompt, deploy) - -- On deploy success: `onCreated()` -> `handleIntentCreated` -> dialog closes, `marketplaceRefreshKey++`, `switchToMyAgents++`, 300ms delayed toast: "Agent created successfully! It will appear in your agents list." - -**Path 2 -- Create with Visual Canvas:** - -- Dialog closes, WorkflowBuilder opens -- After publish: builder closes, marketplace opens on My Agents tab -- `setShowWorkflowBuilder(false)`, `setShowMarketplace(true)`, `setSwitchToMyAgents(k => k + 1)` - -**Path 3 -- Code Your Agent:** - -- Shows DevSpacesFrameworkPicker -> DevSpacesLaunchForm (pre-filled git repo) -- After workspace Running: "Done" button calls `onDone` (closes dialog) -- Instructions: "Once your image is ready, return to the Augment marketplace and use BYO Agent to register and deploy it." -- NamespacePicker removed; namespace auto-derived from user identity - -**Path 4 -- Create from Template:** - -- Shows AgentTemplateBrowser inside dialog -- `handleLaunch` uses `window.open(url, '_blank', 'noopener')` (new tab) -- Calls `onDone()` to close dialog -- user stays on Augment - -**Path 5 -- BYO Agent:** - -- Opens CreateAgentWizard (import flow) -- Steps: Basics -> Deployment (image/git) -> Runtime -> Review -- `POST /kagenti/agents` -> auto-register in chatAgents as draft -- Success: `justCreatedRef` suppresses dialog reopen, refresh + switch to My Agents - -### ChatView Wiring - -**File:** `plugins/augment/src/components/AugmentPage/ChatView.tsx` - -``` -handleCloseIntentDialog: - setIntentDialogOpen(false) - setMarketplaceRefreshKey(k => k + 1) - setSwitchToMyAgents(k => k + 1) - -handleIntentCreated: - setIntentDialogOpen(false) - setMarketplaceRefreshKey(k => k + 1) - setSwitchToMyAgents(k => k + 1) - setTimeout(() => setCreateSuccess('Agent created successfully!...'), 300) - -handleAgentDetail(agentId, framework?): - if agentId has '/' and framework !== 'workflow-builder': Kagenti detail - else: WorkflowAgentDetail (handles skill + workflow agents) -``` - -Props on AgentCreateIntentDialog: `open, onClose, onSelectDeploy, onSelectConfigure, onCreated` - -### MarketplacePage Wiring - -**File:** `plugins/augment/src/components/Marketplace/MarketplacePage.tsx` - -**My Agents filter:** - -``` -agents.filter(a => - a.createdBy === userRef || - (a.governanceRegistered && (a.framework === 'workflow-builder' || a.framework === 'docsclaw')) || - (!a.governanceRegistered && a.lifecycleStage !== 'published') -) -``` - -**onAgentDetail signature:** `(agentId: string, framework?: string) => void` - -- Must pass framework: `onAgentDetail(id, agent?.framework)` - -**switchToMyAgentsKey:** When key changes, `setActiveTab('my-agents')`. - ---- - -## 2. Skills-Based Agent Creation - -### Skills Marketplace Config - -**In `app-config.yaml`:** - -```yaml -augment: - skillsMarketplace: - baseUrl: https://backstage-developer-hub-rhdh.apps.cluster-6crhb.6crhb.sandbox1011.opentlc.com/api/skill-marketplace - agentImage: ghcr.io/redhat-et/docsclaw:latest - advisorUrl: https://skill-advisor-smp-agents.apps.cluster-6crhb.6crhb.sandbox1011.opentlc.com - llmBaseUrl: https://llamastack-llamastack.apps.cluster-6crhb.6crhb.sandbox1011.opentlc.com/v1 - llmModel: gemini/models/gemini-2.5-pro - runtimes: - - id: docsclaw - name: DocsClaw - description: ConfigMap-driven agentic runtime - image: ghcr.io/redhat-et/docsclaw:latest - language: Go - footprint: ~10 MiB per pod - features: [tool-use, a2a-protocol, skill-discovery, multi-provider] - status: available - - id: zeroclaw - name: ZeroClaw - description: Provider-agnostic agent runtime with 30+ channels - image: ghcr.io/pavelanni/zeroclaw:0.7.0-beta-minimal - language: Rust - footprint: ~5 MiB per pod - features: [multi-channel, a2a-protocol, rust-native] - status: available - - id: openfang - name: OpenFang - description: Agent operating system built in Rust - language: Rust - features: [agent-os, autonomous, rust-native] - status: coming-soon -``` - -**Must be declared in `config.d.ts`** (TypeScript schema). - -### RuntimePicker Component - -**File:** `plugins/augment/src/components/SkillsAgentCreation/RuntimePicker.tsx` (163 lines) -**Fetches:** `GET /augment/skills/runtimes` -**Layout:** `Box display="flex" flexWrap="wrap" gap={2}` - -- Each runtime: MUI Card variant="outlined", width 280px -- Available: clickable, full opacity; selected: 2px solid primary.main border -- Coming Soon: opacity 0.5, CardActionArea disabled -- Content: Name (subtitle1, weight 600) + status Chip (success/default) + description (body2) + language/footprint (caption) + feature chips (small, outlined) - -### SkillBrowser Component - -**File:** `plugins/augment/src/components/SkillsAgentCreation/SkillBrowser.tsx` (187 lines) -**Fetches:** `GET /augment/skills` - -- Search: TextField placeholder="Search skills...", size="small", fullWidth -- Domain tabs: MUI Tabs variant="scrollable", first tab "All", domains sorted with localeCompare('en-US') -- Skill list: Box maxHeight 360, overflowY auto -- Each skill: FormControlLabel + Checkbox size="small" -- Label: name (body2, weight 500) + description (caption, text.secondary) -- Selection: string[] of skill names, toggle on click - -### SkillAgentConfigForm Component - -**File:** `plugins/augment/src/components/SkillsAgentCreation/SkillAgentConfigForm.tsx` (98 lines) - -- Agent Name: TextField required, placeholder "my-skill-agent" -- System Prompt: TextField multiline, minRows 3, maxRows 8 -- Runtime: TextField readOnly (selected runtimeId) -- Selected Skills: TextField readOnly ("N skills selected") -- Deploy button: Button variant="contained", "Deploy Agent" / "Deploying..." -- Calls `onDeploy({ name, systemPrompt })` which triggers parent's `handleSkillsDeploy` - -### handleSkillsDeploy (in AgentCreateIntentDialog) - -``` -POST /augment/agents/from-skills -Headers: Content-Type: application/json, X-Backstage-Request: augment -Body: { name, systemPrompt, runtime: skillsRuntimeId, skills: skillsSelected } -On success: onCreated() then handleClose() -``` - -### Backend: POST /agents/from-skills - -**File:** `plugins/augment-backend/src/routes/skillsRoutes.ts` - -1. Validate agentName + 1+ skills -2. Read `agentImage` from config (default: `ghcr.io/redhat-et/docsclaw:latest`) -3. Derive namespace: `{username}-agents` from userRef -4. Call `generateSkillAgentManifests()` from `skillsManifestBuilder.ts` -5. Auto-create namespace if needed -6. Auto-create `llm-secret` if missing (LLM_PROVIDER, LLM_API_KEY, LLM_BASE_URL, LLM_MODEL) -7. Apply K8s manifests (ConfigMap, Deployment, Service) via K8s API -8. Call `registerSkillAgent()` to write governance entry to chatAgents - -**CRITICAL:** `router.ts` must pass `adminConfig` to `registerSkillsRoutes()` for step 8 to work. - -### K8s Manifest Generation - -**File:** `plugins/augment-backend/src/routes/skillsManifestBuilder.ts` - -**ConfigMap:** system-prompt.txt + agent-config.yaml (tools, exec timeout, loop max_iterations: 10) - -**Deployment:** DocsClaw container + init containers: - -- DocsClaw args: `serve --config-dir /config/agent --skills-dir /skills --listen-plain-http` -- One init container per skill: `alpine:3.20` fetches OCI layer blob via wget from quay.io V2 registry API, extracts with `tar xzf` into `/skills/{name}/` -- Shared emptyDir volume between init containers and DocsClaw -- Why init containers: K8s `image:` volume type doesn't extract on OpenShift 4.20 - -**Service:** ports 8000 (HTTP) + 8100 (health) - -**LLM Secret:** LLM*PROVIDER=openai, LLM_API_KEY=not-needed, LLM_BASE_URL, LLM_MODEL (DocsClaw uses LLM*\_ not OPENAI\_\_) - -### Governance Registration - -**`registerSkillAgent()`** writes to chatAgents admin config: - -- `framework: 'docsclaw'` (runtime name, not "skills") -- `chatEndpoint: http://{name}.{namespace}.svc:8000` -- `namespace`, `createdBy`, `lifecycleStage: 'draft'` - -**`buildUnifiedAgentList()`** governance-only loop: iterates chatAgents, skips IDs in `seen`, pushes synthetic ChatAgent entries for skill-only agents: - -- `providerType: 'augment'`, `source: 'skills'`, `framework` from stored config -- Detects skill agents: `storedFramework === 'docsclaw'` || `hasChatEndpoint` -- Non-skill governance entries: `providerType: 'orchestration'`, framework from ID prefix - -**`overlayConfig()`** must copy `pendingAction` and `chatEndpoint` from ChatAgentConfig. - -### Skill Agent Detail View - -**File:** `plugins/augment/src/components/AdminPanels/KagentiPanels/WorkflowAgentDetail.tsx` - -For `framework === 'docsclaw'`: - -- Header badge: "Skill Agent" -- Type: "Skill Agent (DocsClaw Runtime)" -- Endpoint URL: shows chatEndpoint (e.g. `http://doc-agent.guest-agents.svc:8000`) -- Model: from live pod `/v1/models` (e.g. "docsclaw backed by gemini/models/gemini-2.5-pro") -- Inline test chat via skill chat proxy - -### K8s Cleanup on Delete - -In DELETE handler of `agentRoutes.ts`: - -- Detect skill agents via `source === 'skills'` or `chatEndpoint` presence -- Fetch `devSpacesApiUrl` + `devSpacesToken` from adminConfig -- Delete: Deployment (`apps/v1`), Service (`v1`), ConfigMap (`v1/{name}-config`) -- Use `undici.Agent` with `rejectUnauthorized: false` - ---- - -## 3. Approval Workflow (Dual-Mode) - -### AgentApprovalWorkflowService - -**File:** `plugins/augment-backend/src/services/AgentApprovalWorkflowService.ts` - -**Config:** - -```yaml -augment: - agentApproval: - enabled: true/false - serviceUrl: http://sonataflow.sonataflow-infra.svc:8080 - workflowId: agentApproval -``` - -**Methods:** - -- `startWorkflow({ agentId, agentName, submittedBy, agentSource })` -> returns instanceId -- `sendDecision({ instanceId, approved, decidedBy, reason? })` -> sends CloudEvent -- `cancelWorkflow(instanceId)` -> cancels running instance - -### Built-in Mode (enabled: false) - -All transitions happen immediately. No workflow instance IDs. Default for local dev. - -### SonataFlow Mode (enabled: true) - -**Promote (draft -> pending):** - -- Call `approvalService.startWorkflow({ action: 'publish' })` -- Set `pendingAction: 'publish'` on config entry -- Persist `approvalWorkflowInstanceId` - -**Approve:** - -- Call `approvalService.sendDecision({ approved: true })` -- SonataFlow callbacks RHDH with `X-Augment-Workflow-Callback: true` -- Backend applies `pending -> published` - -**Reject:** - -- Call `approvalService.sendDecision({ approved: false, reason })` -- Returns to draft, clears workflow fields - -**GET /agents response includes:** `approvalMode: 'built-in' | 'workflow'` - -### Request Unpublish - -**Endpoint:** `PUT /agents/:agentId/request-unpublish` - -- `published -> pending`, `pendingAction: 'unpublish'` -- Starts unpublish workflow if SonataFlow enabled - -**Review Queue:** - -- Approve unpublish: `pending(unpublish) -> draft` (removed from marketplace) -- Reject unpublish ("Keep Published"): `pending(unpublish) -> published`, pendingAction cleared - -### Withdraw - -**Endpoint:** `PUT /agents/:agentId/withdraw` - -- `pending -> draft`, clears pendingAction and approvalWorkflowInstanceId -- Cancels running SonataFlow instance if any - ---- - -## 4. Agent Lifecycle - -### Stages - -`draft | pending | published | archived` - -### Transitions (7 valid) - -| From | To | Action | Label | -| --------- | --------- | ----------------- | ------------------- | -| draft | pending | submit | Submit for Review | -| pending | published | approve | Approve and Publish | -| pending | draft | reject | Reject | -| pending | draft | withdraw | Withdraw | -| published | pending | request-unpublish | Request Unpublish | -| published | archived | archive | Archive | -| archived | draft | reactivate | Reactivate | - -### Legacy Stage Map - -`registered/review/staging -> pending`, `deployed/production -> published`, `retired -> archived` - -### Startup Reconciliation - -On backend startup (async IIFE): - -1. Call `provider.listAgents()` for all runtime agents -2. Load existing chatAgents configs -3. For each runtime agent NOT in chatAgents: auto-create entry with `lifecycleStage: 'draft'` -4. Save via `adminConfig.set('chatAgents', ...)` - -### Source Counts - -`buildUnifiedAgentList` returns `sources: { byo: number, workflowBuilder: number }` (not kagenti/orchestration). - -### Developer Journey - -- Create agent (any path) -> appears in My Agents as Draft -- Submit for Review (draft -> pending) -- Admin approves -> Published (appears in Explore marketplace) -- Can Withdraw while pending -- Can Request Unpublish when published (goes through approval) - -### Admin Journey - -- Review Queue shows agents in `pending` stage -- Approve publish: pending -> published -- Reject: pending -> draft (with reason) -- Approve unpublish: pending(unpublish) -> draft -- Reject unpublish: pending(unpublish) -> published - ---- - -## 5. Chat and Streaming - -### Skill Agent Chat Proxy - -**File:** `plugins/augment-backend/src/routes/skillChatProxy.ts` - -**CRITICAL: Must run BEFORE `resolveConversationId`** to avoid session-not-found errors. - -**`trySkillChatProxy(req, res, adminConfig, logger)`:** - -1. Load chatAgents from adminConfig -2. Find agent by model name matching agentId where chatEndpoint exists -3. POST to `{chatEndpoint}/v1/chat/completions` with `{ messages, stream: true }` -4. Translate SSE: `stream.started`, `stream.text.delta`, `stream.completed`, `stream.error` -5. `undici.Agent` with `rejectUnauthorized: false` -6. SseHeartbeat for keep-alive - -**DocsClaw API:** POST `/v1/chat/completions` (OpenAI-compatible, NOT Responses API) - -### Chat Architecture Fixes (from transcript) - -- Real streaming via `WorkflowExecutor.executeStream()` (not fake SSE) -- Session persistence: `sessions.addMessage()` after workflow chat -- Kagenti streaming: switched InlineAgentChat to `POST /chat/stream` (SSE) since Kagenti agents are streaming-only - ---- - -## 6. DevSpaces Integration - -### Framework Picker - -**New component:** `DevSpacesFrameworkPicker.tsx` - -- Card grid: Google ADK, LangGraph, CrewAI, OpenAI Agents, DIY/Custom -- Popular badges on ADK and LangGraph -- DIY: dashed border -- Backend: `GET /devspaces/frameworks` from config - -### DevSpacesLaunchForm Changes - -- Remove NamespacePicker; show auto-derived namespace message -- Add `onDone` prop (distinct from `onBack`) -- "Done" button calls `onDone` (closes dialog); "Back" calls `onBack` (framework picker) -- Instructions reference "BYO Agent" for deployment step - -### Auto-namespace - -`resolveNamespaceForUser('user:default/jsmith')` -> `jsmith-devspaces` - -- `ensureNamespace()` auto-creates via K8s API -- Routes call this instead of requiring explicit namespace - -### TLS Fix - -`DevSpacesService.fetchWithTimeout()` uses `undici.Agent({ connect: { rejectUnauthorized: false } })` - ---- - -## 7. Kagenti Integration - -### Config-Seeded Fallback - -In `KagentiProvider.listAgents()`: when API returns 0 agents but `config.agents` is set (e.g. `['mortgage-ai/mortgage-ai-api', 'tr-agents/branch-monitor']`), discover via agent-card endpoints. - -### Auth - -`app-config.local.yaml` with direct Keycloak credentials (env var substitution unreliable under `yarn dev`): - -```yaml -augment: - kagenti: - auth: - tokenEndpoint: https://keycloak-keycloak.apps.cluster-6crhb.../realms/kagenti/protocol/openid-connect/token - clientId: kagenti-api - clientSecret: -``` - ---- - -## 8. Terminology and Labeling - -| Old | New | -| --------------------------------------- | ----------------------------------------------- | -| "Kagenti" (UI label) | Framework name (A2A, LangGraph, ADK) or "Agent" | -| "Orchestration" | "Workflow Builder" | -| "Kagenti Connection" tab | "Agent Ops" | -| Source column | "Type" | -| sources.kagenti / sources.orchestration | sources.byo / sources.workflowBuilder | -| framework: 'skills' | framework: 'docsclaw' | - -**Framework colors** (`marketplace.constants.ts`): - -- docsclaw: `#009596` (PatternFly Cyan), label "DocsClaw" -- LangGraph: `#8b5cf6` -- GoogleADK: `#3b82f6` -- llamastack: `#f59e0b` - -**Lifecycle badges:** - -- Draft: `#6b7280` (gray) -- Pending: `#3b82f6` (blue) -- Published: `#10b981` (green) -- Archived: `#ef4444` (red) - ---- - -## 9. Bug Fixes Catalog - -### 27 bugs fixed (Thu evening - Sat night) - -**1. 403 CSRF (6+ reports, 4 fix iterations)** - -- Root cause: Missing `X-Backstage-Request` header on mutating requests -- Fix: `addCsrfHeader()` in AugmentApi.fetchJson, chatEndpoints.ts, all authFetch calls -- Files: AugmentApi.ts, chatEndpoints.ts, ChatView.tsx - -**2. EditorCanvas TypeError** - -- Root cause: `initialEdges`/`initialNodes` undefined -- Fix: `?? []` null guards in useMemo hooks -- File: EditorCanvas.tsx - -**3. PublishDialog TypeError** - -- Root cause: `workflow.name.trim()` on undefined -- Fix: Null guards for workflow/name/nodes/edges -- File: PublishDialog.tsx - -**4. My Agents empty (7+ reports, 6 fix iterations)** - -- 4a: Agents went to Review Queue not My Agents (redesigned flow) -- 4b: chatAgents ID mismatch + filter too restrictive -- 4c: Guest mode identity filter returned empty -- 4d: "BIG HUGE BUG" -- filter still too restrictive async race -- 4e: Skill agent not appearing (missing governance registration + unified list loop + refresh) -- 4f: Kagenti auto-registered with null createdBy - -**5. Chat "Access denied"** - -- Same root cause as #1 (CSRF header) - -**6. LlamaStack connection error** - -- Fix: Configure remote LlamaStack URL in app-config + skipTlsVerify - -**7. Command Center 500 on approve** - -- Misleading -- 500 was from separate Keycloak call, promote succeeded - -**8. Command Center TypeError** - -- Root cause: Lifecycle rename missed in OpsOverview/ActivityFeed/ToolReviewQueue -- Fix: Updated LIFECYCLE_COLORS references - -**9. Lifecycle stage mismatch crashes** - -- Root cause: Mixed 3/5/4-stage values across codebase -- Fix: Coordinated rename to 4-stage + LEGACY_STAGE_MAP - -**10. Backend 404 on startup** - -- Fix: SQLite config in app-config.yaml (connection.directory) - -**11. Keycloak/RHDH inaccessible** - -- Not code bug -- expired browser session - -**12. Chat "No response" -- Kagenti agents** - -- Root cause: Kagenti agents are streaming-only; InlineAgentChat used non-streaming -- Fix: Switched to POST /chat/stream with SSE parsing - -**13. Chat architecture (streaming, sessions, Keycloak 401)** - -- Real streaming via WorkflowExecutor.executeStream() -- Session persistence after workflow chat -- app-config.local.yaml with direct Keycloak credentials - -**14. Agent visibility UX bugs** - -- Missing "Edit in Builder" button -- Delete shown for non-draft -- Archive/Reactivate not admin-gated - -**15. DevSpaces TLS rejection** - -- Fix: undici.Agent with rejectUnauthorized: false - -**16. AgentTemplateBrowser crash (NotImplementedError)** - -- Fix: try/catch around useApi(catalogApiRef) - -**17. OCI image volumes empty** - -- Root cause: K8s image volume type doesn't extract on OpenShift 4.20 -- Fix: Init containers (alpine:3.20 + wget + tar) - -**18. LLM provider not configured (DocsClaw)** - -- Root cause: DocsClaw uses LLM*\* not OPENAI*\* env vars -- Fix: Updated secret template + auto-create llm-secret - -**19. Chat "No response" -- Skill agents** - -- Root cause: No chat proxy + routing to wrong detail view -- Fix: skillChatProxy.ts, chatRoutes.ts (before session resolution), ChatView routing - -**20-27. Additional fixes:** - -- Save lifecycle jumped to production -> preserves draft -- Editor breaks after save -> correct setActiveWorkflow -- WorkflowDashboard import error -> type rename -- Registry shows Submit to admin -> filtered PromotionActions -- Success toast not appearing -> 300ms setTimeout -- DevSpaces wrong instructions -> "BYO Agent" reference -- BYO Agent missing right arrow -> added matching arrow -- Creation paths don't switch to My Agents -> refresh + switch wiring - ---- - -## 10. 35/35 Test Suite - -**Peak: Saturday May 23, 12:59 PM. Methodology: curl against localhost:7007/api/augment.** - -### Group 1: Agent Visibility (V1-V5) - -- V1: GET /agents returns Kagenti + workflow agents (3+) -- V2: approvalMode: "built-in" present -- V3: Source labels correct (kagenti vs orchestration) -- V4: GET /agents?published=true filters correctly -- V5: GET /status includes approvalMode - -### Group 2: Workflow Lifecycle (D1-D9) - -- D1: Promote to pending -- D2: Verify pending + pendingAction: publish -- D3: Withdraw -> draft -- D4: Re-submit after withdraw -- D5: Admin approve -> published -- D6: Verify in published filter -- D7: Request unpublish -> pending + pendingAction: unpublish -- D8: Admin approve removal -> draft -- D9: Unpublish -> admin rejects -> stays published - -### Group 3: Kagenti Lifecycle (K1-K6) - -- K1: Kagenti agent exists with source: kagenti -- K2: Submit for review -> pending -- K3: Admin approve -> published -- K4: Streaming chat (SSE delta events) -- K5: Request unpublish -> pending -- K6: Admin approve removal -> draft - -### Group 4: Admin Reject (R1-R3) - -- R1: Submit then reject with reason -- R2: Rejection fields in GET /agents -- R3: Re-submit clears rejection fields - -### Group 5: Governance Guards (G1-G8) - -- G1: Direct config write with lifecycleStage -> 400 -- G2: Direct config write with approvalWorkflowInstanceId -> 400 -- G3: Non-admin promote pending->published -> 403 -- G4: Non-admin delete published -> 403 -- G5: Delete published without archive -> 400 -- G6: Invalid transition draft->published -> 400 -- G7: Withdraw non-pending -> 400 -- G8: Request unpublish non-published -> 400 - -### Group 6: Chat (C1-C3) - -- C1: Workflow agent streaming chat -- C2: Session persistence (messages survive reload) -- C3: Kagenti streaming chat - -### Group 7: SonataFlow Mode (S1-S6) - -- S1: approvalMode: "workflow" -- S2: Submit starts workflow (instanceId set) -- S3: Admin approve sends CloudEvent (workflowDecisionSent: true) -- S4: Withdraw with active workflow -- S5: Request unpublish starts workflow -- S6: Workflow failure handling (502) - -### Group 8: Mode Switch (MS1-MS2) - -- MS1: Submit in SonataFlow mode -- MS2: Switch to built-in, approve with stale instanceId - -### Group 9: My Agents Filter (MA1-MA3) - -- MA1: Unpublished Kagenti agents visible -- MA2: Published Kagenti excluded from filter -- MA3: Workflow-builder governanceRegistered agents visible - -### Post-Peak Verification (Saturday afternoon-evening) - -- DevSpaces workspace creation on cluster (Ready/Running) -- Framework picker with 5 cards -- Kagenti import wizard (21 fields validated) -- Skills marketplace (219 skills) -- DocsClaw deploy to cluster (doc-agent in guest-agents namespace) -- DocsClaw chat via LlamaStack + Gemini 2.5 Pro -- Jira tickets RHDHPLAN-1334, 1335, 1336 - ---- - -## 11. Design System - -**File:** `plugins/augment/src/theme/tokens.ts` - -### Spacing (MUI multipliers, 1 unit = 8px) - -xxs: 0.5 (4px), xs: 1 (8px), sm: 1.5 (12px), md: 2 (16px), lg: 3 (24px), xl: 4 (32px), xxl: 6 (48px) - -### Border Radius - -xs: 0.5 (4px), sm: 1 (8px), md: 1.5 (12px), lg: 2 (16px), xl: 2.5 (20px), xxl: 3 (24px), pill: 9999px - -### Typography Scale - -- pageTitle: 1.25rem, weight 700, letter-spacing -0.01em -- sectionTitle: 1rem, weight 600, letter-spacing -0.005em -- body: 0.875rem, weight 400 -- bodySmall: 0.8125rem -- caption: 0.75rem -- micro: 0.6875rem, weight 500 -- code: 0.8125rem - -### Font Families - -- Primary: -apple-system, BlinkMacSystemFont, SF Pro, Helvetica Neue, Segoe UI, Roboto, sans-serif -- Mono: SF Mono, JetBrains Mono, Fira Code, Cascadia Code, Monaco, monospace - -### Icon Sizes - -xs: 14, sm: 16, md: 18, lg: 22, xl: 24 - -### Glass Surface - -`glassSurface(theme, blur=12, opacity?)` -- backdrop-filter blur, theme-aware bg opacity - -### Animations - -- fadeSlideIn: translateY(4px) -> translateY(0), 0.3s ease-out -- staggerDelay(index, baseMs=50): each item delays index \* baseMs ms -- Reduced motion: respects prefers-reduced-motion -- Hover: translateY(-3px) scale(1.01) with accent box-shadow - -### PatternFly Color Alignment - -Colors come from `theme.palette.*` which maps to PatternFly in RHDH: - -- primary.main -> PatternFly Blue #0066CC -- success.main -> PatternFly Green #3E8635 -- info.dark -> PatternFly Purple -- warning.main -> PatternFly Orange #F0AB00 - ---- - -## 12. Backend API Reference - -### Skills Endpoints - -| Method | Path | Description | -| ------ | --------------------------- | ------------------------------------------------- | -| GET | /augment/skills | Proxy to Skills Marketplace API | -| GET | /augment/skills/runtimes | Config-driven runtimes | -| GET | /augment/skills/domains | Unique domains from marketplace | -| POST | /augment/agents/from-skills | Deploy skill agent (manifests + K8s + governance) | - -### Agent Lifecycle Endpoints - -| Method | Path | Description | -| ------ | ------------------------------------- | ------------------------------------------ | -| GET | /augment/agents | Unified agent list with approvalMode | -| PUT | /augment/agents/:id/promote | Promote (with optional SonataFlow) | -| PUT | /augment/agents/:id/demote | Demote | -| PUT | /augment/agents/:id/request-unpublish | Request unpublish (triggers approval) | -| PUT | /augment/agents/:id/withdraw | Withdraw pending submission | -| DELETE | /augment/agents/:id | Delete (with K8s cleanup for skill agents) | - -### DevSpaces Endpoints - -| Method | Path | Description | -| ------ | ----------------------------- | --------------------------------- | -| GET | /augment/devspaces/health | DevSpaces service health | -| GET | /augment/devspaces/frameworks | Configured frameworks | -| POST | /augment/devspaces/workspaces | Create workspace (auto-namespace) | -| GET | /augment/devspaces/workspaces | List workspaces | - -### Chat Endpoints - -| Method | Path | Description | -| ------ | -------------------- | ------------------------------------- | -| POST | /augment/chat | Non-streaming chat | -| POST | /augment/chat/stream | Streaming chat (SSE) with skill proxy | - ---- - -## 13. Skills Marketplace Integration Guide - -This section explains exactly how the augment plugin connects to the existing Skills Marketplace RHDH plugin so that skills appear in the UI browser. - -### Architecture - -``` -Skills Marketplace (RHDH plugin on cluster) - └── GET /api/skill-marketplace/skills → 219 skills in Neo4j graph - └── GET /api/skill-marketplace/domains - └── GET /api/skill-marketplace/graph - └── GET /api/skill-marketplace/bundles - -Augment Backend (proxy) - └── GET /augment/skills → proxies to marketplace baseUrl - └── GET /augment/skills/runtimes → from app-config.yaml - └── GET /augment/skills/domains → derived from marketplace response - -Augment Frontend - └── SkillBrowser.tsx → fetches GET /augment/skills - └── RuntimePicker.tsx → fetches GET /augment/skills/runtimes -``` - -### Config Required (`app-config.yaml`) - -```yaml -augment: - skillsMarketplace: - baseUrl: https://backstage-developer-hub-rhdh.apps.cluster-6crhb.6crhb.sandbox1011.opentlc.com/api/skill-marketplace/skills - agentImage: ghcr.io/redhat-et/docsclaw:latest - advisorUrl: https://skill-advisor-smp-agents.apps.cluster-6crhb.6crhb.sandbox1011.opentlc.com - llmBaseUrl: https://llamastack-llamastack.apps.cluster-6crhb.6crhb.sandbox1011.opentlc.com/v1 - llmModel: gemini/models/gemini-2.5-pro - runtimes: - - id: docsclaw - name: DocsClaw - description: ConfigMap-driven agentic runtime - image: ghcr.io/redhat-et/docsclaw:latest - language: Go - footprint: ~10 MiB per pod - features: [tool-use, a2a-protocol, skill-discovery, multi-provider] - status: available - - id: zeroclaw - name: ZeroClaw - description: Provider-agnostic agent runtime with 30+ channels - image: ghcr.io/pavelanni/zeroclaw:0.7.0-beta-minimal - language: Rust - footprint: ~5 MiB per pod - features: [multi-channel, a2a-protocol, rust-native] - status: available - - id: openfang - name: OpenFang - description: Agent operating system built in Rust - language: Rust - features: [agent-os, autonomous, rust-native] - status: coming-soon -``` - -**IMPORTANT:** `baseUrl` must point to the `/skills` endpoint specifically (not just the plugin root), because `GET /augment/skills` fetches this URL directly. - -### Skills Marketplace API Response Shape - -The marketplace returns a JSON object. The backend proxy at `GET /augment/skills` fetches `skillsConfig.baseUrl` and returns the raw response. - -**Actual response from cluster:** - -```json -{ - "skills": [ - { - "slug": "application-intake", - "name": "Application Intake", - "skillName": "application-intake", - "description": "Automates the intake process for new applications...", - "pluginName": "engineering", - "tags": ["intake", "automation"], - "lifecycleState": "published", - "version": "1.0.0", - "authors": ["platform-team"], - "body": "# Application Intake\n\nThis skill...", - "gitPath": "https://quay.io/rbrhssa/skills:application-intake-1.0.0-published" - } - ] -} -``` - -**Key fields:** - -- `name`: Display name shown in SkillBrowser -- `description`: Shown below the name -- `pluginName`: Used as domain/category for domain filter tabs -- `gitPath`: OCI image reference (with `https://` prefix that must be stripped for K8s) -- `body`: Full SKILL.md content (available from API, no need to fetch from OCI separately) -- `lifecycleState`: "published" or "draft" (38 published, 181 draft out of 219) - -### Frontend-Backend Data Flow Issue - -**Current backend** (`skillsRoutes.ts` line 78): Returns `data` directly from marketplace -- `res.json(data)`. The marketplace returns `{ skills: [...] }`. - -**Current frontend** (`SkillBrowser.tsx` line 66): Expects `{ skills?: SkillDefinition[] }` and reads `data.skills ?? []`. - -**This works** because the marketplace wraps skills in a `{ skills: [...] }` envelope. - -**Frontend SkillDefinition interface:** - -```typescript -interface SkillDefinition { - name: string; - description?: string; - domain?: string; // mapped from pluginName - gitPath?: string; // OCI ref with https:// prefix -} -``` - -**Domain mapping issue:** The marketplace uses `pluginName` for categories, but SkillBrowser filters on `domain`. The backend proxy returns raw marketplace data where the field is `pluginName`, not `domain`. The SkillBrowser domain filter currently won't work because it checks `s.domain` but the data has `s.pluginName`. - -**Fix needed in backend proxy** (`GET /augment/skills`): Normalize the response to map `pluginName` -> `domain`: - -```typescript -const skills = (data.skills ?? []).map(s => ({ - ...s, - domain: s.pluginName ?? s.domain, -})); -res.json({ skills, configured: true, total: skills.length }); -``` - -Or fix in frontend by checking both: `s.domain ?? s.pluginName`. - -### OCI Registry Flow (for deploy) - -When a user creates a skill agent, each selected skill's OCI image must be mounted into the DocsClaw pod. - -**Step 1: gitPath -> OCI ref:** -`https://quay.io/rbrhssa/skills:application-intake-1.0.0-published` -> `quay.io/rbrhssa/skills:application-intake-1.0.0-published` - -**Step 2: Init container fetches the layer:** - -```bash -# Parse registry, repo, tag from OCI ref -MANIFEST=$(wget -qO- "https://${registryHost}/v2/${repoPath}/manifests/${tag}" \ - --header="Accept: application/vnd.oci.image.manifest.v1+json") -DIGEST=$(echo "$MANIFEST" | grep -o '"sha256:[a-f0-9]*"' | tail -1 | tr -d '"') -wget -qO- "https://${registryHost}/v2/${repoPath}/blobs/$DIGEST" | tar xzf - -C /skills/${slug}/ -``` - -**Step 3: DocsClaw reads skills:** -DocsClaw started with `--skills-dir /skills` reads `SKILL.md` + `skill.yaml` from each subdirectory. - -### Two Skill Registries - -| Registry | Skills | Used for | -| ------------------------ | ----------------------------------------------- | ------------------------------ | -| `quay.io/rbrhssa/skills` | 219 marketplace skills | `gitPath` from marketplace API | -| `quay.io/skillimage/` | Pavel's demo skills (document-summarizer, etc.) | Direct OCI refs | - -Both use the same OCI artifact format: a tar.gz layer containing `SKILL.md` + `skill.yaml`. - -### Skills Marketplace A2A Agents (on cluster) - -| Agent | URL | Purpose | Integrated? | -| ---------------- | --------------------------------------------------------- | ------------------------- | ------------------------ | -| skill-advisor | `https://skill-advisor-smp-agents.apps.cluster-6crhb.../` | AI skill recommendations | Config only (advisorUrl) | -| skill-builder | `https://skill-builder-smp-agents.apps.cluster-6crhb.../` | Generate + publish skills | Not integrated | -| bundle-validator | On cluster | Validate skill bundles | Not integrated | -| kg-qa | On cluster | NL queries on skill graph | Not integrated | -| playground | On cluster | Test skills interactively | Not integrated | - -### What Had to Change to Make It Work - -1. **Discovery:** Probed cluster for existing skills infrastructure instead of building from scratch -2. **Proxy:** Backend proxies to marketplace because local dev can't reach cluster directly -3. **Domain mapping:** Marketplace uses `pluginName` for categories; frontend uses `domain` -4. **OCI image volumes failed:** K8s `image:` volume type doesn't extract on OpenShift 4.20 (ImageVolume feature gate not enabled) -- replaced with init containers -5. **Init container:** busybox doesn't support TLS to quay.io -- switched to `alpine:3.20` -6. **LLM env vars:** DocsClaw uses `LLM_*` not `OPENAI_*` -- secret template corrected -7. **LLM secret:** Must be auto-created in target namespace from app-config values -8. **Chat proxy:** DocsClaw exposes `/v1/chat/completions` (OpenAI-compatible), NOT Responses API -- chat must proxy to this endpoint before session resolution - ---- - -## 14. Remaining Gaps (vs Saturday Night Target) - -| # | File | Gap | Size | -| --- | ---------------------------- | ---------------------------------------------------- | --------------------------- | -| 1 | router.ts | Pass adminConfig to registerSkillsRoutes | 1 line | -| 2 | AgentCreateIntentDialog.tsx | Deploy stub needs real API call | ~10 lines | -| 3 | agentRoutes.ts | K8s DELETE cleanup for skill agents | ~30 lines | -| 4 | agentRoutes.ts | Startup reconciliation | ~25 lines | -| 5 | agentRoutes.ts | Source counts byo/workflowBuilder naming | ~5 lines | -| 6 | ChatView.tsx | handleCloseIntentDialog refresh + switch | Already done in uncommitted | -| 7 | ChatView.tsx | handleIntentCreated 300ms toast | Already done in uncommitted | -| 8 | MarketplacePage.tsx | Pass framework in onAgentDetail | ~5 lines | -| 9 | DevSpacesLaunchForm.tsx | Remove NamespacePicker, add onDone, BYO instructions | ~30 lines | -| 10 | AgentTemplateBrowser.tsx | window.open instead of navigate | ~5 lines | -| 11 | DevSpacesFrameworkPicker.tsx | New component + backend endpoint | ~200 lines | -| 12 | config.d.ts | skillsMarketplace + devSpaces.frameworks schemas | ~40 lines | -| 13 | InlineAgentChat.tsx | Extract shared, remove duplicates | ~20 lines | - -Estimated total: ~380 lines of targeted changes. diff --git a/workspaces/augment/docs/adk-web-analysis-summary.md b/workspaces/augment/docs/adk-web-analysis-summary.md deleted file mode 100644 index ad4c4d7dee0..00000000000 --- a/workspaces/augment/docs/adk-web-analysis-summary.md +++ /dev/null @@ -1,144 +0,0 @@ -# Google ADK Web Analysis — Architecture Deep Dive and Augment Opportunity Map - -## Overview - -This document captures the end-to-end architectural analysis of [Google ADK Web](https://github.com/google/adk-web) and its comparison against the Augment chat UI plugin for Backstage/RHDH. The analysis was performed from a product manager's perspective to identify specific patterns worth adopting and those to skip. - ---- - -## Part 1: How Google ADK Web Works End-to-End - -### Architecture - -ADK Web is a **single-route Angular 21 app** where `ChatComponent` is the sole orchestration controller. There is no multi-page routing — the entire UI renders inside ``. - -**Bootstrap chain**: `runtime-config.json` → `main.ts` (standalone bootstrap) → `AppComponent` → `ChatComponent` - -### Services Layer - -| Service | Responsibility | -| ------------------- | -------------------------------------------------------------------------- | -| `AgentService` | SSE streaming via `POST /run_sse` — parses `data:` events to `LlmResponse` | -| `SessionService` | REST CRUD for `/apps/{app}/sessions` | -| `StreamChatService` | WebSocket live audio/video streaming | -| `TraceService` | Span selection bridge between trace tree and event detail | -| `EventService` | Debug trace fetching, Graphviz graph rendering | -| `EvalService` | Evaluation sets and runs | -| `FeedbackService` | Per-message thumbs up/down | -| `UiStateService` | Loading state flags | - -### UI Components - -| Component | Purpose | -| ---------------------- | ------------------------------------------------------------- | -| `ChatPanelComponent` | Message rendering, streaming display, input area | -| `SidePanelComponent` | App selector (`mat-select`), session list, tab container | -| `TraceTabComponent` | Trace tree grouped by invocation | -| `TraceEventComponent` | Bottom detail panel with JSON, LLM request/response, Graphviz | -| `StateTabComponent` | Full session state JSON tree (`ngx-json-viewer`) | -| `ArtifactTabComponent` | Inline artifacts display | -| `EvalTabComponent` | Evaluation UI | -| `CanvasComponent` | Visual agent builder (`ngx-vflow`) | - -### Key Design Decisions - -1. **Agent = "app name"** — selected from a flat `GET /list-apps` dropdown with search. No catalog, cards, or rich metadata. This is a **developer tool**, not a product-facing marketplace. - -2. **SSE streaming** — `AgentService.runSse` posts to `/run_sse`, reads chunks line-by-line. Very similar to Augment's `sseStreaming.ts`. - -3. **Thought display** — inline italic gray text with a "Thought" chip. No separate reasoning panel. - -4. **Tool calls** — stroked Material buttons with bolt icons and JSON tooltip on hover. - -5. **No handoff visualization** — each bot message shows `event.author` as a colored icon tooltip, but no divider or transition UI. - -6. **Dual trace surfaces** — side panel trace tree + resizable bottom panel for event detail. - -7. **Full session state viewer** — `ngx-json-viewer` for the complete session state tree. - -8. **Per-message feedback** — thumbs up/down with structured reasons and free-text comment. - -9. **Drag-resizable panels** — CSS variable-driven `ResizableDrawerDirective`. - -10. **Builder/Canvas** — `ngx-vflow` for visual agent composition. - ---- - -## Part 2: Where Augment Is Already Superior - -These areas represent clear Augment advantages that should **not** be regressed: - -| Area | Augment | ADK Web | -| ------------------------- | ------------------------------------------------------------------------ | ------------------------------------ | -| **Agent Catalog** | Full-screen catalog with search, tabs, featured agents, preview, pinning | Simple `mat-select` dropdown | -| **Welcome Experience** | Branded hero, featured agent cards, conversation starters, prompt groups | Blank chat on load | -| **Handoff Visualization** | `HandoffDivider` with agent name, reason, breadcrumb path | Just a color change on bot icon | -| **Human-in-the-Loop** | Tool approval, form cards, OAuth, secrets prompts | Basic `LongRunningResponseComponent` | -| **Execution Trace** | Inline with messages, sticky during streaming, status counts | Developer-facing raw JSON tree | -| **Streaming UX** | Phase chips, reasoning display, typing cursor, `ThinkingIndicator` | Buffer progress bar | -| **Conversation History** | Grouped by date, search, admin view, rename | Basic session list | -| **Branding/Theming** | Configurable logo, colors, app name | Fixed Google branding | - ---- - -## Part 3: Patterns Adopted from ADK Web - -### ADOPT-1: Per-Message Feedback (Thumbs Up/Down) — **Implemented** - -**Why**: Table-stakes for enterprise AI — enables RLHF data collection, quality monitoring, and user satisfaction measurement. - -**What we built**: - -- `MessageFeedback` component with thumbs up/down buttons -- Expandable panel with structured reasons (positive: Accurate, Helpful, Well-written, Creative; negative: Inaccurate, Not helpful, Incomplete, Too verbose, Off-topic) -- Optional free-text comment field -- Backend `POST /feedback` endpoint with `augment_message_feedback` database table -- Full-stack wiring: `MessageFeedback` → `MessageActionButtons` → `VirtualizedMessageList` → `ChatContainer` → `AugmentApi` → `sessionEndpoints` → backend route → `ChatSessionService.saveFeedback` - -### ADOPT-2: Session State JSON Tree Viewer — **Implemented** - -**Why**: Helps developers and advanced users understand what the agent "remembers" and debug unexpected behavior. ADK Web's `ngx-json-viewer` was a clear gap. - -**What we built**: - -- `JsonTreeViewer` — lightweight recursive React component for rendering JSON trees with expand/collapse, syntax-highlighted values, and depth limiting -- Integrated into `SessionStateInspector` as an expandable "Session State" section -- Backend `GET /sessions/:sessionId/state` endpoint returning session metadata, message counts by role, agent list, and last activity -- Refresh and copy-to-clipboard actions - -### ADOPT-3: Drag-Resizable Side Panel — **Implemented** - -**Why**: Quality-of-life improvement for power users who want wider conversation history or narrower panels for more chat space. - -**What we built**: - -- Drag handle on the left edge of `RightPane` (4px wide, highlighted on hover) -- Mouse event handlers for drag start/move/end with body cursor management -- Width constrained between 280px (min) and 600px (max), defaulting to 340px -- Transitions disabled during drag for smooth resizing, restored on release -- Mobile excluded (mobile uses fixed-width Drawer) - ---- - -## Part 4: Patterns Intentionally Skipped - -| Pattern | Reason | -| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Builder/Canvas** | Augment's agent creation is via Kagenti/configuration, not visual flow building. Different product paradigm with massive scope. | -| **WebSocket Live Audio/Video** | Augment is text-first enterprise chat. Audio/video is a different product category. | -| **Graphviz Event Graph SVG** | Requires Graphviz renderer dependency. Execution trace panel already provides cleaner user-facing timeline. | -| **Infinite Scroll Message Loading** | ADK Web's implementation is incomplete (`UiStateService.lazyLoadMessages` throws "Not implemented"). Augment's `VirtualizedMessageList` already handles this. | - ---- - -## Part 5: The Strategic Summary - -**Augment's chat UI is a significantly more polished, product-grade experience than ADK Web.** ADK Web is a developer debugging tool — it excels at raw inspection (JSON trees, trace spans, event graphs) but lacks the consumer-facing polish that Augment has (branding, agent catalog, handoff visualization, HITL flows, streaming UX). - -The three adopted patterns fill specific gaps: - -1. **Per-message feedback** — the most impactful adoption, addressing a table-stakes enterprise AI requirement -2. **Session state viewer** — developer/power-user debugging capability -3. **Resizable panels** — polish and flexibility - -Everything else in ADK Web is either already done better in Augment or not relevant to its product positioning as an enterprise agentic AI platform embedded in Backstage/RHDH. diff --git a/workspaces/augment/docs/agent-lifecycle-research.md b/workspaces/augment/docs/agent-lifecycle-research.md deleted file mode 100644 index 4067184ac21..00000000000 --- a/workspaces/augment/docs/agent-lifecycle-research.md +++ /dev/null @@ -1,447 +0,0 @@ -# Agent Lifecycle Governance -- Research Findings & Gap Analysis - -## Executive Summary - -This document captures the research findings from exploring the Kagenti API on the live OpenShift cluster, mapping the augment plugin's current agent creation/lifecycle code, and identifying bugs and gaps. It serves as the foundation for the Agent Lifecycle Governance Epic. - ---- - -## 1. Kagenti API Surface (Live Cluster) - -### Cluster Details - -- **Cluster**: `apps.cluster-6crhb.6crhb.sandbox1011.opentlc.com` -- **Kagenti API**: `https://kagenti-api-kagenti-system.apps.cluster-6crhb.6crhb.sandbox1011.opentlc.com` -- **Keycloak**: `https://keycloak-keycloak.apps.cluster-6crhb.6crhb.sandbox1011.opentlc.com` (realm: `kagenti`) -- **RHDH**: namespace `rhdh`, pod `backstage-developer-hub-*` - -### Authentication - -- Keycloak realm `kagenti` with OIDC -- **Public client**: `kagenti` (for UI login) -- **Confidential client**: `kagenti-api` (for service-to-service, used by RHDH backend) -- Token TTL: 60 seconds (short -- must refresh frequently) -- Roles: `kagenti-admin`, `kagenti-operator`, `kagenti-viewer`, `admin` - -### Complete API Endpoints - -| Method | Path | Purpose | -| ---------- | ------------------------------------------------------ | -------------------------------- | -| **GET** | `/api/v1/agents` | List all agents (scoped by auth) | -| **POST** | `/api/v1/agents` | Create agent (deploy to K8s) | -| **GET** | `/api/v1/agents/{ns}/{name}` | Get agent details | -| **DELETE** | `/api/v1/agents/{ns}/{name}` | **Delete agent** (exists!) | -| GET | `/api/v1/agents/build-strategies` | List cluster build strategies | -| POST | `/api/v1/agents/fetch-env-url` | Fetch env from URL | -| POST | `/api/v1/agents/parse-env` | Parse .env content | -| GET | `/api/v1/agents/migration/migratable` | List migratable agents | -| POST | `/api/v1/agents/migration/migrate-all` | Bulk migrate | -| GET | `/api/v1/agents/{ns}/{name}/route-status` | Check HTTP route | -| GET | `/api/v1/agents/{ns}/{name}/shipwright-build` | Build status | -| GET | `/api/v1/agents/{ns}/{name}/shipwright-build-info` | Build config | -| GET/POST | `/api/v1/agents/{ns}/{name}/shipwright-buildrun` | Trigger/check build runs | -| POST | `/api/v1/agents/{ns}/{name}/finalize-shipwright-build` | Finalize build | -| POST | `/api/v1/agents/{ns}/{name}/migrate` | Migrate single agent | -| **GET** | `/api/v1/tools` | List all MCP tools | -| **POST** | `/api/v1/tools` | Create MCP tool | -| **GET** | `/api/v1/tools/{ns}/{name}` | Get tool details | -| **DELETE** | `/api/v1/tools/{ns}/{name}` | Delete tool | -| POST | `/api/v1/tools/{ns}/{name}/connect` | Connect to tool | -| POST | `/api/v1/tools/{ns}/{name}/invoke` | Invoke tool | -| GET | `/api/v1/chat/{ns}/{name}/agent-card` | Get A2A agent card | -| POST | `/api/v1/chat/{ns}/{name}/send` | Send chat message | -| POST | `/api/v1/chat/{ns}/{name}/stream` | Stream chat | -| GET | `/api/v1/namespaces` | List available namespaces | -| GET | `/api/v1/auth/me` | Current user info | -| GET | `/api/v1/auth/config` | Auth configuration | -| GET | `/api/v1/auth/status` | Auth status | -| GET | `/api/v1/auth/userinfo` | Extended user info | -| GET | `/api/v1/config/features` | Feature flags | -| GET | `/api/v1/config/dashboards` | Dashboard config | - -### Key Schemas - -**CreateAgentRequest** (required: `name`, `namespace`): - -``` -name, namespace, protocol (default: "a2a"), framework (default: "LangGraph"), -envVars[], workloadType (default: "deployment"), deploymentMethod (default: "source"), -gitUrl, gitPath, gitBranch, imageTag, registryUrl, registrySecret, -startCommand, containerImage, imagePullSecret, servicePorts[], -createHttpRoute, authBridgeEnabled, spireEnabled, envoyProxyInject, -spiffeHelperInject, clientRegistrationInject, shipwrightConfig -``` - -**AgentSummary**: `name, namespace, description, status, labels, workloadType?, createdAt?` - -**DeleteResponse**: `success, message` - -### K8s CRDs - -| CRD | Purpose | -| ---------------------------------------- | -------------------------------------------- | -| `agentcards.agent.kagenti.dev` | A2A agent card (fetched from running agent) | -| `agentruntimes.agent.kagenti.dev` | Agent runtime (wraps Deployment/StatefulSet) | -| `mcpgatewayextensions.mcp.kagenti.com` | MCP gateway extensions | -| `mcpserverregistrations.mcp.kagenti.com` | MCP server registrations | -| `mcpvirtualservers.mcp.kagenti.com` | MCP virtual servers | - -### Critical Finding: Two Types of Agents - -1. **API-managed agents**: Created via `POST /api/v1/agents`, tracked in Kagenti backend DB + K8s -2. **K8s-native agents**: Deployed directly via Helm/kubectl with `AgentRuntime` + `AgentCard` CRDs - -The current mortgage-ai and branch-monitor agents are K8s-native (deployed via Helm). They have `AgentRuntime` and `AgentCard` CRDs but are **not visible** through `GET /api/v1/agents`. The Kagenti backend only lists agents it created. - ---- - -## 2. RHDH Configuration for Augment Plugin - -```yaml -augment: - provider: kagenti - security: - mode: 'plugin-only' - adminUsers: - - 'user:default/admin' - - 'user:default/temp-admin' - kagenti: - baseUrl: https://kagenti-api-kagenti-system.apps.cluster-6crhb.6crhb.sandbox1011.opentlc.com - namespace: team1 # default namespace for new agents - auth: - tokenEndpoint: ...keycloak.../realms/kagenti/protocol/openid-connect/token - clientId: kagenti-api # confidential client (service-to-service) - clientSecret: ${KAGENTI_CLIENT_SECRET} - showAllNamespaces: true - skipTlsVerify: true -``` - -Available namespaces on cluster: `mortgage-ai`, `team1`, `team2`, `tr-agents` - ---- - -## 3. As-Is User Journeys - -### Journey A: Create Agent via Kagenti Wizard - -**Screen flow**: Command Center > Kagenti Agents tab > "Create Agent" button > `KagentiCreateAgentWizard` - -| Step | Screen | Component | What happens | -| ---- | ---------------------------- | ------------------------------ | ----------------------------------------------------------------- | -| 1 | Kagenti Agents tab | `KagentiAgentsTab.tsx` | Shows agents from Kagenti API. "Create Agent" button opens wizard | -| 2 | Wizard Step 1: Basic Info | `KagentiCreateAgentWizard.tsx` | Name, namespace, framework, protocol | -| 3 | Wizard Step 2: Source | Same | Git URL, branch, path OR container image | -| 4 | Wizard Step 3: Config | Same | Env vars, ports, auth settings | -| 5 | Wizard Step 4: Review | Same | Summary, submit | -| 6 | **POST to Kagenti API** | Backend `kagentiRoutes.ts` | Calls `POST /api/v1/agents` on Kagenti | -| 7 | Agent appears in Kagenti tab | | Status shows as building/deploying | - -**Bug**: After Kagenti creates the agent, no `chatAgents` entry is added to the admin config. This means the agent is invisible to the lifecycle system (draft/review/staging/production). - -### Journey B: Create Agent via Admin Config - -**Screen flow**: Command Center > Agents tab > Manage Agents > Add/Edit in config - -| Step | Screen | What happens | -| ---- | ---------------------------- | ------------------------------------------------------- | -| 1 | Agents tab | `AgentsPanel.tsx` shows agents from `chatAgents` config | -| 2 | Admin clicks "Manage Agents" | Opens config editor for `chatAgents` array | -| 3 | Edit JSON/YAML | Directly add agent entry with lifecycle: "draft" | -| 4 | Save config | PUT `/admin/config/agents` | -| 5 | Agent appears in list | With lifecycle state "draft" | - -**Bug**: No integration with Kagenti. The chatAgents config entry has no link to a real running agent. - -### Journey C: Create Agent via `AgentCreationWizard` (orphan) - -The `AgentCreationWizard` component exists but appears to be disconnected from the routing. It was likely an older implementation that was superseded by the Kagenti wizard. - -### Journey D: Submit Agent for Review - -**Expected flow**: Agent in "draft" state > Click "Submit for Review" > State changes to "review" - -| Step | What should happen | What actually happens | -| ---- | ------------------------- | --------------------------------------------------------------------------- | -| 1 | Open agent detail | Only works for chatAgents-based agents. Kagenti agents may not be clickable | -| 2 | Click "Submit for Review" | Calls `PUT /agents/:id/promote` with target "review" | -| 3 | isValidTransition check | Should enforce draft→review only | -| 4 | **BUG** | Kagenti agents don't have chatAgents entries, so they can't be submitted | - -### Journey E: Browse & Use Agents - -**Screen flow**: Welcome Screen > Featured Agents / "Browse All Agents" - -| Step | Screen | What happens | -| ---- | ------------------ | ------------------------------------------- | -| 1 | Welcome Screen | `FeaturedAgents.tsx` shows published agents | -| 2 | Click "Browse All" | `AgentCatalogDialog.tsx` opens | -| 3 | Click agent | Opens chat with agent | - -**Bug**: Some agents in the catalog are not clickable. Format mismatch between `namespace/name` (Kagenti) and plain `id` (config) identifiers. - -### Journey F: View "My Agents" - -**Expected**: Show only agents created by the current user -**Actual**: Shows all agents (no `createdBy` field exists) - ---- - -## 4. Identified Bugs (Priority Order) - -### BUG-1 (P0): No delete for draft agents - -- **Where**: Agent detail view, admin panel -- **What**: Once an agent is created as a draft, there is no UI button or backend route to delete it -- **Kagenti API**: `DELETE /api/v1/agents/{ns}/{name}` exists and works -- **Fix**: Add `DELETE /agents/:id` route in augment-backend + UI button in agent detail - -### BUG-2 (P0): Kagenti agents not synced to chatAgents config - -- **Where**: `kagentiRoutes.ts` POST handler -- **What**: After `POST /api/v1/agents` succeeds, no `chatAgents` entry is created. This means: - - Agent is invisible to lifecycle stepper - - Can't submit for review - - Can't promote through lifecycle -- **Fix**: Auto-create `chatAgents` entry when Kagenti deploy succeeds - -### BUG-3 (P0): Non-clickable agents in registry - -- **Where**: `AgentCatalogDialog.tsx`, `AgentsPanel.tsx` -- **What**: Agents with `namespace/name` format IDs (from Kagenti) don't match the click handler that expects plain IDs -- **Fix**: Handle both ID formats in navigation/selection - -### BUG-4 (P1): "My Agents" shows all agents - -- **Where**: `AgentsPanel.tsx` "My Agents" tab -- **What**: No `createdBy` field on agent records. All agents shown to all users. -- **Fix**: Add `createdBy` field at creation time, filter in UI - -### BUG-5 (P1): Publish/unpublish bypasses lifecycle validation - -- **Where**: `agentRoutes.ts` publish/unpublish endpoints -- **What**: Direct publish jumps from any state to "production", bypassing `isValidTransition` -- **Fix**: Remove or gate these shortcuts - -### BUG-6 (P2): Docs still reference 3-stage lifecycle - -- **Where**: `docsContent.ts` -- **What**: Documentation references old 3-stage model (draft/active/inactive) instead of current 5-stage (draft/review/staging/production/retired) -- **Fix**: Update docs content - -### BUG-7 (P2): Orphan `AgentCreationWizard` component - -- **Where**: `src/components/AgentCreation/` or similar -- **What**: Older wizard component not connected to routes -- **Fix**: Remove or wire it - ---- - -## 5. To-Be User Journey (Target State) - -### Provider Journey (Agent Creator) - -``` -┌──────────┐ ┌───────────┐ ┌────────────┐ ┌───────────┐ ┌──────────────┐ -│ CREATE │───▶│ DRAFT │───▶│ REVIEW │───▶│ STAGING │───▶│ PRODUCTION │ -│ (Wizard) │ │ │ │ │ │ │ │ │ -└──────────┘ └─────┬─────┘ └─────┬──────┘ └─────┬─────┘ └──────┬───────┘ - │ │ │ │ - ▼ ▼ ▼ ▼ - [Delete] [Approve/Reject] [Test/Verify] [Retire] - │ │ │ - ▼ ▼ ▼ - [Back to Draft] [Back to Review] [RETIRED] -``` - -#### Step-by-step: - -1. **Create**: Provider uses Kagenti wizard OR admin config - - Kagenti wizard: Creates K8s deployment + auto-creates chatAgents entry with `lifecycle: "draft"` - - Config: Directly adds chatAgents entry - - In both cases, `createdBy` is set to current user identity - -2. **Draft**: Agent visible only to creator in "My Agents" - - Can edit configuration - - Can delete (removes from Kagenti + chatAgents) - - Can test via inline chat - - Click "Submit for Review" → moves to `review` - -3. **Review**: Agent visible to admins in "Review Queue" - - Admin reviews agent card, configuration, test results - - Admin approves → moves to `staging` - - Admin rejects → moves back to `draft` with feedback - -4. **Staging**: Agent deployed in staging environment - - Automated or manual testing - - Admin promotes → moves to `production` - - Issues found → moves back to `review` - -5. **Production**: Agent visible in catalog to all consumers - - Appears in Welcome Screen featured agents - - Appears in Agent Catalog Dialog - - Can be retired by admin → moves to `retired` - -6. **Retired**: Agent hidden from catalog, preserved for audit - - Can be reactivated → moves back to `staging` - -### Consumer Journey (Agent User) - -1. **Discover**: Browse agent catalog (Welcome Screen or Command Center) -2. **Select**: Click agent → opens chat interface -3. **Use**: Interact with agent via A2A protocol -4. **Rate/Report**: Provide feedback (future) - ---- - -## 6. Gap Analysis: As-Is vs To-Be - -| Area | As-Is | To-Be | Gap | PR | -| ------------------------------- | ------------------- | ------------------------- | ----------------------------- | ----- | -| Kagenti agent → chatAgents sync | Not synced | Auto-sync on deploy | Auto-create chatAgents entry | PR 4 | -| Delete draft agents | No delete option | UI button + backend route | Add DELETE route + UI | PR 2 | -| Agent clickability in registry | Some not clickable | All clickable | Handle both ID formats | PR 3 | -| "My Agents" scoping | Shows all agents | Shows only user's agents | Add createdBy field | PR 5 | -| Lifecycle validation | Shortcuts bypass it | All transitions validated | Remove shortcuts | PR 6 | -| Workflow agent sync | Not wired | Auto-sync on publish | Wire syncWorkflowToChatAgents | PR 7 | -| Documentation | 3-stage model | 5-stage model | Update docsContent.ts | PR 8 | -| Orphan wizard | Disconnected | Removed or connected | Clean up | PR 10 | - ---- - -## 7. Orchestrator Integration Notes - -### Cluster Infrastructure - -The cluster has: - -- **SonataFlow platform** in `sonataflow-infra` namespace -- **Orchestrator plugin** installed in RHDH (`red-hat-developer-hub-backstage-plugin-orchestrator`) -- **MCP extras** for orchestrator custom actions -- DataIndex service at `http://sonataflow-platform-data-index-service.sonataflow-infra.svc.cluster.local` - -### RHDH Serverless Workflows Research - -Analyzed the [rhdhorchestrator/serverless-workflows](https://github.com/rhdhorchestrator/serverless-workflows) repository to identify reusable approval patterns. Two workflows are most relevant: - -#### Escalation Workflow (`ticketEscalation.sw.yaml`) — Most Reusable - -The escalation workflow demonstrates the canonical approval pattern for RHDH: - -1. **Create ticket** via a subflow (ticketing service abstraction) -2. **Wait for approval event** using a `callback` state with `eventRef: approvalEvent` -3. **Timeout → Escalate** — if no approval within the timeout (PT10S), send a broadcast notification via the RHDH notifications API and re-poll -4. **Approval → Execute** — on approval, proceed to the action (e.g., create K8s namespace) - -Key patterns: - -- Uses **event-driven callbacks** (`type: callback`) rather than polling — cleaner for real-time approvals -- Integrates with **RHDH Notifications API** (`notifications#createNotification`) for user-facing alerts -- Has a **timeout/escalation** loop that prevents approvals from being forgotten -- SonataFlow `specVersion: 0.8` compatible - -#### Create OCP Project (`create-ocp-project.sw.yaml`) — Jira-Based Alternative - -This workflow uses a **Jira polling** pattern for approvals: - -1. Create audit Jira ticket + operations approval ticket -2. **Notify Backstage** via notifications API with link to Jira ticket -3. **Poll Jira** every 60s (`sleep.before: PT60S`) checking `status.statusCategory.key == "done"` -4. Branch on resolution: `Done` → create resource, anything else → denied -5. Close audit ticket with result - -Key patterns: - -- Uses **Jira as the approval system** — good for teams already using Jira for governance -- Polling-based rather than event-driven — simpler but higher latency -- Includes **audit trail** via Jira tickets with workflow instance ID labels -- Uses **RHDH Notifications API** for progress updates - -### Recommended Approach for Agent Lifecycle Approvals - -For the `review → staging` transition (admin approval), the **escalation pattern** is recommended: - -```yaml -# Conceptual agent-approval.sw.yaml -id: agent-lifecycle-approval -specVersion: '0.8' -start: NotifyReviewers -states: - - name: NotifyReviewers - type: operation - actions: - - functionRef: - refName: createNotification - arguments: - recipients: - type: 'entity' - entityRef: .adminGroup - payload: - title: '"Agent review requested: " + .agentId' - description: .agentDescription - topic: 'Agent Lifecycle' - link: '"/augment/admin/agents/" + .agentId' - severity: 'normal' - transition: WaitForApproval - - name: WaitForApproval - type: callback - eventRef: agentApprovalEvent - timeouts: - eventTimeout: PT24H - onErrors: - - errorRef: timeoutError - transition: Escalate - transition: ApproveAgent - - name: Escalate - type: operation - actions: - - functionRef: - refName: createNotification - arguments: - recipients: - type: 'broadcast' - payload: - title: '"ESCALATION: Agent approval pending for " + .agentId' - severity: 'high' - transition: WaitForApproval - - name: ApproveAgent - type: operation - actions: - - functionRef: - refName: promoteAgent - arguments: - agentId: .agentId - targetStage: 'staging' - end: true -``` - -### Integration Points - -To wire this into the augment plugin: - -1. **Trigger**: When `promote(agentId, 'review')` is called, start the SonataFlow workflow via the orchestrator API -2. **Callback**: Admin approves via the RHDH notification action → fires the `agentApprovalEvent` -3. **Action**: The workflow calls back to `PUT /agents/:id/promote` to advance to staging -4. **Fallback**: If the orchestrator is not available, fall back to direct promotion (current behavior) - -### Prerequisites - -- SonataFlow workflow must be deployed to the `sonataflow-infra` namespace -- A custom function definition (`promoteAgent`) must call the augment backend API -- The RHDH Notifications API must be accessible from SonataFlow -- An event listener must translate RHDH notification actions into SonataFlow events - ---- - -## 8. PR Breakdown - -| PR | Title | Priority | Dependencies | -| ----- | ---------------------------------------------- | -------- | ------------ | -| PR 2 | Add DELETE route + UI for draft agents | P0 | None | -| PR 3 | Fix non-clickable agents in registry | P0 | None | -| PR 4 | Auto-create chatAgents entry on Kagenti deploy | P0 | None | -| PR 5 | Add createdBy field and scope My Agents | P1 | PR 4 | -| PR 6 | Remove lifecycle-bypassing shortcuts | P1 | None | -| PR 7 | Wire syncWorkflowToChatAgents on publish | P1 | PR 4 | -| PR 8 | Update docs to 5-stage lifecycle | P2 | None | -| PR 9 | Research RHDH orchestrator for approvals | P2 | None | -| PR 10 | Remove orphan AgentCreationWizard | P2 | None | diff --git a/workspaces/augment/docs/augment-demo-walkthrough.md b/workspaces/augment/docs/augment-demo-walkthrough.md deleted file mode 100644 index f1e7c149d67..00000000000 --- a/workspaces/augment/docs/augment-demo-walkthrough.md +++ /dev/null @@ -1,164 +0,0 @@ -# Augment Demo Walkthrough - -**Purpose:** Walk a Red Hat Developer Hub PM through the key outcomes in a live or recorded demo. -**Duration:** 15-20 minutes -**Prerequisite:** Augment deployed on RHDH with at least one MCP server (e.g., kubernetes-mcp-server) and documents ingested. - ---- - -## Scene 1: First Impressions (2 min) - -**What you show:** The welcome screen - -**Talking points:** - -- Open RHDH and navigate to Augment. The branded welcome screen appears with the customer's logo, name, and colors — all configured from the admin UI, no code. -- Point out the prompt groups: organized cards (e.g., "Migration," "Troubleshooting," "Knowledge") that guide developers toward common tasks. -- Point out the agent gallery: developers can browse available specialist agents, pin favorites, and read descriptions before starting a conversation. -- Call out: "Everything you see here — the name, the colors, the prompt cards, which agents appear — is configurable by the platform team at runtime." - -**Key outcome demonstrated:** White-labeled, governed developer experience - ---- - -## Scene 2: Knowledge Q&A with RAG (3 min) - -**What you show:** Ask a documentation question - -**Script:** - -1. Type: "How should I configure liveness probes for a Spring Boot app on OpenShift?" -2. As the response streams, point out the "Searching knowledge base..." indicator in the streaming UI — the model is retrieving from ingested documentation, not making things up. -3. When the response appears, expand the RAG sources section. Show the chunk text, filename, and relevance score. -4. Say: "This answer is grounded in the customer's own documentation. The platform team ingested these docs from a GitHub repo, and they sync automatically every hour. If someone updates a runbook tomorrow, the knowledge base updates without anyone doing anything." - -**Key outcome demonstrated:** Grounded answers from customer documentation, not hallucination - ---- - -## Scene 3: Multi-Agent Handoff (3 min) - -**What you show:** Ask a question that triggers agent routing - -**Script:** - -1. Type: "My deployment in namespace staging-orders is failing with ImagePullBackOff errors." -2. Point out: the router agent receives this, recognizes it as a cluster operations question, and hands off to the ops specialist. A visual divider appears: "Handed off from Router to Cluster Operations." -3. As the ops agent streams, point out the tool calls appearing in real time: "Calling get_pods on OpenShift Cluster...", "Calling get_events..." -4. Expand a tool call output to show the raw pod/event data. -5. When the answer arrives, say: "The developer asked one question. Behind the scenes, a routing agent identified the right specialist, that specialist called three different tools against the live cluster, and synthesized a diagnosis — all in a few seconds." - -**Key outcome demonstrated:** Specialized agents collaborate on complex questions; developer effort is zero - ---- - -## Scene 4: Human-in-the-Loop Approval (3 min) - -**What you show:** A tool call that requires approval - -**Script:** - -1. Following from Scene 3, type: "Fix the image pull secret for me." -2. The agent proposes a `kubectl` command (or MCP tool call) to create/patch the image pull secret. -3. Because the OpenShift MCP server has `requireApproval: always`, the approval dialog appears. -4. Walk through the dialog: - - Show the tool name and server label - - Show the arguments (editable JSON) - - Point out: "The developer can modify the arguments before approving — or reject with a reason." -5. Approve the action. The stream resumes with the tool output. -6. Say: "The platform team decides which tools require approval. This is not an all-or-nothing switch — it's per-server and per-tool. Destructive operations require sign-off. Read-only operations execute automatically." - -**Key outcome demonstrated:** Sensitive operations stay under human control - ---- - -## Scene 5: Persistent Conversations (1 min) - -**What you show:** Conversation history - -**Script:** - -1. Open the right sidebar showing conversation history. -2. Point out: grouped sessions, search bar, session timestamps. -3. Click a previous session to show it loads with full message history. -4. Say: "Conversations persist across sessions. The developer can pick up where they left off tomorrow, or next week." - -**Key outcome demonstrated:** Continuity across sessions - ---- - -## Scene 6: Admin Panel — Platform Configuration (3 min) - -**What you show:** The admin panel (switch to admin view) - -**Script:** - -1. Click the admin button. Show the Platform tab. -2. **Model connection:** Show the configured model (Llama 3.3 70B), base URL, and the "Test Connection" button. Say: "This is pointing at the customer's own Llama Stack server running on their GPUs. No data leaves the environment." -3. **MCP servers:** Show configured servers with their URLs, auth status, and approval settings. Say: "Each MCP server is a tool endpoint. Adding a new one is configuration, not code." -4. **RAG / Knowledge Base:** Show the vector store, document count, last sync time. Click into the RAG Testing tab and run a quick test query — show chunks and scores. -5. **Safety:** Show safety shields (if configured). Say: "Input shields filter prompt injection. Output shields filter harmful content. The platform team controls this, not the developer." - -**Key outcome demonstrated:** Platform teams govern the AI experience centrally - ---- - -## Scene 7: Admin Panel — Agent Management (2 min) - -**What you show:** The Agents tab (non-Kagenti) or Kagenti agents panel - -**Script:** - -1. Show the list of configured agents with their names, descriptions, and tool access. -2. Open one agent's config: instructions, MCP server assignments, vector store linkage, handoff targets. -3. Say: "Adding a new use case is adding a new agent. The platform team writes instructions, assigns tools and knowledge, and the new specialist is immediately available to developers." -4. If showing Kagenti mode, also show: agent gallery configuration (display names, avatars, featured agents, greeting messages). - -**Key outcome demonstrated:** New use cases via configuration, not code - ---- - -## Scene 8: Branding (1 min) - -**What you show:** The Branding tab - -**Script:** - -1. Show the Appearance section: app name, tagline, logo URL, color pickers, theme presets. -2. Change the primary color or app name. Show the live preview updating. -3. Say: "The portal team can customize everything the developer sees — no restart required." - -**Key outcome demonstrated:** Enterprise white-labeling - ---- - -## Closing Talking Points (2 min) - -Wrap up with the five things to remember: - -1. **Agents that act, not just answer.** They execute real operations against live systems, with human approval for anything sensitive. - -2. **Grounded in customer data.** RAG over the customer's own documentation, synced automatically. Not generic AI — answers from their docs. - -3. **Governed by the platform team.** Agents, tools, knowledge, safety, branding — all config-driven, centrally managed. - -4. **Runs on customer infrastructure.** Open-source models, their GPUs, their data stays theirs. No external API calls. - -5. **No code changes for new use cases.** New specialist agent = new YAML configuration. New knowledge base = new document source. New tool = new MCP server URL. - ---- - -## If the PM Asks About Kagenti Specifically - -Add a bonus scene: - -**Script:** - -1. Show the Kagenti sidebar: namespace picker, agents, tools, builds, sandbox, observability links. -2. Say: "Kagenti adds the infrastructure layer. When agents need to call tools, traffic goes through a centralized Envoy-based MCP gateway. The platform team manages tool access, observability, and security in one place." -3. Show the dashboard links: traces (Phoenix/Jaeger), network (Kiali), MCP inspector. -4. Say: "Every question, every agent decision, every tool call — traceable end to end." -5. Show agent lifecycle: create agent, trigger build (Shipwright), monitor build pipeline. -6. Say: "Different teams can build agents in different frameworks — LangGraph, CrewAI, anything that speaks A2A — and they all appear in the same developer experience." - -**Key outcome demonstrated:** Infrastructure-grade governance for AI agents at scale diff --git a/workspaces/augment/docs/augment-outcomes-and-use-cases.md b/workspaces/augment/docs/augment-outcomes-and-use-cases.md deleted file mode 100644 index 1f016fa148f..00000000000 --- a/workspaces/augment/docs/augment-outcomes-and-use-cases.md +++ /dev/null @@ -1,227 +0,0 @@ -# Augment: Outcomes and Use Cases That Customers Value - -**Audience:** Red Hat Developer Hub product leadership, field teams, customer-facing stakeholders -**Status:** Based on production-deployed codebase (April 2026) - -> For platform capabilities and technical architecture, see [Augment Platform](augment-platform.md). - ---- - -## What Augment Is - -Augment is an agentic AI platform embedded in Red Hat Developer Hub. It lets organizations run **multiple specialist AI agents** that collaborate to help developers — reasoning over questions, executing operations against live infrastructure, and synthesizing answers grounded in real data. - -The platform is **agent framework-agnostic**: - -- **Define agents in configuration** — create specialist agents with their own instructions, tools, model, and knowledge base via YAML or the admin UI. An in-process orchestration layer (ADK) runs multi-agent workflows on top of Llama Stack's inference APIs. -- **Bring your own agents** — deploy agents built in any framework (LangGraph, CrewAI, custom Python, or any A2A-compatible service) as Kubernetes workloads. Kagenti manages them, and Augment lists them from Kagenti's API in an agent gallery for developers. - -Augment connects to **Llama Stack** (inference, RAG, safety APIs) and **Kagenti** (remote agent management, discovery, centralized tool gateway) via their APIs. Everything runs on the customer's own infrastructure. No data leaves the customer's environment. - ---- - -## Outcomes - -### 1. Specialist agents collaborate on complex tasks - -This is not a single chatbot. Augment supports multiple specialist agents, each with its own purpose, tools, and knowledge. In the Llama Stack path, a router agent can be configured as the default entry point — it receives the first message and delegates to the right specialist. In the Kagenti path, developers pick a specialist directly from the agent gallery. Either way, handoffs happen mid-conversation — the developer sees which agent is working and why. - -What this looks like: - -- A developer asks about containerizing an app. The router recognizes it as a migration topic and hands off to the migration specialist — the developer sees a real-time divider showing the handoff. Subsequent messages in the conversation continue with the active specialist, not back through the router. -- The migration specialist has access to migration documentation and the OpenShift cluster. It retrieves relevant guide sections, generates manifests, and can apply them to the cluster with the developer's approval. -- If the developer then asks about monitoring, the active agent can hand off to an ops specialist with different tools and a different knowledge base — all in the same conversation. - -Adding a new specialist is a configuration change. No code, no redeployment — just define the agent's name, instructions, tool access, and knowledge base in YAML or the admin UI. - -**Platform capabilities at work:** Multi-Agent Orchestration, MCP Tool Connectivity, Runtime Configuration. - -### 2. Teams bring their own agents in any framework - -The platform does not lock teams into a single agent framework. Via Kagenti, teams build agents in LangGraph, CrewAI, custom Python, or any framework that supports the A2A protocol — and deploy them as standard Kubernetes workloads. The Augment plugin lists agents from Kagenti's API and surfaces them in an agent gallery for developers. - -What the platform team gets: - -- **Agent gallery.** When Kagenti is the active provider, developers browse, search, and pin favorites across all Kagenti-managed agents. Admins control visibility, display names, avatars, and featured agents via the Chat Experience panel. -- **Centralized governance.** When configured with Kagenti's MCP gateway, all tool traffic flows through a single control point — one place to manage tool access, observe usage (distributed tracing), enforce policy, and issue workload identity (X.509 certificates via SPIFFE/SPIRE per agent pod). -- **Full lifecycle management.** The Augment admin panel supports creating/deleting agents, triggering container builds (Shipwright), monitoring build pipelines, and managing sandbox sessions. - -**Platform capabilities at work:** Provider Abstraction (Kagenti), Multi-Agent Orchestration, Safety and Governance. - -### 3. Agents act on live infrastructure - -Agents don't just answer questions — they execute. Connected via MCP (Model Context Protocol), agents can inspect clusters, apply manifests, query databases, call external APIs, and run operations against any system that speaks the protocol. In backend execution mode, the platform queries each MCP server's tool list automatically; in direct mode, Llama Stack handles tool discovery and execution. - -The answer is not "you might try checking your logs" — it is "your pods in namespace prod-payments are OOMKilled because memory limit is 256Mi but the app uses 380Mi at startup," because the agent just called `get_pods`, `get_events`, and `get_pod_logs` against the live cluster. - -**Platform capabilities at work:** MCP Tool Connectivity, Multi-Agent Orchestration, Streaming Chat UX. - -### 4. Sensitive operations stay under human control - -When an agent proposes a destructive or sensitive action — applying a Kubernetes manifest, deleting a resource, modifying a configuration — the developer is presented with an approval dialog showing exactly what the agent wants to do. - -What the developer gets: - -- **Full transparency.** The approval dialog shows the tool name, the target system, and the exact arguments the agent wants to send. -- **Edit before approve.** The developer can modify the arguments before approving. Change a namespace, adjust a resource limit, fix a typo — then approve. -- **Reject the action.** The developer can reject the tool call. The agent receives the rejection and adjusts its approach — proposing an alternative or asking for clarification. -- **No surprise actions.** The platform team controls which tools require approval and at what granularity — per server, per tool, or blanket approval for everything. - -Beyond tool approval, the platform supports structured human input: form cards for typed data (namespace, parameters), auth cards when external sign-in is needed, and secrets cards for credentials. - -**Platform capabilities at work:** Human-in-the-Loop, MCP Tool Connectivity, Safety and Governance. - -### 5. Agents are grounded in customer knowledge - -When an agent needs documentation context, it uses agentic RAG — the agent decides when retrieval is relevant, searches the customer's own knowledge base, and synthesizes an answer with sources shown alongside. This is one tool agents use, not the whole experience. - -Platform admins manage knowledge without touching the deployment: - -- **Ingest from anywhere.** Upload files via drag-and-drop, connect GitHub repos (private or public, with path filters and glob patterns), or fetch from URLs. Documents auto-sync on a configurable schedule — new files added, changed files updated, deleted files removed. -- **Per-agent knowledge.** Different agents can search different knowledge bases. The migration agent searches migration docs; the ops agent searches runbooks. -- **Test before shipping.** A built-in RAG playground lets admins test search queries, see retrieved chunks with relevance scores, and adjust thresholds before developers see the results. Search mode (semantic, keyword, or hybrid) is configurable per vector store. - -**Platform capabilities at work:** RAG / Knowledge Management, Multi-Agent Orchestration, Runtime Configuration. - -### 6. Enterprise security is built in, not bolted on - -The platform team controls who can access what, what agents can do, and what data is retained: - -- **Access control.** Chat access and admin access are gated by Backstage RBAC, mapped to Keycloak groups. Only authorized users interact with agents; only admins configure them. -- **Content safety.** Input and output filtering via safety shields — prompt injection detection, harmful content filtering, destructive command detection. Configurable per shield, with fail-open or fail-closed behavior. -- **Network protection.** All backend HTTP calls are protected against server-side request forgery (SSRF), including DNS resolution checks to prevent rebinding attacks. -- **Zero Data Retention.** For organizations with strict data retention policies, ZDR mode ensures responses are not stored on the inference server. Conversation continuity is maintained via encrypted reasoning tokens returned to the client, rather than full server-side transcripts. -- **Tool authentication.** Tool connections support multiple authentication methods — OAuth client credentials, Kubernetes service account tokens, static headers, or infrastructure-level mTLS in Kagenti mode. The platform team controls which method each tool uses. - -**Platform capabilities at work:** Safety and Governance, MCP Tool Connectivity (auth chain), Runtime Configuration. - -### 7. The entire experience is white-labeled - -The portal team customizes everything the developer sees — application name, logo, colors, welcome screen, prompt suggestions, agent gallery — all from the admin UI at runtime. The developer sees a branded experience that matches their organization, not a generic chatbot. - -**Platform capabilities at work:** Branding and White-Labeling, Runtime Configuration. - ---- - -## Use Cases - -### Application Migration to OpenShift - -**Persona:** Developer migrating a legacy application to OpenShift - -**The problem:** Migration is high-friction, documentation-heavy, and error-prone. Developers spend days reading guides, writing Dockerfiles, crafting manifests, and debugging deployment issues. - -**What happens with Augment:** - -1. Developer opens Augment in RHDH. The welcome screen shows prompt cards for common migration tasks. - -2. Developer types: "I need to containerize my Spring Boot app and deploy it to OpenShift." - -3. The router agent recognizes this as a migration topic and hands off to the migration specialist. The developer sees a divider: "Handed off from Router to Migration Assistant." - -4. The migration agent searches the knowledge base (ingested with Red Hat migration guides) and combines what it finds with its instructions to provide Dockerfile best practices for OpenShift (non-root, UBI base images), deployment manifest generation, Route/Service configuration, and health check setup. - -5. When the developer says "create the deployment on my cluster," the agent proposes the exact manifest and waits for approval. The developer reviews, edits if needed, and approves. - -6. The conversation persists — the developer can return tomorrow and ask follow-up questions. - -**Why this is valuable:** A specialist agent that combines documentation knowledge with live cluster access reduces time-to-first-deployment from days to hours. - -**Platform capabilities at work:** Multi-Agent Orchestration, RAG, MCP Tool Connectivity, Human-in-the-Loop, Persistent Conversations. - -### OpenShift Operations and Troubleshooting - -**Persona:** Developer or SRE debugging a failing deployment - -**The problem:** Diagnosing cluster issues requires knowing the right `kubectl` commands, understanding event schemas, parsing log formats, and cross-referencing documentation. Junior developers are blocked; senior engineers spend time on repetitive investigation. - -**What happens with Augment:** - -1. Developer asks: "My pods in namespace prod-payments keep crashing. What's wrong?" - -2. The router hands off to the cluster operations specialist. - -3. The ops agent inspects the cluster — listing pods and their status, fetching recent events, reading container logs, checking resource limits vs. actual usage. Each step appears in real time in the streaming UI. - -4. The agent synthesizes: "Your pods are OOMKilled. The memory limit is 256Mi but the app is using 380Mi at startup. You can fix this by increasing the memory limit." The answer includes links to the relevant troubleshooting runbook from the knowledge base. - -5. If the developer says "increase the memory limit to 512Mi," the agent proposes the change and waits for approval. - -**Why this is valuable:** The developer never leaves RHDH. The ops agent does the investigation and proposes the fix — with approval before any change is applied. - -**Platform capabilities at work:** Multi-Agent Orchestration, MCP Tool Connectivity, RAG, Human-in-the-Loop, Streaming Chat UX. - -### Multi-Framework Agent Collaboration - -**Persona:** Platform team operating a multi-team, multi-framework environment - -**The problem:** Different teams want to build agents in different frameworks. Without centralized governance, tool access is fragmented, security is inconsistent, and observability is nonexistent. - -**What happens with Augment:** - -1. The platform team deploys Kagenti alongside RHDH. The Augment plugin connects to Kagenti as its provider. - -2. Different teams build specialized agents in their preferred frameworks — LangGraph for a security scanner, CrewAI for a cost optimizer, a custom Python service for code review — and deploy them as standard Kubernetes workloads. - -3. The Augment agent gallery lists all agents available through Kagenti. The Augment plugin queries Kagenti's API to list agents and caches their agent cards for the gallery UI. Developers see all available agents in one place. - -4. When configured with a centralized MCP gateway, all tool traffic flows through a single control point. The platform team has one place to manage tool access, observe tool usage (distributed tracing), enforce policy, and issue infrastructure-level workload identity (X.509 certificates via SPIFFE/SPIRE per agent pod). - -5. The Augment admin panel provides full lifecycle management: create/delete agents, trigger container builds, monitor build pipelines, manage sandbox sessions. - -**Why this is valuable:** Different teams build agents independently in any framework. The platform team gets centralized governance, observability, and security. The developer gets a unified experience. - -**Platform capabilities at work:** Provider Abstraction (Kagenti), Multi-Agent Orchestration, Safety and Governance. - -### Internal Knowledge Q&A - -**Persona:** Any developer on the platform - -**The problem:** Tribal knowledge and scattered documentation are the top productivity killers in large engineering organizations. Developers don't know where to look, and the answer is often buried in a Google Doc, a Confluence page, or someone's head. - -**What happens with Augment:** - -1. The platform team connects internal documentation — engineering docs, runbooks, onboarding guides — to Augment. Documents auto-sync hourly from GitHub repos. - -2. Developer asks: "How do we handle database migrations in the payments service?" - -3. The agent searches the knowledge base. The developer sees "Searching knowledge base..." in real time. The agent synthesizes an answer with citations to the source documents. The developer can expand RAG sources to see the raw chunks, filenames, and relevance scores. - -4. Developer drills down: "Show me the rollback procedure." The agent retrieves the specific runbook section. - -**Why this is valuable:** Agents grounded in the customer's documentation surface the right answer from the right document. Automatic sync keeps the knowledge base current as docs are updated. - -**Platform capabilities at work:** RAG / Knowledge Management, Multi-Agent Orchestration, Streaming Chat UX, Persistent Conversations. - ---- - -## What Makes This Different - -1. **Agents, not a chatbot.** Multiple specialist agents collaborate on complex tasks. They hand off mid-conversation without losing context. Each agent has its own tools, knowledge, and instructions. - -2. **Any framework.** Teams build agents in LangGraph, CrewAI, custom Python, or any A2A-compatible framework and deploy them as Kubernetes workloads. Kagenti manages them; Augment surfaces them in a gallery for developers. - -3. **Agents that act.** Agents call tools against live systems — inspecting clusters, applying manifests, querying databases. Every action is visible to the developer and requires approval for sensitive operations. - -4. **Human in the loop.** Sensitive operations require explicit developer approval. The developer sees exactly what the agent wants to do and can edit the parameters before approving. - -5. **Grounded in customer data.** Agents use agentic RAG — searching customer documentation when needed, with sources shown alongside the answer. Automatic sync keeps knowledge current. - -6. **Governed.** Access control, content safety, SSRF protection, per-tool approval policies, Zero Data Retention mode. The platform team controls everything. - -7. **On your infrastructure.** Your models, your GPUs, your data stays yours. No external API calls for inference. Llama Stack serves any compatible model. - ---- - -## Questions - -| Question | Answer | -| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| How do we add a new use case? | Configure a new agent with instructions, tool access, and a knowledge base. No code changes, no redeployment. | -| Can teams bring their own agents? | Yes — deploy agents built in any framework as Kubernetes workloads via Kagenti. They appear in the agent gallery automatically. | -| What models does it support? | Any model served by Llama Stack's Responses API — Llama 3.3 is the default, but any compatible model works. Model is a configuration parameter, changeable per agent. | -| How does it handle multi-tenancy? | Namespace-based scoping in Kagenti mode with namespace allowlists. Backstage identity scoping in all modes. | -| What about audit and compliance? | Safety shields log violations. Approval decisions are stored in session history. ZDR mode for data retention compliance. | -| What about cost/token control? | Token usage shown in the UI. Configurable caps on output tokens, tool calls, and agent turns prevent runaway loops. | -| What is the deployment footprint? | RHDH plugin + Llama Stack server + PostgreSQL. Optional Kagenti platform for multi-framework agent governance. | -| What if the AI server goes down? | The UI shows a provider-offline banner. Config and sessions are unaffected. Chat resumes when the server recovers. Provider can be hot-swapped to a backup without restart. | diff --git a/workspaces/augment/docs/augment-platform.md b/workspaces/augment/docs/augment-platform.md deleted file mode 100644 index ed8dcae76eb..00000000000 --- a/workspaces/augment/docs/augment-platform.md +++ /dev/null @@ -1,427 +0,0 @@ -# Augment Platform - -**Audience:** Principal engineers, architecture reviewers, technical leadership -**Status:** Based on production-deployed codebase (April 2026) - -> For customer outcomes and use case scenarios, see [Augment Outcomes and Use Cases](augment-outcomes-and-use-cases.md). - ---- - -## What Augment Is - -Augment is an agentic AI platform embedded in Red Hat Developer Hub. It connects to **Llama Stack** and **Kagenti** via their APIs to provide a multi-agent conversational experience — each agent with its own instructions, tools, model, and knowledge base, collaborating to reason over questions, execute operations against live systems, and synthesize answers grounded in customer documentation. - -- **Llama Stack** (`augment.llamaStack.baseUrl`) provides inference (`POST /v1/responses`), RAG (`/v1/vector_stores`, `/v1/files`), safety (`/v1/safety/run-shield`), and model discovery (`GET /v1/models`) APIs. Augment orchestrates multi-agent workflows on top of these APIs using an in-process orchestration layer (`@augment-adk/augment-adk`). -- **Kagenti** (`augment.kagenti.baseUrl`) provides remote agent management, discovery, streaming chat (A2A protocol over SSE), and a centralized MCP tool gateway. Augment connects to Kagenti's API via `KagentiApiClient` with Keycloak OAuth2 authentication. - -Customers can define agents in configuration (YAML or admin UI) for the Llama Stack path, or bring their own agents built in any framework (LangGraph, CrewAI, custom) via Kagenti. All inference runs on the customer's own infrastructure using any model served by Llama Stack (Llama 3.3 is the default). - -The platform is composed of three packages: a frontend plugin (React, streaming chat UI, admin panels), a backend plugin (Express, orchestration, provider abstraction), and a shared common package (types, permissions). It ships as a Backstage dynamic plugin — no fork required. - ---- - -## Platform Capabilities - -### 1. Multi-Agent Orchestration - -The platform supports multiple specialist agents orchestrated by a router. Each agent has its own instructions, tool access, knowledge base, and model configuration. The router receives every first message and delegates to the right specialist via declared handoff targets. - -How agents are defined (from the config schema): - -```yaml -augment: - agents: - router: - name: Router - instructions: 'You triage questions. Hand off to migration-assistant for migration topics, to ops-agent for cluster issues.' - handoffs: [migration-assistant, ops-agent] - migration-assistant: - name: Migration Assistant - instructions: 'You help developers migrate applications to OpenShift...' - mcpServers: [openshift] - enableRAG: true - vectorStoreIds: [vs_migration_docs] - handoffDescription: 'Specialist for application migration to OpenShift' - ops-agent: - name: Cluster Operations - instructions: 'You diagnose and fix OpenShift cluster issues...' - mcpServers: [openshift] - enableRAG: true - vectorStoreIds: [vs_runbooks] - handoffDescription: 'Specialist for OpenShift troubleshooting' - defaultAgent: router - maxAgentTurns: 10 -``` - -Properties: - -- **Router pattern.** The default agent receives every first message. Its instructions define how to triage. Handoff targets are declared — the orchestrator generates `transfer_to_{agent}` functions automatically. -- **Per-agent tool scoping.** Each agent sees only the MCP servers listed in its `mcpServers` field. -- **Per-agent knowledge bases.** Each agent searches its own vector stores via `vectorStoreIds`. -- **Config-driven.** Adding a new specialist is a YAML change or admin UI edit. No code, no redeployment. -- **Handoff visualization.** The UI shows a divider with agent names during handoffs. The developer sees the routing decision in real time. - -Complete `AgentConfig` interface: - -```typescript -interface AgentConfig { - name: string; - instructions: string; - handoffDescription?: string; - model?: string; - mcpServers?: string[]; // subset of global MCP server IDs - handoffs?: string[]; // generates transfer_to_{key} functions - asTools?: string[]; // generates call_{key} functions - enableRAG?: boolean; - vectorStoreIds?: string[]; - enableWebSearch?: boolean; - enableCodeInterpreter?: boolean; - functions?: FunctionDefinition[]; - toolChoice?: ToolChoiceConfig; - reasoning?: ReasoningConfig; - handoffInputSchema?: Record; // structured handoff metadata - handoffInputFilter?: 'none' | 'removeToolCalls' | 'summaryOnly'; - toolUseBehavior?: 'run_llm_again' | 'stop_on_first_tool'; - outputSchema?: OutputSchema; // validated structured output - enabled?: boolean; // runtime handoff gating - toolGuardrails?: ToolGuardrailRule[]; - guardrails?: string[]; // server-side shield IDs -} -``` - -### 2. RAG / Knowledge Management - -The platform provides a full pipeline for ingesting, indexing, and searching customer documentation. - -**Ingestion sources:** - -- **File upload** — drag-and-drop in the admin UI (`.md`, `.txt`, `.pdf`, `.json`, `.yaml`) -- **GitHub repos** — specify `owner/repo`, branch, path filter, glob patterns; supports private repos with tokens -- **URLs** — fetch from any accessible URL (with SSRF protection) -- **Local directories** — for development/testing - -**Indexing:** Vector stores with configurable embedding models (default: `sentence-transformers/all-MiniLM-L6-v2`), chunking strategies (auto or static with configurable chunk size/overlap), and hybrid search settings (semantic + BM25 keyword, with configurable weights). - -**Sync:** `syncSchedule: '1h'` triggers periodic re-sync. `syncMode: 'full'` adds new files, updates changed files (content-hash based), and removes deleted files. `syncMode: 'append'` only adds new files. - -**Admin playground:** Built-in RAG testing lets admins run search queries (see retrieved chunks with relevance scores), run generate queries (RAG context + LLM), compare search modes, and adjust thresholds — all before deploying to users. - -**Technical detail:** `DocumentIngestionService.fetchFromSources()` → content-hash categorization against `augment_content_hashes` DB table → `POST /v1/files` (multipart) → `POST /v1/vector_stores/{id}/files` with chunking config. At inference time, `ToolsBuilder.buildTools()` adds `{ type: 'file_search', vector_store_ids, max_num_results, ranking_options }`. Llama Stack executes `file_search` server-side; results appear in `stream.rag.results` events. - -### 3. MCP Tool Connectivity - -Any tool that speaks the Model Context Protocol can be connected. Tool discovery is automatic — the platform queries each server's tool list and makes them available to agents. - -```yaml -mcpServers: - - id: openshift - name: OpenShift Cluster - type: streamable-http - url: https://kubernetes-mcp.example.com/mcp - requireApproval: always - serviceAccount: - name: mcp-client-sa - namespace: backstage -``` - -**Two execution modes** (selected by `augment.toolExecutionMode`): - -- **Direct mode (default):** Tools are passed to Llama Stack as `type: 'mcp'` entries. Llama Stack opens MCP connections and calls tools directly. The Backstage backend never sees tool traffic. -- **Backend mode:** `BackendToolExecutor` opens MCP SDK `Client` connections via `StreamableHTTPClientTransport`. Tools are discovered via `client.listTools()`, converted to `type: 'function'` tools with `serverId__toolName` naming, and sent to Llama Stack. When the model returns `function_call` items, `BackendToolExecutor.executeTool()` routes the call to the correct MCP client. Required for air-gapped deployments where Llama Stack cannot reach MCP servers. - -**Authentication chain** — `McpAuthService.getServerHeaders(server)` resolves auth per server using a 4-level priority: - -1. **`authRef`** — Named config from `augment.mcpAuth[]` (OAuth client_credentials or ServiceAccount) -2. **Inline `oauth`** — Per-server OAuth client_credentials to a `tokenUrl` -3. **Inline `serviceAccount`** — Kubernetes TokenRequest API for ServiceAccount tokens -4. **Global `security.mcpOAuth`** — Fallback in `full` security mode - -Token caching with TTL, in-flight deduplication (concurrent callers share one token fetch), TLS control via `skipTlsVerify`. - -### 4. Human-in-the-Loop - -When an agent proposes a sensitive action, the platform pauses the inference loop and presents the developer with an interactive dialog. - -**Tool approval:** Per-server, per-tool granularity via `requireApproval` field (`always`, `never`, or explicit tool name lists). The approval dialog shows the tool name, arguments as editable JSON, and the server label. The developer can modify arguments before approving, or reject with a reason that feeds back to the model. - -**Additional interactive flows:** - -| Flow | When It Triggers | What the Developer Sees | -| ------------------ | ------------------------------------------------------------------ | ------------------------------------------------------- | -| **Form input** | Agent needs structured data (namespace, parameters) | A form card with typed fields rendered in the chat | -| **OAuth required** | Agent needs the developer to authenticate with an external service | An auth card with a "Sign in" button and the target URL | -| **Secrets input** | Agent needs credentials the developer must supply | A secrets card with password fields | - -**Technical detail:** The SSE stream enters a `pending_approval` phase. The frontend reducer transitions to a terminal interactive state (blocking stray text deltas). After approval/form-submit/auth-confirm, `POST /chat/approve` sends the response, the backend continues the inference loop, and a new stream begins. - -### 5. Safety and Governance - -**Security modes** — three levels, chosen by the platform team: - -| Mode | Access Control | MCP Tool Auth | Recommendation | -| ------------- | ------------------------------ | ------------------------------------------ | -------------------------- | -| `none` | Open to all | No auth | Development only | -| `plugin-only` | Keycloak OIDC + Backstage RBAC | MCP servers use their own service accounts | **Production recommended** | -| `full` | Keycloak + RBAC | OAuth tokens passed to MCP servers | Experimental | - -**Security enforcement points:** - -| Layer | Mechanism | Implementation | -| --------------------- | ------------------------------ | ---------------------------------------------------------------------- | -| Plugin access | Backstage permission framework | `augmentAccessPermission` checked in `createRouter()` auth middleware | -| Admin access | Backstage permission framework | `augmentAdminPermission` checked in `requireAdminAccess()` middleware | -| MCP tool auth | 4-level priority chain | `McpAuthService.getServerHeaders()` | -| SSRF | URL + DNS validation | `SsrfGuard.isPrivateUrlWithDns()` on ingestion and MCP connections | -| Content safety | Llama Stack Safety API | `SafetyService.checkInput/checkOutput()` with shields | -| Destructive detection | Regex patterns | `safetyPatterns` in admin config, applied before inference | -| Tool approval | HITL dialog | `requireApproval` per MCP server, enforced by `BackendApprovalHandler` | -| Data retention | ZDR mode | `store: false` on all Responses API calls, encrypted reasoning tokens | -| Transport | TLS / optional mTLS | `skipTlsVerify` flag; Kagenti mode adds Istio ambient mesh | - -**SSRF protection detail:** `SsrfGuard.isPrivateUrlWithDns(url)` validates protocol, checks against blocked hostnames, pattern-matches private IP ranges (RFC-1918, loopback, link-local, IPv6 ULA, cloud metadata), and resolves DNS to re-check all resolved addresses (prevents DNS rebinding attacks). - -### 6. Runtime Configuration - -The platform resolves configuration at runtime using a two-layer model: - -``` -EffectiveConfig = YAML baseline + database admin overrides -``` - -- **29 keys** are configurable from the admin UI at runtime (model, system prompt, temperature, agents, MCP servers, vector stores, safety, branding, prompt groups). -- **18 keys** are YAML-only (security mode, admin users, OAuth secrets, document sources, ZDR mode). -- Database overrides win per-key. If the database is unreachable, YAML-only fallback with a warning. -- The merged result is cached for 5 seconds; admin saves invalidate the cache immediately. - -**Technical detail:** `RuntimeConfigResolver.resolve()` checks the cache (5s TTL). If stale, `buildEffectiveConfig()` runs: `loadYamlBaseline()` (synchronous, from in-memory Backstage config) → `applyDbOverrides()` (async, parallel reads of 27 admin keys from PostgreSQL) → `snapshot()` (deep-clone to prevent cache mutation). Concurrent callers share one in-flight build (Promise deduplication). A generation counter prevents stale builds from caching after an invalidation. Three special merge rules: `branding` (shallow merge), `mcpServers` (smart merge preserving YAML-only auth fields), `agents` (full replacement from DB). - -### 7. Provider Abstraction - -The entire AI backend is behind a provider interface. Two providers ship today: - -- **Llama Stack (Responses API):** Full-featured — multi-agent orchestration, RAG, safety shields, evaluation, MCP tools. Runs any model served by Llama Stack (Llama 3.3 is the default) on the customer's own GPU infrastructure. All inference, embedding, and safety run on-premises. -- **Kagenti:** Remote agent platform integration where agents run as Kubernetes workloads with their own lifecycle, accessed via the Kagenti API. Adds infrastructure-grade security (SPIFFE/SPIRE workload identity, Istio ambient mesh with mTLS, centralized MCP gateway via Envoy). - -The provider can be switched at runtime from the admin UI. The extension point system (`augmentProviderExtensionPoint`) allows third-party providers to be registered without forking the plugin. - -**`AgenticProvider` interface:** - -```typescript -interface AgenticProvider { - readonly id: string; - readonly displayName: string; - initialize(): Promise; - postInitialize(): Promise; - getStatus(): Promise; - chat(request: ChatRequest): Promise; - chatStream( - request: ChatRequest, - onEvent: (event: NormalizedStreamEvent) => void, - signal?: AbortSignal, - ): Promise; - listModels(): Promise>; - readonly conversations?: ConversationCapability; - readonly rag?: RAGCapability; - readonly safety?: SafetyCapability; - readonly evaluation?: EvaluationCapability; - invalidateRuntimeConfig?(): void; - shutdown?(): Promise; -} -``` - -**Provider registry:** - -| Provider | `implemented` | Capabilities | -| ------------ | :-----------: | ------------------------------------------------------------- | -| `llamastack` | true | chat, rag, safety, evaluation, conversations, mcpTools, tools | -| `kagenti` | true | chat only (rag/safety/evaluation handled by remote agents) | -| `googleadk` | false | Placeholder for future | - -**Hot-swap:** `ProviderManager.switchProvider(type)` uses a Promise-based mutex and "start new, then stop old" strategy — no gap in service during a swap. - -### 8. Branding and White-Labeling - -The portal team can customize everything the developer sees: - -- **Brand identity.** Application name, tagline, logo URL, custom bot avatar, favicon — all from the admin UI or YAML. -- **Color system.** Primary, secondary, success, warning, error, info colors. Theme presets (`default`, `enterprise`) or fully custom hex values. -- **Welcome experience.** Prompt groups (grouped cards with icons, colors, ordering) on the welcome screen, configurable via drag-and-drop in the admin UI. Featured agents appear prominently. -- **Agent gallery.** When Kagenti is the provider, the welcome screen shows an agent gallery with search, favorites, and detail drawers. - -### 9. Streaming Chat UX - -Every response streams via SSE. The UI shows reasoning steps, tool calls, RAG searches, and agent handoffs in real time. - -**SSE event protocol** — all events are JSON objects with a `type` field in `stream.*` namespace: - -| Event Type | Payload | Frontend Phase | -| ------------------------ | ---------------------------------------------- | ---------------------- | -| `stream.started` | `responseId`, `model`, `createdAt` | `thinking` | -| `stream.text.delta` | `delta` (text chunk) | `generating` | -| `stream.reasoning.delta` | `delta` (reasoning chunk) | `reasoning` | -| `stream.tool.started` | `callId`, `name`, `serverLabel`, `arguments` | `calling_tools` | -| `stream.tool.completed` | `callId`, `name`, `output` | `calling_tools` | -| `stream.tool.approval` | `callId`, `name`, `arguments`, `responseId` | `pending_approval` | -| `stream.agent.handoff` | `fromAgent`, `toAgent`, `reason` | updates `currentAgent` | -| `stream.rag.results` | `sources[]` (filename, text, score) | `searching` | -| `stream.form.request` | `taskId`, `form` (StreamFormDescriptor) | `form_input` | -| `stream.auth.required` | `authType`, `url`, `demands` | `auth_required` | -| `stream.artifact` | `artifactId`, `content`, `append`, `lastChunk` | -- | -| `stream.citation` | `citations[]` | -- | -| `stream.completed` | `usage`, `agentName`, `responseId` | `completed` | -| `stream.error` | `error`, `code` | -- | - -**State machine:** - -``` -connecting → thinking → [reasoning] → [discovering_tools] → [searching] - → [calling_tools] → [executing_backend_tools] → generating → completed - ↓ - pending_approval (modal) - ↓ - form_input (inline card) - ↓ - auth_required (inline card) -``` - -Interactive phases are terminal until the user acts. - -### 10. Persistent Conversations - -Sessions are stored in PostgreSQL and survive pod restarts. Conversation history is searchable with session grouping. A developer can ask a follow-up question in a different session. - ---- - -## Technical Architecture - -### System Topology - -``` -Developer (browser) - | - | HTTPS - v -Augment Frontend Plugin (@red-hat-developer-hub/backstage-plugin-augment) - React SPA inside Backstage, communicates via DiscoveryApi + FetchApi - | - | SSE (POST /chat/stream) + REST (/sessions, /admin/*, /kagenti/*) - v -Augment Backend Plugin (@red-hat-developer-hub/backstage-plugin-augment-backend) - Backstage backend-plugin, Express router, PostgreSQL for sessions + config - | - +--- ResponsesApiProvider (Llama Stack path) - | |--- ResponsesApiCoordinator (composition root) - | | |--- ConfigLoader → RuntimeConfigResolver → EffectiveConfig - | | |--- ClientManager → ResponsesApiClient (HTTP to Llama Stack) - | | |--- AgentGraphManager → resolveAgentGraph() → AgentGraphSnapshot - | | |--- AdkOrchestrator → @augment-adk/augment-adk runStream() - | | |--- ChatDepsBuilder → per-request ChatDeps - | | |--- McpAuthService (4-level auth priority chain) - | | |--- BackendToolExecutor (optional, for backend MCP mode) - | | |--- BackendApprovalStore + BackendApprovalHandler (HITL) - | | |--- VectorStoreFacade → VectorStoreService + DocumentSyncService - | | |--- ConversationFacade → ConversationService - | |--- SafetyService (Llama Stack Safety API) - | |--- EvaluationService (Llama Stack Scoring API) - | - +--- KagentiProvider (Kagenti path) - | |--- KeycloakTokenManager (OAuth2 client_credentials) - | |--- KagentiApiClient (HTTP + SSE to Kagenti API) - | |--- KagentiStreamNormalizer (A2A protocol → NormalizedStreamEvent) - | |--- KagentiSandboxClient, KagentiAdminClient - | - +--- ProviderManager (hot-swap with mutex, "start new then stop old") - | - v -Llama Stack Server (OpenAI-compatible Responses API) - POST /v1/responses, /v1/vector_stores, /v1/files, /v1/models, /v1/safety/* - | - +--- MCP Servers (Streamable HTTP or SSE transport) - kubernetes-mcp-server, docs-mcp, custom MCP endpoints -``` - -### Request Lifecycle: Chat Stream (Llama Stack Path) - -``` -1. POST /chat/stream - ↓ -2. Express route → chatRoutes.ts → provider.chatStream(request, onEvent, signal) - ↓ -3. ResponsesApiProvider.chatStream() - → ResponsesApiCoordinator.chatStream() - ↓ -4. AgentGraphManager.getSnapshot() - Resolves EffectiveConfig (YAML + DB via RuntimeConfigResolver) - → resolveAgentGraph() if agents defined, else single-agent fallback - → Returns AgentGraphSnapshot { agents: Map, defaultAgentKey, maxTurns } - Cache keyed by generation counter; invalidated on admin save - ↓ -5. AdkOrchestrator.chatStream(request, snapshot, onEvent, buildDepsForAgent, signal) - → resolveAgentContinuity(): resume from stored RunState or start at defaultAgent - → buildRunOptions(): model adapter, agent configs, MCP servers, function tools - → @augment-adk/augment-adk runStream(userInput, runOptions) - ↓ -6. ADK multi-turn loop (inside @augment-adk): - For each turn: - a. POST /v1/responses with agent's {instructions, tools, model, store, previous_response_id} - b. Stream SSE response - c. If tool_call → execute tool (MCP via BackendToolExecutor or Llama Stack direct) - d. If handoff → switch active agent, emit agent_start/handoff_occurred events - e. If approval_requested → emit approval event, pause for HITL - f. Loop until final text response or maxTurns - ↓ -7. mapAdkEventToFrontend(event) → JSON strings in stream.* namespace - → ResponsesApiProvider passes through or normalizes via normalizeLlamaStackEvent() - → SSE write to HTTP response - ↓ -8. Frontend: parseSSEStream() → updateStreamingState() → React render -``` - -### Kagenti Provider: Wire Protocol - -When `provider: kagenti`, the backend does not run Llama Stack. All inference is handled by remote Kagenti agents. - -**Auth chain:** `loadKagentiConfig()` → `KeycloakTokenManager` (OAuth2 `client_credentials` POST to `tokenEndpoint`, cached with expiry buffer, deduped in-flight) → `KagentiApiClient` (attaches `Authorization: Bearer` header, retries on 429/502/503/504 with exponential backoff, forwards `X-Backstage-User` from request context). - -**Chat stream path:** - -1. `KagentiProvider.chatStream()` resolves namespace/agent from request -2. Maps Backstage session ID to Kagenti context ID (bounded map with 10% LRU eviction at 10k entries) -3. `KagentiApiClient.chatStream(namespace, name, message, contextId, onLine, signal, metadata)` — SSE connection to Kagenti API -4. Each SSE JSON line → `KagentiStreamNormalizer.normalize()`: - - **A2A protocol** (`statusUpdate` with states: `submitted`, `working`, `input_required`, `auth_required`, `completed`, `failed`, `canceled`) → mapped to `NormalizedStreamEvent` using `@kagenti/adk` handlers - - **Legacy flat format** (backward compat with older Kagenti API) → direct mapping - - **JSON-RPC errors** (codes -32700 through -32007) → `stream.error` -5. Normalized events emitted to frontend via same SSE protocol as Llama Stack path - ---- - -## Feature Maturity - -| Area | Working Features | API-Only (No UI) | Test Coverage | -| -------------------------- | ---------------- | ---------------- | --------------------------- | -| Chat and streaming | 23 | 0 | Stream normalizer: 40 tests | -| Human-in-the-loop | 8 | 0 | Part of route tests | -| Agent management (Kagenti) | 7 | 2 (migration) | HTTP client: 36 tests | -| Tool management (Kagenti) | 10 | 0 | Provider: 18 tests | -| Sandbox (Kagenti) | 7 | 8 | Sandbox client tests | -| Platform admin (Kagenti) | 11 | 1 | Config loader: 7 tests | -| Total | **66 working** | **11 API-only** | Express routes: 33 tests | - -80 features total. 66 have both backend and frontend wired. 11 have backend routes but no frontend UI (sandbox chat, sandbox file browsing, agent migration). E2E tests are stubbed but not yet implemented. - ---- - -## Engineering Q&A - -| Question | Answer | -| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| What is the API surface to Llama Stack? | Single endpoint: `POST /v1/responses` for all inference. Plus `/v1/models` (discovery), `/v1/vector_stores` (RAG), `/v1/files` (ingestion), `/v1/safety/run-shield` (safety). No proprietary SDK — raw HTTP/JSON. | -| How does provider hot-swap work? | `ProviderManager.switchProvider()` acquires a Promise-based mutex, creates the new provider via `createProvider()`, calls `initialize()` + `postInitialize()`, swaps the reference, then shuts down the old provider. Concurrent requests during swap hit the old provider until the swap completes. | -| What prevents config corruption from concurrent admin writes? | `RuntimeConfigResolver` uses a generation counter. `invalidateCache()` bumps the generation; any in-flight `buildEffectiveConfig()` checks its start-generation against current and skips caching if stale. Deep-clone `snapshot()` prevents callers from mutating the cache. | -| What is the ADK and how does it relate to Llama Stack? | `@augment-adk/augment-adk` is an in-process orchestration library that implements the multi-agent loop (router → specialist handoffs, tool calls, streaming). It calls Llama Stack's Responses API as the LLM backend. The ADK handles agent continuity (resume from stored RunState), turn counting, and handoff logic. | -| How does the Kagenti stream protocol differ from the Llama Stack one? | Kagenti uses the A2A (Agent-to-Agent) protocol: JSON-RPC over SSE with `TaskStatusUpdateEvent` and `TaskArtifactUpdateEvent` payloads. `KagentiStreamNormalizer` maps these to the same `NormalizedStreamEvent` union used by the Llama Stack path, so the frontend sees identical event shapes from both providers. | -| How are MCP tools authenticated? | Four-level priority: `authRef` (named config), inline `oauth` (client_credentials), Kubernetes `ServiceAccount` (TokenRequest API), global `security.mcpOAuth`. In Kagenti mode, mTLS via Istio ambient mesh replaces all of these. | -| What models does it support? | Any model served by Llama Stack's `/v1/responses` endpoint — Llama 3.3 is the default, but any compatible model works. Model is a per-agent config parameter (`AgentConfig.model`). `testModel()` verifies connectivity + inference capability. | -| What if the AI server goes down? | `StatusService` detects failure, frontend shows provider-offline banner. Config and sessions in PostgreSQL are unaffected. `ProviderManager` supports hot-swap to a different provider without restart. | -| What is the deployment footprint? | RHDH plugin (dynamic or static install) + Llama Stack server (inference, embedding, safety) + PostgreSQL (sessions, admin config) + optional Kagenti platform (gateway, identity, observability). | diff --git a/workspaces/augment/docs/chat-ui-e2e-analysis.md b/workspaces/augment/docs/chat-ui-e2e-analysis.md deleted file mode 100644 index ae6ec110978..00000000000 --- a/workspaces/augment/docs/chat-ui-e2e-analysis.md +++ /dev/null @@ -1,189 +0,0 @@ -# Chat UI End-to-End Analysis: Confirmed Issues - -**Date:** April 2026 -**Scope:** Complete chat UI flow — from `ChatInput` keystroke through SSE streaming to backend provider orchestration -**Method:** Line-by-line code read of every layer, issue confirmed with exact file/line references - ---- - -## Architecture (quick reference) - -``` -ChatInput → useChatActions → useStreamingChat → AugmentApi (chatEndpoints) - → fetch POST /chat/stream → parseSSEStream - → updateStreamingState (reducer) → RAF batch → StreamingMessage UI - → buildBotResponse → onMessagesChange - -Backend: -chatRoutes POST /chat/stream → parseChatRequest → resolveConversationId - → provider.chatStream() → SSE event forwarding → data: [DONE] - -LlamaStack path: ResponsesApiProvider → ResponsesApiCoordinator - → AdkOrchestrator (runStream) → BackstageModelAdapter → Llama Stack /v1/responses - -Kagenti path: KagentiProvider → KagentiApiClient.chatStream - → KagentiStreamNormalizer → NormalizedStreamEvent -``` - ---- - -## Confirmed Issues (prioritized by customer impact) - -### P0 — Garbled output after network hiccup - -| | | -| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Issue** | SSE retry duplicates partial events — no reducer state reset between attempts | -| **Files** | `plugins/augment/src/api/chatEndpoints.ts:58-106` (retry loop), `plugins/augment/src/hooks/useStreamingChat.ts:227-231` (event callback) | -| **Root cause** | `streamSSE` retries up to 3 times. Each attempt calls `streamSSEAttempt` with the same `onEvent` callback. The callback updates `currentStreamingState` via `updateStreamingState`. On retry, the backend produces a fresh stream from scratch but the frontend's `currentStreamingState` still has text, tool calls, and handoffs from the failed attempt. | -| **Reproduction** | Simulate a network drop mid-stream (e.g., kill the connection after ~5 text.delta events). The retry sends the same request; the backend generates the full response again. The frontend accumulates both halves. | -| **Impact** | Duplicated/garbled text in the chat bubble. Tool calls may appear twice. | -| **Fix** | Reset `currentStreamingState` to `createInitialStreamingState()` at the start of each retry in `streamSSE`. Emit a synthetic `stream.error` with `code: 'reconnecting'` first (already done), then reset state before the new attempt. | - -### P1 — Conversation continuity silently breaks under load - -| | | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Issue** | `AdkOrchestrator.conversationStates` evicts entries at cap=500 with no warning | -| **Files** | `plugins/augment-backend/src/providers/llamastack/adk-adapters/AdkOrchestrator.ts:79-80` (constant), `504-509` (eviction) | -| **Root cause** | In-memory `Map` tracks which agent was active per conversation. When >500 entries exist, the oldest is silently deleted (line 507-508). Evicted conversations lose agent continuity — the next message restarts from `defaultAgent` instead of continuing with the active specialist. | -| **Reproduction** | Run 501+ concurrent conversations. The 1st conversation's `RunState` is evicted. User returns to it and finds the router agent instead of the specialist they were talking to. No error, no log. | -| **Impact** | User says "continue" and gets a different agent. Silent — no error shown. | -| **Fix** | (a) Add a warn log when eviction happens. (b) Persist `RunState` to the database alongside the session, not just in-memory. (c) Consider LRU eviction instead of insertion-order eviction. | - -### P1 — Backend function tools visible to all agents - -| | | -| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Issue** | In backend tool execution mode, all discovered MCP tools are available to every agent regardless of per-agent `mcpServers` configuration | -| **Files** | `plugins/augment-backend/src/providers/llamastack/adk-adapters/AdkOrchestrator.ts:301-305` (documented in comment), `307-336` (tool discovery), `269` (single `functionTools` array passed to all agents) | -| **Root cause** | `discoverBackendTools` returns a flat `FunctionTool[]` that is passed to `RunOptions.functionTools`. The ADK's `buildAgentTools` filters `mcpServers` for MCP-type tools but does not filter `functionTools`. All agents in the graph see all function tools. The code's own comment says: "For backend-proxied function tools, proper per-agent filtering requires an ADK-level change." | -| **Reproduction** | Configure two agents: Agent A with `mcpServers: [server-a]`, Agent B with `mcpServers: [server-b]`. Enable backend tool execution. Agent A can call server-b's tools. | -| **Impact** | Security/scope violation. A docs-only agent could access cluster mutation tools. | -| **Fix** | Requires ADK change in `buildAgentTools` to accept per-agent function tool filters, or partition `functionTools` by `mcpServers` config before passing to `RunOptions`. | - -### P2 — Final content not scrolled into view - -| | | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Issue** | `flushStreamingState` does not invoke `onFlush` callback (scroll-to-bottom) | -| **Files** | `plugins/augment/src/hooks/useStreamingStateBatching.ts:38-51` (`flushStreamingState`), `53-69` (`scheduleStreamingUpdate` — calls `onFlush`), `plugins/augment/src/hooks/useStreamingChat.ts:302,315` (flush calls) | -| **Root cause** | During streaming, `scheduleStreamingUpdate` queues an RAF that calls `onFlushRef.current?.()` (scroll-to-bottom). At stream end, `flushStreamingState()` cancels any pending RAF and applies state synchronously — but never calls `onFlush`. Any text that arrived between the last RAF and the synchronous flush renders below the viewport without auto-scroll. | -| **Reproduction** | Send a long message. At the very end, the last few lines may render below the fold. User must manually scroll down. Most visible with fast responses that complete before the RAF fires. | -| **Impact** | UX annoyance — user misses the end of the answer. | -| **Fix** | Add `onFlushRef.current?.()` at the end of `flushStreamingState` (after `setState`). | - -### P2 — Form/auth errors are silent - -| | | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Issue** | `handleFormSubmit`, `handleAuthConfirm`, `handleSecretsSubmit` catch errors with `debugError` only — no user-visible feedback | -| **Files** | `plugins/augment/src/components/ChatContainer/ChatContainer.tsx:386-387` (form), `436-437` (auth), `472-473` (secrets) | -| **Root cause** | Each handler's `catch` block calls `debugError(...)` (console.error in dev, noop in prod). The `finally` block clears `streamingState` and `isTyping`, so the form/auth card disappears. No error message is shown. | -| **Reproduction** | Backend returns a 500 for `/chat/approve` while a form card is displayed. The form disappears. No feedback. | -| **Impact** | User doesn't know the action failed. They think it succeeded because the form went away. | -| **Fix** | Add error state (like `useToolApproval`'s `approvalError`) and surface it as a snackbar or inline error in the form/auth card. | -| **Bonus** | `handleFormCancel` (line 403-415) is fire-and-forget — it clears UI immediately without waiting for the backend response. If cancel fails server-side, the agent is still waiting for form input while the UI has moved on. | - -### P2 — Kagenti session maps evict under high volume - -| | | -| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Issue** | `kagentiSessionMap` and `sessionAgentMap` bounded at 10k with ~10% eviction | -| **Files** | `plugins/augment-backend/src/providers/kagenti/KagentiProvider.ts:66` (constant), `76-94` (`boundedMapSet`), `89-91` (warn log) | -| **Root cause** | Same pattern as the ADK conversation state issue but with a higher cap. Evicted sessions lose the Backstage-session-to-Kagenti-context mapping. A user returning to an old session starts a fresh Kagenti context instead of resuming. | -| **Reproduction** | Deployment with >10k active sessions. Oldest 1k are evicted. Users returning to those sessions start fresh conversations. | -| **Impact** | Context loss for old sessions. Less severe than #2 because: (a) higher cap, (b) Kagenti context IDs are persisted to DB and hydrated on access (line 647-656), so this mainly affects sessions accessed during the same pod lifecycle that haven't been hydrated. | -| **Fix** | The DB hydration at `hydrateSessionContext` mostly mitigates this. The main gap is that hydration is only triggered from `chatRoutes.ts` when a request comes in — the in-memory map eviction is logged but doesn't trigger re-hydration. Consider removing the in-memory cap entirely and relying on DB lookups, or using an LRU cache. | - -### P3 — No runtime validation of SSE events - -| | | -| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Issue** | `JSON.parse` result typed as `StreamingEvent` without validation | -| **Files** | `plugins/augment/src/api/sseStreaming.ts:65` | -| **Root cause** | After `JSON.parse(data)`, the result is assumed to match the `NormalizedStreamEvent` union. No `type` field check, no required field validation. Unknown types fall to `default: return state` (harmless). Known types with wrong shapes (e.g., `stream.tool.started` without `callId`) create malformed state entries. | -| **Impact** | Hard-to-debug failures during version upgrades or protocol mismatches. Low probability in normal operation. | -| **Fix** | Add a minimal guard: check that `typeof event.type === 'string'` before passing to `onEvent`. Optionally validate known types have required fields. | - -### P3 — `ResponsesApiProvider` type casts bypass safety - -| | | -| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Issue** | Multiple `as unknown as NormalizedStreamEvent` casts in stream normalization | -| **Files** | `plugins/augment-backend/src/providers/llamastack/ResponsesApiProvider.ts:400,417,425` | -| **Root cause** | When bridging raw ADK events to `NormalizedStreamEvent`, the provider uses double casts that bypass TypeScript's structural checks. If the event shape diverges, TypeScript won't catch it. | -| **Impact** | Type-safety gap; bugs surface at runtime only. Contained to the LlamaStack path. | -| **Fix** | Replace `as unknown as NormalizedStreamEvent` with a proper normalization function that validates the shape, or create typed factory functions (e.g., `createStreamEvent('stream.agent.handoff', { fromAgent, toAgent })`). | - -### P3 — Stale closure risk in `sendMessage` - -| | | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Issue** | `messages` array captured at invocation used at completion | -| **Files** | `plugins/augment/src/hooks/useStreamingChat.ts:340` (`onMessagesChange([...messages, newMessage, botResponse])`) | -| **Root cause** | `sendMessage(text, messages)` receives `messages` as a parameter. Between invocation and completion (the entire streaming duration), the parent's `messages` state could change. The completion uses the stale snapshot. | -| **Mitigations** | `isTyping` disables send (ChatInput line 283), `createController()` aborts prior streams (line 174), session guard (line 339). These make the bug very unlikely in normal use. | -| **Impact** | Latent. Would only trigger if mitigations are weakened in future refactoring. | -| **Fix** | Use a ref for messages (like `useToolApproval` uses `messagesRef`) or use functional state update. | - -### P3 — MCP connection leak on provider hot-swap - -| | | -| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Issue** | `ResponsesApiProvider` has no `shutdown()`, so `BackendToolExecutor` MCP clients are never closed when swapping providers | -| **Files** | `plugins/augment-backend/src/providers/llamastack/ResponsesApiProvider.ts` (no `shutdown` method), `plugins/augment-backend/src/providers/ProviderManager.ts:132` (`old.shutdown?.()`), `plugins/augment-backend/src/providers/responses-api/tools/BackendToolExecutor.ts` (`closeAllClients()` exists but is never called) | -| **Root cause** | `ProviderManager.performSwap` calls `old.shutdown?.()`. `KagentiProvider` implements `shutdown()` (clearing maps, destroying client). `ResponsesApiProvider` does not. `BackendToolExecutor.closeAllClients()` exists but nothing invokes it from the LlamaStack provider teardown path. | -| **Impact** | Leaked MCP WebSocket/HTTP connections after provider switch. Low frequency since hot-swap is rare (admin panel action). | -| **Fix** | Add `shutdown()` to `ResponsesApiProvider` that calls `this.orchestrator.shutdown()`, which should call `BackendToolExecutor.closeAllClients()`. | - -### P3 — Reducer phase guard is a manual allowlist - -| | | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Issue** | HITL phase guard blocks events by allowlist — new event types are silently dropped | -| **Files** | `plugins/augment/src/components/StreamingMessage/StreamingMessage.reducer.ts:80-101` | -| **Root cause** | During `pending_approval`, `form_input`, or `auth_required` phases, the reducer only processes explicitly listed event types (11 types). All other events hit `default: return state`. Notably, `stream.agent.handoff` is blocked — a handoff during an approval flow is silently dropped. | -| **Impact** | Maintenance burden. Adding new event types without updating the allowlist causes silent data loss during HITL flows. | -| **Fix** | Consider inverting the logic — block specific disruptive types (text, reasoning, RAG) instead of allowing specific types. Or add `stream.agent.handoff` to the allowlist and add a comment noting the pattern for future contributors. | - -### P3 — Kagenti fallback emits `stream.completed` without `usage` - -| | | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Issue** | When Kagenti falls back from streaming to non-streaming on `-32603`, the synthetic `stream.completed` has no `usage` field | -| **Files** | `plugins/augment-backend/src/providers/kagenti/KagentiProvider.ts:441` | -| **Root cause** | The fallback path at line 434 calls `this.chat(request)` which returns a `ChatResponse` with `content` and `responseId` but no `usage`. The synthetic `stream.completed` at line 441 is `{ type: 'stream.completed' }` with no usage data. | -| **Impact** | Token usage not shown in the UI for agents that don't support streaming. Minor. | -| **Fix** | Include `usage` from the non-streaming response if available, or document that token usage is not available for non-streaming Kagenti agents. | - ---- - -## Additional Findings (not bugs, but architecture notes) - -1. **Output safety is post-stream.** Backend safety check on accumulated text runs after the stream completes (`chatRoutes.ts:524-545`). The text has already been sent to the client. The safety violation event is a warning appended after the fact, not a prevention. This is by design but worth noting. - -2. **`handleFormCancel` is fire-and-forget.** At `ChatContainer.tsx:403-415`, the cancel call `.catch(err => debugError(...))` runs async while the UI immediately clears. If the cancel fails, the agent is still waiting for form input. - -3. **Duplicate completion signals.** Backend emits `stream.completed` event AND `data: [DONE]\n\n`. Frontend ignores `[DONE]` in `parseSSEStream` (correct). Not a bug today, but the dual-signal pattern is fragile. - -4. **Re-export drift.** Frontend `types.ts` re-exports 14 of 19 individual event interfaces from common. Missing: `StreamBackendToolExecutingEvent`, `StreamAgentHandoffEvent`, `StreamFormRequestEvent`, `StreamAuthRequiredEvent`, `StreamArtifactEvent`, `StreamCitationEvent`. The union `NormalizedStreamEvent` is available so there's no functional impact, but it's inconsistent. - ---- - -## Fix Priority Summary - -| Priority | Issue | Effort | Risk | -| -------- | ----------------------------------- | -------------------------------- | ------ | -| P0 | SSE retry duplication | Low (reset state in retry loop) | Low | -| P1 | Conversation state eviction | Medium (DB persistence) | Medium | -| P1 | Backend tools visible to all agents | High (ADK change needed) | Medium | -| P2 | Missed scroll-to-bottom | Low (one line) | None | -| P2 | Silent form/auth errors | Low (add error state) | Low | -| P2 | Kagenti session map eviction | Low (DB hydration mostly covers) | Low | -| P3 | No runtime SSE validation | Low (type guard) | None | -| P3 | Type casts in ResponsesApiProvider | Medium (refactor normalization) | Low | -| P3 | Stale closure in sendMessage | Low (use ref) | None | -| P3 | MCP connection leak | Low (add shutdown method) | None | -| P3 | Reducer allowlist maintenance | Low (invert logic) | None | -| P3 | Kagenti fallback missing usage | Low (pass usage field) | None | diff --git a/workspaces/augment/docs/cloud-concierge-executive-brief.md b/workspaces/augment/docs/cloud-concierge-executive-brief.md deleted file mode 100644 index fbc5c0faac5..00000000000 --- a/workspaces/augment/docs/cloud-concierge-executive-brief.md +++ /dev/null @@ -1,111 +0,0 @@ -# Cloud Concierge - -**An agentic platform for developers, backed by enterprise-grade infrastructure** - ---- - -## What It Is - -Cloud Concierge is an agentic platform embedded in the developer portal. Its agents reason over questions, call tools connected to live systems, and synthesize answers grounded in real data — combining language understanding with direct access to infrastructure and knowledge bases. - -It is designed to operate on a cloud-native infrastructure layer that handles security, tool routing, and observability — so that agents run in production with the same governance applied to any enterprise workload. - ---- - -## What It Does - -**For developers:** - -- Ask a question in natural language. A routing agent determines which specialist to engage — a cluster engineer, a product knowledge expert, a code assistant — and delegates automatically. - -- Each specialist agent can execute operations against real infrastructure: inspect clusters, query documentation stores, check deployment status, search knowledge bases. Answers combine the model's reasoning with data retrieved from these tools. - -- Conversations carry context. Follow-up questions build on prior answers. Agents hand off to each other mid-conversation when the topic shifts, without losing state. - -- Sensitive operations can require explicit developer approval before execution. When enabled, the agent pauses and presents the proposed action for review. The developer stays in control. - -**For platform and security teams:** - -- All tool traffic flows through a single gateway. Tools are registered once and discovered automatically. Adding a new tool does not require changes to any agent — the gateway handles routing. - -- The infrastructure layer can issue and rotate cryptographic identities for each agent and tool automatically, replacing static API keys and shared secrets with certificate-based authentication. - -- When running within the service mesh, all communication between services is encrypted and mutually authenticated at the infrastructure layer. An unauthorized caller cannot reach an agent — the connection is refused before any application code runs. - -- Access policies — which agents can call which tools, under what conditions — are defined as infrastructure configuration, managed by platform teams, and enforced consistently across all traffic. - -- The platform supports end-to-end distributed tracing: from the developer's question, through the agent's reasoning, to each tool call and its result — giving platform teams visibility into what agents are doing, what they are accessing, and what they are returning. - ---- - -## Why It Matters - -**AI agents are not chatbots — they are network services that execute code.** - -When an agent queries a cluster, calls an API, or retrieves confidential documentation, it is performing the same operations a human operator would. It needs identity, authorization, encryption, and an audit trail — the same controls applied to any service in a production environment. - -Most agentic deployments lack this. Each agent manages its own credentials. There is no centralized policy. There is limited visibility into what tools were called or why. Security is treated as an application concern rather than an infrastructure one. - -Cloud Concierge, paired with its infrastructure layer, treats agents as first-class workloads: - -- **Identity is automatic.** Agents authenticate with certificates issued by the platform, not with static keys or passwords. -- **Policy is centralized.** Access control lives in one place, not scattered across agent configurations. -- **Observability is infrastructure-level.** Tool calls, agent decisions, and request flows can be traced and audited without instrumenting each agent individually. -- **Agents are framework-neutral.** The infrastructure supports a standard agent communication protocol, allowing specialists built in different frameworks to interoperate without custom integration. - ---- - -## How It Comes Together - -``` -Developer asks a question in the portal - │ - ▼ -Cloud Concierge routes to the right specialist agent - │ - ▼ -Agent reasons, then calls tools through the unified gateway - │ - ▼ -Gateway verifies identity, enforces policy, routes to the tool - │ - ▼ -Tool executes against live infrastructure, returns result - │ - ▼ -Agent synthesizes the answer, requests approval if configured - │ - ▼ -Developer gets a grounded, traceable answer -``` - -In the fully integrated state, every step in this chain is authenticated, encrypted, policy-checked, and traceable. - ---- - -## Delivery Approach - -The integration is incremental, not a re-architecture: - -| Step | What happens | What changes | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | -| **Gateway** | All tool traffic routes through the unified gateway | Configuration change only — no code modified in Cloud Concierge | -| **Identity** | Agents authenticate through the infrastructure layer, consolidating per-tool credentials into a single identity provider | Credential configuration simplified — one provider replaces many | -| **Observability** | Cloud Concierge emits trace data that the infrastructure collects and visualizes | Additive — existing behavior is unchanged | -| **External agents** | Specialists built in other frameworks become available alongside built-in agents | New capability — existing agents are unaffected | - -Each step delivers value independently. There is no big-bang migration. - ---- - -## Five Things to Remember - -1. **Agents that act, not just answer.** They execute real operations against live systems, with human approval available for anything sensitive. - -2. **Security at the infrastructure layer.** Cryptographic identity, encrypted transport, and centralized policy — managed by the platform, not by each agent individually. - -3. **One gateway for all tools.** Register a tool once. Every authorized agent can discover and use it. No per-agent wiring. - -4. **Built for traceability.** The architecture supports end-to-end tracing of every question, every agent decision, and every tool call. - -5. **No framework lock-in.** The infrastructure supports standard agent communication protocols, allowing different frameworks to interoperate. diff --git a/workspaces/augment/docs/handover-reference.md b/workspaces/augment/docs/handover-reference.md deleted file mode 100644 index 93c846447cf..00000000000 --- a/workspaces/augment/docs/handover-reference.md +++ /dev/null @@ -1,466 +0,0 @@ -# Augment Plugin -- Technical Reference - -**Branch:** feat/kagenti-provider -**Companion to:** handover-slides.md (presentation deck) - -This document is the written reference for engineers who need to develop, debug, or extend the Augment plugin. It covers every architectural layer with exact file paths and key code locations. - ---- - -## 1. Package Structure - -``` -workspaces/augment/ - packages/ - app/ -- Backstage frontend app (development shell) - backend/ -- Backstage backend app (development shell) - plugins/ - augment-common/ -- Shared types, permissions, constants - augment/ -- Frontend plugin (UI, hooks, API client) - augment-backend/ -- Backend plugin (routes, providers, services) -``` - -| Package | npm Name | Backstage Role | Version | -| --------------- | ------------------------------------------------------- | --------------- | ------- | -| augment-common | @red-hat-developer-hub/backstage-plugin-augment-common | common-library | 0.1.0 | -| augment | @red-hat-developer-hub/backstage-plugin-augment | frontend-plugin | 0.1.0 | -| augment-backend | @red-hat-developer-hub/backstage-plugin-augment-backend | backend-plugin | 0.1.0 | - ---- - -## 2. Provider Abstraction - -### 2.1 AgenticProvider Interface - -**File:** `plugins/augment-backend/src/providers/types.ts` (lines 252-367) - -Required methods: - -- `initialize(): Promise` -- `postInitialize(): Promise` -- `getStatus(): Promise` -- `chat(request: ChatRequest): Promise` -- `chatStream(request: ChatRequest, onEvent: (event: NormalizedStreamEvent) => void, signal?: AbortSignal): Promise` - -Optional capabilities: - -- `conversations?: ConversationCapability` -- `rag?: RAGCapability` -- `safety?: SafetyCapability` -- `evaluation?: EvaluationCapability` - -Optional admin methods: - -- `listModels?()`, `testModel?()`, `generateSystemPrompt?()`, `getEffectiveConfig?()` - -### 2.2 Built-in Providers - -**File:** `plugins/augment-backend/src/providers/registry.ts` - -| Provider | ID | Class | Capabilities | -| ---------- | ---------- | -------------------- | ------------------------------------------------------------- | -| LlamaStack | llamastack | ResponsesApiProvider | chat, rag, safety, evaluation, conversations, mcpTools, tools | -| Kagenti | kagenti | KagentiProvider | chat, mcpTools | -| Google ADK | googleadk | (stub) | Not implemented | - -### 2.3 Provider Factory - -**File:** `plugins/augment-backend/src/providers/factory.ts` - -```typescript -switch (providerType) { - case 'llamastack': - return new ResponsesApiProvider({ logger, config, database, adminConfig }); - case 'kagenti': - return new KagentiProvider({ logger, config }); - case 'googleadk': - throw new Error('Google ADK provider is not yet implemented.'); - default: - // Check extension-registered factories -} -``` - -### 2.4 ProviderManager (Hot-Swap) - -**File:** `plugins/augment-backend/src/providers/ProviderManager.ts` - -Strategy: create new -> initialize -> swap reference -> shutdown old. Mutex lock prevents concurrent swaps. - -### 2.5 Extension Point - -**File:** `plugins/augment-backend/src/extensions.ts` - -Extension ID: `augment.providers` - -External backend modules call `registerProvider(descriptor, factory)` to add custom providers. - ---- - -## 3. LlamaStack Provider - -### 3.1 Class Hierarchy - -**Entry:** `plugins/augment-backend/src/providers/llamastack/ResponsesApiProvider.ts` - -ResponsesApiProvider composes: - -- ResponsesApiCoordinator (central wiring) - - AdkOrchestrator (@augment-adk/augment-adk) - - ClientManager (caches ResponsesApiClient, probes /v1/version) - - ConfigResolutionService (YAML + DB merge, TTL cache) - - ConfigLoader (raw YAML) - - McpConfigLoader (MCP servers + auth) - - McpAuthService (OAuth, K8s SA tokens, TLS) - - AgentGraphManager (multi-agent resolution) - - ChatDepsBuilder (per-agent dependencies) - - VectorStoreFacade (lazy init, doc sync, search) - - ConversationFacade (history persistence) - - BackendApprovalHandler / BackendApprovalStore (HITL) - - DocumentSyncService (scheduled sync) -- SafetyService (input/output shields) -- EvaluationService (quality scoring) -- StatusService / StatusAggregator (health) - -### 3.2 Key Files - -| File | Purpose | -| ---------------------------------------------- | -------------------------------------------- | -| `llamastack/ResponsesApiProvider.ts` | AgenticProvider implementation | -| `llamastack/ResponsesApiCoordinator.ts` | Central wiring, delegates to AdkOrchestrator | -| `llamastack/adk-adapters/AdkOrchestrator.ts` | @augment-adk/augment-adk integration | -| `llamastack/ClientManager.ts` | HTTP client caching | -| `llamastack/config/ConfigResolutionService.ts` | Config merge + cache | -| `llamastack/config/ConfigLoader.ts` | YAML config loading | -| `llamastack/config/McpConfigLoader.ts` | MCP server configs | -| `llamastack/auth/McpAuthService.ts` | MCP authentication | -| `llamastack/AgentGraphManager.ts` | Multi-agent graph | -| `llamastack/VectorStoreFacade.ts` | RAG lifecycle | -| `llamastack/safety/SafetyService.ts` | Safety shields | -| `llamastack/safety/EvaluationService.ts` | Quality evaluation | -| `llamastack/status/StatusService.ts` | Health checks | - -### 3.3 Stream Normalization - -**File:** `providers/responses-api/stream/StreamEventNormalizer.ts` (re-exported from `llamastack/index.ts`) - -Maps Llama Stack SSE event types (response.output_text.delta, MCP call events, etc.) to NormalizedStreamEvent union. - ---- - -## 4. Kagenti Provider - -### 4.1 Class Hierarchy - -**Entry:** `plugins/augment-backend/src/providers/kagenti/KagentiProvider.ts` - -KagentiProvider composes: - -- KagentiApiClient (REST + SSE to Kagenti API, ~50 endpoints) -- KagentiSandboxClient (sandbox operations) -- KagentiAdminClient (models, LLM, integrations) -- KeycloakTokenManager (OAuth2 client_credentials) -- KagentiStreamNormalizer (A2A -> NormalizedStreamEvent) -- KagentiAgentCardCache (5-min TTL, 500 max) -- kagentiApprovalHandler (HITL resume) - -### 4.2 Key Files - -| File | Purpose | -| ------------------------------------------- | ------------------------------ | -| `kagenti/KagentiProvider.ts` | AgenticProvider implementation | -| `kagenti/client/KagentiApiClient.ts` | REST + SSE client | -| `kagenti/client/KagentiSandboxClient.ts` | Sandbox operations | -| `kagenti/client/KagentiAdminClient.ts` | Admin API | -| `kagenti/client/KeycloakTokenManager.ts` | OAuth2 token management | -| `kagenti/stream/KagentiStreamNormalizer.ts` | A2A event normalization | -| `kagenti/KagentiAgentCardCache.ts` | Agent card caching | -| `kagenti/kagentiApprovalHandler.ts` | HITL tool approval | -| `kagenti/kagentiConversationCapability.ts` | Conversation stubs | -| `kagenti/kagentiNamespaceUtils.ts` | Namespace resolution | -| `kagenti/config/KagentiConfigLoader.ts` | Config loading + validation | - -### 4.3 Session Mapping - -In-memory bounded LRU maps: - -- `kagentiSessionMap`: backstageSessionId -> kagentiContextId -- `sessionAgentMap`: kagentiContextId -> agentId (namespace/name) -- Max entries: 10,000 per map -- Eviction: 10% batch when limit reached -- Primary lookup is in-memory, but chatRoutes.ts hydrates from DB on miss: if an in-memory entry is missing, it checks `sessions.getSession(sessionId, userRef).conversationId` and calls `kagenti.hydrateSessionContext()` to repopulate the map. Sessions are recoverable after restart, but not pre-loaded. - -### 4.4 Agent Card Cache - -- Key: `namespace/name` -- TTL: 5 minutes (AGENT_CARD_CACHE_TTL_MS) -- Max entries: 500 (overflow evicts oldest) -- Optional Zod validation when `config.validateResponses = true` -- Processes cards via @kagenti/adk/core handleAgentCard for demands/resolveMetadata - -### 4.5 Stream Normalization - -**File:** `kagenti/stream/KagentiStreamNormalizer.ts` - -| A2A Status | NormalizedStreamEvent | -| --------------- | ------------------------------------------------------- | -| WORKING | stream.reasoning.delta | -| COMPLETED | stream.text.delta + stream.text.done + stream.completed | -| FAILED | stream.error (from ERROR_EXTENSION_URI) | -| CANCELED | stream.completed | -| REJECTED | stream.error (kagenti_rejected) | -| INPUT_REQUIRED | stream.form.request or stream.tool.approval | -| AUTH_REQUIRED | stream.auth.required | -| Artifact update | stream.artifact + stream.text.delta | -| JSON-RPC error | stream.error (-32603 triggers streaming fallback) | - -Also handles: TRAJECTORY_EXTENSION_URI (reasoning), CITATION_EXTENSION_URI (citations), agent handoff detection. - -### 4.6 Keycloak Authentication - -**File:** `kagenti/client/KeycloakTokenManager.ts` - -- Grant type: client_credentials -- Token endpoint: configurable via `augment.kagenti.auth.tokenEndpoint` -- Expiry buffer: configurable (default 60s) -- Streaming: `getTokenForStreaming(minLifetimeMs)` discards cache if remaining lifetime is insufficient -- TLS: optional skipTlsVerify -- 401 handling: `clearCache()` bumps generation, clears token + pending promise - -### 4.7 HITL Approval - -**File:** `kagenti/kagentiApprovalHandler.ts` - -Three approval types: - -1. **Generic:** approval metadata with callId, approved, toolName, reason, toolArguments -2. **secrets_response:** parses JSON map -> ADK auth/secrets extension URI -3. **oauth_confirm:** sends OAuth redirect confirmed extension - -All approvals resume via `apiClient.chatStream` with contextId + metadata in A2A payload. - -### 4.8 Kagenti API Endpoints (called by KagentiApiClient) - -**Unprefixed:** GET /health, GET /ready - -**Under /api/v1:** - -- Auth: /auth/config, /auth/status, /auth/userinfo, /auth/me -- Config: /config/features, /config/dashboards -- Namespaces: /namespaces -- Chat: /chat/{ns}/{name}/agent-card, /send, /stream -- Agents: CRUD /agents, migration, Shipwright builds, env parsing -- Tools: CRUD /tools, connect, invoke, Shipwright builds -- Contexts: /contexts/{id}/history - ---- - -## 5. Backend Routes - -### 5.1 Route Registration Order - -**File:** `plugins/augment-backend/src/router.ts` - -``` -1. registerStatusRoutes -- /health, /status, /branding (PUBLIC) -2. router.use(requirePluginAccess) -- auth gate -3. registerChatRoutes -- /chat, /chat/stream, /chat/approve -4. registerDocumentRoutes -- /documents, /sync, /safety/status, /evaluation/status -5. registerConfigRoutes -- /workflows, /quick-actions, /prompt-groups -6. registerSessionRoutes -- /sessions CRUD -7. registerConversationRoutes -- /conversations CRUD -8. registerAdminRoutes -- /admin/* (requireAdminAccess inside) -9. if (provider.id === 'kagenti'): - registerKagentiRoutes -- /kagenti/* - registerKagentiSandboxRoutes - registerKagentiAdminRoutes -``` - -### 5.2 SSE Streaming Implementation - -**File:** `plugins/augment-backend/src/routes/chatRoutes.ts` - -Key functions: - -- `setupSseStream(res, logger)` -- sets headers, creates AbortController, links to res.close -- `createStreamEventForwarder(res, logger, ...)` -- writes `data: JSON\n\n`, handles backpressure -- `handleStreamErrorAndCleanup(res, error, ...)` -- sends stream.error event, ends response - -Headers: - -``` -Content-Type: text/event-stream -Cache-Control: no-cache -Connection: keep-alive -X-Accel-Buffering: no -Content-Encoding: identity -``` - -Completion: `data: [DONE]\n\n` + `res.end()` - ---- - -## 6. Frontend Architecture - -### 6.1 Plugin Registration - -**File:** `plugins/augment/src/plugin.ts` - -- createPlugin({ id: 'augment' }) with rootRouteRef -- API factory: augmentApiRef -> AugmentApiClient -- Routable extension: AugmentPage (lazy loaded) -- Icon: AugmentIcon - -### 6.2 Page Structure - -**File:** `plugins/augment/src/components/AugmentPage/AugmentPage.tsx` - -Two modes controlled by useAdminView (localStorage): - -- Chat mode: ChatContainer + RightPane -- Admin mode: provider-specific sidebar + panels - -Provider-conditional rendering: `liveStatus?.providerId === 'kagenti'` switches between KagentiSidebar and CommandCenterHeader. - -### 6.3 Component Directory - -**Shared (both providers):** - -- ChatContainer/ -- main chat shell, virtualized message list -- ChatMessage/ -- message rendering (markdown, math, tools, RAG) -- StreamingMessage/ -- live streaming with reducer state machine -- WelcomeScreen/ -- prompt groups + workflows -- ChatInput/ -- multiline input, attachments -- ConversationHistory/ -- session list, search, grouping -- RightPane/ -- sidebar with agent info, status -- ToolApprovalDialog/ -- HITL approval UI - -**LlamaStack admin:** - -- AdminPanels/ModelToolsPanel/ -- model, tools, MCP, RAG, safety -- AdminPanels/AgentsPanel/ -- multi-agent editor -- AdminPanels/BrandingPanel/ -- branding customization - -**Kagenti admin:** - -- AdminPanels/KagentiPanels/ -- 7 panels + wizards (agents, tools, builds, sandbox, dashboard links, admin, home) - -### 6.4 Hook Inventory - -**Core chat:** useStreamingChat, useStreamingStateBatching, useChatSessions, useToolApproval, useChatKeyboardShortcuts, useScrollToBottom - -**Data fetching:** useApiQuery (generic), useBackendStatus, useStatus (30s poll), useBranding, useWelcomeData - -**Admin:** useAdminView, useAdminConfig, useEffectiveConfig, useConfigSync, useModels, useProviders, useGeneratePrompt, useFormState, useToast - -**RAG:** useDocuments, useVectorStoreConfig, useVectorStores, useRagTest, useRagGenerate, useFileUpload - -**Kagenti:** useKagentiToolsList, useKagentiToolDetail, useKagentiAgentDetail, useAgentWizardForm, useToolWizardForm, useToolInvoke, useAgentTemplates - -### 6.5 API Client Modules - -**File:** `plugins/augment/src/api/` - -| Module | Endpoints | -| --------------------- | ----------------------------------------------------------------- | -| chatEndpoints | POST /chat, /chat/stream, /chat/approve | -| sessionEndpoints | CRUD /sessions, admin listing | -| conversationEndpoints | /conversations (by response, by conversation, chain) | -| documentEndpoints | RAG admin: vector stores, documents, rag-test | -| adminEndpoints | Providers, models, config CRUD, safety/eval status | -| kagentiEndpoints/ | All /kagenti/\* endpoints (health, agents, tools, sandbox, admin) | - ---- - -## 7. Security - -### 7.1 Three Security Modes - -**Config:** `augment.security.mode` (default: 'plugin-only') - -**File:** `plugins/augment-backend/src/middleware/security.ts` - -| Mode | Plugin Access | Admin Access | User Identity | -| ----------- | ------------------------- | ------------------------ | ------------------- | -| none | Skipped | Everyone | user:default/guest | -| plugin-only | augment.access permission | adminUsers allow-list | Real user principal | -| full | augment.access permission | augment.admin permission | Real user principal | - -### 7.2 Permissions - -**File:** `plugins/augment-common/src/permissions.ts` - -- `augment.access` -- action: 'read' (basic plugin access) -- `augment.admin` -- action: 'update' (admin features) - -### 7.3 Kagenti-Specific Security - -- Keycloak OAuth2 via KeycloakTokenManager (client_credentials) -- Namespace validation via validateNamespaceParam (403 for unauthorized) -- User context propagation via X-Backstage-User header - ---- - -## 8. Configuration - -### 8.1 Config Schema - -**File:** `plugins/augment-backend/config.d.ts` - -Key blocks: - -- `augment.provider` -- 'llamastack' | 'kagenti' -- `augment.security.*` -- mode, adminUsers, mcpOAuth -- `augment.llamaStack.*` -- baseUrl, model, token, TLS, toolChoice, RAG, etc. -- `augment.kagenti.*` -- baseUrl, namespace, auth, sandbox, builds, etc. (~250 lines) -- `augment.documents.*` -- sources, sync schedule -- `augment.mcpServers.*` -- MCP server configs -- `augment.branding.*` -- app name, colors, logos -- `augment.safety.*` / `augment.evaluation.*` -- `augment.promptGroups.*` -- welcome screen cards - -### 8.2 Config Precedence - -YAML baseline -> DB admin overrides (AdminConfigService) -> RuntimeConfigResolver (TTL cache) - -### 8.3 DB Tables - -| Table | Service | Purpose | -| -------------------- | ------------------ | ---------------------------------- | -| augment_admin_config | AdminConfigService | JSON key-value for admin overrides | -| augment_sessions | ChatSessionService | Chat sessions per user | - ---- - -## 9. Shared Types (augment-common) - -### 9.1 Streaming Protocol - -**File:** `plugins/augment-common/src/types/streaming.ts` - -NormalizedStreamEvent is a discriminated union of 16+ event types that both providers must produce. The frontend reducer only processes these shapes. - -### 9.2 Config Keys - -**File:** `plugins/augment-common/src/types/provider.ts` - -- `BuiltInProviderType = 'llamastack' | 'googleadk' | 'kagenti'` -- `GLOBAL_CONFIG_KEYS` -- systemPrompt, branding, promptGroups, etc. -- `PROVIDER_SCOPED_KEYS` -- stored as `provider::key` in DB -- `scopedConfigKey(provider, key)` / `isProviderScopedKey(key)` helpers - -### 9.3 Kagenti Types - -**File:** `plugins/augment-common/src/types/kagenti.ts` - -Shared DTOs for agents, tools, agent cards, builds, sandbox, integrations, LLM teams/keys, triggers, Dev Spaces. - ---- - -## 10. Known Issues and Gotchas - -1. **Kagenti session mapping is in-memory with DB hydration** -- maps are cleared on restart but chatRoutes hydrates from DB conversationId on first access (MEDIUM) -2. **Kagenti conversations capability is stubs** -- history via local DB only (MEDIUM) -3. **Kagenti listModels calls LlamaStack** -- cross-provider dependency (MEDIUM) -4. **Streaming fallback on -32603** -- no incremental output (LOW) -5. **googleadk is a stub** -- factory throws if selected (LOW) -6. **kagenti.ts types are large** -- consider splitting (LOW) diff --git a/workspaces/augment/docs/handover-slides.md b/workspaces/augment/docs/handover-slides.md deleted file mode 100644 index 2a0f8c38c29..00000000000 --- a/workspaces/augment/docs/handover-slides.md +++ /dev/null @@ -1,450 +0,0 @@ -# Augment Plugin -- Engineering Handover - -## Slide Deck Content + Speaker Notes - -**Branch:** feat/kagenti-provider -**Duration:** 90 minutes -**Audience:** RHDH plugin engineering team - ---- - -## SLIDE 1 -- Title - -### On Slide: - -**Augment Plugin -- Engineering Handover** - -Branch: feat/kagenti-provider -Red Hat Developer Hub Plugins - -### Speaker Notes: - -This session covers the full architecture of the Augment plugin -- an AI chat assistant plugin for Red Hat Developer Hub (Backstage). We will walk through both provider implementations (LlamaStack and Kagenti), the streaming pipeline, the frontend, security, and the admin Command Center. The goal is that by the end of this session, you can independently develop, debug, and extend any part of this plugin. All code is on the feat/kagenti-provider branch of redhat-developer/rhdh-plugins. - ---- - -## SLIDE 2 -- Why This Plugin Exists (5 min) - -### On Slide: - -- **Augment** = AI chat assistant embedded in RHDH (Backstage) -- Two provider backends: - - **LlamaStack** -- full AI stack (chat, RAG, safety, evaluation, multi-agent) - - **Kagenti** -- Kubernetes-native agent operations platform -- Single plugin surface, provider-switchable via `app-config.yaml` -- One line to switch: `augment.provider: 'llamastack'` or `augment.provider: 'kagenti'` - -### Speaker Notes: - -The Augment plugin gives RHDH users an AI chat experience directly inside the developer portal. It is designed to be provider-agnostic -- you configure which AI backend to use in app-config.yaml and the entire plugin adapts. LlamaStack is the original, full-featured provider that owns the entire AI runtime. Kagenti was added on this branch as a second first-class provider targeting Kubernetes-native agent operations -- it manages agent lifecycle, builds, and execution on K8s instead of owning the inference pipeline. The key architectural insight is that both providers normalize to the same streaming contract, so the frontend works identically regardless of which backend is active. - ---- - -## SLIDE 3 -- Package Topology (5 min) - -### On Slide: - -**[SCREENSHOT: augment-package-topology.canvas.tsx]** - -Three packages in `workspaces/augment/plugins/`: - -1. `augment-common` -- Types, Permissions, Constants (common-library) -2. `augment` -- Frontend UI, Hooks, API Client (frontend-plugin) -3. `augment-backend` -- Express Routes, Providers, Services (backend-plugin) - -External dependencies: - -- `@augment-adk/augment-adk` -- ADK orchestration for LlamaStack -- `@kagenti/adk` -- A2A protocol handling for Kagenti -- `@modelcontextprotocol/sdk` -- MCP client - -### Speaker Notes: - -Three packages in the workspace, following standard Backstage plugin conventions. Common holds the shared contract -- types, permissions, and streaming event definitions. It is the bridge that keeps frontend and backend in sync. Frontend is a single Backstage frontend-plugin with one routable extension -- the entire chat and admin UI is behind a single mount point. Backend is where the complexity lives: Express routes, two full provider implementations, database services, and security middleware. Both providers live inside augment-backend as subdirectories, not as separate packages. Call out the two external SDKs: @augment-adk/augment-adk is Google's ADK used as the orchestration layer inside LlamaStack, and @kagenti/adk provides A2A protocol handling for the Kagenti provider. - -**Live code:** Show `workspaces/augment/package.json` -- the workspace definition with packages/_ and plugins/_. - ---- - -## SLIDE 4 -- Provider Abstraction: The Core Design Decision (10 min) - -### On Slide: - -**The `AgenticProvider` interface** (providers/types.ts, lines 252-367): - -Required methods: - -- `initialize()` / `postInitialize()` -- `getStatus()` -- health and connectivity -- `chat(request)` -- synchronous chat -- `chatStream(request, onEvent, signal)` -- SSE streaming - -Optional capabilities (interfaces): - -- `conversations` -- history persistence -- `rag` -- vector stores, documents, search -- `safety` -- input/output shields -- `evaluation` -- quality scoring - -Supporting infrastructure: - -- `ProviderManager` -- hot-swap with mutex lock -- `factory.ts` -- switch on `augment.provider` config -- `registry.ts` -- built-in descriptors + dynamic registration -- `extensions.ts` -- `augment.providers` extension point for third-party - -### Speaker Notes: - -This is the most important architectural decision in the plugin. The AgenticProvider interface is the boundary between the HTTP layer and any AI runtime. The router never calls LLM APIs directly -- it always goes through the provider. This means you can swap the entire AI backend without changing any route handler. - -Walk the team through providers/types.ts lines 252-367. Point out that chat and chatStream are the only required methods -- everything else is optional. This lets providers declare what they support: LlamaStack supports everything; Kagenti supports chat and tools but not RAG or safety. - -Show the ProviderManager class -- it does hot-swap with a mutex: create new provider -> initialize -> swap the reference -> shutdown old. This ensures no service gap during a provider switch. The factory.ts switch statement is simple: 'llamastack' -> ResponsesApiProvider, 'kagenti' -> KagentiProvider. - -The extension point at extensions.ts is important for the team to know: any backend module can register a custom provider by calling registerProvider(descriptor, factory) on the augment.providers extension point. This is how third-party providers would be added without forking the plugin. - -**Live code:** Open providers/types.ts, factory.ts, registry.ts in sequence. - ---- - -## SLIDE 5 -- Provider Capability Comparison (part of slide 4) - -### On Slide: - -**[SCREENSHOT: augment-provider-comparison.canvas.tsx]** - -### Speaker Notes: - -Walk through each row of the capability matrix. Key takeaways: LlamaStack owns the full AI runtime -- models, RAG, safety, evaluation. Kagenti delegates to a K8s platform that manages agent lifecycle. They have complementary strengths. Note that Kagenti listModels actually calls the LlamaStack endpoint -- this is a design choice for model management reuse, but it means the Kagenti config still needs a valid augment.llamaStack.baseUrl if you want model listing. Also note that Kagenti has agent and tool lifecycle management (create, build, deploy, delete) which LlamaStack does not have -- LlamaStack's agents are configured in YAML/admin config, not dynamically built on K8s. - ---- - -## SLIDE 6 -- LlamaStack Provider Deep Dive (10 min) - -### On Slide: - -**[SCREENSHOT: augment-llamastack-architecture.canvas.tsx]** - -Key classes: - -- `ResponsesApiProvider` -- the AgenticProvider implementation -- `ResponsesApiCoordinator` -- central wiring, delegates to AdkOrchestrator -- `AdkOrchestrator` -- uses @augment-adk/augment-adk for orchestration -- `ClientManager` -- caches ResponsesApiClient instances -- `ConfigResolutionService` -- merges YAML + DB config with TTL cache -- `VectorStoreFacade` -- lazy vector store init + RAG lifecycle -- `SafetyService` / `EvaluationService` -- input/output checks -- `McpAuthService` -- OAuth client_credentials, K8s SA tokens - -### Speaker Notes: - -Walk through ResponsesApiProvider.ts. The key insight is that chat and chatStream both go through AdkOrchestrator, which uses Google's ADK library. This is NOT a separate Google ADK provider -- it is the orchestration runtime used inside the LlamaStack provider for single-agent and multi-agent workflows. - -Show the ResponsesApiCoordinator -- it is the central wiring class that creates and connects all the services. During initialization, OrchestratorInitializer validates config, loads security settings, sets up the API client, and optionally creates RuntimeConfigResolver for admin DB overrides. - -Point out ConfigResolutionService: it merges YAML baseline with DB admin overrides, caches with a TTL, and invalidates on admin writes. This is how admin panel changes take effect without a restart. - -VectorStoreFacade does lazy initialization -- ensureVectorStoreReady() is called on first RAG use, not at startup. This means the plugin starts fast even if the vector store is slow to connect. - -McpAuthService handles MCP server authentication: OAuth client_credentials, Kubernetes service account tokens, header injection, and TLS control. - -**Live code:** Open ResponsesApiProvider.ts, then ResponsesApiCoordinator.ts. - ---- - -## SLIDE 7 -- Kagenti Provider Deep Dive (15 min) - -### On Slide: - -**[SCREENSHOT: augment-kagenti-architecture.canvas.tsx]** - -Key classes: - -- `KagentiProvider` -- the AgenticProvider implementation -- `KagentiApiClient` -- REST + SSE client (~50 endpoints) -- `KagentiSandboxClient` -- sandbox operations -- `KagentiAdminClient` -- models, LLM, integrations -- `KeycloakTokenManager` -- OAuth2 client_credentials with streaming-aware refresh -- `KagentiStreamNormalizer` -- A2A task events -> NormalizedStreamEvent -- `KagentiAgentCardCache` -- 5-min TTL, 500 max entries -- `kagentiApprovalHandler` -- HITL resume via chatStream + A2A metadata - -### Speaker Notes: - -Walk through KagentiProvider.ts. Key design decisions to call out: - -1. SESSION MAPPING: Two bounded LRU maps (10k entries each, 10% eviction batch): `kagentiSessionMap` maps backstageSessionId to kagentiContextId, and `sessionAgentMap` maps contextId to agentId. These are in-memory, but NOT lost on restart -- chatRoutes.ts hydrates from the DB's conversationId field on first access after a miss. The key nuance: sessions are recoverable but not pre-loaded, so the first request after restart has a small DB lookup overhead. - -2. AGENT CARD CACHING: KagentiAgentCardCache with 5-minute TTL and 500 max entries. When an agent card has extensions, it processes them through @kagenti/adk/core handleAgentCard to extract demands and resolveMetadata, which are merged into chat requests. - -3. A2A METADATA: Before sending a chat request, the provider merges agent card demands and resolveMetadata into the A2A payload. This lets Kagenti agents declare what context they need. - -4. STREAMING FALLBACK: If the Kagenti API returns JSON-RPC error -32603 (streaming unsupported) and no content has been received yet, the provider falls back to synchronous chat() and emits synthetic stream.started + stream.text.delta + stream.completed events. The user sees a delayed response with no incremental output. - -5. KEYCLOAK AUTH: KeycloakTokenManager performs OAuth2 client_credentials grant. Tokens are cached with a configurable expiry buffer (default 60s). For streaming, getTokenForStreaming(minLifetimeMs) ensures the token will survive the duration of the SSE connection. - -**Live code:** Open KagentiProvider.ts, then KagentiApiClient.ts, then KagentiStreamNormalizer.ts. - ---- - -## SLIDE 8 -- Kagenti API Surface (part of slide 7) - -### On Slide: - -53+ backend routes for Kagenti, conditionally registered: - -- Config/Health: 4 endpoints -- Agents CRUD + builds: 15+ endpoints -- Tools CRUD + builds: 12+ endpoints -- Sandbox (sessions, chat, files, sidecars, deploy): 25+ endpoints -- Admin (models, LLM, integrations, triggers): 10+ endpoints - -All namespaced routes go through `validateNamespaceParam` -> 403 if unauthorized. - -Routes registered only when `providerManager.provider.id === 'kagenti'`. - -### Speaker Notes: - -Walk through kagentiRoutes.ts to show the registration pattern. The key architectural decision is that these routes are conditionally registered -- if you switch to LlamaStack, none of these routes exist. Show the namespace middleware: validateNamespaceParam reads the namespace from params or query, then calls kagenti.validateNamespace(ns). If the namespace is not in the configured allow-list, it returns 403. Also show the user context propagation: router.use('/kagenti', ...) resolves the Backstage user and calls kagenti.setUserContext(userRef) so the Kagenti API knows who is making the request. - -**Live code:** Open kagentiRoutes.ts, kagentiAgentRoutes.ts (to show the agent CRUD pattern). - ---- - -## SLIDE 9 -- Streaming Pipeline End-to-End (10 min) - -### On Slide: - -**[SCREENSHOT: augment-streaming-pipeline.canvas.tsx]** - -### Speaker Notes: - -Walk through two parallel paths side by side. - -BACKEND: In chatRoutes.ts, POST /chat/stream is the entry point. parseChatRequest validates the body. setupSseStream sets the SSE headers (Content-Type: text/event-stream, X-Accel-Buffering: no, Content-Encoding: identity) and creates an AbortController. createStreamEventForwarder builds a callback that handles backpressure: if res.write() returns false, events queue in a pending array until the socket emits 'drain'. Then provider.chatStream(request, forwarder, signal) is called. - -PROVIDER NORMALIZATION: LlamaStack path receives raw SSE from the Responses API and runs normalizeLlamaStackEvent() to produce NormalizedStreamEvent. Kagenti path receives A2A task status updates and runs KagentiStreamNormalizer.normalize() to produce the same NormalizedStreamEvent types. The frontend never sees provider-specific event formats. - -FRONTEND: sseStreaming.ts reads 'data: ' lines and JSON-parses each into a StreamingEvent. useStreamingStateBatching batches updates using requestAnimationFrame for smooth 60fps rendering. The StreamingMessage component has a reducer-based state machine with phases: idle -> started -> streaming -> completed. VirtualizedMessageList renders the full conversation with auto-scroll. - -KEY DETAIL: On client disconnect, the AbortController fires, which propagates through the signal parameter to stop the upstream provider work. This prevents zombie streaming connections. - -**Live code:** Open chatRoutes.ts, focus on setupSseStream and createStreamEventForwarder. - ---- - -## SLIDE 10 -- HITL Tool Approval Flow (part of slide 9) - -### On Slide: - -**Tool Approval Flow:** - -1. Backend emits `stream.tool.approval` event -2. Frontend `useToolApproval` hook detects it, pauses streaming -3. `ToolApprovalDialog` shows tool name, arguments (JSON-editable) -4. User clicks Approve or Reject -5. `POST /chat/approve` with decision -6. Backend resumes streaming - -**Kagenti-specific approval types:** - -- Generic tool approval (callId, approved, toolName, reason) -- `secrets_response` -- ADK auth/secrets extension (JSON map of secrets) -- `oauth_confirm` -- OAuth redirect confirmation - -### Speaker Notes: - -The HITL flow is the same from the frontend perspective regardless of provider. The difference is how the backend resumes. - -For LlamaStack: submitToolApproval resumes via the Responses API conversation -- it is a continuation of the existing response. - -For Kagenti: kagentiApprovalHandler.ts resumes via a NEW chatStream call with the contextId + approval metadata in the A2A payload. There are three types of Kagenti approval: generic (tool approval with callId and arguments), secrets_response (parses a JSON map of secrets and sends as an ADK auth/secrets extension URI), and oauth_confirm (sends an OAuth redirect confirmed extension). The approval handler then parses the response stream to extract content, any new pending approvals, handoff markers, and tool execution results. - -**Live code:** Open kagentiApprovalHandler.ts, show buildApprovalMetadata and submitApproval. - ---- - -## SLIDE 11 -- Frontend Architecture (10 min) - -### On Slide: - -**[SCREENSHOT: augment-frontend-architecture.canvas.tsx]** - -### Speaker Notes: - -Show plugin.ts (frontend) -- single createPlugin with id 'augment', one rootRouteRef. No sub-routes. The AugmentPage component is lazy-loaded via createRoutableExtension. This is a deliberate design decision: chat and admin are toggled via React state (useAdminView hook), not Backstage route refs. This avoids navigation complexity. - -Open AugmentPage.tsx -- show the two modes. When viewMode is 'chat', it renders ChatContainer + RightPane. When viewMode is 'admin', it checks liveStatus?.providerId: if 'kagenti', it renders KagentiSidebar + the Kagenti panel component; otherwise it renders CommandCenterHeader + the LlamaStack panel component. - -The provider-aware rendering is important: there are providerId === 'kagenti' checks in ChatContainer, ChatInput, AgentInfoSection, and the admin panels. These adapt the UI behavior -- for example, Kagenti shows agent name in the sidebar while LlamaStack shows RAG and MCP status. - -Walk through the hook layers: core chat hooks (useStreamingChat, useChatSessions, useToolApproval) are provider-agnostic. Kagenti-specific hooks (useKagentiToolsList, useKagentiAgentDetail) live in KagentiPanels/ and only activate when Kagenti is the active provider. - -The API client (AugmentApiClient) composes endpoint modules: chatEndpoints, sessionEndpoints, conversationEndpoints, documentEndpoints, adminEndpoints, and kagentiEndpoints. Each module is a collection of functions that call fetchJson or streamSSE on the base URL. - -**Live code:** Open AugmentPage.tsx, show the provider-conditional rendering. - ---- - -## SLIDE 12 -- Security Model (5 min) - -### On Slide: - -**[SCREENSHOT: augment-security-model.canvas.tsx]** - -### Speaker Notes: - -Walk through middleware/security.ts. Key points: - -1. Route registration ORDER matters: statusRoutes (health, status, branding) are mounted BEFORE requirePluginAccess middleware. This means /status and /branding are accessible without augment.access permission -- intentionally public so the frontend SecurityGate can check backend readiness. - -2. In 'none' mode, everyone is admin and the user identity is user:default/guest. This is for local development only. - -3. In 'plugin-only' mode, requirePluginAccess uses httpAuth.credentials(req) to get the user and permissions.authorize(augmentAccessPermission) to check access. Admin access falls back to an adminUsers allow-list in config. - -4. In 'full' mode, admin access uses the augmentAdminPermission through Backstage's permission framework. This integrates with RBAC policies. - -5. Kagenti adds TWO more security layers on top: Keycloak OAuth2 (KeycloakTokenManager handles client_credentials with streaming-aware token refresh) and namespace validation (validateNamespaceParam returns 403 for unauthorized namespaces). - -6. User context propagation: for Kagenti, the X-Backstage-User header is set on every API request via setUserContext(). - -**Live code:** Open middleware/security.ts. - ---- - -## SLIDE 13 -- Configuration and Persistence (5 min) - -### On Slide: - -**Configuration layers:** - -1. `config.d.ts` -- declarative schema with @visibility annotations -2. `app-config.yaml` -- YAML baseline -3. `AdminConfigService` -- DB overrides (Knex, augment_admin_config table) -4. `RuntimeConfigResolver` -- merges YAML + DB with TTL cache - -**Key config blocks:** - -- `augment.provider` -- 'llamastack' or 'kagenti' -- `augment.llamaStack.*` -- baseUrl, model, token, toolChoice, RAG, etc. -- `augment.kagenti.*` -- baseUrl, namespace, auth, sandbox, builds, etc. (~250 lines) -- `augment.security.*` -- mode, adminUsers, mcpOAuth - -**DB tables:** - -- `augment_admin_config` -- JSON key-value for admin overrides -- `augment_sessions` -- chat sessions per user - -### Speaker Notes: - -Show config.d.ts -- it is the definitive schema. The Kagenti block is about 250 lines covering baseUrl, auth (Keycloak), namespaces, timeouts, sandbox settings, migration, pagination, feature overrides, and dashboards. Point out the @visibility annotations: backend means the value is only available server-side; frontend means it is sent to the browser; secret means it is never logged. - -Explain the config precedence: YAML baseline is the starting point. Admin panel writes go through AdminConfigService to the augment_admin_config DB table. RuntimeConfigResolver merges both with a TTL cache and invalidates when admin writes happen. This lets admins change settings without restarting the backend. - -Note the Kagenti-specific gotcha: session-to-contextId mapping is in-memory only (not in the DB). If the backend restarts, all Kagenti chat contexts are lost. - -**Live code:** Show config.d.ts, then app-config.yaml (current settings). - ---- - -## SLIDE 14 -- Backend Route Architecture (part of slides 4-8) - -### On Slide: - -**[SCREENSHOT: augment-route-architecture.canvas.tsx]** - -### Speaker Notes: - -This is the complete route inventory. Walk through the four auth layers: public (before middleware), plugin access, admin access, and Kagenti-only (conditionally registered). Point out the handler wrapper: most handlers use createWithRoute from routeWrapper.ts for logging + try/catch -> sendRouteError. The /chat/stream endpoint is special -- it has its own SSE lifecycle and does not use the standard wrapper. - -For Kagenti: the routes tree is large -- 53+ endpoints covering config, agents, tools, builds, sandbox (sessions, chat, files, sidecars, deploy, token usage), and admin (models, LLM teams/keys, integrations, triggers). All namespaced routes go through validateNamespaceParam. - ---- - -## SLIDE 15 -- Known Gaps, Gotchas, and Future Work (5 min) - -### On Slide: - -**[SCREENSHOT: augment-gaps-and-gotchas.canvas.tsx]** - -### Speaker Notes: - -Be honest about these with the team. The session maps (kagentiSessionMap and sessionAgentMap) are in-memory LRU caches, but they are NOT lost on restart. chatRoutes.ts has a hydration path: when the in-memory map has no entry for a session, it checks the DB's conversationId field via sessions.getSession() and calls kagenti.hydrateSessionContext() to repopulate the map. So sessions are recoverable -- the first request after restart just has a small DB lookup overhead. The real gap is that the sessionAgentMap (contextId -> agentId) is only in-memory with no DB backing, so the agent association may be lost. - -The listModels cross-dependency means Kagenti config still needs a valid augment.llamaStack.baseUrl. The streaming fallback on JSON-RPC -32603 is a workaround -- as Kagenti agents gain streaming support, it becomes less relevant. - -The augment-common kagenti.ts types file is large and growing. Consider splitting into sub-modules for better maintainability. - ---- - -## SLIDE 16 -- Developer Onboarding Quickstart (part of slide 15) - -### On Slide: - -**Getting started:** - -``` -git checkout feat/kagenti-provider -cd workspaces/augment -yarn install -export KAGENTI_CLIENT_SECRET= -yarn dev -# Open http://localhost:3000 -``` - -**Start reading here:** - -1. `plugin.ts` (backend) -- bootstrap flow -2. `router.ts` -- full route tree -3. `providers/types.ts` -- the AgenticProvider contract -4. `KagentiProvider.ts` -- Kagenti implementation -5. `AugmentPage.tsx` -- frontend entry point -6. `augment-common/types/streaming.ts` -- streaming contract - -### Speaker Notes: - -Give the team these entry points in order. Start with plugin.ts for the backend bootstrap -- it shows how everything is wired together. Then router.ts for the complete route tree and middleware order. Then providers/types.ts for the AgenticProvider contract. From there, branch into either KagentiProvider.ts or ResponsesApiProvider.ts depending on which provider they are working on. For the frontend, start at AugmentPage.tsx and work down through the component tree. For the shared contract, streaming.ts in augment-common defines the NormalizedStreamEvent union that both providers must produce. - ---- - -## SLIDE 17 -- Q&A (10 min) - -### On Slide: - -**Questions?** - -### Speaker Notes (anticipated questions): - -**Q: Why single route with state toggle instead of sub-routes?** -A: Avoids Backstage navigation complexity. Admin is a secondary mode, not a primary surface. The useAdminView hook manages this via localStorage. It also means the plugin has a single mount point in the Backstage app, simplifying configuration. - -**Q: Why ADK inside the LlamaStack provider?** -A: ADK (@augment-adk/augment-adk) provides the multi-agent orchestration layer. It is an implementation detail of the LlamaStack provider, not a separate provider type. It handles agent graph resolution, tool execution, and conversation management within the LlamaStack runtime. - -**Q: How does hot-swap work?** -A: ProviderManager creates and initializes the new provider BEFORE swapping the reference, ensuring no service gap. A mutex lock prevents concurrent swaps. After the swap, the old provider is shut down gracefully. If initialization of the new provider fails, the swap does not happen. - -**Q: How is backpressure handled in SSE?** -A: The stream event forwarder checks the return value of res.write(). If it returns false (Node.js backpressure signal), events are queued in a pending array. When the socket emits 'drain', the pending events are flushed. This prevents memory buildup if the client is slow to consume events. - -**Q: What happens when Kagenti streaming fails?** -A: If JSON-RPC error -32603 (streaming unsupported) is received AND no content has been streamed yet, KagentiProvider.chatStream() falls back to synchronous chat(). It then emits synthetic stream.started, stream.text.delta (with the full response), and stream.completed events. The user sees a delayed response with no incremental output. - -**Q: Why is Kagenti session mapping in-memory?** -A: Design tradeoff for performance. The bounded LRU maps (10k entries) are fast and avoid DB round-trips on every chat message. The in-memory maps are cleared on restart, but chatRoutes.ts hydrates the session-to-context mapping from the DB's conversationId field on the first request. The sessionAgentMap (contextId to agentId) has no DB backing, so the agent association may need to be re-resolved after restart. - -**Q: How does Kagenti namespace security work?** -A: Three layers: (1) Config allow-list in augment.kagenti.namespaces or showAllNamespaces flag; (2) validateNamespaceParam Express middleware on all namespaced routes; (3) Kagenti API server itself may have its own namespace RBAC. - -**Q: Can I add a third provider?** -A: Yes, via the augment.providers extension point. Create a Backstage backend module that calls augmentProviderExtensionPoint.registerProvider(descriptor, factory). The descriptor defines capabilities and config fields; the factory returns an AgenticProvider implementation. Set augment.provider to your provider ID in app-config.yaml. - ---- - -## Handover Deliverables Checklist - -- [ ] Slide deck (use this document + canvas screenshots) -- [ ] Technical reference (see handover-reference.md) -- [ ] Canvas screenshots for all 7 diagrams -- [ ] Shared access to the feat/kagenti-provider branch -- [ ] app-config.yaml with credentials for dev environment diff --git a/workspaces/augment/docs/kagenti-augment-integration-research.md b/workspaces/augment/docs/kagenti-augment-integration-research.md deleted file mode 100644 index 9ff69e5d861..00000000000 --- a/workspaces/augment/docs/kagenti-augment-integration-research.md +++ /dev/null @@ -1,478 +0,0 @@ -# Kagenti + Augment Plugin: Integration Research Findings - -**Date:** 2026-04-02 -**Scope:** Value proposition and technical fitment of integrating the RHDH Augment plugin with Kagenti - ---- - -## Executive Summary - -Kagenti and the Augment plugin are **highly complementary** with minimal overlap. Augment provides the developer-facing AI assistant experience (chat UI, agent orchestration logic, conversation management) while Kagenti provides production-grade cloud-native infrastructure (MCP gateway, zero-trust identity, agent lifecycle, observability). The two occupy different layers of the stack, making integration natural rather than forced. - -**Recommendation:** Start with **Option A (Kagenti as MCP/Agent Infrastructure Layer)** — point Augment's `BackendToolExecutor` at Kagenti's MCP Gateway and adopt Kagenti's auth model. This delivers the highest value (centralized MCP management, zero-trust security, observability) with the lowest risk (minimal Augment code changes, no architectural overhaul). - ---- - -## 1. Capability Overlap vs. Complementarity Matrix - -| Dimension | Augment Today | Kagenti Offers | Relationship | Value Add | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------- | -| **MCP connections** | Direct SDK client per server (`mcpClient.ts`, `BackendToolExecutor`) | Centralized Envoy-based MCP Gateway with tool routing, prefixing, and hot-reload | **Complement** | Eliminates per-server connection management, adds routing + load balancing | -| **MCP auth** | Per-server OAuth/SA/headers chain in `McpAuthService` (4-level priority: authRef > inline OAuth > SA > global mcpOAuth) | SPIFFE/SPIRE workload identity + Keycloak OIDC + mTLS via Istio Ambient | **Complement** | Replaces static credentials with cryptographic identity; no secrets in ConfigMaps | -| **Agent orchestration** | ADK in-process (`AdkOrchestrator` with `run()`/`runStream()`, handoffs, conversation state) | A2A protocol for cross-framework agent communication | **Complement** | A2A enables remote agents (LangGraph, CrewAI) alongside ADK agents | -| **Agent discovery** | Static YAML + DB config (`AgentGraphManager`, `ConfigResolutionService`) | K8s-native: labeled Deployments + AgentCard CRD with `/.well-known/agent-card.json` + JWS verification | **Complement** | Dynamic agent registration without restarts or DB updates | -| **Tool policy** | SSRF guard (`SsrfGuard.ts`) + HITL approval handler (`BackendApprovalHandler`) | Envoy policy plugins (Authorino/Kuadrant), rate limiting, per-tool ACLs via Keycloak groups | **Complement** | Centralized, declarative policy enforcement across all MCP traffic | -| **Observability** | Backstage `LoggerService` only — no tracing, no metrics | OpenTelemetry + Phoenix/Jaeger for distributed tracing; Kiali for network visualization; MLflow for LLM tracing | **Complement** | Production-grade observability for chat turns, tool calls, and agent handoffs | - -**Zero overlap or conflicts identified.** Kagenti operates below the application layer where Augment operates. - ---- - -## 2. MCP Gateway Integration (Highest Value) - -### Current Architecture - -Augment manages MCP connections in two modes: - -- **Backend mode (default):** `BackendToolExecutor` opens individual `@modelcontextprotocol/sdk` `Client` connections per server URL. Each server is configured in `augment.mcpServers[]` with its own URL, auth, and tool filtering. Tool names are prefixed as `{serverId}__{toolName}`. - -- **Direct mode:** MCP servers are passed as `type: 'mcp'` tools to Llama Stack, which opens its own connections using `server_url` + `headers`. - -### Kagenti MCP Gateway Architecture - -The MCP Gateway consists of three components: - -1. **Broker** — Control hub that tracks servers, exposes filtered tool views, manages tool prefixes, and stores policy catalogs -2. **Router** — Runtime data path that examines requests, applies routing logic, injects metadata (`x-mcp-tool`, `x-mcp-server`), and validates auth -3. **Discovery Controller** — Watches `MCPServerRegistration` CRDs and `HTTPRoute` resources, dynamically updates broker/router - -### Wire Protocol Compatibility - -**Critical finding:** The MCP Gateway exposes standard MCP over Streamable HTTP at a single `/mcp` endpoint. This is the same transport that Augment's `StreamableHTTPClientTransport` in `mcpClient.ts` uses. The gateway is **wire-compatible** with Augment's existing SDK client — no SDK changes needed. - -The gateway also supports OAuth protected resource discovery at `/.well-known/oauth-protected-resource`, which aligns with Augment's OAuth auth flow. - -### Integration Path - -**Backend mode integration (recommended):** - -1. Configure a single `MCPServerConfig` in `augment.mcpServers[]` pointing to the Kagenti MCP Gateway URL (e.g., `http://mcp-gateway.kagenti-system.svc.cluster.local/mcp`) -2. The gateway handles routing to individual MCP servers via tool prefixes -3. `BackendToolExecutor` connects to one endpoint instead of N servers -4. Tool prefix collision is handled by the gateway's `toolPrefix` per `MCPServerRegistration` - -**Change required in Augment:** Minimal. The `BackendToolExecutor.discoverTools()` method already connects to URLs via the SDK client and lists tools. Pointing it at a single gateway URL works without code changes — only config changes: - -```yaml -augment: - mcpServers: - - id: kagenti-gateway - name: Kagenti MCP Gateway - type: streamable-http - url: http://mcp-gateway.kagenti-system.svc.cluster.local/mcp -``` - -**Considerations:** - -- Augment's `serverId__toolName` prefixing and Kagenti's `toolPrefix_toolName` may double-prefix. Either disable Augment's prefixing for gateway-backed servers or configure the gateway without prefixes. -- Latency: adds one network hop (Envoy proxy). In-cluster latency is typically <1ms; acceptable for tool calls that involve external API calls. -- SSRF guard in Augment becomes unnecessary when all traffic goes through the gateway (the gateway controls backend topology). - -### Direct mode integration: - -For direct mode, set `server_url` to the gateway URL in `McpToolBuilder`. Llama Stack connects to the gateway instead of individual servers. Same wire protocol compatibility applies. - ---- - -## 3. Security Model Integration - -### Current Augment Auth Architecture - -`McpAuthService` implements a 4-level priority chain per MCP server: - -1. `authRef` — Named config from `augment.mcpAuth.*` (OAuth or ServiceAccount) -2. Inline `oauth` — Client credentials flow to a `tokenUrl` -3. Inline `serviceAccount` — K8s TokenRequest API for SA tokens -4. Global `security.mcpOAuth` — Fallback in `full` security mode - -This is **508 lines** of token management code with caching, expiry buffers, in-flight deduplication, and TLS control. - -### Kagenti Security Model - -Kagenti replaces all of the above with infrastructure-level identity: - -| Layer | Mechanism | Effect | -| --------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------- | -| **Workload identity** | SPIFFE/SPIRE issues X.509 SVIDs per pod: `spiffe://{domain}/ns/{ns}/sa/{sa}` | Each pod has a cryptographic identity; no static secrets | -| **Transport** | Istio Ambient mesh — per-node `ztunnel` enforces mTLS at L4 | All pod-to-pod traffic encrypted; no sidecar overhead | -| **Authorization** | Istio `AuthorizationPolicy` CRDs — namespace-level and service-level ACLs | Declarative YAML, not application code | -| **Token exchange** | RFC 8693 OAuth2 token exchange via Keycloak | Short-lived, scoped tokens for cross-boundary calls | -| **Injection** | Auto-injected sidecars: `spiffe-helper` + `kagenti-client-registration` | Zero config per agent/tool pod | - -### Integration Path - -**Option 1: "Kagenti mode" in McpAuthService (minimal change)** - -Add a new auth type that trusts the mesh: - -```typescript -// In McpAuthService.getServerHeaders() -if (this.securityConfig.mode === 'kagenti') { - // mTLS handles auth at the transport layer - // No Bearer token needed — ztunnel verifies identity - return headers; -} -``` - -When RHDH runs inside the Kagenti mesh, all traffic to MCP servers (via the gateway) is authenticated by mTLS. The entire `McpAuthService` priority chain becomes a no-op. - -**Option 2: Kagenti as the OAuth provider (moderate change)** - -Configure Augment's existing `security.mcpOAuth` to point to Kagenti's Keycloak instance: - -```yaml -augment: - security: - mode: full - mcpOAuth: - tokenUrl: https://keycloak.kagenti-system.svc.cluster.local/realms/mcp/protocol/openid-connect/token - clientId: augment-backend - clientSecret: ${KEYCLOAK_CLIENT_SECRET} - scopes: [openid, mcp-tools] -``` - -This requires no code changes — just configuration. The MCP Gateway validates the token via Authorino/Keycloak. - -**Recommendation:** Option 2 for initial integration (config-only change), then Option 1 when RHDH is fully inside the mesh. - -### Security Improvement Quantification - -| Metric | Augment Today | With Kagenti | -| ------------------------ | ------------------------------------ | ------------------------------ | -| Static secrets in config | Per-server `clientSecret`, SA tokens | Zero — SPIFFE handles identity | -| Token management code | 508 lines (`McpAuthService`) | 0 lines (mesh handles it) | -| Credential rotation | Manual config update | Automatic SVID rotation | -| Auth policy location | Scattered across YAML, DB, code | Centralized K8s CRDs | - ---- - -## 4. Agent Lifecycle and Discovery - -### Current Augment Agent Architecture - -Agents are defined in two sources, merged by `ConfigResolutionService`: - -1. **YAML** (`app-config.yaml` → `augment.agents`): Static, requires restart -2. **Database** (`augment_admin_config` table, key `agents`): Dynamic via admin UI, but still a JSON blob - -`AgentGraphManager` resolves these into an `AgentGraphSnapshot` — a frozen graph of `ResolvedAgent` objects used per-request. Agents run **in-process** within the Backstage Node.js runtime via the ADK. - -### Kagenti Agent Lifecycle - -Kagenti manages agents as **Kubernetes workloads**: - -1. Deploy agent as a standard K8s Deployment with label `kagenti.io/type: agent` -2. AgentCard Sync Controller creates an `AgentCard` CRD automatically -3. AgentCard Controller fetches `/.well-known/agent-card.json` from the pod -4. JWS signature verification via SPIFFE x5c signing -5. Agent becomes discoverable via K8s API - -### Architectural Mismatch - -**Key tension:** Augment agents are in-process ADK constructs (functions within the Node.js process). Kagenti agents are out-of-process services (separate pods/deployments). - -This is **not a blocker** — it's a design spectrum: - -| Agent Type | Where It Runs | Protocol | Use Case | -| ---------------------------- | -------------------------------- | -------------- | -------------------------------------------------------- | -| **ADK agent** (Augment) | In-process, same Node.js runtime | Function calls | Low-latency, tightly coupled (router, specialist agents) | -| **Kagenti agent** (external) | Separate pod/deployment | A2A over HTTP | Independent lifecycle, different framework, scaling | - -### Integration Path: Hybrid Agent Discovery - -Extend `AgentGraphManager` to support a third agent source: - -1. **YAML** (static, restart-required) -2. **Database** (dynamic, admin UI) -3. **Kagenti K8s API** (dynamic, fully automated) - -Implementation sketch: - -```typescript -// In AgentGraphManager.resolveSnapshot() -if (config.kagentiDiscovery?.enabled) { - const kagentiAgents = await this.discoverKagentiAgents( - config.kagentiDiscovery, - ); - agents = { ...agents, ...kagentiAgents }; -} -``` - -Each Kagenti-discovered agent would be represented as an `AgentConfig` with: - -- `type: 'a2a'` (new type) -- `endpoint: ` -- `skills: ` - -The ADK orchestrator would treat these as remote agents, routing to them via A2A instead of in-process handoffs. - -### Value Proposition - -- **Agent marketplace:** RHDH users see all available agents (both built-in ADK and Kagenti-managed) in one place -- **Framework diversity:** LangGraph, CrewAI, BeeAI agents alongside ADK agents -- **Independent scaling:** Kagenti agents scale independently of the Backstage process -- **No restart:** New agents are auto-discovered via K8s labels - ---- - -## 5. A2A Protocol Feasibility - -### Protocol Maturity - -A2A reached **v1.0.0 in March 2026** — production-ready: - -- 22,955 GitHub stars, 140 contributors -- SDKs: Python, Go, **JavaScript**, Java, .NET -- Backed by 150+ organizations including Google, Red Hat, IBM -- Full specification: JSON-RPC over HTTPS, Agent Cards for discovery, task lifecycle - -### A2A vs. ADK In-Process Handoffs - -| Aspect | ADK Handoffs (current) | A2A Protocol | -| --------------------- | ------------------------------------------ | --------------------------------------- | -| **Latency** | Microseconds (function call) | Milliseconds (HTTP round-trip) | -| **State sharing** | Full — shared memory, conversation context | Partial — task artifacts, not raw state | -| **Framework lock-in** | ADK only | Any A2A-compatible framework | -| **Scaling** | Monolithic (scales with Backstage) | Independent per agent | -| **Failure isolation** | One agent crash affects all | Isolated — circuit breaker possible | - -### Recommendation: Additive, Not Replacement - -A2A should **supplement** ADK's in-process orchestration, not replace it: - -1. **Core agents** (Router, specialist agents with sub-second response needs) stay in-process via ADK -2. **External agents** (domain-specific, long-running, different-framework) accessed via A2A through Kagenti - -Implementation approach in `AdkOrchestrator`: - -```typescript -// New agent type in AgentConfig -interface AgentConfig { - // ... existing fields - type?: 'adk' | 'a2a'; // default: 'adk' - a2aEndpoint?: string; // URL for A2A agents - a2aCapabilities?: string[]; // from Agent Card skills -} - -// In buildRunOptions, A2A agents become function tools that delegate -const a2aFunctionTool: FunctionTool = { - type: 'function', - name: sanitizeName(agent.name), - description: agent.instructions, - parameters: { type: 'object', properties: { task: { type: 'string' } } }, - execute: async args => { - return await a2aClient.sendTask(agent.a2aEndpoint, args.task); - }, -}; -``` - -### JavaScript SDK Status - -The A2A JavaScript SDK exists and is Apache 2.0 licensed. It would be added as a dependency to `augment-backend`. - ---- - -## 6. Observability Gap Analysis - -### Current State - -Augment has **no distributed tracing**. All logging goes through Backstage's `LoggerService` — structured text logs, no trace correlation, no spans. - -Critical operations that lack observability: - -- `ResponsesApiService.chatTurn()` — LLM API calls -- `BackendToolExecutor.executeTool()` — MCP tool executions -- `AdkOrchestrator.chat()/chatStream()` — agent runs and handoffs -- `BackendApprovalHandler` — HITL approval flows - -### Kagenti Observability Stack - -| Component | Role | Status | -| --------------------------- | ------------------------------------------- | --------------------- | -| **OpenTelemetry Collector** | Ingests spans from all services | Production-ready | -| **Phoenix** | LLM-specific trace visualization | Deployed with Kagenti | -| **Jaeger** | General distributed tracing UI | Optional backend | -| **MLflow** | LLM tracing with GenAI semantic conventions | Integration available | -| **Kiali** | Service mesh network visualization | Via Istio | - -### Integration Path - -**Phase 1: OpenTelemetry instrumentation in Augment (moderate effort)** - -Add `@opentelemetry/api` and create spans at key points: - -```typescript -// In ResponsesApiService.chatTurn() -const span = tracer.startSpan('augment.chat_turn', { - attributes: { - 'gen_ai.system': 'llama-stack', - 'gen_ai.request.model': deps.config.model, - 'augment.agent': agentName, - 'augment.conversation_id': conversationId, - }, -}); - -// In BackendToolExecutor.executeTool() -const span = tracer.startSpan('augment.tool_call', { - attributes: { - 'mcp.server_id': serverId, - 'mcp.tool_name': toolName, - }, -}); -``` - -**Phase 2: Export to Kagenti's collector** - -Configure the OTEL exporter to send to Kagenti's collector: - -```yaml -augment: - observability: - otlpEndpoint: http://otel-collector.kagenti-system.svc.cluster.local:4317 - serviceName: augment-backend -``` - -**Known pitfall (from Kagenti deployment experience):** Use gRPC port 4317, not HTTP port 4318. Silent trace drops occur with port mismatch. - -### Observability Value - -| Metric | Before | After | -| ------------------------ | -------- | ---------------------------------------- | -| Trace correlation | None | Full request → agent → tool chain | -| LLM latency visibility | Log grep | Phoenix dashboard | -| Tool execution debugging | Log grep | Per-tool spans with arguments/results | -| Agent handoff tracking | None | Span hierarchy showing routing decisions | -| Network topology | None | Kiali visualization | - ---- - -## 7. Deployment Topology Recommendation - -### Option A: Kagenti as MCP/Agent Infrastructure Layer (Recommended) - -``` -┌─────────────────────────────────────────────────────────┐ -│ RHDH (Backstage) │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ Augment Plugin │ │ -│ │ ┌──────────────┐ ┌────────────────────────┐ │ │ -│ │ │ ADK │ │ BackendToolExecutor │ │ │ -│ │ │ Orchestrator │ │ (single gateway conn) │ │ │ -│ │ └──────┬───────┘ └──────────┬─────────────┘ │ │ -│ └─────────┼─────────────────────┼─────────────────┘ │ -│ │ │ │ -└────────────┼─────────────────────┼───────────────────────┘ - │ A2A (future) │ MCP over Streamable HTTP - │ │ -┌────────────┼─────────────────────┼───────────────────────┐ -│ │ Kagenti Platform │ │ -│ ┌─────────▼────────┐ ┌────────▼──────────────────┐ │ -│ │ Kagenti Agents │ │ MCP Gateway │ │ -│ │ (A2A workloads) │ │ ┌──────┐ ┌──────────┐ │ │ -│ │ │ │ │Broker│ │Router │ │ │ -│ │ - LangGraph │ │ └───┬──┘ └────┬─────┘ │ │ -│ │ - BeeAI │ │ │ │ │ │ -│ │ - CrewAI │ │ ▼ ▼ │ │ -│ └───────────────────┘ │ ┌──────┐ ┌──────────┐ │ │ -│ │ │MCP-A │ │MCP-B │ │ │ -│ ┌───────────────────┐ │ └──────┘ └──────────┘ │ │ -│ │ Observability │ └───────────────────────────┘ │ -│ │ OTEL + Phoenix │ │ -│ │ + Kiali │ ┌───────────────────────────┐ │ -│ └───────────────────┘ │ Auth Bridge │ │ -│ │ SPIFFE/SPIRE + Keycloak │ │ -│ └───────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────┐ │ -│ │ Istio Ambient Mesh (mTLS via ztunnel) │ │ -│ └──────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────┘ -``` - -**What changes in Augment:** - -| Component | Change | Effort | -| ----------------------------- | -------------------------------------------------------------- | ----------- | -| `augment.mcpServers[]` config | Single gateway URL instead of N server URLs | Config only | -| `McpAuthService` | Point `security.mcpOAuth` to Kagenti's Keycloak | Config only | -| `SsrfGuard` | Disable for gateway-backed servers (gateway controls topology) | 1-line flag | -| OTEL instrumentation | Add spans in `chatTurn()`, `executeTool()`, `chat()` | ~100 lines | -| `AgentGraphManager` | Add Kagenti K8s API discovery source (Phase 2) | ~200 lines | - -**What stays the same:** - -- All ADK orchestration logic (in-process agents, handoffs, conversation state) -- Frontend UI (chat, admin panels, agent capabilities) -- Conversation persistence, HITL approval flows -- Llama Stack / Responses API integration - -### Option B: Kagenti as Full Agent Platform (Future) - -In this model, Augment's ADK agents are deployed as Kagenti-managed workloads communicating via A2A. This gives full scaling independence and framework flexibility but requires: - -- Extracting each agent into a standalone A2A service -- Replacing in-process conversation state with distributed state (Redis/DB) -- Rewriting the streaming pipeline for A2A's task lifecycle model -- Accepting the latency cost of HTTP round-trips for every handoff - -**Verdict:** Not justified today. The latency and complexity cost outweighs the scaling benefits for RHDH's typical deployment (single tenant, moderate concurrency). - -### Option C: Hybrid (Recommended Long-term) - -Start with Option A. Incrementally add: - -1. **Phase 1 (weeks):** MCP Gateway + Keycloak auth (config-only changes) -2. **Phase 2 (sprint):** OTEL instrumentation in Augment backend -3. **Phase 3 (sprint):** A2A agent type in `AgentGraphManager` for external agents -4. **Phase 4 (quarter):** Evaluate moving compute-heavy agents (RAG indexing, code analysis) to Kagenti workloads - ---- - -## 8. Proof-of-Concept Scope - -**Smallest change to validate the integration:** - -1. Deploy Kagenti on the existing OpenShift cluster (already has RHDH) -2. Deploy the existing MCP servers (e.g., `okp-mcp`, `ocp-mcp`) behind the Kagenti MCP Gateway -3. Change Augment's `mcpServers` config to point to the single gateway URL -4. Configure Augment's `security.mcpOAuth` to use Kagenti's Keycloak -5. Verify: tool discovery, tool execution, HITL approvals all work through the gateway -6. Measure: latency delta (direct vs. gateway) - -**Expected effort:** 1-2 days of infrastructure work, zero code changes in Augment. - -**Success criteria:** - -- All existing MCP tools discoverable through gateway -- Tool execution latency increase < 10ms (in-cluster) -- OAuth flow works end-to-end (Augment → Keycloak → Gateway → MCP server) - ---- - -## 9. Risks and Open Questions - -| Risk | Severity | Mitigation | -| ------------------------------------------------------------------------------------------------------------------------------ | -------- | ----------------------------------------------------------------------------------------- | -| **Tool prefix collision** — Augment's `serverId__` and gateway's `toolPrefix_` may double-prefix | Medium | Configure gateway without toolPrefix OR strip Augment's prefix for gateway-backed servers | -| **HITL through gateway** — Augment's `requireApproval` interception happens before MCP call; gateway adds another policy layer | Low | HITL stays in Augment (application layer); gateway policy is additive (network layer) | -| **Streaming through gateway** — SSE/streaming MCP responses through Envoy | Low | Envoy supports streaming; the Router handles SSE transport | -| **Kagenti maturity** — v0.6.0-alpha (not GA) | Medium | Use for MCP Gateway only (most mature component); avoid depending on alpha CRD APIs | -| **A2A JS SDK maturity** — Less battle-tested than Python SDK | Medium | Start with Python-based external agents; JS SDK for later phases | -| **Observability silent failures** — OTEL port/filter mismatches cause silent trace drops | Medium | Validate trace pipeline end-to-end in PoC; use gRPC port 4317 | - -### Open Questions - -1. **Can Kagenti's MCP Gateway aggregate tool lists from multiple servers into a single `listTools` response?** — Yes (confirmed from gateway docs: broker aggregates and the gateway exposes a unified tool view) -2. **Does the gateway preserve MCP session state (for stateful tools)?** — Needs validation in PoC -3. **How does Kagenti handle MCP server health when a backend is down?** — Gateway has readiness status on `MCPServerRegistration` CRs; need to verify behavior during partial outages -4. **Can RHDH run inside the Istio Ambient mesh?** — RHDH is a standard Kubernetes deployment; no known incompatibility, but needs testing with ztunnel diff --git a/workspaces/augment/examples/templates/agent-template.yaml b/workspaces/augment/examples/templates/agent-template.yaml deleted file mode 100644 index 1a59842919b..00000000000 --- a/workspaces/augment/examples/templates/agent-template.yaml +++ /dev/null @@ -1,83 +0,0 @@ -apiVersion: scaffolder.backstage.io/v1beta3 -kind: Template -metadata: - name: google-adk-agent - title: Google ADK Agent - description: Scaffold a Python agent using the Google Agent Development Kit (ADK) with built-in tool use, memory, and multi-agent orchestration. - tags: - - python - - google-adk - - agent -spec: - owner: user:guest - type: agent - - parameters: - - title: Agent details - required: - - name - - description - properties: - name: - title: Agent Name - type: string - description: Unique name for the agent (lowercase, hyphens allowed) - ui:autofocus: true - pattern: '^[a-z][a-z0-9-]*$' - description: - title: Description - type: string - description: Brief description of what the agent does - owner: - title: Owner - type: string - description: Owner of the agent (e.g. team or user) - ui:field: OwnerPicker - ui:options: - catalogFilter: - kind: [Group, User] - - title: Choose a location - required: - - repoUrl - properties: - repoUrl: - title: Repository Location - type: string - ui:field: RepoUrlPicker - ui:options: - allowedHosts: - - github.com - - steps: - - id: fetch-base - name: Fetch Base - action: fetch:template - input: - url: ./agent-content - values: - name: ${{ parameters.name }} - description: ${{ parameters.description }} - - - id: publish - name: Publish - action: publish:github - input: - allowedHosts: ['github.com'] - description: 'Google ADK Agent: ${{ parameters.name }}' - repoUrl: ${{ parameters.repoUrl }} - defaultBranch: main - - - id: register - name: Register - action: catalog:register - input: - repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }} - catalogInfoPath: /catalog-info.yaml - - output: - links: - - title: Repository - url: ${{ steps['publish'].output.remoteUrl }} - - title: Open in catalog - icon: catalog - entityRef: ${{ steps['register'].output.entityRef }} diff --git a/workspaces/augment/examples/templates/crewai-agent-template.yaml b/workspaces/augment/examples/templates/crewai-agent-template.yaml deleted file mode 100644 index d8674a25100..00000000000 --- a/workspaces/augment/examples/templates/crewai-agent-template.yaml +++ /dev/null @@ -1,84 +0,0 @@ -apiVersion: scaffolder.backstage.io/v1beta3 -kind: Template -metadata: - name: crewai-agent - title: CrewAI Agent - description: Scaffold a Python multi-agent system using CrewAI with role-based agents, task delegation, and collaborative workflows. - tags: - - python - - crewai - - multi-agent - - agent -spec: - owner: user:guest - type: agent - - parameters: - - title: Agent details - required: - - name - - description - properties: - name: - title: Agent Name - type: string - description: Unique name for the agent (lowercase, hyphens allowed) - ui:autofocus: true - pattern: '^[a-z][a-z0-9-]*$' - description: - title: Description - type: string - description: Brief description of what the agent does - owner: - title: Owner - type: string - description: Owner of the agent (e.g. team or user) - ui:field: OwnerPicker - ui:options: - catalogFilter: - kind: [Group, User] - - title: Choose a location - required: - - repoUrl - properties: - repoUrl: - title: Repository Location - type: string - ui:field: RepoUrlPicker - ui:options: - allowedHosts: - - github.com - - steps: - - id: fetch-base - name: Fetch Base - action: fetch:template - input: - url: ./agent-content - values: - name: ${{ parameters.name }} - description: ${{ parameters.description }} - - - id: publish - name: Publish - action: publish:github - input: - allowedHosts: ['github.com'] - description: 'CrewAI Agent: ${{ parameters.name }}' - repoUrl: ${{ parameters.repoUrl }} - defaultBranch: main - - - id: register - name: Register - action: catalog:register - input: - repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }} - catalogInfoPath: /catalog-info.yaml - - output: - links: - - title: Repository - url: ${{ steps['publish'].output.remoteUrl }} - - title: Open in catalog - icon: catalog - entityRef: ${{ steps['register'].output.entityRef }} diff --git a/workspaces/augment/examples/templates/langgraph-agent-template.yaml b/workspaces/augment/examples/templates/langgraph-agent-template.yaml deleted file mode 100644 index c1d581d30d9..00000000000 --- a/workspaces/augment/examples/templates/langgraph-agent-template.yaml +++ /dev/null @@ -1,84 +0,0 @@ -apiVersion: scaffolder.backstage.io/v1beta3 -kind: Template -metadata: - name: langgraph-agent - title: LangGraph Agent - description: Scaffold a Python agent using LangGraph with stateful graph-based orchestration, tool calling, and human-in-the-loop support. - tags: - - python - - langgraph - - langchain - - agent -spec: - owner: user:guest - type: agent - - parameters: - - title: Agent details - required: - - name - - description - properties: - name: - title: Agent Name - type: string - description: Unique name for the agent (lowercase, hyphens allowed) - ui:autofocus: true - pattern: '^[a-z][a-z0-9-]*$' - description: - title: Description - type: string - description: Brief description of what the agent does - owner: - title: Owner - type: string - description: Owner of the agent (e.g. team or user) - ui:field: OwnerPicker - ui:options: - catalogFilter: - kind: [Group, User] - - title: Choose a location - required: - - repoUrl - properties: - repoUrl: - title: Repository Location - type: string - ui:field: RepoUrlPicker - ui:options: - allowedHosts: - - github.com - - steps: - - id: fetch-base - name: Fetch Base - action: fetch:template - input: - url: ./agent-content - values: - name: ${{ parameters.name }} - description: ${{ parameters.description }} - - - id: publish - name: Publish - action: publish:github - input: - allowedHosts: ['github.com'] - description: 'LangGraph Agent: ${{ parameters.name }}' - repoUrl: ${{ parameters.repoUrl }} - defaultBranch: main - - - id: register - name: Register - action: catalog:register - input: - repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }} - catalogInfoPath: /catalog-info.yaml - - output: - links: - - title: Repository - url: ${{ steps['publish'].output.remoteUrl }} - - title: Open in catalog - icon: catalog - entityRef: ${{ steps['register'].output.entityRef }} diff --git a/workspaces/augment/examples/templates/tool-template.yaml b/workspaces/augment/examples/templates/tool-template.yaml deleted file mode 100644 index 5015337d646..00000000000 --- a/workspaces/augment/examples/templates/tool-template.yaml +++ /dev/null @@ -1,84 +0,0 @@ -apiVersion: scaffolder.backstage.io/v1beta3 -kind: Template -metadata: - name: fast-mcp-server - title: Fast MCP Server - description: Scaffold a Python MCP tool server using FastMCP with auto-discovery, SSE transport, and production-ready configuration. - tags: - - python - - mcp - - fastmcp - - tool -spec: - owner: user:guest - type: tool - - parameters: - - title: Tool details - required: - - name - - description - properties: - name: - title: Tool Name - type: string - description: Unique name for the MCP tool (lowercase, hyphens allowed) - ui:autofocus: true - pattern: '^[a-z][a-z0-9-]*$' - description: - title: Description - type: string - description: Brief description of what the tool does - owner: - title: Owner - type: string - description: Owner of the tool (e.g. team or user) - ui:field: OwnerPicker - ui:options: - catalogFilter: - kind: [Group, User] - - title: Choose a location - required: - - repoUrl - properties: - repoUrl: - title: Repository Location - type: string - ui:field: RepoUrlPicker - ui:options: - allowedHosts: - - github.com - - steps: - - id: fetch-base - name: Fetch Base - action: fetch:template - input: - url: ./tool-content - values: - name: ${{ parameters.name }} - description: ${{ parameters.description }} - - - id: publish - name: Publish - action: publish:github - input: - allowedHosts: ['github.com'] - description: 'Fast MCP Server: ${{ parameters.name }}' - repoUrl: ${{ parameters.repoUrl }} - defaultBranch: main - - - id: register - name: Register - action: catalog:register - input: - repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }} - catalogInfoPath: /catalog-info.yaml - - output: - links: - - title: Repository - url: ${{ steps['publish'].output.remoteUrl }} - - title: Open in catalog - icon: catalog - entityRef: ${{ steps['register'].output.entityRef }} diff --git a/workspaces/augment/package.json b/workspaces/augment/package.json deleted file mode 100644 index 0a5c3fc7d22..00000000000 --- a/workspaces/augment/package.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "name": "@internal/augment", - "version": "1.0.0", - "private": true, - "engines": { - "node": "22 || 24" - }, - "scripts": { - "dev": "NODE_OPTIONS=--no-node-snapshot ; yarn workspaces foreach -A --include @red-hat-developer-hub/backstage-plugin-augment --include @red-hat-developer-hub/backstage-plugin-augment-backend --parallel -v -i run start", - "start": "NODE_OPTIONS=--no-node-snapshot ; backstage-cli repo start", - "build:backend": "yarn workspace backend build", - "build:all": "backstage-cli repo build --all", - "build:api-reports": "yarn tsc:full; yarn build:api-reports:only", - "build:api-reports:only": "backstage-repo-tools api-reports -o ae-wrong-input-file-type,ae-undocumented --validate-release-tags -a 'plugins/*'", - "build:knip-reports": "backstage-repo-tools knip-reports", - "build-image": "yarn workspace backend build-image", - "tsc": "tsc", - "tsc:full": "tsc --skipLibCheck true --incremental false", - "clean": "backstage-cli repo clean", - "test": "backstage-cli repo test", - "test:all": "yarn prettier:check && yarn lint:all && backstage-cli repo test --coverage", - "test:e2e": "echo Skipping until we have tests: playwright test", - "fix": "backstage-cli repo fix", - "lint": "backstage-cli repo lint --since origin/main", - "lint:all": "backstage-cli repo lint", - "prettier:check": "prettier --check .", - "prettier:fix": "prettier --write .", - "chores": "yarn prettier:fix && yarn lint:all --fix && yarn tsc:full && yarn build:api-reports && yarn test:all", - "new": "backstage-cli new --scope @red-hat-developer-hub", - "postinstall": "node scripts/patch-agents-core.js && cd ../../ && yarn install" - }, - "workspaces": { - "packages": [ - "packages/*", - "plugins/*" - ] - }, - "repository": { - "type": "git", - "url": "https://github.com/redhat-developer/rhdh-plugins", - "directory": "workspaces/augment" - }, - "devDependencies": { - "@backstage/cli": "^0.34.5", - "@backstage/e2e-test-utils": "^0.1.1", - "@backstage/repo-tools": "^0.16.0", - "@changesets/cli": "^2.27.1", - "@playwright/test": "1.64.1", - "knip": "^5.27.4", - "node-gyp": "^9.0.0", - "prettier": "^3.7.4", - "typescript": "^5.9.0" - }, - "resolutions": { - "@types/react": "^18", - "@types/react-dom": "^18", - "lru-cache": "^8" - }, - "prettier": "@backstage/cli/config/prettier", - "lint-staged": { - "*.{js,jsx,ts,tsx,mjs,cjs}": [ - "eslint --fix", - "prettier --write" - ], - "*.{json,md}": [ - "prettier --write" - ] - }, - "maintainers": [ - "@rrbanda" - ] -} diff --git a/workspaces/augment/packages/app/.eslintrc.js b/workspaces/augment/packages/app/.eslintrc.js deleted file mode 100644 index e2a53a6ad28..00000000000 --- a/workspaces/augment/packages/app/.eslintrc.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/workspaces/augment/packages/app/knip-report.md b/workspaces/augment/packages/app/knip-report.md deleted file mode 100644 index e31b5609e51..00000000000 --- a/workspaces/augment/packages/app/knip-report.md +++ /dev/null @@ -1,13 +0,0 @@ -# Knip report - -## Unused devDependencies (6) - -| Name | Location | Severity | -| :-------------------------- | :---------------- | :------- | -| @testing-library/user-event | package.json:58:6 | error | -| @testing-library/jest-dom | package.json:56:6 | error | -| @testing-library/react | package.json:57:6 | error | -| @backstage/test-utils | package.json:54:6 | error | -| @testing-library/dom | package.json:55:6 | error | -| cross-env | package.json:60:6 | error | - diff --git a/workspaces/augment/packages/app/package.json b/workspaces/augment/packages/app/package.json deleted file mode 100644 index 7bc7f4ad258..00000000000 --- a/workspaces/augment/packages/app/package.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "name": "app", - "version": "0.0.0", - "private": true, - "bundled": true, - "repository": { - "type": "git", - "url": "https://github.com/redhat-developer/rhdh-plugins", - "directory": "workspaces/augment/packages/app" - }, - "backstage": { - "role": "frontend" - }, - "scripts": { - "start": "backstage-cli package start", - "build": "backstage-cli package build", - "clean": "backstage-cli package clean", - "test": "backstage-cli package test", - "lint": "backstage-cli package lint" - }, - "dependencies": { - "@backstage/app-defaults": "^1.6.5", - "@backstage/catalog-model": "^1.7.6", - "@backstage/cli": "^0.34.5", - "@backstage/core-app-api": "^1.18.0", - "@backstage/core-components": "^0.17.5", - "@backstage/core-plugin-api": "^1.10.9", - "@backstage/integration-react": "^1.2.9", - "@backstage/plugin-api-docs": "^0.12.10", - "@backstage/plugin-catalog": "^1.31.2", - "@backstage/plugin-catalog-common": "^1.1.5", - "@backstage/plugin-catalog-graph": "^0.4.22", - "@backstage/plugin-catalog-import": "^0.13.4", - "@backstage/plugin-catalog-react": "^1.20.1", - "@backstage/plugin-org": "^0.6.43", - "@backstage/plugin-permission-react": "^0.4.36", - "@backstage/plugin-scaffolder": "^1.34.0", - "@backstage/plugin-search": "^1.4.29", - "@backstage/plugin-search-react": "^1.9.3", - "@backstage/plugin-techdocs": "^1.14.1", - "@backstage/plugin-techdocs-module-addons-contrib": "^1.1.27", - "@backstage/plugin-techdocs-react": "^1.3.2", - "@backstage/plugin-user-settings": "^0.8.25", - "@backstage/theme": "^0.6.8", - "@material-ui/core": "^4.12.2", - "@material-ui/icons": "^4.9.1", - "@red-hat-developer-hub/backstage-plugin-augment": "workspace:*", - "react": "^18.0.2", - "react-dom": "^18.0.2", - "react-router": "^6.3.0", - "react-router-dom": "^6.3.0" - }, - "devDependencies": { - "@backstage/test-utils": "^1.7.11", - "@testing-library/dom": "^9.0.0", - "@testing-library/jest-dom": "^6.0.0", - "@testing-library/react": "^14.0.0", - "@testing-library/user-event": "^14.0.0", - "@types/react-dom": "*", - "cross-env": "^7.0.0" - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - }, - "files": [ - "dist" - ] -} diff --git a/workspaces/augment/packages/app/public/android-chrome-192x192.png b/workspaces/augment/packages/app/public/android-chrome-192x192.png deleted file mode 100644 index eec0ae25b971cae8eb0033c9af7e0f676d1df663..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13599 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 EANz~b2mk;8 diff --git a/workspaces/augment/packages/app/public/apple-touch-icon.png b/workspaces/augment/packages/app/public/apple-touch-icon.png deleted file mode 100644 index 3158830ac778a62ff8f08da0e9eeee6e8ada8bfc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12619 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 VSn|oqbSlsa22WQ%mvv4FO#qym7y1AI diff --git a/workspaces/augment/packages/app/public/favicon-32x32.png b/workspaces/augment/packages/app/public/favicon-32x32.png deleted file mode 100644 index c0915ece75949f3d917134f55193949927edc633..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1686 zcmd5*YdF&j82@WYD9ojqT$Wo{SSXitp^eRqxy{@++t}P`atlKxQX0)YMm6_KR%p4F z$}J+6LMeH2OXWDHjylI#AJ2#L{k+fbeSgn;`91HK_szmP+3%H7kpcicc}5$z1N!Px-7om}akWbt233fdJ10ExN)z&Z~ATcQ$c2>@ad0I)&=0IMPZ zfJBuET&w|L$8)@+J4!Sl|Nk(stgJlvmnlJ&;dZvl8tO{Q%IJ*5_~q$1+xa={mdc0(T`t!w?RZl>^IxA*%bGu3bF$<*nzTrF> zoQ&9oiO|s=ORAp|1maSVle9kTR(cJ300u{&4iq#dewwjMjZ;umbia|8`fV}#)u6uZ z2{{=Vn6Y8_#3SbXb1ic-&s#nMteerJ5NT@9WJFf_0Z{rXvToRrSOV|5nT6}g)=)7clCVKtHJ1*VPs-V;@XTM)*hF8A>dvO zHk*Y?X8JaA;})mFp7;7TRRuk~8$R*Snh{9txf3X8a4%y!TuP;MHGAGHB;T*cTs()( zPBU^uyOrcpI-AkwlQc~ccBy9xRRz@U7IH^D@piFSO__BF#e(AN-^z2yOmV!NrfGJV z)K=@)T;PiA16X29aqmP@px{GT+&pJ*OCU>i#GE8dw3RuCdu^ zY|d<%y|L(_@zJ9#_m z=Lc$*iilj{bjSDeNH|lRP>yLE)Yq?l!XVsP?uHhR(xnf>5p}vuAuF-j7*tA0oTAH zhFP&ZPMN7z8!K6i|K8u7|AYJvRll0p!ov{KSr|$njU-`o&7fVZN5@WQ|g`Mlirnap=oY~b#iCdHeScp|7sct zd}FL!1NSuuCnVle?ygaP975V!)pzN#*|A{{1* z$fS~)G%HFBO+IIv4>f5HNGY_6SF`0>Gf0kWIFv^nU=X CgrE5U diff --git a/workspaces/augment/packages/app/public/favicon.ico b/workspaces/augment/packages/app/public/favicon.ico deleted file mode 100644 index 5e45e5dfbde6f39603d5be60d933c1af14dffb1e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15086 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 D+pS%} diff --git a/workspaces/augment/packages/app/public/index.html b/workspaces/augment/packages/app/public/index.html deleted file mode 100644 index 00859982f2d..00000000000 --- a/workspaces/augment/packages/app/public/index.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - <%= config.getOptionalString('app.title') ?? 'Backstage' %> - - - -
- - - diff --git a/workspaces/augment/packages/app/public/manifest.json b/workspaces/augment/packages/app/public/manifest.json deleted file mode 100644 index 4a7c1b4ec42..00000000000 --- a/workspaces/augment/packages/app/public/manifest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "short_name": "Backstage", - "name": "Backstage", - "icons": [ - { - "src": "favicon.ico", - "sizes": "48x48", - "type": "image/png" - } - ], - "start_url": "./index.html", - "display": "standalone", - "theme_color": "#000000", - "background_color": "#ffffff" -} diff --git a/workspaces/augment/packages/app/public/robots.txt b/workspaces/augment/packages/app/public/robots.txt deleted file mode 100644 index 01b0f9a1073..00000000000 --- a/workspaces/augment/packages/app/public/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * diff --git a/workspaces/augment/packages/app/public/safari-pinned-tab.svg b/workspaces/augment/packages/app/public/safari-pinned-tab.svg deleted file mode 100644 index 0f500b30023..00000000000 --- a/workspaces/augment/packages/app/public/safari-pinned-tab.svg +++ /dev/null @@ -1 +0,0 @@ -Created by potrace 1.11, written by Peter Selinger 2001-2013 \ No newline at end of file diff --git a/workspaces/augment/packages/app/src/App.tsx b/workspaces/augment/packages/app/src/App.tsx deleted file mode 100644 index 108f5692046..00000000000 --- a/workspaces/augment/packages/app/src/App.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Navigate, Route } from 'react-router-dom'; -import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs'; -import { - CatalogEntityPage, - CatalogIndexPage, - catalogPlugin, -} from '@backstage/plugin-catalog'; -import { - CatalogImportPage, - catalogImportPlugin, -} from '@backstage/plugin-catalog-import'; -import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder'; -import { orgPlugin } from '@backstage/plugin-org'; -import { SearchPage } from '@backstage/plugin-search'; -import { - TechDocsIndexPage, - techdocsPlugin, - TechDocsReaderPage, -} from '@backstage/plugin-techdocs'; -import { TechDocsAddons } from '@backstage/plugin-techdocs-react'; -import { ReportIssue } from '@backstage/plugin-techdocs-module-addons-contrib'; -import { UserSettingsPage } from '@backstage/plugin-user-settings'; -import { apis } from './apis'; -import { entityPage } from './components/catalog/EntityPage'; -import { searchPage } from './components/search/SearchPage'; -import { Root } from './components/Root'; - -import { - AlertDisplay, - OAuthRequestDialog, - SignInPage, -} from '@backstage/core-components'; -import { createApp } from '@backstage/app-defaults'; -import { AppRouter, FlatRoutes } from '@backstage/core-app-api'; -import { CatalogGraphPage } from '@backstage/plugin-catalog-graph'; -import { RequirePermission } from '@backstage/plugin-permission-react'; -import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha'; -import { AugmentPage } from '@red-hat-developer-hub/backstage-plugin-augment'; - -const app = createApp({ - apis, - components: { - SignInPage: props => , - }, - bindRoutes({ bind }) { - bind(catalogPlugin.externalRoutes, { - createComponent: scaffolderPlugin.routes.root, - viewTechDoc: techdocsPlugin.routes.docRoot, - createFromTemplate: scaffolderPlugin.routes.selectedTemplate, - }); - bind(apiDocsPlugin.externalRoutes, { - registerApi: catalogImportPlugin.routes.importPage, - }); - bind(scaffolderPlugin.externalRoutes, { - registerComponent: catalogImportPlugin.routes.importPage, - viewTechDoc: techdocsPlugin.routes.docRoot, - }); - bind(orgPlugin.externalRoutes, { - catalogIndex: catalogPlugin.routes.catalogIndex, - }); - }, -}); - -const routes = ( - - } /> - } /> - } - > - {entityPage} - - } /> - } - > - - - - - } /> - } /> - - - - } - /> - } /> - }> - {searchPage} - - } /> - } /> - -); - -export default app.createRoot( - <> - - - - {routes} - - , -); diff --git a/workspaces/augment/packages/app/src/apis.ts b/workspaces/augment/packages/app/src/apis.ts deleted file mode 100644 index 762d3206bf3..00000000000 --- a/workspaces/augment/packages/app/src/apis.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - ScmIntegrationsApi, - scmIntegrationsApiRef, - ScmAuth, -} from '@backstage/integration-react'; -import { - AnyApiFactory, - configApiRef, - createApiFactory, -} from '@backstage/core-plugin-api'; - -export const apis: AnyApiFactory[] = [ - createApiFactory({ - api: scmIntegrationsApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), - }), - ScmAuth.createDefaultApiFactory(), -]; diff --git a/workspaces/augment/packages/app/src/components/Root/LogoFull.tsx b/workspaces/augment/packages/app/src/components/Root/LogoFull.tsx deleted file mode 100644 index f19cfdf8bbb..00000000000 --- a/workspaces/augment/packages/app/src/components/Root/LogoFull.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { makeStyles } from '@material-ui/core'; - -const useStyles = makeStyles({ - svg: { - width: 'auto', - height: 30, - }, - path: { - fill: '#7df3e1', - }, -}); - -const LogoFull = () => { - const classes = useStyles(); - - return ( - - - Augment - - - ); -}; - -export default LogoFull; diff --git a/workspaces/augment/packages/app/src/components/Root/LogoIcon.tsx b/workspaces/augment/packages/app/src/components/Root/LogoIcon.tsx deleted file mode 100644 index 1016e4288d9..00000000000 --- a/workspaces/augment/packages/app/src/components/Root/LogoIcon.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { makeStyles } from '@material-ui/core'; - -const useStyles = makeStyles({ - svg: { - width: 'auto', - height: 28, - }, - path: { - fill: '#7df3e1', - }, -}); - -const LogoIcon = () => { - const classes = useStyles(); - - return ( - - - A - - - ); -}; - -export default LogoIcon; diff --git a/workspaces/augment/packages/app/src/components/Root/Root.tsx b/workspaces/augment/packages/app/src/components/Root/Root.tsx deleted file mode 100644 index c6c10d1d1cb..00000000000 --- a/workspaces/augment/packages/app/src/components/Root/Root.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { PropsWithChildren } from 'react'; -import { makeStyles } from '@material-ui/core'; -import HomeIcon from '@material-ui/icons/Home'; -import ExtensionIcon from '@material-ui/icons/Extension'; -import LibraryBooks from '@material-ui/icons/LibraryBooks'; -import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; -import ChatIcon from '@material-ui/icons/Chat'; -import LogoFull from './LogoFull'; -import LogoIcon from './LogoIcon'; -import { - Settings as SidebarSettings, - UserSettingsSignInAvatar, -} from '@backstage/plugin-user-settings'; -import { SidebarSearchModal } from '@backstage/plugin-search'; -import { - Sidebar, - sidebarConfig, - SidebarDivider, - SidebarGroup, - SidebarItem, - SidebarPage, - SidebarScrollWrapper, - SidebarSpace, - useSidebarOpenState, - Link, -} from '@backstage/core-components'; -import MenuIcon from '@material-ui/icons/Menu'; -import SearchIcon from '@material-ui/icons/Search'; -import { MyGroupsSidebarItem } from '@backstage/plugin-org'; -import GroupIcon from '@material-ui/icons/People'; - -const useSidebarLogoStyles = makeStyles({ - root: { - width: sidebarConfig.drawerWidthClosed, - height: 3 * sidebarConfig.logoHeight, - display: 'flex', - flexFlow: 'row nowrap', - alignItems: 'center', - marginBottom: -14, - }, - link: { - width: sidebarConfig.drawerWidthClosed, - marginLeft: 24, - }, -}); - -const SidebarLogo = () => { - const classes = useSidebarLogoStyles(); - const { isOpen } = useSidebarOpenState(); - - return ( -
- - {isOpen ? : } - -
- ); -}; - -export const Root = ({ children }: PropsWithChildren<{}>) => ( - - - - } to="/search"> - - - - }> - - - - - - - - - - - - } - to="/settings" - > - - - - {children} - -); diff --git a/workspaces/augment/packages/app/src/components/Root/index.ts b/workspaces/augment/packages/app/src/components/Root/index.ts deleted file mode 100644 index 9908988f279..00000000000 --- a/workspaces/augment/packages/app/src/components/Root/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { Root } from './Root'; diff --git a/workspaces/augment/packages/app/src/components/catalog/EntityPage.tsx b/workspaces/augment/packages/app/src/components/catalog/EntityPage.tsx deleted file mode 100644 index 5685b4fbf24..00000000000 --- a/workspaces/augment/packages/app/src/components/catalog/EntityPage.tsx +++ /dev/null @@ -1,404 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Button, Grid } from '@material-ui/core'; -import { - EntityApiDefinitionCard, - EntityConsumedApisCard, - EntityConsumingComponentsCard, - EntityHasApisCard, - EntityProvidedApisCard, - EntityProvidingComponentsCard, -} from '@backstage/plugin-api-docs'; -import { - EntityAboutCard, - EntityDependsOnComponentsCard, - EntityDependsOnResourcesCard, - EntityHasComponentsCard, - EntityHasResourcesCard, - EntityHasSubcomponentsCard, - EntityHasSystemsCard, - EntityLayout, - EntityLinksCard, - EntitySwitch, - EntityOrphanWarning, - EntityProcessingErrorsPanel, - isComponentType, - isKind, - hasCatalogProcessingErrors, - isOrphan, - hasRelationWarnings, - EntityRelationWarning, -} from '@backstage/plugin-catalog'; -import { - EntityUserProfileCard, - EntityGroupProfileCard, - EntityMembersListCard, - EntityOwnershipCard, -} from '@backstage/plugin-org'; -import { EntityTechdocsContent } from '@backstage/plugin-techdocs'; -import { EmptyState } from '@backstage/core-components'; -import { - Direction, - EntityCatalogGraphCard, -} from '@backstage/plugin-catalog-graph'; -import { - RELATION_API_CONSUMED_BY, - RELATION_API_PROVIDED_BY, - RELATION_CONSUMES_API, - RELATION_DEPENDENCY_OF, - RELATION_DEPENDS_ON, - RELATION_HAS_PART, - RELATION_PART_OF, - RELATION_PROVIDES_API, -} from '@backstage/catalog-model'; - -import { TechDocsAddons } from '@backstage/plugin-techdocs-react'; -import { ReportIssue } from '@backstage/plugin-techdocs-module-addons-contrib'; - -const techdocsContent = ( - - - - - -); - -const cicdContent = ( - // This is an example of how you can implement your company's logic in entity page. - // You can for example enforce that all components of type 'service' should use GitHubActions - - {/* - Here you can add support for different CI/CD services, for example - using @backstage-community/plugin-github-actions as follows: - - - - */} - - - Read more - - } - /> - - -); - -const entityWarningContent = ( - <> - - - - - - - - - - - - - - - - - - - - - - - - -); - -const overviewContent = ( - - {entityWarningContent} - - - - - - - - - - - - - - -); - -const serviceEntityPage = ( - - - {overviewContent} - - - - {cicdContent} - - - - - - - - - - - - - - - - - - - - - - - - - - {techdocsContent} - - -); - -const websiteEntityPage = ( - - - {overviewContent} - - - - {cicdContent} - - - - - - - - - - - - - - - {techdocsContent} - - -); - -/** - * NOTE: This page is designed to work on small screens such as mobile devices. - * This is based on Material UI Grid. If breakpoints are used, each grid item must set the `xs` prop to a column size or to `true`, - * since this does not default. If no breakpoints are used, the items will equitably share the available space. - * https://material-ui.com/components/grid/#basic-grid. - */ - -const defaultEntityPage = ( - - - {overviewContent} - - - - {techdocsContent} - - -); - -const componentPage = ( - - - {serviceEntityPage} - - - - {websiteEntityPage} - - - {defaultEntityPage} - -); - -const apiPage = ( - - - - {entityWarningContent} - - - - - - - - - - - - - - - - - - - - - - - - - - - - -); - -const userPage = ( - - - - {entityWarningContent} - - - - - - - - - -); - -const groupPage = ( - - - - {entityWarningContent} - - - - - - - - - - - - - - - -); - -const systemPage = ( - - - - {entityWarningContent} - - - - - - - - - - - - - - - - - - - - - - - - -); - -const domainPage = ( - - - - {entityWarningContent} - - - - - - - - - - - - -); - -export const entityPage = ( - - - - - - - - - {defaultEntityPage} - -); diff --git a/workspaces/augment/packages/app/src/components/search/SearchPage.tsx b/workspaces/augment/packages/app/src/components/search/SearchPage.tsx deleted file mode 100644 index 5d651901f0f..00000000000 --- a/workspaces/augment/packages/app/src/components/search/SearchPage.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { makeStyles, Theme, Grid, Paper } from '@material-ui/core'; - -import { CatalogSearchResultListItem } from '@backstage/plugin-catalog'; -import { - catalogApiRef, - CATALOG_FILTER_EXISTS, -} from '@backstage/plugin-catalog-react'; -import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs'; - -import { SearchType } from '@backstage/plugin-search'; -import { - SearchBar, - SearchFilter, - SearchResult, - SearchPagination, - useSearch, -} from '@backstage/plugin-search-react'; -import { - CatalogIcon, - Content, - DocsIcon, - Header, - Page, -} from '@backstage/core-components'; -import { useApi } from '@backstage/core-plugin-api'; - -const useStyles = makeStyles((theme: Theme) => ({ - bar: { - padding: theme.spacing(1, 0), - }, - filters: { - padding: theme.spacing(2), - marginTop: theme.spacing(2), - }, - filter: { - '& + &': { - marginTop: theme.spacing(2.5), - }, - }, -})); - -const SearchPage = () => { - const classes = useStyles(); - const { types } = useSearch(); - const catalogApi = useApi(catalogApiRef); - - return ( - -
- - - - - - - - - , - }, - { - value: 'techdocs', - name: 'Documentation', - icon: , - }, - ]} - /> - - {types.includes('techdocs') && ( - { - // Return a list of entities which are documented. - const { items } = await catalogApi.getEntities({ - fields: ['metadata.name'], - filter: { - 'metadata.annotations.backstage.io/techdocs-ref': - CATALOG_FILTER_EXISTS, - }, - }); - - const names = items.map(entity => entity.metadata.name); - names.sort(); - return names; - }} - /> - )} - - - - - - - - } /> - } /> - - - - - - ); -}; - -export const searchPage = ; diff --git a/workspaces/augment/packages/app/src/index.tsx b/workspaces/augment/packages/app/src/index.tsx deleted file mode 100644 index b86386144fa..00000000000 --- a/workspaces/augment/packages/app/src/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import '@backstage/cli/asset-types'; -import ReactDOM from 'react-dom/client'; -import App from './App'; - -ReactDOM.createRoot(document.getElementById('root')!).render(); diff --git a/workspaces/augment/packages/backend/.eslintrc.js b/workspaces/augment/packages/backend/.eslintrc.js deleted file mode 100644 index e2a53a6ad28..00000000000 --- a/workspaces/augment/packages/backend/.eslintrc.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/workspaces/augment/packages/backend/knip-report.md b/workspaces/augment/packages/backend/knip-report.md deleted file mode 100644 index f8cfe55f85c..00000000000 --- a/workspaces/augment/packages/backend/knip-report.md +++ /dev/null @@ -1,14 +0,0 @@ -# Knip report - -## Unused dependencies (7) - -| Name | Location | Severity | -| :------------------------------------ | :---------------- | :------- | -| @backstage/plugin-search-backend-node | package.json:42:6 | error | -| @backstage/plugin-permission-common | package.json:35:6 | error | -| @backstage/plugin-permission-node | package.json:36:6 | error | -| @backstage/plugin-auth-node | package.json:29:6 | error | -| @backstage/config | package.json:25:6 | error | -| node-gyp | package.json:47:6 | error | -| app | package.json:45:6 | error | - diff --git a/workspaces/augment/packages/backend/package.json b/workspaces/augment/packages/backend/package.json deleted file mode 100644 index f0d4b6f1afb..00000000000 --- a/workspaces/augment/packages/backend/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "backend", - "version": "0.0.0", - "main": "dist/index.cjs.js", - "types": "src/index.ts", - "private": true, - "repository": { - "type": "git", - "url": "https://github.com/redhat-developer/rhdh-plugins", - "directory": "workspaces/augment/packages/backend" - }, - "backstage": { - "role": "backend" - }, - "scripts": { - "start": "backstage-cli package start", - "build": "backstage-cli package build", - "lint": "backstage-cli package lint", - "test": "backstage-cli package test", - "clean": "backstage-cli package clean", - "build-image": "docker build ../.. -f Dockerfile --tag backstage" - }, - "dependencies": { - "@backstage/backend-defaults": "^0.14.2", - "@backstage/config": "^1.3.6", - "@backstage/plugin-app-backend": "^0.5.8", - "@backstage/plugin-auth-backend": "^0.25.6", - "@backstage/plugin-auth-backend-module-guest-provider": "^0.2.14", - "@backstage/plugin-auth-node": "^0.6.9", - "@backstage/plugin-catalog-backend": "^3.2.0", - "@backstage/plugin-catalog-backend-module-logs": "^0.1.16", - "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "^0.2.14", - "@backstage/plugin-permission-backend": "^0.7.8", - "@backstage/plugin-permission-backend-module-allow-all-policy": "^0.2.18", - "@backstage/plugin-permission-common": "^0.9.3", - "@backstage/plugin-permission-node": "^0.10.6", - "@backstage/plugin-proxy-backend": "^0.6.8", - "@backstage/plugin-scaffolder-backend": "^3.0.1", - "@backstage/plugin-search-backend": "^2.0.8", - "@backstage/plugin-search-backend-module-catalog": "^0.3.10", - "@backstage/plugin-search-backend-module-techdocs": "^0.4.8", - "@backstage/plugin-search-backend-node": "^1.3.17", - "@backstage/plugin-techdocs-backend": "^2.1.2", - "@red-hat-developer-hub/backstage-plugin-augment-backend": "workspace:*", - "app": "link:../app", - "better-sqlite3": "^12.0.0", - "node-gyp": "^9.0.0" - }, - "devDependencies": { - "@backstage/cli": "^0.34.5" - }, - "files": [ - "dist" - ] -} diff --git a/workspaces/augment/packages/backend/src/index.ts b/workspaces/augment/packages/backend/src/index.ts deleted file mode 100644 index b67f2fa8efc..00000000000 --- a/workspaces/augment/packages/backend/src/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createBackend } from '@backstage/backend-defaults'; - -const backend = createBackend(); - -backend.add(import('@backstage/plugin-app-backend')); -backend.add(import('@backstage/plugin-proxy-backend')); - -backend.add(import('@backstage/plugin-scaffolder-backend')); - -backend.add(import('@backstage/plugin-techdocs-backend')); - -backend.add(import('@backstage/plugin-auth-backend')); -backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); - -backend.add(import('@backstage/plugin-catalog-backend')); -backend.add( - import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'), -); -backend.add(import('@backstage/plugin-catalog-backend-module-logs')); - -backend.add(import('@backstage/plugin-permission-backend')); -backend.add( - import('@backstage/plugin-permission-backend-module-allow-all-policy'), -); - -backend.add(import('@backstage/plugin-search-backend')); -backend.add(import('@backstage/plugin-search-backend-module-catalog')); -backend.add(import('@backstage/plugin-search-backend-module-techdocs')); - -backend.add(import('@red-hat-developer-hub/backstage-plugin-augment-backend')); - -backend.start(); diff --git a/workspaces/augment/plugins/.gitkeep b/workspaces/augment/plugins/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/workspaces/augment/plugins/augment-backend/.eslintrc.js b/workspaces/augment/plugins/augment-backend/.eslintrc.js deleted file mode 100644 index b17a00e31ad..00000000000 --- a/workspaces/augment/plugins/augment-backend/.eslintrc.js +++ /dev/null @@ -1,2 +0,0 @@ -module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); - diff --git a/workspaces/augment/plugins/augment-backend/CHANGELOG.md b/workspaces/augment/plugins/augment-backend/CHANGELOG.md deleted file mode 100644 index c34a1ba1c1e..00000000000 --- a/workspaces/augment/plugins/augment-backend/CHANGELOG.md +++ /dev/null @@ -1,47 +0,0 @@ -# @red-hat-developer-hub/backstage-plugin-augment-backend - -## 0.3.0 - -### Minor Changes - -- aa44c81: Bump minor after the release-2.1 cut so main patch versions do not collide with the maintenance line on npm. - -### Patch Changes - -- f6f46c0: Updated dependency `@openai/agents-core` to `^0.21.0`. -- Updated dependencies [aa44c81] - - @red-hat-developer-hub/backstage-plugin-augment-common@0.3.0 - -## 0.2.2 - -### Patch Changes - -- 44358a3: Updated dependency `@openai/agents-core` to `^0.20.0`. - Updated dependency `katex` to `^0.19.0`. - -## 0.2.1 - -### Patch Changes - -- 8addf37: Updated dependency `@openai/agents-core` to `^0.17.0`. - -## 0.2.0 - -### Minor Changes - -- b418448: Add agent registry governance: unified agent listings with governanceRegistered flag, register-for-governance API, strict lifecycle transitions on publish/unpublish, and Registry UI with unregistered agent warnings and bulk actions. -- afaed59: Initial release of the Augment AI assistant plugin for Backstage. Provides a chat interface backed by Llama Stack with RAG, tool calling, multi-agent orchestration, and configurable safety guardrails. -- 3e18b17: Agent lifecycle hardening: add 5-stage promotion pipeline (draft/review/staging/production/retired), ownership-gated operations, rejection tracking, role-aware UI, admin polling dashboards, cascading delete, and SonataFlow agent-approval workflow. -- 717a651: Add SonataFlow agent approval workflow integration with dual-mode support (immediate or workflow-based transitions), request-unpublish and withdraw endpoints. - -### Patch Changes - -- 64becad: Updated dependency `@openai/agents-core` to `^0.14.0`. -- fa3478a: Updated dependency `@openai/agents-core` to `^0.11.0`. - Updated dependency `katex` to `^0.17.0`. -- 8d3503a: Updated dependency `@openai/agents-core` to `^0.13.0`. -- Updated dependencies [b418448] -- Updated dependencies [afaed59] -- Updated dependencies [3e18b17] -- Updated dependencies [717a651] - - @red-hat-developer-hub/backstage-plugin-augment-common@0.2.0 diff --git a/workspaces/augment/plugins/augment-backend/README.md b/workspaces/augment/plugins/augment-backend/README.md deleted file mode 100644 index ec5b61a66c4..00000000000 --- a/workspaces/augment/plugins/augment-backend/README.md +++ /dev/null @@ -1,357 +0,0 @@ -# Augment Backend - -Backend plugin for Augment — a multi-agent AI assistant built on Llama Stack's OpenAI-compatible Responses API. - -Every agent is a **client-side abstraction** (`instructions` + `tools` + `model`), following the [OpenAI Agents SDK pattern](https://openai.github.io/openai-agents-python/multi_agent/). Agent configs in YAML are assembled into parameterized `POST /v1/responses` calls — there is no persistent agent resource on the server. - -## Features - -- **Multi-Agent Orchestration**: Define router and specialist agents with handoffs and agents-as-tools patterns -- **OpenAI Agents SDK Compatibility**: `transfer_to_{agent}` handoffs, `call_{agent}` tool delegation, handoff input filters, tool guardrails, output schemas -- **Config-Driven Ingestion**: Documents are automatically synced from configured sources -- **Multiple Sources**: Support for local directories, URLs, and GitHub repositories -- **Llama Stack Integration**: Uses Llama Stack's OpenAI-compatible APIs (`/v1/responses`, `/v1/conversations`, `/v1/vector_stores`, `/v1/files`) -- **File Search (RAG)**: Built-in `file_search` tool backed by vector stores -- **Conversations API**: Persistent chat sessions via Llama Stack's `/v1/conversations` — context is maintained across agent handoffs -- **Server-Side Prompt Management**: Reference versioned prompts from Llama Stack's `/v1/prompts` API via `promptRef` -- **Periodic Sync**: Optional scheduled re-sync to keep documents up-to-date -- **Status Monitoring**: Health checks for provider and vector store connections -- **3 Security Modes**: Flexible authentication (`none`, `plugin-only`, `full`) -- **MCP Server Integration**: Connect to MCP servers for extended tool capabilities -- **Safety Guardrails**: Input/output shields via Llama Stack Safety API, plus per-agent tool guardrails - -## Installation - -```bash -yarn --cwd packages/backend add @red-hat-developer-hub/backstage-plugin-augment-backend -``` - -```ts -// In packages/backend/src/index.ts -const backend = createBackend(); -backend.add(import('@red-hat-developer-hub/backstage-plugin-augment-backend')); -``` - -## How Multi-Agent Orchestration Works - -The `ResponsesApiCoordinator` reads agent definitions from YAML config and builds each agent's `POST /v1/responses` call at runtime: - -1. **Agent resolution**: The coordinator looks up the target agent's `AgentConfig` (instructions, tools, model, temperature, etc.) -2. **Tool assembly**: Handoffs become `transfer_to_{key}` function tools; agents-as-tools become `call_{key}` function tools; MCP tools, `file_search`, `web_search`, and custom functions are merged in -3. **API call**: A single `POST /v1/responses` is sent with the assembled payload -4. **Tool execution**: If the response includes tool calls (MCP, handoff, RAG, etc.), the coordinator executes them and loops back for another response -5. **Handoff handling**: When a `transfer_to_{key}` tool is called, the coordinator switches the active agent and continues the conversation loop with the new agent's instructions and tools - -All agents within a session share the same Llama Stack `conversation_id` via the Conversations API, maintaining full context across handoffs. - -### Agent Configuration Reference - -Each agent supports the full OpenAI Agents SDK surface area: - -| Field | Description | -| ----------------------- | ------------------------------------------------------------------------------- | -| `name` | Display name of the agent | -| `instructions` | System prompt (the complete prompt sent to the LLM) | -| `handoffs` | Agent keys this agent can delegate to (`transfer_to_{key}` functions) | -| `asTools` | Agent keys this agent can call as tools (`call_{key}` functions) | -| `mcpServers` | Subset of MCP server IDs this agent can use | -| `enableRAG` | Whether this agent has access to `file_search` | -| `enableWebSearch` | Whether this agent has access to `web_search` | -| `enableCodeInterpreter` | Whether this agent can run code | -| `functions` | Custom function definitions specific to this agent | -| `handoffDescription` | Description shown to other agents when this agent is a handoff target | -| `handoffInputSchema` | JSON Schema for structured handoff metadata (maps to OpenAI `inputType`) | -| `handoffInputFilter` | How history is transformed on handoff: `none`, `removeToolCalls`, `summaryOnly` | -| `toolUseBehavior` | What happens after tool results: `run_llm_again` or `stop_on_first_tool` | -| `outputSchema` | JSON Schema for structured output validation | -| `toolGuardrails` | Per-tool input/output guardrail rules | -| `guardrails` | Server-side shield IDs for this agent | -| `toolChoice` | Override tool choice strategy (`auto`, `required`, `none`, or specific tool) | -| `reasoning` | Reasoning/chain-of-thought configuration | -| `model` | Override LLM model for this agent | -| `temperature` | Sampling temperature override | -| `maxToolCalls` | Cap on tool calls per response to prevent loops | -| `maxOutputTokens` | Override max output tokens | -| `promptRef` | Reference to a server-managed prompt template in Llama Stack's Prompts API | -| `truncation` | Context window truncation strategy (`auto` or `disabled`) | - -## Configuration - -```yaml -augment: - # ============================================================================= - # SECURITY - 3 Modes Available (see docs/SECURITY_MODES.md for details) - # ============================================================================= - security: - mode: 'plugin-only' # Options: 'none' | 'plugin-only' | 'full' - - llamaStack: - baseUrl: 'https://your-llama-stack-server.com' - vectorStoreId: 'your-vector-store-id' - model: 'gemini/gemini-2.5-flash' - chunkingStrategy: 'static' - maxChunkSizeTokens: 200 - chunkOverlapTokens: 50 - skipTlsVerify: true # For self-signed certs - - # Document sources for automatic ingestion - documents: - syncMode: full # 'full' or 'append' - syncSchedule: '1h' # Optional: periodic sync - sources: - # Local directory - - type: directory - path: ./docs/knowledge-base - patterns: - - '**/*.md' - - '**/*.yaml' - - # URLs - - type: url - urls: - - https://example.com/docs/guide.md - - # GitHub repository - - type: github - repo: 'org/documentation' - branch: main - path: docs/ - patterns: - - '*.md' - token: ${GITHUB_TOKEN} - - # MCP Servers for extended tool capabilities - mcpServers: - - id: openshift-server - name: OpenShift MCP Server - type: streamable-http # or 'sse' - url: 'https://mcp-server.example.com/mcp' - requireApproval: 'never' # or 'always' or { always: [...], never: [...] } - - # Multi-agent orchestration - agents: - triage: - name: Triage Agent - instructions: 'You are a triage agent. Route to the appropriate specialist.' - handoffs: [docs, kubernetes] - toolChoice: { type: 'required' } - temperature: 0.1 - docs: - name: Documentation Specialist - instructions: 'You answer questions from the knowledge base.' - enableRAG: true - handoffDescription: 'Handles documentation and knowledge-base questions' - kubernetes: - name: Kubernetes Specialist - instructions: 'You help with Kubernetes and OpenShift operations.' - mcpServers: [openshift-server] - handoffDescription: 'Handles Kubernetes and OpenShift operations' -``` - -### Kagenti Provider - -To use Kagenti as the AI provider instead of Llama Stack, set `augment.provider` to `kagenti` and add the `augment.kagenti` configuration block: - -```yaml -augment: - provider: kagenti - - kagenti: - # Required: Kagenti API server URL - baseUrl: https://kagenti-api-kagenti-system.apps.ocp.example.com - - # Required: OAuth2 client credentials for Kagenti API authentication - auth: - tokenEndpoint: https://keycloak.example.com/realms/kagenti/protocol/openid-connect/token - clientId: backstage-augment - clientSecret: ${KAGENTI_CLIENT_SECRET} - - # Optional: Default namespace for agents and tools (default: "default") - namespace: my-namespace - - # Optional: Restrict to specific namespaces (allowlist) - namespaces: - - team-a - - team-b - - # Optional: Show all namespaces in the namespace picker (default: true) - showAllNamespaces: true - - # Optional: Default agent name for chat - agentName: my-agent - - # Optional: Skip TLS verification for self-signed certs (default: false) - skipTlsVerify: false - - # Optional: Timeout configuration - requestTimeoutMs: 30000 - streamTimeoutMs: 300000 - maxRetries: 3 - - # Optional: Local overrides for feature flags from the Kagenti API - featureOverrides: - sandbox: true - integrations: true - triggers: true - - # Optional: Dashboard URL overrides - dashboards: - traces: https://jaeger.example.com - network: https://kiali.example.com - mcpInspector: https://mcp-inspector.example.com - mcpProxy: https://mcp-proxy.example.com - keycloakConsole: https://keycloak.example.com/admin - - # Optional: Sandbox default settings - sandbox: - sessionTtlMinutes: 30 - defaultSkill: python - sidecar: - autoApprove: false -``` - -### Kagenti Agent Security - -Kagenti supports several layers of agent security that are integrated with the Augment plugin: - -**Service Authentication**: The plugin authenticates to the Kagenti API using OAuth2 client credentials (configured via `augment.kagenti.auth`). All API calls use a shared service token. - -**User Identity Propagation**: The Backstage user's identity is forwarded to the Kagenti API via the `X-Backstage-User` header on every request, enabling per-user audit trails and access control on the Kagenti side. - -**Namespace Access Control**: When `namespaces` is configured as an allowlist, the plugin enforces that users can only access agents and tools within those namespaces. If `showAllNamespaces: false`, only the default `namespace` is accessible. - -**Route-Level Authorization**: Kagenti mutation routes (create/delete agents, tools, builds, LLM configuration, integrations) require admin access (`augment.admin` permission). Read-only routes (list agents, list tools, view agent cards) are accessible to all plugin users. - -**Auth Bridge**: When creating agents or tools, the "Auth bridge enabled" flag enables identity propagation between services. The calling user's identity is forwarded to the tool via Kagenti's auth bridge, allowing the tool to make authenticated calls on behalf of the user. - -**SPIRE (Workload Identity)**: The "SPIRE enabled" flag enables SPIFFE-based workload identity with mutual TLS. When enabled, the tool receives a cryptographic workload identity and communicates with other services using mTLS. - -**A2A Interactive Security**: During agent conversations, Kagenti agents can request: - -- **User Approval**: Human-in-the-loop confirmation before executing tools -- **OAuth Authentication**: Redirect the user to an identity provider for authentication -- **Secrets**: Prompt the user for credentials or API keys -- **Form Input**: Request structured data from the user - -### Kagenti — API-Only Features (No UI) - -The following 9 capabilities have full backend routes and client support but are **not available in the Augment UI**. They can be accessed programmatically via the REST API. - -#### Sandbox (requires `sandbox` feature flag) - -| Feature | Endpoints | Notes | -| ----------------------- | ------------------------------------------------- | --------------------------------------------------------- | -| Sandbox Chat | `POST /kagenti/sandbox/:ns/chat` | Non-streaming chat within a sandbox session | -| Sandbox Streaming Chat | `POST /kagenti/sandbox/:ns/chat/stream` | SSE streaming chat within a sandbox session | -| Session Subscribe (SSE) | `GET /kagenti/sandbox/:ns/sessions/:id/subscribe` | Real-time session event stream | -| Create / Delete Sandbox | `POST /DELETE /kagenti/sandbox/:ns/...` | Sandbox lifecycle management | -| Browse Sandbox Files | `GET /kagenti/sandbox/:ns/files/...` | File browsing, directory listing, file content retrieval | -| Sidecar Management | `POST /kagenti/sandbox/:ns/.../sidecars/...` | Enable, disable, configure, approve, deny sidecars | -| Session Chain / History | `GET .../chain`, `GET .../history` | Detailed session execution chain and conversation history | - -#### Agent Management - -| Feature | Endpoints | Notes | -| --------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | -| Agent Migration | `POST /kagenti/agents/:ns/:name/migrate`, `POST .../migrate-all` | Migrate agents from legacy CRD format to deployment-based format | - -#### Integrations (requires `integrations` feature flag) - -| Feature | Endpoints | Notes | -| ------------------ | ------------------------------------- | ---------------------------------------------- | -| Update Integration | `PUT /kagenti/integrations/:ns/:name` | Modify an existing integration's configuration | - -These are tracked as planned enhancements. The core agent chat flow uses the main `chatStream` endpoint and is fully functional in the UI. - -## Document Sources - -### Directory Source - -Fetch documents from a local directory: - -```yaml -- type: directory - path: ./docs/knowledge-base # Relative to Backstage root - patterns: - - '**/*.md' - - '**/*.yaml' - - '**/*.json' -``` - -### URL Source - -Fetch documents from URLs: - -```yaml -- type: url - urls: - - https://raw.githubusercontent.com/org/repo/main/README.md - - https://internal-wiki.example.com/api/export/runbooks.md - headers: - Authorization: 'Bearer ${API_TOKEN}' -``` - -### GitHub Source - -Fetch documents from a GitHub repository: - -```yaml -- type: github - repo: 'your-org/documentation' - branch: main - path: docs/ # Optional: subdirectory - patterns: - - '**/*.md' - token: ${GITHUB_TOKEN} # Required for private repos -``` - -## Sync Behavior - -- **On Startup**: Documents are synced immediately when the plugin starts -- **Periodic Sync**: If `syncSchedule` is set, documents are re-synced at the specified interval -- **Sync Modes**: - - `append`: Only add new documents, never remove - - `full`: Add new documents and remove documents no longer in sources - -## API Reference - -### Backstage Plugin API - -| Endpoint | Method | Description | -| -------------------------------- | ------ | -------------------------------------------------------------------------- | -| `/api/augment/chat` | POST | Send chat messages with streaming RAG responses (proxied to Responses API) | -| `/api/augment/documents` | GET | List indexed documents (read-only) | -| `/api/augment/conversations` | GET | List conversation history | -| `/api/augment/conversations/:id` | GET | Get a specific conversation | -| `/api/augment/conversations/:id` | DELETE | Delete a conversation | -| `/api/augment/sync` | POST | Trigger manual document sync | -| `/api/augment/status` | GET | Get service and MCP server status | - -### Underlying Llama Stack APIs - -The backend plugin communicates with these Llama Stack endpoints: - -| Llama Stack Endpoint | Used For | -| ------------------------------------ | ------------------------------------------------------------------------- | -| `POST /v1/responses` | All LLM inference — each agent config becomes a unique parameterized call | -| `POST /v1/conversations` | Creating and managing persistent chat sessions across agent handoffs | -| `GET /v1/conversations/:id/messages` | Retrieving conversation history | -| `POST /v1/vector_stores` | Creating and managing RAG vector stores | -| `POST /v1/vector_stores/:id/files` | Attaching documents to vector stores | -| `POST /v1/files` | Uploading documents for ingestion | -| `GET /v1/models` | Model discovery and validation | -| `POST /v1/safety/run-shield` | Running input/output safety guardrails | -| `GET /v1/prompts` | Fetching server-managed prompt templates | - -## Further Documentation - -| Document | Description | -| ----------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| [CONFIG_REFERENCE.md](../augment-common/docs/CONFIG_REFERENCE.md) | YAML requirements, code-level defaults, admin UI scope, and precedence rules | -| [TROUBLESHOOTING.md](../augment-common/docs/TROUBLESHOOTING.md) | Diagnostic steps for common issues (400 errors, plugin not loading, RAG failures) | -| [SECURITY_MODES.md](../augment-common/docs/SECURITY_MODES.md) | Detailed explanation of the 3 security modes | -| [MCP_SERVER_SETUP.md](../augment-common/docs/MCP_SERVER_SETUP.md) | How to configure MCP servers | - -## License - -Apache-2.0 diff --git a/workspaces/augment/plugins/augment-backend/config.d.ts b/workspaces/augment/plugins/augment-backend/config.d.ts deleted file mode 100644 index 4907b5565bf..00000000000 --- a/workspaces/augment/plugins/augment-backend/config.d.ts +++ /dev/null @@ -1,1503 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export interface Config { - /** - * Configuration for Augment plugin - * - * ## Security Modes - * - * Augment supports 3 security modes, configurable via `security.mode`: - * - * ### Mode 1: 'none' - No Access Control - * - Anyone can access Augment - * - MCP servers are called without authentication - * - Use for development or trusted internal environments - * - * ### Mode 2: 'plugin-only' - Plugin Access Control (Default) - * - Users must be in a Keycloak group to access Augment - * - MCP servers are called without authentication (they use their own service accounts) - * - Authorization enforced at Backstage layer via RBAC - * - * ### Mode 3: 'full' - Full Authentication (EXPERIMENTAL) - * - Users must be in a Keycloak group to access Augment - * - MCP servers receive OAuth tokens for authentication - * - End-to-end authentication chain - * - ⚠️ NOT FULLY TESTED - use plugin-only for production - * - * @example Mode 1: No access control - * ```yaml - * augment: - * security: - * mode: 'none' - * ``` - * - * @example Mode 2: Plugin-only (recommended) - * ```yaml - * augment: - * security: - * mode: 'plugin-only' - * ``` - * - * @example Mode 3: Full authentication - * ```yaml - * augment: - * security: - * mode: 'full' - * mcpOAuth: - * tokenUrl: 'https://keycloak.example.com/realms/demo/protocol/openid-connect/token' - * clientId: 'augment-backend' - * clientSecret: ${MCP_OAUTH_CLIENT_SECRET} - * scopes: - * - openid - * ``` - * - * ## RBAC Configuration (for modes 2 and 3) - * - * Configure Backstage RBAC to map Keycloak groups to permissions: - * - * @example rbac-policy.csv - * ```csv - * g, group:default/augment-users, role:default/augment-user - * p, role:default/augment-user, augment.access, read, allow - * ``` - */ - augment?: { - /** - * Enable debug logging in browser console - * Can also be toggled at runtime via: window.enableAugmentDebug() - * @visibility frontend - */ - debug?: boolean; - - /** - * Security configuration - controls authentication and authorization - * @visibility backend - */ - security?: { - /** - * Security mode: - * - 'none': No access control (anyone can use Augment) - * - 'plugin-only': Keycloak group controls plugin access, MCP servers are open (default) - * - 'full': Keycloak auth for both plugin access AND MCP server authentication - * @visibility backend - */ - mode?: 'none' | 'plugin-only' | 'full'; - - /** - * Custom message shown when access is denied - * @visibility frontend - */ - accessDeniedMessage?: string; - - /** - * User entity refs that should have admin access. - * Used as a fallback when the Backstage permission framework is disabled - * (permission.enabled: false). When permissions ARE enabled (e.g., with RBAC configured), - * admin access is determined by RBAC policy and this list is ignored. - * - * Entity refs follow the format: 'user:default/' - * The username is derived from the Keycloak email local part - * (via the emailLocalPartMatchingUserEntityName sign-in resolver). - * - * @example - * ```yaml - * augment: - * security: - * adminUsers: - * - 'user:default/admin' - * - 'user:default/platform-lead' - * ``` - * @visibility backend - */ - adminUsers?: string[]; - - /** - * OAuth configuration for MCP server authentication - * Only used in 'full' mode - provides tokens for MCP server calls - * @visibility backend - */ - mcpOAuth?: { - /** - * OAuth token endpoint URL (e.g., Keycloak token endpoint) - * @visibility backend - */ - tokenUrl: string; - - /** - * OAuth client ID (service account client) - * @visibility backend - */ - clientId: string; - - /** - * OAuth client secret - * @visibility secret - */ - clientSecret: string; - - /** - * OAuth scopes to request (default: ['openid']) - * @visibility backend - */ - scopes?: string[]; - }; - }; - /** - * Tool execution mode for MCP servers - * - 'direct': LlamaStack calls MCP servers directly (default) - * - 'backend': Backstage backend executes MCP tools on behalf of LlamaStack - * (required for disconnected/air-gapped clusters where LlamaStack cannot - * reach MCP servers) - * @visibility backend - */ - toolExecutionMode?: 'direct' | 'backend'; - - /** - * Inference server configuration for RAG and chat - */ - llamaStack: { - /** - * Base URL for the inference server - * @visibility frontend - */ - baseUrl: string; - - /** - * IDs of vector stores to use for RAG (searches all) - * Takes precedence over vectorStoreId if both specified - * @visibility backend - */ - vectorStoreIds?: string[]; - - /** - * ID of the vector store to use for RAG (backward compatibility) - * Use vectorStoreIds for multiple stores - * @visibility backend - */ - vectorStoreId?: string; - - /** - * Name for auto-created vector store (if vectorStoreId not specified) - * Default: 'techx-db' - * @visibility backend - */ - vectorStoreName?: string; - - /** - * Model to use for chat completions - * @visibility frontend - */ - model: string; - - /** - * Embedding model for vector store creation - * Default: 'sentence-transformers/all-MiniLM-L6-v2' - * @visibility backend - */ - embeddingModel?: string; - - /** - * Embedding dimension for vector store creation - * Default: 384 - * @visibility backend - */ - embeddingDimension?: number; - - /** - * Optional API token for authentication - * @visibility secret - */ - token?: string; - - /** - * Chunking strategy for file uploads - * @visibility backend - */ - chunkingStrategy?: 'auto' | 'static'; - - /** - * Max chunk size in tokens (for static chunking) - * @visibility backend - */ - maxChunkSizeTokens?: number; - - /** - * Chunk overlap in tokens (for static chunking) - * @visibility backend - */ - chunkOverlapTokens?: number; - - /** - * Skip TLS certificate verification (for self-signed certs) - * Default: false (secure by default) - * Set to true only for development with self-signed certificates - * WARNING: Enabling this in production is a security risk - * @visibility backend - */ - skipTlsVerify?: boolean; - - /** - * Enable verbose logging for streaming events - * Default: false (minimal logging) - * When enabled, logs details of every SSE event for debugging - * This creates a lot of log output - use only for debugging - * @visibility backend - */ - verboseStreamLogging?: boolean; - - /** - * Tool choice configuration - controls how the model selects tools - * - 'auto': Model decides when to call tools (default) - * - 'required': Model must call at least one tool - * - 'none': Model cannot call any tools - * - object with type: 'function' to force a specific function - * - object with type: 'allowed_tools' to restrict available tools - * - * @example Force specific function - * ```yaml - * toolChoice: - * type: function - * name: search_catalog - * ``` - * - * @example Restrict to specific tools - * ```yaml - * toolChoice: - * type: allowed_tools - * mode: auto - * tools: - * - type: file_search - * - type: mcp - * server_label: openshift - * ``` - * @visibility backend - */ - toolChoice?: - | 'auto' - | 'required' - | 'none' - | { - type: 'function'; - name: string; - } - | { - type: 'allowed_tools'; - mode?: 'auto' | 'required'; - tools: Array< - | { type: 'file_search' } - | { type: 'web_search' } - | { type: 'code_interpreter' } - | { type: 'mcp'; server_label: string } - | { type: 'function'; name: string } - >; - }; - - /** - * Whether to allow parallel tool calls - * - true: Model can call multiple tools in a single turn (default) - * - false: Model can only call one tool at a time (sequential) - * @visibility backend - */ - parallelToolCalls?: boolean; - - /** - * Structured output format - forces model to return JSON matching schema - * Uses Responses API text.format field - * - * @example - * ```yaml - * textFormat: - * type: json_schema - * json_schema: - * name: deployment_issues - * strict: true - * schema: - * type: object - * properties: - * issues: - * type: array - * items: - * type: object - * properties: - * severity: - * type: string - * enum: [critical, warning, info] - * description: - * type: string - * required: [issues] - * additionalProperties: false - * ``` - * @visibility backend - */ - /** - * Note: textFormat is configured via YAML but not validated by TypeScript schema - * due to Backstage config loader limitations with complex nested objects. - * See the example in the JSDoc above for proper YAML structure. - */ - textFormat?: { - type: 'json_schema'; - json_schema: { - name: string; - /** JSON Schema object - defined in YAML, not validated by TypeScript */ - schema: object; - strict?: boolean; - }; - }; - - /** - * Custom function definitions for the model to call - * These are added as tools alongside file_search and MCP servers - * - * @example - * ```yaml - * functions: - * - name: search_catalog - * description: Search the Backstage catalog for entities - * parameters: - * type: object - * properties: - * kind: - * type: string - * enum: [Component, API, System, Group] - * description: Entity kind to search for - * query: - * type: string - * description: Search query - * required: [kind, query] - * additionalProperties: false - * strict: true - * ``` - * @visibility backend - */ - functions?: Array<{ - /** Function name */ - name: string; - /** Description of when and how to use the function */ - description: string; - /** JSON Schema for function parameters - defined in YAML */ - parameters: object; - /** Enforce strict schema compliance (default: true) */ - strict?: boolean; - }>; - - /** - * Enable Zero Data Retention (ZDR) mode for enterprise compliance - * When enabled: - * - Responses are NOT stored on the server (store: false) - * - Encrypted reasoning tokens are returned for stateless operation - * - Conversation history features will be disabled - * - * Use this for organizations with strict data retention policies - * Default: false - * @visibility backend - */ - zdrMode?: boolean; - - /** - * Enable the built-in web_search tool - * When enabled, the model can search the web for real-time information - * Useful for questions about current events, latest documentation, etc. - * Default: false - * @visibility backend - */ - enableWebSearch?: boolean; - - /** - * Search mode for RAG file search - * - 'auto': Let the model decide (default) - * - 'keyword': BM25 keyword search only - * - 'semantic': Embedding-based semantic search only - * - 'hybrid': Combine keyword and semantic search - * @visibility backend - */ - searchMode?: 'auto' | 'keyword' | 'semantic' | 'hybrid'; - - /** - * Weight for BM25 keyword search in hybrid mode (0-1) - * Only used when searchMode is 'hybrid' - * Default: 0.5 - * @visibility backend - */ - bm25Weight?: number; - - /** - * Weight for semantic search in hybrid mode (0-1) - * Only used when searchMode is 'hybrid' - * Default: 0.5 - * @visibility backend - */ - semanticWeight?: number; - - /** - * Maximum number of results returned by file search - * Default: 10 - * @visibility backend - */ - fileSearchMaxResults?: number; - - /** - * Minimum relevance score threshold for file search results (0-1) - * Results below this score are filtered out - * @visibility backend - */ - fileSearchScoreThreshold?: number; - - /** - * Enable the built-in code_interpreter tool - * When enabled, the model can execute Python code for data analysis - * Useful for analyzing data, generating charts, running calculations - * Default: false - * @visibility backend - */ - enableCodeInterpreter?: boolean; - }; - - /** - * Document sources for automatic ingestion - * @visibility backend - */ - documents?: { - /** - * Sync mode: 'full' syncs all (add new, remove deleted), 'append' only adds new - * @visibility backend - */ - syncMode?: 'full' | 'append'; - - /** - * How often to sync documents (cron expression or duration like '1h', '30m') - * If not set, only syncs on startup - * @visibility backend - */ - syncSchedule?: string; - - /** - * Document sources to ingest - * @visibility backend - */ - sources?: Array< - | { - /** - * Local directory source - */ - type: 'directory'; - /** - * Path to the directory (relative to Backstage root or absolute) - */ - path: string; - /** - * Glob patterns to match files (e.g., '**\/*.md') - */ - patterns?: string[]; - } - | { - /** - * URL source - fetch documents from URLs - */ - type: 'url'; - /** - * List of URLs to fetch documents from - */ - urls: string[]; - } - | { - /** - * GitHub repository source - */ - type: 'github'; - /** - * Repository in format 'owner/repo' - */ - repo: string; - /** - * Branch to fetch from (default: main) - */ - branch?: string; - /** - * Path within the repository (default: root) - */ - path?: string; - /** - * Glob patterns to match files - */ - patterns?: string[]; - /** - * GitHub token for private repos - * @visibility secret - */ - token?: string; - } - >; - }; - - /** - * System prompt for the AI assistant - * @visibility backend - */ - systemPrompt?: string; - - /** - * Quick prompts for common queries - */ - quickPrompts?: Array<{ - /** - * Title of the quick prompt - * @visibility frontend - */ - title: string; - - /** - * Description of what this prompt does - * @visibility frontend - */ - description: string; - - /** - * The actual prompt text - * @visibility frontend - */ - prompt: string; - - /** - * Category for grouping prompts - * @visibility frontend - */ - category: string; - }>; - - /** - * MCP server configurations - * @visibility backend - */ - mcpServers?: Array<{ - /** - * Unique identifier for the server - * @visibility backend - */ - id: string; - - /** - * Display name for the server - * @visibility frontend - */ - name: string; - - /** - * Server type - only 'streamable-http' and 'sse' are supported - * @visibility backend - */ - type: 'streamable-http' | 'sse'; - - /** - * URL for the MCP server - * @visibility backend - */ - url: string; - - /** - * OAuth configuration for dynamic token authentication - * If configured, Augment will obtain tokens from the OAuth provider - * and include them in requests to this MCP server - * - * Alternative: Use authRef to reference a shared config from mcpAuth - * @visibility backend - */ - oauth?: { - /** - * OAuth token endpoint URL - * @visibility backend - */ - tokenUrl: string; - - /** - * OAuth client ID (service account) - * @visibility backend - */ - clientId: string; - - /** - * OAuth client secret - * @visibility secret - */ - clientSecret: string; - - /** - * OAuth scopes to request (default: ['openid']) - * @visibility backend - */ - scopes?: string[]; - }; - - /** - * Kubernetes ServiceAccount authentication - * Reads the token from a ServiceAccount in the cluster - * - * Alternative: Use authRef to reference a shared config from mcpAuth - * - * @example - * ```yaml - * mcpServers: - * - id: openshift - * serviceAccount: - * name: mcp-client-sa - * namespace: backstage - * ``` - * @visibility backend - */ - serviceAccount?: { - /** - * ServiceAccount name in the cluster - * @visibility backend - */ - name: string; - - /** - * Namespace of the ServiceAccount (default: current namespace or 'default') - * @visibility backend - */ - namespace?: string; - }; - - /** - * Human-in-the-loop approval requirement - * - 'always': All tools require user approval - * - 'never': Tools execute automatically (default) - * - object with always/never arrays for fine-grained control - * @visibility backend - */ - requireApproval?: - | 'always' - | 'never' - | { - always?: string[]; - never?: string[]; - }; - }>; - - /** - * Branding configuration for enterprise customization - * @visibility frontend - */ - branding?: { - /** - * Application name displayed in the UI - * Default: 'Augment' - * @visibility frontend - */ - appName?: string; - - /** - * Tagline displayed below the app name - * Default: 'Your AI-powered platform assistant' - * @visibility frontend - */ - tagline?: string; - - /** - * Primary brand color (hex) - * Default: '#9333ea' (purple) - * @visibility frontend - */ - primaryColor?: string; - - /** - * Secondary brand color (hex) - * Default: '#8b5cf6' (violet) - * @visibility frontend - */ - secondaryColor?: string; - - /** - * Success state color (hex) - * Default: '#10b981' (emerald) - * @visibility frontend - */ - successColor?: string; - - /** - * Warning state color (hex) - * Default: '#f59e0b' (amber) - * @visibility frontend - */ - warningColor?: string; - - /** - * Error state color (hex) - * Default: '#ef4444' (red) - * @visibility frontend - */ - errorColor?: string; - - /** - * Info state color (hex) - * Default: '#0ea5e9' (sky blue) - * @visibility frontend - */ - infoColor?: string; - - /** - * Custom logo URL (relative or absolute) - * @visibility frontend - */ - logoUrl?: string; - - /** - * Custom bot avatar URL (http/https only) - * Overrides the default sparkle icon with a custom image - * @visibility frontend - */ - botAvatarUrl?: string; - - /** - * Theme preset to use ('default', 'enterprise', etc.) - * Presets provide pre-configured color palettes. - * - * Available presets: - * - 'default': Steel blue professional theme - * - 'enterprise': Professional navy/blue theme - * - * Default: 'default' - * @visibility frontend - */ - themePreset?: 'default' | 'enterprise' | string; - }; - - /** - * Safety configuration for content moderation (optional) - * Uses safety shields for input/output validation - * @visibility backend - */ - safety?: { - /** - * Enable safety checks - * Default: false - * @visibility backend - */ - enabled?: boolean; - - /** - * Shield IDs to register with the inference server - * @visibility backend - */ - registerShields?: Array<{ - /** - * Shield identifier - */ - shieldId: string; - /** - * Provider ID (e.g., 'llama-guard', 'prompt-guard') - */ - providerId: string; - /** - * Provider-specific shield ID - */ - providerShieldId: string; - }>; - - /** - * Shield IDs to apply to user input - * @visibility backend - */ - inputShields?: string[]; - - /** - * Shield IDs to apply to AI output - * @visibility backend - */ - outputShields?: string[]; - - /** - * Behavior when a shield API call fails (network error, timeout, etc.) - * - 'allow': Allow the message through on error (fail-open, less secure) - * - 'block': Block the message on error (fail-closed, more secure, default) - * @visibility backend - */ - onError?: 'allow' | 'block'; - }; - - /** - * Response evaluation configuration (optional) - * Uses provider Scoring API to assess response quality - * @visibility backend - */ - evaluation?: { - /** - * Enable response evaluation - * Default: false - * @visibility backend - */ - enabled?: boolean; - - /** - * Scoring function IDs to use for evaluation - * Available functions depend on the inference server configuration - * Common: 'braintrust::answer-relevancy', 'braintrust::faithfulness' - * @visibility backend - */ - scoringFunctions?: string[]; - - /** - * Minimum acceptable score (0-1) - * Scores below this threshold log warnings - * Default: 0.7 - * @visibility backend - */ - minScoreThreshold?: number; - - /** - * Behavior when a scoring API call fails (network error, timeout, etc.) - * - 'skip': Skip evaluation and return no result (default) - * - 'fail': Return a failed evaluation result to flag the issue - * @visibility backend - */ - onError?: 'skip' | 'fail'; - }; - - /** - * Override auto-detected Llama Stack server capabilities. - * Useful when the plugin cannot detect the server version or when - * running a custom/forked build of Llama Stack. - * - * @example - * ```yaml - * augment: - * serverCapabilities: - * strictField: false # Llama Stack < 0.6.0 - * maxOutputTokens: false - * ``` - * @visibility backend - */ - serverCapabilities?: { - /** Can send `{ type: 'function' }` tools (default: true) */ - functionTools?: boolean; - /** Can include the `strict` field on function tools (default: true for >= 0.6.0) */ - strictField?: boolean; - /** Can send `max_output_tokens` in the request (default: true for >= 0.6.0) */ - maxOutputTokens?: boolean; - /** Can send `{ type: 'mcp' }` tools (default: true) */ - mcpTools?: boolean; - /** Supports `parallel_tool_calls` parameter (default: true) */ - parallelToolCalls?: boolean; - }; - - /** - * Agent approval workflow configuration (SonataFlow integration). - * When enabled, lifecycle transitions go through an approval workflow. - * When disabled, transitions happen immediately (built-in mode). - * @visibility backend - */ - agentApproval?: { - /** - * Enable SonataFlow approval workflow for agent lifecycle transitions - * @visibility backend - */ - enabled?: boolean; - - /** - * SonataFlow service URL (e.g. http://sonataflow.namespace.svc:8080) - * @visibility backend - */ - serviceUrl?: string; - - /** - * SonataFlow workflow ID to start for approval requests - * @visibility backend - */ - workflowId?: string; - }; - - /** - * Kagenti agent operations platform configuration. - * Required when augment.provider is 'kagenti'. - * @visibility backend - */ - kagenti?: { - /** - * Base URL for the Kagenti API - * @visibility backend - */ - baseUrl: string; - - /** - * Default Kubernetes namespace for agent/tool operations - * Default: 'default' - * @visibility backend - */ - namespace?: string; - - /** - * Namespace allowlist. If set, only these namespaces are visible. - * Overrides showAllNamespaces. If omitted, all enabled namespaces are shown. - * @visibility backend - */ - namespaces?: string[]; - - /** - * Show all Kagenti-enabled namespaces - * Ignored when 'namespaces' allowlist is set - * Default: true - * @visibility backend - */ - showAllNamespaces?: boolean; - - /** - * Default agent name for chat (in the default namespace) - * If not set, listModels() returns all agents across namespaces - * @visibility backend - */ - agentName?: string; - - /** - * Agent allowlist for the chat model picker (namespace/name format) - * If set, only these agents appear in listModels() - * @visibility backend - */ - agents?: string[]; - - /** - * Skip TLS certificate verification (for self-signed certs) - * Default: false - * @visibility backend - */ - skipTlsVerify?: boolean; - - /** - * Enable verbose logging for streaming events - * Default: false - * @visibility backend - */ - verboseStreamLogging?: boolean; - - /** - * HTTP request timeout in milliseconds (non-streaming) - * Default: 30000 (30s) - * @visibility backend - */ - requestTimeoutMs?: number; - - /** - * Stream request timeout in milliseconds (0 = unlimited) - * Default: 300000 (5 min) - * @visibility backend - */ - streamTimeoutMs?: number; - - /** - * Max retry attempts for retryable HTTP errors (429, 502, 503, 504) - * Default: 3 - * @visibility backend - */ - maxRetries?: number; - - /** - * Base delay between retries in milliseconds (exponential backoff) - * Default: 1000 - * @visibility backend - */ - retryBaseDelayMs?: number; - - /** - * Seconds before token expiry to refresh (buffer) - * Default: 60 - * @visibility backend - */ - tokenExpiryBufferSeconds?: number; - - /** - * Override dashboard URLs returned by Kagenti API - * Useful when Augment accesses Kagenti through a different route - * @visibility backend - */ - dashboards?: { - /** MCP Inspector URL override @visibility backend */ - mcpInspector?: string; - /** MCP Proxy URL override @visibility backend */ - mcpProxy?: string; - /** Distributed traces dashboard URL override @visibility backend */ - traces?: string; - /** Network dashboard URL override @visibility backend */ - network?: string; - /** Keycloak console URL override @visibility backend */ - keycloakConsole?: string; - /** Domain name override @visibility backend */ - domainName?: string; - }; - - /** - * Sandbox default settings - * @visibility backend - */ - sandbox?: { - /** - * Default session TTL for cleanup (minutes) - * Used as fallback when ttl_minutes is not provided by the caller - * @visibility backend - */ - sessionTtlMinutes?: number; - - /** - * Default skill for sandbox chat - * @visibility backend - */ - defaultSkill?: string; - - /** - * Sidecar defaults - * @visibility backend - */ - sidecar?: { - /** - * Default auto_approve for sidecar enable requests - * Default: false - * @visibility backend - */ - autoApprove?: boolean; - }; - }; - - /** - * Migration policy defaults - * @visibility backend - */ - migration?: { - /** - * Default for delete_old on agent migrate - * Default: false - * @visibility backend - */ - deleteOld?: boolean; - - /** - * Default for dry_run on migrate-all - * Default: false - * @visibility backend - */ - dryRun?: boolean; - }; - - /** - * Pagination defaults for list endpoints - * @visibility backend - */ - pagination?: { - /** - * Default page size when limit is not specified - * Default: 50 - * @visibility backend - */ - defaultLimit?: number; - - /** - * Maximum allowed page size (caps user-provided limits) - * Default: 200 - * @visibility backend - */ - maxLimit?: number; - }; - - /** - * Enable Zod schema validation on agent card and stream payloads - * from @kagenti/adk. Logs warnings on validation failure but never - * breaks the flow (fail-open). Useful for catching API contract drift. - * Default: false - * @visibility backend - */ - validateResponses?: boolean; - - /** - * Local overrides for Kagenti feature flags. - * Merged on top of flags fetched from the Kagenti API. - * Useful for testing without a running Kagenti server or - * forcing features on/off regardless of server state. - * @visibility backend - */ - featureOverrides?: { - /** Override sandbox feature flag */ - sandbox?: boolean; - /** Override integrations feature flag */ - integrations?: boolean; - /** Override triggers feature flag */ - triggers?: boolean; - }; - - /** - * Keycloak authentication for Kagenti API - * Uses OAuth2 Client Credentials Grant - * @visibility backend - */ - auth: { - /** - * Keycloak token endpoint URL - * @visibility backend - */ - tokenEndpoint: string; - - /** - * OAuth2 client ID - * @visibility backend - */ - clientId: string; - - /** - * OAuth2 client secret - * @visibility secret - */ - clientSecret: string; - }; - }; - - /** - * AI provider type - * Supported: 'llamastack', 'kagenti' - * Default: 'llamastack' - * @visibility backend - */ - provider?: 'llamastack' | 'kagenti'; - - /** - * Shared OAuth configurations for MCP server authentication. - * Servers reference these by `authRef` to avoid duplicating credentials. - * - * @example - * ```yaml - * mcpAuth: - * - serverId: openshift - * oauth: - * tokenUrl: https://keycloak.example.com/realms/demo/protocol/openid-connect/token - * clientId: mcp-client - * clientSecret: ${MCP_OAUTH_SECRET} - * ``` - * @visibility backend - */ - mcpAuth?: Array<{ - /** Server ID to match against mcpServers[].id or mcpServers[].authRef */ - serverId: string; - /** OAuth configuration */ - oauth: { - /** @visibility backend */ - tokenUrl: string; - /** @visibility backend */ - clientId: string; - /** @visibility secret */ - clientSecret: string; - /** @visibility backend */ - scopes?: string[]; - }; - }>; - - /** - * Prompt groups - grouped prompt cards for the welcome screen - * @visibility frontend - */ - promptGroups?: Array<{ - /** - * Unique identifier - */ - id: string; - /** - * Display title - */ - title: string; - /** - * Description text - */ - description?: string; - /** - * Icon name - */ - icon?: string; - /** - * Group color (hex) - */ - color?: string; - /** - * Display order (lower numbers first) - */ - order?: number; - /** - * Prompt cards in this group - */ - cards?: Array<{ - /** - * Card title - */ - title: string; - /** - * Card description - */ - description?: string; - /** - * Prompt to send when clicked - */ - prompt: string; - /** - * Icon name - */ - icon?: string; - /** - * Mark as coming soon - */ - comingSoon?: boolean; - /** - * Custom coming soon label - */ - comingSoonLabel?: string; - }>; - }>; - - /** - * Workflows - multi-step guided experiences - * @visibility frontend - */ - workflows?: Array<{ - /** - * Unique identifier - */ - id: string; - /** - * Workflow name - */ - name: string; - /** - * Workflow description - */ - description?: string; - /** - * Icon name - */ - icon?: string; - /** - * Category: 'Deploy', 'Migrate', 'Daily' - */ - category?: string; - /** - * Mark as coming soon - */ - comingSoon?: boolean; - /** - * Workflow steps - */ - steps?: Array<{ - /** - * Step title - */ - title: string; - /** - * Prompt for this step - */ - prompt: string; - /** - * Step description - */ - description?: string; - }>; - }>; - - /** - * Interactive guided experience tours for onboarding and feature discovery. - * Each tour is a named walkthrough with ordered steps that highlight UI elements. - * @visibility frontend - */ - tours?: Array<{ - /** - * Unique tour identifier - * @visibility frontend - */ - id: string; - /** - * Display title shown in the tour launcher - * @visibility frontend - */ - title: string; - /** - * Short description of what this tour covers - * @visibility frontend - */ - description?: string; - /** - * Category for grouping: getting-started, agent-journeys, platform - * @visibility frontend - */ - category?: string; - /** - * Estimated minutes to complete - * @visibility frontend - */ - estimatedMinutes?: number; - /** - * Which persona this tour targets: developer, admin, or both - * @visibility frontend - */ - persona?: 'developer' | 'admin' | 'both'; - /** - * Which page this tour is designed for: marketplace, command-center, or any - * @visibility frontend - */ - page?: 'marketplace' | 'command-center' | 'any'; - /** - * Ordered steps in the tour - * @visibility frontend - */ - steps: Array<{ - /** - * CSS selector or [data-tour="..."] attribute to highlight - * @visibility frontend - */ - target?: string; - /** - * Step title shown in the popover - * @visibility frontend - */ - title: string; - /** - * Step description/body shown in the popover - * @visibility frontend - */ - description?: string; - /** - * Popover position relative to target: top, bottom, left, right - * @visibility frontend - */ - side?: string; - /** - * Action to execute before highlighting this step - * @visibility frontend - */ - action?: { - /** - * Action type: navigate, openAgentIntent, selectAgentIntent, openToolIntent, selectToolDeploy, closeDialogs, setWizardStep, setDeployMethod, clickSelector - * @visibility frontend - */ - type: string; - /** - * Target panel for navigate action - * @visibility frontend - */ - panel?: string; - /** - * CSS selector for clickSelector action - * @visibility frontend - */ - selector?: string; - /** - * Wizard step index for setWizardStep action - * @visibility frontend - */ - step?: number; - /** - * Deployment method for setDeployMethod action - * @visibility frontend - */ - method?: string; - /** - * Card ID for selectAgentIntent action - * @visibility frontend - */ - cardId?: string; - }; - /** - * Selector to wait for after action executes (if different from target) - * @visibility frontend - */ - waitFor?: string; - }>; - }>; - - /** - * Skills Marketplace configuration - * @visibility backend - */ - skillsMarketplace?: { - /** - * Base URL for the skills marketplace backend - * @visibility backend - */ - baseUrl?: string; - - /** - * Default agent container image - * @visibility backend - */ - agentImage?: string; - - /** - * Advisor service URL - * @visibility backend - */ - advisorUrl?: string; - - /** - * LLM base URL for skill evaluation - * @visibility backend - */ - llmBaseUrl?: string; - - /** - * LLM model name for skill evaluation - * @visibility backend - */ - llmModel?: string; - - /** - * Available runtimes for skill deployment - * @visibility backend - */ - runtimes?: Array<{ - /** Runtime identifier */ - id: string; - /** Display name */ - name: string; - /** Runtime description */ - description?: string; - /** Container image */ - image?: string; - /** Programming language */ - language?: string; - /** Resource footprint: small, medium, large */ - footprint?: string; - /** Supported features */ - features?: string[]; - /** Runtime status */ - status?: string; - }>; - }; - - /** - * Dev Spaces configuration - * @visibility backend - */ - devSpaces?: { - /** - * Available frameworks for DevSpaces scaffolding - * @visibility backend - */ - frameworks?: Array<{ - /** Framework identifier */ - id: string; - /** Display name */ - name: string; - /** Framework description */ - description?: string; - /** Tags (e.g. language, paradigm) */ - tags?: string[]; - /** Starter repo URL */ - starterRepo?: string; - /** Badge label (e.g. "Popular") */ - badge?: string; - }>; - }; - }; -} diff --git a/workspaces/augment/plugins/augment-backend/dev/index.ts b/workspaces/augment/plugins/augment-backend/dev/index.ts deleted file mode 100644 index da48ad21e5e..00000000000 --- a/workspaces/augment/plugins/augment-backend/dev/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { createBackend } from '@backstage/backend-defaults'; - -/** - * Augment Backend Development Server - * - * Supports both Guest auth and Keycloak OIDC. - * Configure auth.providers.oidc in app-config.yaml for Keycloak. - */ - -const backend = createBackend(); - -// Add auth backend for authentication -// This enables the auth providers configured in app-config.yaml -backend.add(import('@backstage/plugin-auth-backend')); -// Add guest provider for development (works without session middleware) -backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); -// Add OIDC provider module for Keycloak integration (requires OIDC configured) -backend.add(import('@backstage/plugin-auth-backend-module-oidc-provider')); - -// Add Augment plugin -backend.add(import('../src')); - -backend.start(); diff --git a/workspaces/augment/plugins/augment-backend/knip-report.md b/workspaces/augment/plugins/augment-backend/knip-report.md deleted file mode 100644 index 7960c90a92a..00000000000 --- a/workspaces/augment/plugins/augment-backend/knip-report.md +++ /dev/null @@ -1,9 +0,0 @@ -# Knip report - -## Unused devDependencies (2) - -| Name | Location | Severity | -| :--------------------- | :---------------- | :------- | -| @types/express-session | package.json:73:6 | error | -| express-session | package.json:76:6 | error | - diff --git a/workspaces/augment/plugins/augment-backend/package.json b/workspaces/augment/plugins/augment-backend/package.json deleted file mode 100644 index 642be544d04..00000000000 --- a/workspaces/augment/plugins/augment-backend/package.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "name": "@red-hat-developer-hub/backstage-plugin-augment-backend", - "version": "0.3.0", - "main": "src/index.ts", - "types": "src/index.ts", - "license": "Apache-2.0", - "description": "Backend plugin for Augment - configurable AI assistant with RAG, tool calling, and safety guardrails", - "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" - }, - "backstage": { - "role": "backend-plugin", - "pluginId": "augment", - "pluginPackage": "@red-hat-developer-hub/backstage-plugin-augment-backend", - "pluginPackages": [ - "@red-hat-developer-hub/backstage-plugin-augment", - "@red-hat-developer-hub/backstage-plugin-augment-backend", - "@red-hat-developer-hub/backstage-plugin-augment-common" - ] - }, - "repository": { - "type": "git", - "url": "https://github.com/redhat-developer/rhdh-plugins", - "directory": "workspaces/augment/plugins/augment-backend" - }, - "keywords": [ - "backstage", - "backstage-plugin", - "backstage-backend-plugin", - "rag", - "agentic", - "ai", - "mcp", - "chat", - "vector-store" - ], - "scripts": { - "build": "backstage-cli package build", - "clean": "backstage-cli package clean", - "lint": "backstage-cli package lint", - "prepack": "backstage-cli package prepack", - "postpack": "backstage-cli package postpack", - "start": "backstage-cli package start", - "test": "backstage-cli package test" - }, - "dependencies": { - "@backstage/backend-defaults": "^0.14.2", - "@backstage/backend-plugin-api": "^1.9.0", - "@backstage/config": "^1.3.3", - "@backstage/errors": "^1.2.7", - "@backstage/plugin-permission-common": "^0.9.2", - "@kagenti/adk": "^0.8.1", - "@modelcontextprotocol/sdk": "^1.25.2", - "@openai/agents-core": "^0.21.0", - "@red-hat-developer-hub/backstage-plugin-augment-common": "workspace:^", - "expr-eval": "^2.0.2", - "express": "^4.17.1", - "express-promise-router": "^4.1.0", - "form-data": "^4.0.0", - "multer": "^2.0.2", - "p-limit": "^3.1.0", - "undici": "^7.0.0" - }, - "devDependencies": { - "@backstage/backend-test-utils": "^1.6.0", - "@backstage/cli": "^0.34.5", - "@backstage/plugin-auth-backend": "^0.25.7", - "@backstage/plugin-auth-backend-module-guest-provider": "^0.2.15", - "@backstage/plugin-auth-backend-module-oidc-provider": "^0.4.10", - "@types/express": "^4.17.6", - "@types/express-session": "^1.18.2", - "@types/multer": "^2.0.0", - "@types/supertest": "^2.0.12", - "express-session": "^1.18.2", - "knex": "^3.1.0", - "supertest": "^6.2.4" - }, - "files": [ - "dist", - "config.d.ts" - ], - "configSchema": "config.d.ts" -} diff --git a/workspaces/augment/plugins/augment-backend/report.api.md b/workspaces/augment/plugins/augment-backend/report.api.md deleted file mode 100644 index b0a0b523fa1..00000000000 --- a/workspaces/augment/plugins/augment-backend/report.api.md +++ /dev/null @@ -1,1100 +0,0 @@ -## API Report File for "@red-hat-developer-hub/backstage-plugin-augment-backend" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts -import type { AdminConfigKey } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { AugmentStatus } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { BackendFeature } from '@backstage/backend-plugin-api'; -import type { CacheService } from '@backstage/backend-plugin-api'; -import type { ChatAgent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { ChatMessage } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ChatResponse } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { ConversationSummary } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { DatabaseService } from '@backstage/backend-plugin-api'; -import { DocumentInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { EvaluationResult } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { ExtensionPoint } from '@backstage/backend-plugin-api'; -import { FileFormat } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { InputTokensDetails } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { MCPServerStatus } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { NormalizedStreamEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { OutputTokensDetails } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { PromptCapabilities } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { PromptCard } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { PromptGroup } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ProviderDescriptor } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { ProviderStatus } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ProviderType } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { QuickAction } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { RAGSource } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { ResponseUsage } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { RootConfigService } from '@backstage/backend-plugin-api'; -import { Router } from 'express'; -import { SecurityMode } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { SyncResult } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { ToolCallInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { VectorStoreInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { VectorStoreStatus } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { Workflow } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { WorkflowStep } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -// @public -export class AdminConfigService { - constructor(database: DatabaseService, logger: LoggerService); - delete(key: AdminConfigKey): Promise; - deleteScopedValue( - key: AdminConfigKey, - providerId: ProviderType, - ): Promise; - get(key: AdminConfigKey): Promise; - getEntry(key: AdminConfigKey): Promise< - | { - configKey: AdminConfigKey; - configValue: unknown; - updatedAt: string; - updatedBy: string; - } - | undefined - >; - getScopedValue( - key: AdminConfigKey, - providerId: ProviderType, - ): Promise; - // (undocumented) - initialize(): Promise; - listAll(): Promise< - Array<{ - configKey: AdminConfigKey; - updatedAt: string; - updatedBy: string; - }> - >; - resolveStorageKey(key: AdminConfigKey, providerId: ProviderType): string; - set(key: AdminConfigKey, value: unknown, updatedBy: string): Promise; - setScopedValue( - key: AdminConfigKey, - value: unknown, - providerId: ProviderType, - updatedBy: string, - ): Promise; - static validateKey(key: string): AdminConfigKey; -} - -// @public -export interface AgenticProvider { - // (undocumented) - chat(request: ChatRequest): Promise; - // (undocumented) - chatStream( - request: ChatRequest, - onEvent: (event: NormalizedStreamEvent) => void, - signal?: AbortSignal, - ): Promise; - // (undocumented) - conversations?: ConversationCapability; - readonly displayName: string; - // (undocumented) - evaluation?: EvaluationCapability; - // (undocumented) - generateSystemPrompt?( - description: string, - model?: string, - capabilities?: PromptCapabilities, - ): Promise; - // (undocumented) - getAuthToken?(): Promise; - // (undocumented) - getEffectiveConfig?(): Promise>; - // (undocumented) - getSessionContextId?(backstageSessionId: string): Promise; - // (undocumented) - getStatus(): Promise; - // (undocumented) - hydrateSessionContext?( - backstageSessionId: string, - contextId: string, - model?: string, - ): Promise; - readonly id: string; - // (undocumented) - initialize(): Promise; - // (undocumented) - invalidateRuntimeConfig?(): void; - // (undocumented) - listAgents?(): Promise; - // (undocumented) - listModels?(): Promise< - Array<{ - id: string; - owned_by?: string; - model_type?: string; - }> - >; - // (undocumented) - postInitialize(): Promise; - // (undocumented) - rag?: RAGCapability; - // (undocumented) - refreshDynamicConfig?(): Promise; - // (undocumented) - registerRoutes?(router: Router, deps: unknown): void; - // (undocumented) - safety?: SafetyCapability; - // (undocumented) - setUserContext?(userRef: string): void; - // (undocumented) - shutdown?(): Promise; - // (undocumented) - submitApproval?(approval: { - responseId: string; - callId: string; - approved: boolean; - toolName?: string; - toolArguments?: string; - reason?: string; - }): Promise<{ - content?: string; - responseId?: string; - toolExecuted?: boolean; - toolOutput?: string; - pendingApproval?: { - approvalRequestId: string; - toolName: string; - serverLabel?: string; - arguments?: string; - }; - handoff?: unknown; - }>; - // (undocumented) - testModel?( - model?: string, - baseUrl?: string, - ): Promise<{ - connected: boolean; - modelFound: boolean; - canGenerate: boolean; - error?: string; - }>; -} - -// @public -export interface AgenticProviderExtensionPoint { - registerProvider( - descriptor: ProviderDescriptor, - factory: AgenticProviderFactory, - ): void; -} - -// @public -export type AgenticProviderFactory = ( - options: CreateProviderOptions, -) => AgenticProvider; - -// @public (undocumented) -export interface AgenticProviderStatus { - // (undocumented) - capabilities?: { - chat: boolean; - rag: { - available: boolean; - reason?: string; - }; - mcpTools: { - available: boolean; - reason?: string; - }; - agentCatalog?: boolean; - agentSelection?: boolean; - agentCards?: boolean; - }; - // (undocumented) - configurationErrors: string[]; - // (undocumented) - mcpServers: MCPServerStatus[]; - // (undocumented) - provider: ProviderStatus; - // (undocumented) - ready: boolean; - // (undocumented) - securityMode: SecurityMode; - // (undocumented) - timestamp: string; - // (undocumented) - vectorStore: VectorStoreStatus; -} - -// @public -export type AllowedToolSpec = - | { - type: 'file_search'; - } - | { - type: 'web_search'; - } - | { - type: 'code_interpreter'; - } - | { - type: 'mcp'; - server_label: string; - } - | { - type: 'function'; - name: string; - }; - -// @public -export interface ApprovalFilter { - always?: string[]; - never?: string[]; -} - -// @public -export interface ApprovalResult { - // (undocumented) - content: string; - handoff?: { - fromAgent: string; - toAgent: string; - }; - outputTruncated?: boolean; - pendingApproval?: { - approvalRequestId: string; - toolName: string; - serverLabel?: string; - arguments?: string; - }; - // (undocumented) - responseId: string; - // (undocumented) - toolExecuted: boolean; - // (undocumented) - toolOutput?: string; -} - -// @public -const augmentPlugin: BackendFeature; -export default augmentPlugin; - -// @public -export const augmentProviderExtensionPoint: ExtensionPoint; - -export { AugmentStatus }; - -export { ChatMessage }; - -// @public -export interface ChatRequest { - conversationId?: string; - // (undocumented) - enableRAG?: boolean; - // (undocumented) - messages: ChatMessage[]; - model?: string; - previousResponseId?: string; - sessionId?: string; -} - -export { ChatResponse }; - -// @public (undocumented) -export interface ConversationCapability { - // (undocumented) - create(): Promise<{ - conversationId: string; - }>; - // (undocumented) - delete(responseId: string, conversationId?: string): Promise; - deleteContainer?(conversationId: string): Promise; - // (undocumented) - get(responseId: string): Promise; - // (undocumented) - getByResponseChain(responseId: string): Promise; - // (undocumented) - getInputs(responseId: string): Promise; - // (undocumented) - getProcessedMessages(conversationId: string): Promise; - // (undocumented) - list(limit?: number, after?: string): Promise; - // (undocumented) - submitApproval(approval: ToolApproval): Promise; -} - -// @public -export interface ConversationDetails { - // (undocumented) - conversationId?: string; - // (undocumented) - createdAt: Date; - // (undocumented) - id: string; - // (undocumented) - input: unknown; - // (undocumented) - model: string; - // (undocumented) - output: Array<{ - type: string; - id?: string; - role?: string; - content?: Array<{ - type: string; - text: string; - }>; - status?: string; - name?: string; - call_id?: string; - arguments?: string; - output?: string; - error?: string; - server_label?: string; - results?: Array<{ - text: string; - filename?: string; - file_id?: string; - score?: number; - attributes?: Record; - }>; - }>; - // (undocumented) - previousResponseId?: string; - // (undocumented) - status: string; - // (undocumented) - usage?: ResponseUsage; -} - -// @public -export interface ConversationItem { - // (undocumented) - arguments?: string; - // (undocumented) - call_id?: string; - // (undocumented) - content?: unknown; - created_at?: number; - // (undocumented) - error?: string; - // (undocumented) - id?: string; - // (undocumented) - name?: string; - // (undocumented) - output?: string; - // (undocumented) - queries?: string[]; - // (undocumented) - results?: Array<{ - filename?: string; - file_id?: string; - text?: string; - score?: number; - attributes?: Record; - }>; - // (undocumented) - role?: string; - // (undocumented) - server_label?: string; - // (undocumented) - status?: string; - summary?: Array<{ - type: string; - text: string; - }>; - // (undocumented) - type: string; -} - -// @public -export interface ConversationItemsResult { - // (undocumented) - items: ConversationItem[]; -} - -// @public -export interface ConversationListResult { - // (undocumented) - conversations: ConversationSummary[]; - // (undocumented) - hasMore: boolean; - // (undocumented) - lastId?: string; -} - -export { ConversationSummary }; - -// @public -export interface CreateProviderOptions { - // (undocumented) - adminConfig?: AdminConfigService; - // (undocumented) - cache?: CacheService; - // (undocumented) - config: RootConfigService; - // (undocumented) - database?: DatabaseService; - // (undocumented) - logger: LoggerService; -} - -// @public -export interface DirectorySource { - path: string; - patterns?: string[]; - // (undocumented) - type: 'directory'; -} - -export { DocumentInfo }; - -// @public -export interface DocumentsConfig { - sources: DocumentSource[]; - syncMode: 'full' | 'append'; - syncSchedule?: string; -} - -// @public -export type DocumentSource = DirectorySource | UrlSource | GitHubSource; - -// @public (undocumented) -export interface EvalStatus { - // (undocumented) - enabled: boolean; - // (undocumented) - error?: string; - // (undocumented) - scoringFunctions: string[]; -} - -// @public -export interface EvaluatedChatResponse extends ChatResponse { - evaluation?: EvaluationResult; -} - -// @public (undocumented) -export interface EvaluationCapability { - // (undocumented) - evaluateResponse( - userMessage: string, - assistantResponse: string, - context?: string[], - ): Promise; - // (undocumented) - getStatus(): Promise; - // (undocumented) - isEnabled(): boolean; -} - -// @public -export interface EvaluationConfig { - enabled: boolean; - minScoreThreshold?: number; - onError?: 'skip' | 'fail'; - scoringFunctions?: string[]; -} - -export { EvaluationResult }; - -// @public -export interface FileAttributes { - [key: string]: string | undefined; - content_type?: string; - module?: string; - source_url?: string; - title?: string; -} - -export { FileFormat }; - -// @public -export interface FunctionDefinition { - description: string; - name: string; - parameters: Record; - strict?: boolean; -} - -// @public -export interface GitHubSource { - branch?: string; - path?: string; - patterns?: string[]; - repo: string; - token?: string; - // (undocumented) - type: 'github'; -} - -// @public -export interface InputItem { - // (undocumented) - arguments?: string; - // (undocumented) - call_id?: string; - // (undocumented) - content?: unknown; - // (undocumented) - id?: string; - // (undocumented) - name?: string; - // (undocumented) - output?: string; - // (undocumented) - role?: string; - // (undocumented) - status?: string; - // (undocumented) - type: string; -} - -// @public -export interface InputItemsResult { - // (undocumented) - hasMore: boolean; - // (undocumented) - items: InputItem[]; -} - -export { InputTokensDetails }; - -// @public -export interface LlamaStackConfig { - baseUrl: string; - bm25Weight?: number; - chunkingStrategy: 'auto' | 'static'; - chunkOverlapTokens: number; - embeddingDimension?: number; - embeddingModel: string; - enableCodeInterpreter?: boolean; - enableWebSearch?: boolean; - fileSearchMaxResults?: number; - fileSearchScoreThreshold?: number; - functions?: FunctionDefinition[]; - maxChunkSizeTokens: number; - maxOutputTokens?: number; - maxToolCalls?: number; - model: string; - parallelToolCalls?: boolean; - reasoning?: ReasoningConfig; - safetyIdentifier?: string; - searchMode?: 'semantic' | 'keyword' | 'hybrid'; - semanticWeight?: number; - skipTlsVerify?: boolean; - temperature?: number; - textFormat?: { - type: 'json_schema'; - json_schema: { - name: string; - schema: Record; - strict?: boolean; - }; - }; - token?: string; - toolChoice?: ToolChoiceConfig; - toolScoping?: ToolScopingConfig; - truncation?: 'auto' | 'disabled'; - vectorStoreIds: string[]; - vectorStoreName: string; - verboseStreamLogging?: boolean; - zdrMode?: boolean; -} - -// @public -export type MCPAuthConfig = - | ({ - type: 'oauth'; - } & OAuthClientConfig) - | ({ - type: 'serviceAccount'; - } & MCPServerServiceAccountConfig); - -// @public -export interface MCPServerConfig { - allowedTools?: string[]; - authRef?: string; - headers?: Record; - id: string; - name: string; - oauth?: OAuthClientConfig; - requireApproval?: 'always' | 'never' | ApprovalFilter; - serviceAccount?: MCPServerServiceAccountConfig; - type: 'streamable-http' | 'sse'; - url: string; -} - -// @public -export interface MCPServerServiceAccountConfig { - name: string; - namespace?: string; -} - -export { MCPServerStatus }; - -// @public -export interface OAuthClientConfig { - clientId: string; - clientSecret: string; - scopes?: string[]; - tokenUrl: string; -} - -export { OutputTokensDetails }; - -// @public -export interface PendingToolApproval { - approvalId: string; - confirmationMessage?: string; - requestedAt: string; - responseId: string; - severity: 'info' | 'warning' | 'critical'; - toolCall: { - callId: string; - name: string; - serverLabel: string; - arguments: string; - parsedArguments: Record; - }; -} - -// @public -export interface ProcessedMessage { - agentName?: string; - createdAt?: string; - // (undocumented) - ragSources?: ProcessedRagSource[]; - reasoning?: string; - // (undocumented) - role: 'user' | 'assistant' | 'system'; - // (undocumented) - text: string; - // (undocumented) - toolCalls?: ProcessedToolCall[]; -} - -// @public -export interface ProcessedRagSource { - // (undocumented) - attributes?: Record; - // (undocumented) - fileId?: string; - // (undocumented) - filename: string; - // (undocumented) - score?: number; - // (undocumented) - text?: string; -} - -// @public -export interface ProcessedToolCall { - // (undocumented) - arguments?: string; - // (undocumented) - error?: string; - // (undocumented) - id: string; - // (undocumented) - name: string; - // (undocumented) - output?: string; - // (undocumented) - serverLabel: string; - // (undocumented) - status: string; -} - -export { PromptCard }; - -export { PromptGroup }; - -export { ProviderStatus }; - -export { QuickAction }; - -// @public (undocumented) -export interface RAGCapability { - // (undocumented) - addVectorStoreId?(id: string): void; - // (undocumented) - createVectorStoreWithConfig?(overrides: Record): Promise<{ - vectorStoreId: string; - vectorStoreName: string; - created: boolean; - embeddingModel: string; - embeddingDimension?: number; - }>; - // (undocumented) - deleteDocument?( - fileId: string, - vectorStoreId?: string, - ): Promise<{ - success: boolean; - }>; - // (undocumented) - deleteVectorStore?(vectorStoreId: string): Promise<{ - success: boolean; - filesDeleted: number; - }>; - // (undocumented) - generateAnswer?( - query: string, - maxResults?: number, - vectorStoreId?: string, - vectorStoreIds?: string[], - ): Promise<{ - query: string; - answer: string; - chunks: Array<{ - text: string; - score?: number; - fileId?: string; - fileName?: string; - vectorStoreId?: string; - }>; - model: string; - totalResults: number; - }>; - // (undocumented) - getActiveVectorStoreIds(): string[] | Promise; - // (undocumented) - getDefaultVectorStoreId(): string | undefined; - // (undocumented) - getVectorStoreConfig?(): Promise<{ - vectorStoreName: string; - embeddingModel: string; - embeddingDimension: number; - searchMode?: 'semantic' | 'keyword' | 'hybrid'; - bm25Weight?: number; - semanticWeight?: number; - chunkingStrategy: 'auto' | 'static'; - maxChunkSizeTokens: number; - chunkOverlapTokens: number; - fileSearchMaxResults?: number; - fileSearchScoreThreshold?: number; - } | null>; - // (undocumented) - getVectorStoreStatus?(): Promise<{ - exists: boolean; - vectorStoreId?: string; - vectorStoreName?: string; - documentCount?: number; - embeddingModel?: string; - ready: boolean; - }>; - // (undocumented) - listDocuments(vectorStoreId?: string): Promise; - // (undocumented) - listVectorStores(): Promise; - // (undocumented) - removeVectorStoreId?(id: string): void; - // (undocumented) - searchVectorStore?( - query: string, - maxResults?: number, - vectorStoreId?: string, - vectorStoreIds?: string[], - ): Promise<{ - query: string; - chunks: Array<{ - text: string; - score?: number; - fileId?: string; - fileName?: string; - vectorStoreId?: string; - }>; - vectorStoreId: string; - totalResults: number; - }>; - // (undocumented) - syncDocuments(): Promise; - // (undocumented) - updateVectorStore?( - vectorStoreId: string, - updates: Record, - ): Promise; - // (undocumented) - uploadDocument?( - fileName: string, - content: Buffer, - vectorStoreId?: string, - ): Promise<{ - fileId: string; - fileName: string; - status: string; - }>; -} - -export { RAGSource }; - -// @public -export interface ReasoningConfig { - effort?: 'low' | 'medium' | 'high'; - summary?: 'auto' | 'concise' | 'detailed' | 'none'; -} - -// @public -export interface ResponseInputItem { - call_id?: string; - content?: - | string - | Array<{ - type: string; - text?: string; - }>; - id?: string; - output?: string; - role?: 'user' | 'assistant' | 'system'; - status?: string; - type: string; -} - -// @public -export interface ResponseInputItemList { - // (undocumented) - data: ResponseInputItem[]; - // (undocumented) - first_id?: string; - // (undocumented) - has_more: boolean; - // (undocumented) - last_id?: string; -} - -export { ResponseUsage }; - -// @public (undocumented) -export interface SafetyCapability { - // (undocumented) - checkInput(text: string): Promise; - // (undocumented) - checkOutput(text: string): Promise; - // (undocumented) - getStatus(): Promise; - // (undocumented) - isEnabled(): boolean; -} - -// @public -export interface SafetyChatResponse extends ChatResponse { - filtered?: boolean; - filterReason?: string; - pendingAction?: string; - requiresConfirmation?: boolean; -} - -// @public (undocumented) -export interface SafetyCheckResult { - // (undocumented) - category?: string; - // (undocumented) - safe: boolean; - // (undocumented) - violation?: string; -} - -// @public -export interface SafetyConfig { - enabled: boolean; - inputShields?: string[]; - onError?: 'allow' | 'block'; - outputShields?: string[]; - registerShields?: ShieldRegistration[]; -} - -// @public (undocumented) -export interface SafetyStatus { - // (undocumented) - enabled: boolean; - // (undocumented) - error?: string; - // (undocumented) - shields: string[]; -} - -// @public -export interface SafetyViolation { - // (undocumented) - metadata?: Record; - // (undocumented) - user_message?: string; - // (undocumented) - violation_level: 'info' | 'warn' | 'error'; -} - -// @public -export interface ScoreResult { - // (undocumented) - metadata?: Record; - // (undocumented) - score_type: string; - // (undocumented) - value: number; -} - -// @public -export interface ScoringFunctionInfo { - // (undocumented) - description?: string; - // (undocumented) - identifier: string; - // (undocumented) - params?: Record; - // (undocumented) - provider_id: string; - // (undocumented) - provider_resource_id: string; - // (undocumented) - type: string; -} - -// @public -export interface SecurityConfig { - accessDeniedMessage?: string; - mcpOAuth?: OAuthClientConfig; - mode: SecurityMode; -} - -export { SecurityMode }; - -// @public -export interface ShieldInfo { - // (undocumented) - identifier: string; - // (undocumented) - params?: Record; - // (undocumented) - provider_id: string; - // (undocumented) - provider_resource_id: string; - // (undocumented) - type: string; -} - -// @public -export interface ShieldRegistration { - providerId: string; - providerShieldId: string; - shieldId: string; -} - -// @public -export interface StoredResponse { - created_at: number; - id: string; - model: string; - output: Array<{ - type: string; - id?: string; - role?: string; - content?: Array<{ - type: string; - text: string; - }>; - }>; - status: 'completed' | 'failed' | 'in_progress'; -} - -// @public -export interface StoredResponseList { - data: StoredResponse[]; - first_id?: string; - has_more: boolean; - last_id?: string; -} - -// @public -export interface ToolApproval { - // (undocumented) - approved: boolean; - // (undocumented) - callId: string; - // (undocumented) - responseId: string; - // (undocumented) - toolArguments?: string; - // (undocumented) - toolName?: string; -} - -// @public -export interface ToolApprovalResponse { - approvalId: string; - approved: boolean; - modifiedArguments?: string; - rejectionReason?: string; -} - -// @public -export interface ToolApprovalResult { - continueChat: boolean; - error?: string; - executed: boolean; - output?: string; -} - -export { ToolCallInfo }; - -// @public -export type ToolChoiceConfig = - | 'auto' - | 'required' - | 'none' - | { - type: 'function'; - name: string; - } - | { - type: 'allowed_tools'; - mode?: 'auto' | 'required'; - tools: AllowedToolSpec[]; - }; - -// @public -export interface ToolScopingConfig { - activationThreshold: number; - enabled: boolean; - maxToolsPerTurn: number; - minScore: number; -} - -// @public -export interface UploadDocumentsRequest { - // (undocumented) - files: Array<{ - fileName: string; - content: string; - }>; -} - -// @public -export interface UploadDocumentsResponse { - // (undocumented) - failed: Array<{ - fileName: string; - error: string; - }>; - // (undocumented) - success: boolean; - // (undocumented) - uploaded: DocumentInfo[]; -} - -// @public -export interface UrlSource { - headers?: Record; - // (undocumented) - type: 'url'; - urls: string[]; -} - -export { VectorStoreInfo }; - -export { VectorStoreStatus }; - -export { Workflow }; - -export { WorkflowStep }; -``` diff --git a/workspaces/augment/plugins/augment-backend/src/constants.ts b/workspaces/augment/plugins/augment-backend/src/constants.ts deleted file mode 100644 index 2c3fa881cce..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/constants.ts +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// --------------------------------------------------------------------------- -// Timeout constants (milliseconds unless suffixed with _S for seconds) -// --------------------------------------------------------------------------- - -/** API request timeout for synchronous LLM inference calls. */ -export const API_REQUEST_TIMEOUT_MS = 120_000; - -/** Streaming request timeout for long-running LLM responses. */ -export const STREAM_REQUEST_TIMEOUT_MS = 300_000; - -/** Timeout for walking a response chain in ConversationService. */ -export const RESPONSE_CHAIN_TIMEOUT_MS = 15_000; - -/** Default HTTP timeout for fetchWithTlsControl. */ -export const DEFAULT_HTTP_TIMEOUT_MS = 30_000; - -// --------------------------------------------------------------------------- -// Token / auth constants -// --------------------------------------------------------------------------- - -/** Buffer (seconds) before token expiry to trigger a refresh. */ -export const TOKEN_EXPIRY_BUFFER_S = 60; - -/** Minimum token lifetime (seconds) to consider caching. */ -export const MIN_TOKEN_LIFETIME_S = 300; - -// --------------------------------------------------------------------------- -// Input validation limits -// --------------------------------------------------------------------------- - -/** Maximum length for description/query fields in admin routes. */ -export const MAX_DESCRIPTION_LENGTH = 2_000; - -/** Maximum length for system prompt configuration. */ -export const MAX_SYSTEM_PROMPT_LENGTH = 50_000; - -/** Maximum number of messages in a single chat request. */ -export const MAX_MESSAGES_PER_REQUEST = 200; - -/** Maximum length for approval request string fields. */ -export const MAX_APPROVAL_FIELD_LENGTH = 1_000; - -// --------------------------------------------------------------------------- -// Cache / registry size limits -// --------------------------------------------------------------------------- - -/** Maximum entries in the conversation registry (LRU-style cap). */ -export const MAX_CONVERSATION_REGISTRY_SIZE = 10_000; - -/** Maximum entries in the MCP auth token cache. */ -export const MAX_TOKEN_CACHE_SIZE = 1_000; - -/** Maximum entries in the DocumentSyncService content hash cache. */ -export const MAX_CONTENT_HASH_CACHE_SIZE = 10_000; - -/** Default expiration for OAuth tokens (seconds) when no expiry is provided. */ -export const DEFAULT_TOKEN_EXPIRATION_S = 3_600; - -// --------------------------------------------------------------------------- -// Pagination / list limits -// --------------------------------------------------------------------------- - -/** Default page size for vector store file listing. */ -export const VECTOR_STORE_PAGE_SIZE = 100; - -/** Maximum conversations to return from a listing query. */ -export const MAX_CONVERSATIONS_LIMIT = 100; - -/** Maximum session list limit. */ -export const MAX_SESSION_LIST_LIMIT = 500; - -/** Default session list limit for admin queries. */ -export const DEFAULT_SESSION_LIST_LIMIT = 100; - -/** Maximum characters in a session title. */ -export const MAX_SESSION_TITLE_LENGTH = 200; - -// --------------------------------------------------------------------------- -// Content length limits -// --------------------------------------------------------------------------- - -/** Maximum length for a message preview (truncated for session title). */ -export const MESSAGE_PREVIEW_MAX_LENGTH = 200; - -/** Maximum length of a tool output item logged during approval. */ -export const APPROVAL_OUTPUT_LOG_MAX_LENGTH = 500; - -/** Maximum file size for document uploads (bytes). */ -export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; - -// --------------------------------------------------------------------------- -// Config validation limits -// --------------------------------------------------------------------------- - -/** Maximum length for config values in validation. */ -export const MAX_CONFIG_VALUE_SIZE = 100_000; - -/** Maximum length for a single message content field (characters). */ -export const MAX_MESSAGE_CONTENT_LENGTH = 100_000; - -/** Maximum length for a model identifier. */ -export const MAX_MODEL_LENGTH = 200; - -/** Maximum length for branding text fields. */ -export const MAX_BRANDING_FIELD_LENGTH = 500; - -// --------------------------------------------------------------------------- -// Config defaults -// --------------------------------------------------------------------------- - -/** Default embedding dimension when not specified. */ -export const DEFAULT_EMBEDDING_DIMENSION = 384; - -/** Default vector store name when not specified in config. */ -export const DEFAULT_VECTOR_STORE_NAME = 'augment-knowledge-base'; - -/** Default chunk size for document ingestion. */ -export const DEFAULT_CHUNK_SIZE = 512; - -/** Default chunk overlap for document ingestion. */ -export const DEFAULT_CHUNK_OVERLAP = 50; - -/** Default limit for conversation listing. */ -export const DEFAULT_CONVERSATIONS_LIMIT = 50; - -/** Cache TTL for RuntimeConfigResolver (ms). - * Admin saves trigger explicit invalidation, so this can be generous - * to avoid redundant DB reads within the same request lifecycle. */ -export const CONFIG_CACHE_TTL_MS = 30_000; - -// --------------------------------------------------------------------------- -// Stream / loop limits -// --------------------------------------------------------------------------- - -/** Max auto-reapprovals for chained HITL duplicate requests. */ -export const MAX_AUTO_REAPPROVALS = 3; - -/** Max retries for transient API failures (502, 503, 504, ECONNRESET). */ -export const MAX_API_RETRIES = 2; - -/** Base delay in ms for exponential backoff between API retries. */ -export const RETRY_BASE_DELAY_MS = 1000; - -/** TTL for BackendToolExecutor's cached tool schemas (ms). */ -export const BACKEND_TOOL_DISCOVERY_TTL_MS = 5 * 60 * 1000; - -// --------------------------------------------------------------------------- -// Default model / prompt fallbacks -// --------------------------------------------------------------------------- - -/** Default system prompt used when none is configured. */ -export const DEFAULT_SYSTEM_PROMPT = 'You are a helpful assistant.'; - -/** Default LLM model identifier used as fallback in provider code. */ -export const DEFAULT_MODEL = 'meta-llama/Llama-3.3-8B-Instruct'; - -/** Default embedding model for vector stores. */ -export const DEFAULT_EMBEDDING_MODEL = 'sentence-transformers/all-MiniLM-L6-v2'; - -/** - * Default `include` fields for Responses API requests. - * Ensures file_search results are returned in the response output. - */ -export const DEFAULT_INCLUDE_FIELDS: readonly string[] = [ - 'file_search_call.results', -]; - -/** - * Extended `include` fields for ZDR mode. - * Adds encrypted reasoning content alongside file_search results. - */ -export const ZDR_INCLUDE_FIELDS: readonly string[] = [ - 'file_search_call.results', - 'reasoning.encrypted_content', -]; diff --git a/workspaces/augment/plugins/augment-backend/src/extensions.ts b/workspaces/augment/plugins/augment-backend/src/extensions.ts deleted file mode 100644 index 5d83abcfe07..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/extensions.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createExtensionPoint } from '@backstage/backend-plugin-api'; -import type { ProviderDescriptor } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { AgenticProvider } from './providers/types'; -import type { CreateProviderOptions } from './providers/factory'; - -/** - * Factory function that creates an AgenticProvider from standard options. - * Extension modules implement this to provide their own provider. - * - * @public - */ -export type AgenticProviderFactory = ( - options: CreateProviderOptions, -) => AgenticProvider; - -/** - * Extension point for registering custom agentic providers. - * - * Backend modules use this to register additional AI providers - * (beyond the built-in ones) without forking the plugin: - * - * ```ts - * import { augmentProviderExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-augment-backend'; - * - * export default createBackendModule({ - * pluginId: 'augment', - * moduleId: 'my-provider', - * register(reg) { - * reg.registerInit({ - * deps: { providers: augmentProviderExtensionPoint }, - * async init({ providers }) { - * providers.registerProvider(descriptor, factory); - * }, - * }); - * }, - * }); - * ``` - * - * @public - */ -export interface AgenticProviderExtensionPoint { - /** - * Register a provider implementation. - * - * @param descriptor - Metadata describing the provider (id, capabilities, config fields) - * @param factory - Function that creates the provider instance - */ - registerProvider( - descriptor: ProviderDescriptor, - factory: AgenticProviderFactory, - ): void; -} - -/** - * Extension point for registering custom agentic providers. - * - * @public - */ -export const augmentProviderExtensionPoint = - createExtensionPoint({ - id: 'augment.providers', - }); diff --git a/workspaces/augment/plugins/augment-backend/src/index.ts b/workspaces/augment/plugins/augment-backend/src/index.ts deleted file mode 100644 index 9e0994f7895..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/index.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Augment Backend Plugin - * - * @packageDocumentation - */ - -export { augmentPlugin as default } from './plugin'; -export { - augmentProviderExtensionPoint, - type AgenticProviderExtensionPoint, - type AgenticProviderFactory, -} from './extensions'; - -// Re-exports from common (shared between frontend and backend) -export { FileFormat } from './types'; -export type { - ChatMessage, - ToolCallInfo, - RAGSource, - EvaluationResult, - DocumentInfo, - ProviderStatus, - VectorStoreStatus, - MCPServerStatus, - SecurityMode, - AugmentStatus, - VectorStoreInfo, - WorkflowStep, - Workflow, - QuickAction, - PromptCard, - PromptGroup, - ConversationSummary, - ResponseUsage, - InputTokensDetails, - OutputTokensDetails, -} from './types'; - -// Services (used in provider factory) -export { AdminConfigService } from './services/AdminConfigService'; - -// Provider factory types -export type { CreateProviderOptions } from './providers/factory'; -// Explicit re-export of types used in interfaces (required for API report) -export type { - ConversationItem, - InputItem, - ProcessedRagSource, - ProcessedToolCall, -} from './providers/llamastack/conversationTypes'; -export type { - AgenticProvider, - AgenticProviderStatus, - ApprovalResult, - ConversationCapability, - ConversationDetails, - ConversationItemsResult, - ConversationListResult, - EvaluationCapability, - EvalStatus, - InputItemsResult, - ProcessedMessage, - RAGCapability, - SafetyCapability, - SafetyCheckResult, - SafetyStatus, - ToolApproval, -} from './providers/types'; - -// Backend-specific public types -export type { - // Configuration - ToolScopingConfig, - AllowedToolSpec, - ReasoningConfig, - ToolChoiceConfig, - LlamaStackConfig, - FunctionDefinition, - DirectorySource, - UrlSource, - GitHubSource, - DocumentSource, - DocumentsConfig, - FileAttributes, - // Chat request/response - ChatRequest, - ChatResponse, - UploadDocumentsRequest, - UploadDocumentsResponse, - // MCP server config - ApprovalFilter, - OAuthClientConfig, - MCPServerServiceAccountConfig, - MCPAuthConfig, - MCPServerConfig, - // Security - SecurityConfig, - // HITL - PendingToolApproval, - ToolApprovalResponse, - ToolApprovalResult, - // Safety - ShieldRegistration, - SafetyConfig, - ShieldInfo, - SafetyViolation, - SafetyChatResponse, - // Evaluation - EvaluationConfig, - ScoringFunctionInfo, - ScoreResult, - EvaluatedChatResponse, - // Conversation history - StoredResponse, - StoredResponseList, - ResponseInputItem, - ResponseInputItemList, -} from './types'; diff --git a/workspaces/augment/plugins/augment-backend/src/middleware/csrf.test.ts b/workspaces/augment/plugins/augment-backend/src/middleware/csrf.test.ts deleted file mode 100644 index 57e2f4b686d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/middleware/csrf.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import express from 'express'; -import request from 'supertest'; - -function createCsrfApp(): express.Express { - const app = express(); - app.use(express.json()); - - const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); - const CSRF_EXEMPT_PATHS = new Set(['/health']); - app.use((req, res, next) => { - if (!MUTATING_METHODS.has(req.method)) { - next(); - return; - } - if (CSRF_EXEMPT_PATHS.has(req.path)) { - next(); - return; - } - if (!req.headers['x-backstage-request']) { - res.status(403).json({ error: 'Missing X-Backstage-Request header' }); - return; - } - next(); - }); - - app.get('/status', (_req, res) => res.json({ ok: true })); - app.post('/chat', (_req, res) => res.json({ ok: true })); - app.put('/agents/a1/promote', (_req, res) => res.json({ ok: true })); - app.delete('/sessions/s1', (_req, res) => res.json({ ok: true })); - app.post('/health', (_req, res) => res.json({ ok: true })); - - return app; -} - -describe('CSRF middleware', () => { - const app = createCsrfApp(); - - it('allows GET requests without CSRF header', async () => { - const res = await request(app).get('/status'); - expect(res.status).toBe(200); - }); - - it('rejects POST without X-Backstage-Request header', async () => { - const res = await request(app) - .post('/chat') - .send({ messages: [{ role: 'user', content: 'hi' }] }); - expect(res.status).toBe(403); - expect(res.body.error).toContain('X-Backstage-Request'); - }); - - it('rejects PUT without X-Backstage-Request header', async () => { - const res = await request(app) - .put('/agents/a1/promote') - .send({ targetStage: 'review' }); - expect(res.status).toBe(403); - }); - - it('rejects DELETE without X-Backstage-Request header', async () => { - const res = await request(app).delete('/sessions/s1'); - expect(res.status).toBe(403); - }); - - it('allows POST with X-Backstage-Request header', async () => { - const res = await request(app) - .post('/chat') - .set('X-Backstage-Request', 'augment') - .send({ messages: [{ role: 'user', content: 'hi' }] }); - expect(res.status).toBe(200); - }); - - it('allows PUT with X-Backstage-Request header', async () => { - const res = await request(app) - .put('/agents/a1/promote') - .set('X-Backstage-Request', 'augment') - .send({}); - expect(res.status).toBe(200); - }); - - it('exempts /health from CSRF check', async () => { - const res = await request(app).post('/health').send({}); - expect(res.status).toBe(200); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/middleware/rateLimiter.ts b/workspaces/augment/plugins/augment-backend/src/middleware/rateLimiter.ts deleted file mode 100644 index db30fd0f503..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/middleware/rateLimiter.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type express from 'express'; - -interface RateLimitEntry { - count: number; - resetAt: number; -} - -/** - * Simple in-memory sliding-window rate limiter. - * Uses the client IP as the rate limit key. - */ -export function createRateLimiter(opts: { - windowMs: number; - maxRequests: number; -}): express.RequestHandler { - const { windowMs, maxRequests } = opts; - const store = new Map(); - - setInterval(() => { - const now = Date.now(); - for (const [key, entry] of store) { - if (entry.resetAt <= now) { - store.delete(key); - } - } - }, windowMs).unref(); - - return (req, res, next) => { - const key = req.ip || req.socket.remoteAddress || 'unknown'; - const now = Date.now(); - let entry = store.get(key); - - if (!entry || entry.resetAt <= now) { - entry = { count: 0, resetAt: now + windowMs }; - store.set(key, entry); - } - - entry.count++; - - res.setHeader('X-RateLimit-Limit', String(maxRequests)); - res.setHeader( - 'X-RateLimit-Remaining', - String(Math.max(0, maxRequests - entry.count)), - ); - res.setHeader('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000))); - - if (entry.count > maxRequests) { - res.status(429).json({ - error: 'Too Many Requests', - message: `Rate limit exceeded. Try again in ${Math.ceil((entry.resetAt - now) / 1000)} seconds.`, - }); - return; - } - - next(); - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/middleware/security.test.ts b/workspaces/augment/plugins/augment-backend/src/middleware/security.test.ts deleted file mode 100644 index 15117ef1626..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/middleware/security.test.ts +++ /dev/null @@ -1,518 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NotAllowedError } from '@backstage/errors'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import { createSecurityMiddleware } from './security'; -import { createMockLogger, createMockResponse } from '../test-utils/mocks'; -import type express from 'express'; - -function createMockRequest( - overrides?: Partial, -): express.Request { - return { - ...overrides, - } as express.Request; -} - -function createMockNext(): express.NextFunction { - return jest.fn(); -} - -describe('createSecurityMiddleware', () => { - const mockLogger = createMockLogger(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('requirePluginAccess', () => { - it('calls next() when security mode is "none"', async () => { - const next = createMockNext(); - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: jest.fn(), - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: jest.fn(), - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'none', - permissionsEnabled: false, - }); - - await middleware.requirePluginAccess( - createMockRequest(), - createMockResponse(), - next, - ); - - expect(next).toHaveBeenCalled(); - expect(mockLogger.debug).toHaveBeenCalledWith( - 'Security mode is "none" - skipping access control', - ); - }); - - it('calls next() when credentials and permission check succeed', async () => { - const next = createMockNext(); - const res = createMockResponse(); - const mockCredentials = jest.fn().mockResolvedValue({ - principal: { type: 'user', userEntityRef: 'user:default/alice' }, - }); - const mockAuthorize = jest - .fn() - .mockResolvedValue([{ result: AuthorizeResult.ALLOW }]); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: mockAuthorize, - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: true, - }); - - await middleware.requirePluginAccess(createMockRequest(), res, next); - - expect(mockCredentials).toHaveBeenCalled(); - expect(mockAuthorize).toHaveBeenCalled(); - expect(next).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalled(); - }); - - it('sends 403 when permission is DENY', async () => { - const next = createMockNext(); - const res = createMockResponse(); - const mockCredentials = jest.fn().mockResolvedValue({ - principal: { type: 'user', userEntityRef: 'user:default/bob' }, - }); - const mockAuthorize = jest - .fn() - .mockResolvedValue([{ result: AuthorizeResult.DENY }]); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: mockAuthorize, - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: true, - }); - - await middleware.requirePluginAccess(createMockRequest(), res, next); - - expect(next).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: 'Access Denied', - message: expect.any(String), - }), - ); - }); - - it('sends 403 with custom accessDeniedMessage when provided', async () => { - const next = createMockNext(); - const res = createMockResponse(); - const mockCredentials = jest.fn().mockResolvedValue({}); - const mockAuthorize = jest - .fn() - .mockResolvedValue([{ result: AuthorizeResult.DENY }]); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: mockAuthorize, - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: true, - accessDeniedMessage: 'Custom access denied message', - }); - - await middleware.requirePluginAccess(createMockRequest(), res, next); - - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: 'Access Denied', - message: 'Custom access denied message', - }), - ); - }); - - it('sends 401 when credentials fail (not NotAllowedError)', async () => { - const next = createMockNext(); - const res = createMockResponse(); - const mockCredentials = jest - .fn() - .mockRejectedValue(new Error('No token')); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: jest.fn(), - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: true, - }); - - await middleware.requirePluginAccess(createMockRequest(), res, next); - - expect(next).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: 'Unauthorized', - message: 'You must be logged in to access Augment', - }), - ); - }); - - it('sends 403 when NotAllowedError is thrown', async () => { - const next = createMockNext(); - const res = createMockResponse(); - const mockCredentials = jest - .fn() - .mockRejectedValue(new NotAllowedError('Not allowed')); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: jest.fn(), - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: true, - }); - - await middleware.requirePluginAccess(createMockRequest(), res, next); - - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: 'Access Denied', - message: 'Not allowed', - }), - ); - }); - }); - - describe('checkIsAdmin', () => { - it('returns true when security mode is "none"', async () => { - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: jest.fn(), - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: jest.fn(), - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'none', - permissionsEnabled: false, - }); - - const result = await middleware.checkIsAdmin(createMockRequest()); - - expect(result).toBe(true); - }); - - it('returns true when permissionsEnabled and RBAC allows', async () => { - const mockCredentials = jest.fn().mockResolvedValue({ - principal: { type: 'user', userEntityRef: 'user:default/admin' }, - }); - const mockAuthorize = jest - .fn() - .mockResolvedValue([{ result: AuthorizeResult.ALLOW }]); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: mockAuthorize, - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: true, - }); - - const result = await middleware.checkIsAdmin(createMockRequest()); - - expect(result).toBe(true); - }); - - it('returns false when permissionsEnabled and RBAC denies', async () => { - const mockCredentials = jest.fn().mockResolvedValue({ - principal: { type: 'user', userEntityRef: 'user:default/user1' }, - }); - const mockAuthorize = jest - .fn() - .mockResolvedValue([{ result: AuthorizeResult.DENY }]); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: mockAuthorize, - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: true, - }); - - const result = await middleware.checkIsAdmin(createMockRequest()); - - expect(result).toBe(false); - }); - - it('returns false when permissionsEnabled and credentials throw', async () => { - const mockCredentials = jest - .fn() - .mockRejectedValue(new Error('Auth failed')); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: jest.fn(), - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: true, - }); - - const result = await middleware.checkIsAdmin(createMockRequest()); - - expect(result).toBe(false); - }); - - it('returns true when adminUsers includes user ref', async () => { - const mockCredentials = jest.fn().mockResolvedValue({ - principal: { type: 'user', userEntityRef: 'user:default/admin' }, - }); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: jest.fn(), - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: false, - adminUsers: ['user:default/admin', 'user:default/other'], - }); - - const result = await middleware.checkIsAdmin(createMockRequest()); - - expect(result).toBe(true); - }); - - it('returns false when adminUsers does not include user ref', async () => { - const mockCredentials = jest.fn().mockResolvedValue({ - principal: { type: 'user', userEntityRef: 'user:default/regular' }, - }); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: jest.fn(), - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: false, - adminUsers: ['user:default/admin'], - }); - - const result = await middleware.checkIsAdmin(createMockRequest()); - - expect(result).toBe(false); - }); - - it('returns false when adminUsers is empty or undefined', async () => { - const mockCredentials = jest.fn().mockResolvedValue({ - principal: { type: 'user', userEntityRef: 'user:default/admin' }, - }); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: jest.fn(), - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: false, - adminUsers: [], - }); - - const result = await middleware.checkIsAdmin(createMockRequest()); - - expect(result).toBe(false); - }); - }); - - describe('getUserRef', () => { - it('returns GUEST_USER_REF when security mode is "none"', async () => { - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: jest.fn(), - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: jest.fn(), - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'none', - permissionsEnabled: false, - }); - - const result = await middleware.getUserRef(createMockRequest()); - - expect(result).toBe('user:default/guest'); - }); - - it('returns user ref from credentials when available', async () => { - const mockCredentials = jest.fn().mockResolvedValue({ - principal: { type: 'user', userEntityRef: 'user:default/alice' }, - }); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: jest.fn(), - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: false, - }); - - const result = await middleware.getUserRef(createMockRequest()); - - expect(result).toBe('user:default/alice'); - expect(mockCredentials).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ allow: ['user'] }), - ); - }); - - it('throws when credentials fail', async () => { - const mockCredentials = jest.fn().mockRejectedValue(new Error('No user')); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: jest.fn(), - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: false, - }); - - await expect(middleware.getUserRef(createMockRequest())).rejects.toThrow( - 'No user', - ); - }); - }); - - describe('requireAdminAccess', () => { - it('calls next() when checkIsAdmin returns true', async () => { - const next = createMockNext(); - const mockCredentials = jest.fn().mockResolvedValue({ - principal: { type: 'user', userEntityRef: 'user:default/admin' }, - }); - const mockAuthorize = jest - .fn() - .mockResolvedValue([{ result: AuthorizeResult.ALLOW }]); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: mockAuthorize, - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: true, - }); - - await middleware.requireAdminAccess( - createMockRequest(), - createMockResponse(), - next, - ); - - expect(next).toHaveBeenCalled(); - }); - - it('sends 403 when checkIsAdmin returns false', async () => { - const next = createMockNext(); - const res = createMockResponse(); - const mockCredentials = jest.fn().mockResolvedValue({ - principal: { type: 'user', userEntityRef: 'user:default/regular' }, - }); - const mockAuthorize = jest - .fn() - .mockResolvedValue([{ result: AuthorizeResult.DENY }]); - - const middleware = createSecurityMiddleware({ - logger: mockLogger, - httpAuth: { - credentials: mockCredentials, - } as unknown as import('@backstage/backend-plugin-api').HttpAuthService, - permissions: { - authorize: mockAuthorize, - } as unknown as import('@backstage/backend-plugin-api').PermissionsService, - securityMode: 'plugin-only', - permissionsEnabled: true, - }); - - await middleware.requireAdminAccess(createMockRequest(), res, next); - - expect(next).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: 'Admin Access Denied', - message: expect.stringContaining('admin permissions'), - }), - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/middleware/security.ts b/workspaces/augment/plugins/augment-backend/src/middleware/security.ts deleted file mode 100644 index a34a735f953..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/middleware/security.ts +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - LoggerService, - HttpAuthService, - PermissionsService, -} from '@backstage/backend-plugin-api'; -import { NotAllowedError } from '@backstage/errors'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import { - augmentAccessPermission, - augmentAdminPermission, - type SecurityMode, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type express from 'express'; -import { toErrorMessage } from '../services/utils'; - -const GUEST_USER_REF = 'user:default/guest'; - -export interface SecurityDeps { - logger: LoggerService; - httpAuth: HttpAuthService; - permissions: PermissionsService; - securityMode: SecurityMode; - accessDeniedMessage?: string; - adminUsers?: string[]; - permissionsEnabled: boolean; -} - -export function createSecurityMiddleware(deps: SecurityDeps) { - const { - logger, - httpAuth, - permissions, - securityMode, - accessDeniedMessage, - adminUsers, - permissionsEnabled, - } = deps; - - const securityConfig = { - mode: securityMode, - accessDeniedMessage, - }; - - async function getUserRef(req: express.Request): Promise { - if (securityConfig.mode === 'none') { - try { - const credentials = await httpAuth.credentials(req, { - allow: ['user'], - }); - return credentials.principal.userEntityRef; - } catch { - return GUEST_USER_REF; - } - } - const credentials = await httpAuth.credentials(req, { allow: ['user'] }); - return credentials.principal.userEntityRef; - } - - const requirePluginAccess: express.RequestHandler = async ( - req, - res, - next, - ) => { - if (securityConfig.mode === 'none') { - logger.debug('Security mode is "none" - skipping access control'); - next(); - return; - } - - try { - const credentials = await httpAuth.credentials(req); - - const decision = ( - await permissions.authorize([{ permission: augmentAccessPermission }], { - credentials, - }) - )[0]; - - logger.debug(`Plugin access check: augment.access = ${decision.result}`); - - if (decision.result !== AuthorizeResult.ALLOW) { - logger.warn( - `Augment access denied for user (result=${decision.result})`, - ); - const deniedMessage = - securityConfig.accessDeniedMessage || - 'You do not have permission to access Augment. ' + - 'Please contact your administrator to request access.'; - res.status(403).json({ - error: 'Access Denied', - message: deniedMessage, - }); - return; - } - - next(); - } catch (error) { - const errorMessage = toErrorMessage(error); - logger.warn(`Auth/permission check failed: ${errorMessage}`); - - if (error instanceof NotAllowedError) { - res.status(403).json({ - error: 'Access Denied', - message: error.message, - }); - return; - } - - res.status(401).json({ - error: 'Unauthorized', - message: 'You must be logged in to access Augment', - }); - } - }; - - /** - * Check whether the request user has admin permission. - * Returns true for admin users, false otherwise. - * Never throws — returns false on any error. - * - * Decision path: - * 1. security.mode === 'none' → everyone is admin (dev mode) - * 2. permission.enabled === true → delegate to Backstage RBAC - * 3. Otherwise → check user entity ref against augment.security.adminUsers - */ - async function checkIsAdmin(req: express.Request): Promise { - if (securityConfig.mode === 'none') { - return true; - } - - if (permissionsEnabled) { - try { - const credentials = await httpAuth.credentials(req); - const decision = ( - await permissions.authorize( - [{ permission: augmentAdminPermission }], - { credentials }, - ) - )[0]; - return decision.result === AuthorizeResult.ALLOW; - } catch { - logger.debug('Permission check threw, treating as non-admin'); - return false; - } - } - - if (!adminUsers || adminUsers.length === 0) { - logger.debug('checkIsAdmin: no adminUsers configured, returning false'); - return false; - } - try { - const userRef = await getUserRef(req); - const isMatch = adminUsers.includes(userRef); - logger.info( - `checkIsAdmin: userRef="${userRef}", adminCount=${adminUsers.length}, match=${isMatch}`, - ); - return isMatch; - } catch { - logger.debug('Permission check threw, treating as non-admin'); - return false; - } - } - - const requireAdminAccess: express.RequestHandler = async (req, res, next) => { - if (await checkIsAdmin(req)) { - next(); - return; - } - logger.warn('Augment admin access denied for user'); - res.status(403).json({ - error: 'Admin Access Denied', - message: - 'You do not have admin permissions for Augment. ' + - 'Contact your administrator to request access.', - }); - }; - - return { - requirePluginAccess, - checkIsAdmin, - requireAdminAccess, - getUserRef, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/parsers/chatRequestParsers.test.ts b/workspaces/augment/plugins/augment-backend/src/parsers/chatRequestParsers.test.ts deleted file mode 100644 index 3eee210e237..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/parsers/chatRequestParsers.test.ts +++ /dev/null @@ -1,332 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { InputError } from '@backstage/errors'; -import { parseChatRequest, parseApprovalRequest } from './chatRequestParsers'; - -describe('chatRequestParsers', () => { - describe('parseChatRequest', () => { - it('parses valid request with messages, enableRAG, previousResponseId, conversationId, sessionId', () => { - const body = { - messages: [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi there' }, - ], - enableRAG: true, - previousResponseId: 'resp-123', - conversationId: 'conv-456', - sessionId: 'sess-789', - }; - - const result = parseChatRequest(body); - - expect(result.messages).toEqual([ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi there' }, - ]); - expect(result.enableRAG).toBe(true); - expect(result.previousResponseId).toBe('resp-123'); - expect(result.conversationId).toBe('conv-456'); - expect(result.sessionId).toBe('sess-789'); - }); - - it('throws on missing messages', () => { - const body = { enableRAG: false }; - - expect(() => parseChatRequest(body)).toThrow(InputError); - expect(() => parseChatRequest(body)).toThrow( - 'messages must be a non-empty array', - ); - }); - - it('throws on empty messages array', () => { - const body = { messages: [] }; - - expect(() => parseChatRequest(body)).toThrow(InputError); - expect(() => parseChatRequest(body)).toThrow( - 'messages must be a non-empty array', - ); - }); - - it('throws on non-array messages', () => { - expect(() => parseChatRequest({ messages: 'not-array' })).toThrow( - InputError, - ); - expect(() => parseChatRequest({ messages: null })).toThrow(InputError); - expect(() => parseChatRequest({ messages: {} })).toThrow(InputError); - }); - - it('throws when body is not an object', () => { - expect(() => parseChatRequest(null)).toThrow(InputError); - expect(() => parseChatRequest(undefined)).toThrow(InputError); - expect(() => parseChatRequest('string')).toThrow(InputError); - expect(() => parseChatRequest(123)).toThrow(InputError); - expect(() => parseChatRequest(null)).toThrow( - 'Request body must be a JSON object', - ); - }); - - it('handles optional fields with defaults', () => { - const body = { - messages: [{ role: 'user', content: 'Hi' }], - }; - - const result = parseChatRequest(body); - - expect(result.messages).toEqual([{ role: 'user', content: 'Hi' }]); - expect(result.enableRAG).toBe(false); - expect(result.previousResponseId).toBeUndefined(); - expect(result.conversationId).toBeUndefined(); - expect(result.sessionId).toBeUndefined(); - }); - - it('accepts enableRAG true and false', () => { - expect( - parseChatRequest({ - messages: [{ role: 'user', content: 'x' }], - enableRAG: true, - }).enableRAG, - ).toBe(true); - expect( - parseChatRequest({ - messages: [{ role: 'user', content: 'x' }], - enableRAG: false, - }).enableRAG, - ).toBe(false); - expect( - parseChatRequest({ messages: [{ role: 'user', content: 'x' }] }) - .enableRAG, - ).toBe(false); - }); - - it('accepts user, assistant, and system roles', () => { - const body = { - messages: [ - { role: 'system', content: 'You are helpful' }, - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi' }, - ], - }; - - const result = parseChatRequest(body); - expect(result.messages).toHaveLength(3); - expect(result.messages[0].role).toBe('system'); - expect(result.messages[1].role).toBe('user'); - expect(result.messages[2].role).toBe('assistant'); - }); - - it('throws on invalid message role', () => { - const body = { - messages: [{ role: 'invalid', content: 'Hello' }], - }; - - expect(() => parseChatRequest(body)).toThrow(InputError); - expect(() => parseChatRequest(body)).toThrow( - 'Message role must be user, assistant, or system', - ); - }); - - it('throws when message lacks role or content', () => { - expect(() => parseChatRequest({ messages: [{ content: 'Hi' }] })).toThrow( - 'Each message must have role (string) and content (string)', - ); - expect(() => parseChatRequest({ messages: [{ role: 'user' }] })).toThrow( - 'Each message must have role (string) and content (string)', - ); - expect(() => - parseChatRequest({ messages: [{ role: 123, content: 'Hi' }] }), - ).toThrow('Each message must have role (string) and content (string)'); - expect(() => - parseChatRequest({ messages: [{ role: 'user', content: 456 }] }), - ).toThrow('Each message must have role (string) and content (string)'); - }); - - it('throws when message is not an object', () => { - expect(() => parseChatRequest({ messages: ['string'] })).toThrow( - 'Each message must be an object with role and content', - ); - expect(() => parseChatRequest({ messages: [null] })).toThrow( - 'Each message must be an object with role and content', - ); - }); - - it('throws on invalid optional field types', () => { - expect(() => - parseChatRequest({ - messages: [{ role: 'user', content: 'x' }], - previousResponseId: 123, - }), - ).toThrow('previousResponseId must be a string'); - expect(() => - parseChatRequest({ - messages: [{ role: 'user', content: 'x' }], - conversationId: 456, - }), - ).toThrow('conversationId must be a string'); - expect(() => - parseChatRequest({ - messages: [{ role: 'user', content: 'x' }], - sessionId: [], - }), - ).toThrow('sessionId must be a string'); - }); - }); - - describe('parseApprovalRequest', () => { - it('parses valid request with responseId, callId, approved, toolName, toolArguments', () => { - const body = { - responseId: 'resp-1', - callId: 'call-1', - approved: true, - toolName: 'get_weather', - toolArguments: '{"location":"NYC"}', - }; - - const result = parseApprovalRequest(body); - - expect(result.responseId).toBe('resp-1'); - expect(result.callId).toBe('call-1'); - expect(result.approved).toBe(true); - expect(result.toolName).toBe('get_weather'); - expect(result.toolArguments).toBe('{"location":"NYC"}'); - }); - - it('parses valid rejection request (approved: false) without toolName', () => { - const body = { - responseId: 'resp-1', - callId: 'call-1', - approved: false, - }; - - const result = parseApprovalRequest(body); - - expect(result.responseId).toBe('resp-1'); - expect(result.callId).toBe('call-1'); - expect(result.approved).toBe(false); - expect(result.toolName).toBeUndefined(); - expect(result.toolArguments).toBeUndefined(); - }); - - it('throws on missing responseId', () => { - expect(() => - parseApprovalRequest({ - callId: 'call-1', - approved: false, - }), - ).toThrow(InputError); - expect(() => - parseApprovalRequest({ - callId: 'call-1', - approved: false, - }), - ).toThrow('responseId is required and must be a non-empty string'); - }); - - it('throws on empty responseId', () => { - expect(() => - parseApprovalRequest({ - responseId: '', - callId: 'call-1', - approved: false, - }), - ).toThrow('responseId is required and must be a non-empty string'); - }); - - it('throws on missing callId', () => { - expect(() => - parseApprovalRequest({ - responseId: 'resp-1', - approved: false, - }), - ).toThrow('callId is required and must be a non-empty string'); - }); - - it('throws on empty callId', () => { - expect(() => - parseApprovalRequest({ - responseId: 'resp-1', - callId: '', - approved: false, - }), - ).toThrow('callId is required and must be a non-empty string'); - }); - - it('throws on missing approved', () => { - expect(() => - parseApprovalRequest({ - responseId: 'resp-1', - callId: 'call-1', - }), - ).toThrow('approved must be a boolean'); - }); - - it('throws on non-boolean approved', () => { - expect(() => - parseApprovalRequest({ - responseId: 'resp-1', - callId: 'call-1', - approved: 'true', - }), - ).toThrow('approved must be a boolean'); - expect(() => - parseApprovalRequest({ - responseId: 'resp-1', - callId: 'call-1', - approved: 1, - }), - ).toThrow('approved must be a boolean'); - }); - - it('throws when approving without toolName', () => { - expect(() => - parseApprovalRequest({ - responseId: 'resp-1', - callId: 'call-1', - approved: true, - }), - ).toThrow('toolName is required when approving a tool call'); - expect(() => - parseApprovalRequest({ - responseId: 'resp-1', - callId: 'call-1', - approved: true, - toolName: '', - }), - ).toThrow('toolName is required when approving a tool call'); - }); - - it('throws when toolArguments is not a string', () => { - expect(() => - parseApprovalRequest({ - responseId: 'resp-1', - callId: 'call-1', - approved: true, - toolName: 'tool', - toolArguments: 123, - }), - ).toThrow('toolArguments must be a string'); - }); - - it('throws when body is not an object', () => { - expect(() => parseApprovalRequest(null)).toThrow( - 'Request body must be a JSON object', - ); - expect(() => parseApprovalRequest(undefined)).toThrow( - 'Request body must be a JSON object', - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/parsers/chatRequestParsers.ts b/workspaces/augment/plugins/augment-backend/src/parsers/chatRequestParsers.ts deleted file mode 100644 index 1e0a5edf755..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/parsers/chatRequestParsers.ts +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { InputError } from '@backstage/errors'; -import type { ChatRequest } from '../types'; -import { - MAX_MESSAGES_PER_REQUEST, - MAX_MESSAGE_CONTENT_LENGTH, - MAX_APPROVAL_FIELD_LENGTH, - MAX_MODEL_LENGTH, -} from '../constants'; - -const VALID_ROLES = new Set(['user', 'assistant', 'system']); - -/** - * Parses and validates a chat request body. - * @throws InputError when the body is invalid - */ -export function parseChatRequest(body: unknown): ChatRequest { - if (!body || typeof body !== 'object') { - throw new InputError('Request body must be a JSON object'); - } - - const { - messages, - enableRAG, - previousResponseId, - conversationId, - sessionId, - model, - } = body as Record; - - if (!Array.isArray(messages) || messages.length === 0) { - throw new InputError('messages must be a non-empty array'); - } - - if (messages.length > MAX_MESSAGES_PER_REQUEST) { - throw new InputError( - `messages array exceeds maximum of ${MAX_MESSAGES_PER_REQUEST} items`, - ); - } - - for (const msg of messages) { - if (!msg || typeof msg !== 'object') { - throw new InputError( - 'Each message must be an object with role and content', - ); - } - const { role, content } = msg as Record; - if (typeof role !== 'string' || typeof content !== 'string') { - throw new InputError( - 'Each message must have role (string) and content (string)', - ); - } - if (!VALID_ROLES.has(role)) { - throw new InputError('Message role must be user, assistant, or system'); - } - if (content.length > MAX_MESSAGE_CONTENT_LENGTH) { - throw new InputError( - `Message content exceeds maximum of ${MAX_MESSAGE_CONTENT_LENGTH} characters`, - ); - } - } - - if ( - previousResponseId !== undefined && - typeof previousResponseId !== 'string' - ) { - throw new InputError('previousResponseId must be a string'); - } - if (conversationId !== undefined && typeof conversationId !== 'string') { - throw new InputError('conversationId must be a string'); - } - if (sessionId !== undefined && typeof sessionId !== 'string') { - throw new InputError('sessionId must be a string'); - } - if ( - typeof sessionId === 'string' && - (sessionId.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(sessionId)) - ) { - throw new InputError( - 'sessionId must be 1-128 characters, alphanumeric with hyphens/underscores only', - ); - } - if (model !== undefined && typeof model !== 'string') { - throw new InputError('model must be a string'); - } - if (typeof model === 'string' && model.length > MAX_MODEL_LENGTH) { - throw new InputError( - `model exceeds maximum of ${MAX_MODEL_LENGTH} characters`, - ); - } - - return { - messages: messages as ChatRequest['messages'], - enableRAG: enableRAG === true, - previousResponseId: previousResponseId as string | undefined, - conversationId: conversationId as string | undefined, - sessionId: sessionId as string | undefined, - model: model as string | undefined, - }; -} - -/** - * Parses and validates an approval request body. - * @throws InputError when the body is invalid - */ -export function parseApprovalRequest(body: unknown): { - responseId: string; - callId: string; - approved: boolean; - toolName?: string; - toolArguments?: string; - reason?: string; -} { - if (!body || typeof body !== 'object') { - throw new InputError('Request body must be a JSON object'); - } - - const { responseId, callId, approved, toolName, toolArguments, reason } = - body as Record; - - if (typeof responseId !== 'string' || !responseId) { - throw new InputError( - 'responseId is required and must be a non-empty string', - ); - } - if (responseId.length > MAX_APPROVAL_FIELD_LENGTH) { - throw new InputError( - `responseId exceeds maximum of ${MAX_APPROVAL_FIELD_LENGTH} characters`, - ); - } - if (typeof callId !== 'string' || !callId) { - throw new InputError('callId is required and must be a non-empty string'); - } - if (callId.length > MAX_APPROVAL_FIELD_LENGTH) { - throw new InputError( - `callId exceeds maximum of ${MAX_APPROVAL_FIELD_LENGTH} characters`, - ); - } - if (typeof approved !== 'boolean') { - throw new InputError('approved must be a boolean'); - } - if (approved && (typeof toolName !== 'string' || !toolName)) { - throw new InputError('toolName is required when approving a tool call'); - } - if (toolArguments !== undefined && typeof toolArguments !== 'string') { - throw new InputError('toolArguments must be a string'); - } - if ( - typeof toolArguments === 'string' && - toolArguments.length > MAX_MESSAGE_CONTENT_LENGTH - ) { - throw new InputError( - `toolArguments exceeds maximum of ${MAX_MESSAGE_CONTENT_LENGTH} characters`, - ); - } - - if (reason !== undefined && typeof reason !== 'string') { - throw new InputError('reason must be a string'); - } - if (typeof reason === 'string' && reason.length > MAX_APPROVAL_FIELD_LENGTH) { - throw new InputError( - `reason exceeds maximum of ${MAX_APPROVAL_FIELD_LENGTH} characters`, - ); - } - - return { - responseId, - callId, - approved, - toolName: toolName as string | undefined, - toolArguments: toolArguments as string | undefined, - reason: reason as string | undefined, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/plugin.ts b/workspaces/augment/plugins/augment-backend/src/plugin.ts deleted file mode 100644 index ca92b460e19..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/plugin.ts +++ /dev/null @@ -1,320 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - coreServices, - createBackendPlugin, -} from '@backstage/backend-plugin-api'; -import { augmentPermissions } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ProviderType } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { - augmentProviderExtensionPoint, - type AgenticProviderFactory, -} from './extensions'; -import type { ProviderDescriptor } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { createRouter } from './router'; -import { - createProvider, - registerProviderFactory, - registerProvider, - ProviderManager, -} from './providers'; -import { ChatSessionService } from './services/ChatSessionService'; -import { AdminConfigService } from './services/AdminConfigService'; -import { toErrorMessage } from './services/utils'; - -const SYNC_TASK_TIMEOUT_MINUTES = 30; - -/** - * Parse duration string to milliseconds - * Supports: '30s', '5m', '1h', '1d' - */ -function parseDuration(duration: string): number { - const match = duration.match(/^(\d+)(s|m|h|d)$/); - if (!match) { - throw new Error(`Invalid duration format: ${duration}`); - } - const value = parseInt(match[1], 10); - const unit = match[2]; - switch (unit) { - case 's': - return value * 1000; - case 'm': - return value * 60 * 1000; - case 'h': - return value * 60 * 60 * 1000; - case 'd': - return value * 24 * 60 * 60 * 1000; - default: - throw new Error(`Unknown duration unit: ${unit}`); - } -} - -/** - * Augment backend plugin - * - * Provides agentic AI chat with RAG, tool calling, and safety guardrails. - * The agentic provider is configurable via `augment.provider` in app-config.yaml - * (default: 'llamastack'). Each provider implements the AgenticProvider - * interface and brings its own chat, RAG, safety, and evaluation capabilities. - * - * @public - */ -export const augmentPlugin = createBackendPlugin({ - pluginId: 'augment', - register(env) { - const extensionProviders: Array<{ - descriptor: ProviderDescriptor; - factory: AgenticProviderFactory; - }> = []; - - env.registerExtensionPoint(augmentProviderExtensionPoint, { - registerProvider(descriptor, factory) { - extensionProviders.push({ descriptor, factory }); - }, - }); - - env.registerInit({ - deps: { - logger: coreServices.logger, - config: coreServices.rootConfig, - httpRouter: coreServices.httpRouter, - httpAuth: coreServices.httpAuth, - permissions: coreServices.permissions, - permissionsRegistry: coreServices.permissionsRegistry, - scheduler: coreServices.scheduler, - database: coreServices.database, - cache: coreServices.cache, - }, - async init({ - logger, - httpRouter, - config, - scheduler, - httpAuth, - permissions, - permissionsRegistry, - database, - cache, - }) { - logger.info('Initializing Augment backend plugin'); - - for (const { descriptor, factory } of extensionProviders) { - registerProvider(descriptor); - registerProviderFactory(descriptor.id, factory); - logger.info( - `Registered extension provider: ${descriptor.id} (${descriptor.displayName})`, - ); - } - - // Register the single plugin-level permission with the permissions framework - // This controls access to the entire Augment plugin (all or nothing) - // Configure via RBAC policies to restrict access to specific Keycloak groups - permissionsRegistry.addPermissions(augmentPermissions); - logger.info( - 'Registered augment.access permission for plugin-level access control', - ); - - // Get security mode early to configure auth policies correctly - const securityMode = - config.getOptionalString('augment.security.mode') || 'plugin-only'; - logger.info(`Augment security mode: ${securityMode}`); - - // Initialize shared admin config service (DB-backed, shared by provider and router) - const adminConfig = new AdminConfigService(database, logger); - await adminConfig.initialize(); - - // Create the provider factory options (shared across hot-swaps) - const providerOptions = { - logger, - config, - database, - adminConfig, - cache, - }; - - // Factory function for creating providers by type - const providerFactory = (type: ProviderType) => - createProvider(providerOptions, type); - - // Create and initialize the initial provider - const initialProvider = createProvider(providerOptions); - let initErrorMessage: string | undefined; - try { - await initialProvider.initialize(); - await initialProvider.postInitialize(); - } catch (initError) { - initErrorMessage = toErrorMessage(initError); - logger.error( - `Provider initialization failed: ${initErrorMessage}. ` + - 'The plugin will start but all provider routes will return 503 until the issue is resolved. ' + - 'Check your augment.kagenti configuration in app-config.yaml.', - ); - } - - const providerManager = new ProviderManager( - initialProvider, - providerFactory, - logger, - initErrorMessage, - ); - - // Set up periodic document sync if configured and provider supports RAG - const syncSchedule = config.getOptionalString( - 'augment.documents.syncSchedule', - ); - if (syncSchedule && providerManager.provider.rag) { - try { - const intervalMs = parseDuration(syncSchedule); - logger.info( - `Setting up periodic document sync every ${syncSchedule} (${intervalMs}ms)`, - ); - - await scheduler.scheduleTask({ - id: 'augment-document-sync', - frequency: { milliseconds: intervalMs }, - timeout: { minutes: SYNC_TASK_TIMEOUT_MINUTES }, - fn: async () => { - const currentProvider = providerManager.provider; - logger.info('Running scheduled document sync'); - try { - const result = await currentProvider.rag!.syncDocuments(); - logger.info( - `Scheduled sync completed: added=${result.added}, updated=${result.updated}, removed=${result.removed}`, - ); - } catch (error) { - logger.error( - `Scheduled sync failed: ${toErrorMessage(error)}`, - ); - } - }, - }); - } catch (error) { - logger.warn( - `Invalid sync schedule format: ${syncSchedule}. Use formats like '30m', '1h', '1d'`, - ); - } - } - - // Initialize session service (local DB for session metadata) - let sessions: ChatSessionService | undefined; - try { - const sessionService = new ChatSessionService(database, logger); - await sessionService.initialize(); - sessions = sessionService; - logger.info('Chat session service initialized'); - } catch (sessError) { - logger.error( - `Session service initialization failed: ${toErrorMessage( - sessError, - )}. Sessions will be unavailable.`, - ); - } - - const DEFAULT_RETENTION_DAYS = 90; - const retentionDays = - config.getOptionalNumber('augment.sessions.retentionDays') ?? - DEFAULT_RETENTION_DAYS; - if (sessions && retentionDays > 0) { - await scheduler.scheduleTask({ - id: 'augment-session-retention', - frequency: { hours: 24 }, - timeout: { minutes: 10 }, - initialDelay: { minutes: 5 }, - fn: async () => { - const purged = await sessions.purgeExpiredSessions(retentionDays); - if (purged > 0) { - logger.info( - `Session retention: purged ${purged} session(s) older than ${retentionDays} days`, - ); - } - }, - }); - logger.info( - `Session retention enabled: sessions older than ${retentionDays} day(s) will be purged daily`, - ); - } - - // Register HTTP routes - httpRouter.use( - await createRouter({ - logger, - config, - httpAuth, - permissions, - database, - providerManager, - sessions, - adminConfig, - cache, - }), - ); - - // Health check endpoint is always unauthenticated (for load balancers/k8s probes) - httpRouter.addAuthPolicy({ - path: '/health', - allow: 'unauthenticated', - }); - - // Auth policy depends on security mode: - // - 'none': all endpoints allow unauthenticated access - // - 'plugin-only' or 'full': require user-cookie (OIDC session) - const authPolicy: 'unauthenticated' | 'user-cookie' = - securityMode === 'none' ? 'unauthenticated' : 'user-cookie'; - - const protectedPaths = [ - '/status', - '/branding', - '/sessions', - '/conversations', - '/workflows', - '/quick-actions', - '/documents', - '/chat', - '/chat/stream', - '/chat/approve', - '/agents', - '/sync', - '/safety/status', - '/evaluation/status', - '/vector-stores', - '/prompt-groups', - '/admin', - '/kagenti', - '/scoring-functions', - '/scoring', - '/benchmarks', - '/datasets', - ]; - - for (const path of protectedPaths) { - httpRouter.addAuthPolicy({ path, allow: authPolicy }); - } - - if (securityMode === 'none') { - logger.info( - 'Auth policy: All endpoints allow unauthenticated access (mode: none)', - ); - } else { - logger.info( - 'Auth policy: Endpoints require user-cookie authentication (mode: plugin-only/full)', - ); - } - - logger.info('Augment backend plugin initialized successfully'); - }, - }); - }, -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/ProviderManager.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/ProviderManager.test.ts deleted file mode 100644 index d2083c54e48..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/ProviderManager.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ProviderManager } from './ProviderManager'; -import type { AgenticProvider } from './types'; -import { createMockLogger } from '../test-utils/mocks'; - -type ProviderType = 'llamastack' | 'googleadk'; - -function createMockProvider(id: ProviderType): AgenticProvider { - return { - id, - displayName: `Provider ${id}`, - initialize: jest.fn().mockResolvedValue(undefined), - postInitialize: jest.fn().mockResolvedValue(undefined), - shutdown: jest.fn().mockResolvedValue(undefined), - getStatus: jest.fn().mockResolvedValue({ ready: true }), - } as unknown as AgenticProvider; -} - -describe('ProviderManager', () => { - const logger = createMockLogger(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('provider', () => { - it('returns the initial provider', () => { - const initial = createMockProvider('llamastack'); - const factory = jest.fn(); - const manager = new ProviderManager(initial, factory, logger); - expect(manager.provider).toBe(initial); - }); - }); - - describe('switchProvider', () => { - it('creates, initializes, and postInitializes new provider, then shuts down old one', async () => { - const oldProvider = createMockProvider('llamastack'); - const newProvider = createMockProvider('googleadk'); - const factory = jest.fn().mockReturnValue(newProvider); - const manager = new ProviderManager(oldProvider, factory, logger); - - await manager.switchProvider('googleadk'); - - expect(factory).toHaveBeenCalledWith('googleadk'); - expect(newProvider.initialize).toHaveBeenCalled(); - expect(newProvider.postInitialize).toHaveBeenCalled(); - expect(oldProvider.shutdown).toHaveBeenCalled(); - expect(manager.provider).toBe(newProvider); - }); - - it('to the same provider ID is a no-op', async () => { - const provider = createMockProvider('llamastack'); - const factory = jest.fn(); - const manager = new ProviderManager(provider, factory, logger); - - await manager.switchProvider('llamastack'); - - expect(factory).not.toHaveBeenCalled(); - expect(provider.initialize).not.toHaveBeenCalled(); - expect(provider.shutdown).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining('already active'), - ); - }); - - it('isSwapping returns true during a swap', async () => { - let resolveInit: () => void; - const initPromise = new Promise(r => { - resolveInit = r; - }); - const oldProvider = createMockProvider('llamastack'); - const newProvider = createMockProvider('googleadk'); - (newProvider.initialize as jest.Mock).mockReturnValue(initPromise); - const factory = jest.fn().mockReturnValue(newProvider); - const manager = new ProviderManager(oldProvider, factory, logger); - - const swapPromise = manager.switchProvider('googleadk'); - expect(manager.isSwapping).toBe(true); - resolveInit!(); - await swapPromise; - expect(manager.isSwapping).toBe(false); - }); - - it('concurrent swap throws "already in progress"', async () => { - let resolveInit: () => void; - const initPromise = new Promise(r => { - resolveInit = r; - }); - const oldProvider = createMockProvider('llamastack'); - const newProvider = createMockProvider('googleadk'); - (newProvider.initialize as jest.Mock).mockReturnValue(initPromise); - const factory = jest.fn().mockReturnValue(newProvider); - const manager = new ProviderManager(oldProvider, factory, logger); - - const swapPromise = manager.switchProvider('googleadk'); - await expect(manager.switchProvider('googleadk')).rejects.toThrow( - 'already in progress', - ); - resolveInit!(); - await swapPromise; - }); - - it('if new provider init fails, old provider remains active', async () => { - const oldProvider = createMockProvider('llamastack'); - const newProvider = createMockProvider('googleadk'); - (newProvider.initialize as jest.Mock).mockRejectedValue( - new Error('init failed'), - ); - const factory = jest.fn().mockReturnValue(newProvider); - const manager = new ProviderManager(oldProvider, factory, logger); - - await expect(manager.switchProvider('googleadk')).rejects.toThrow( - 'init failed', - ); - expect(manager.provider).toBe(oldProvider); - expect(oldProvider.shutdown).not.toHaveBeenCalled(); - }); - - it('if old provider shutdown fails, swap still succeeds', async () => { - const oldProvider = createMockProvider('llamastack'); - (oldProvider.shutdown as jest.Mock).mockRejectedValue( - new Error('shutdown failed'), - ); - const newProvider = createMockProvider('googleadk'); - const factory = jest.fn().mockReturnValue(newProvider); - const manager = new ProviderManager(oldProvider, factory, logger); - - await manager.switchProvider('googleadk'); - - expect(manager.provider).toBe(newProvider); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('shutdown error'), - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/ProviderManager.ts b/workspaces/augment/plugins/augment-backend/src/providers/ProviderManager.ts deleted file mode 100644 index 89a5ec50957..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/ProviderManager.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ProviderType } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { toErrorMessage } from '../services/utils'; -import type { AgenticProvider } from './types'; - -/** - * Factory function signature for creating providers by type. - * @internal - */ -export type ProviderFactoryFn = (type: ProviderType) => AgenticProvider; - -/** - * Manages the lifecycle of the active AgenticProvider. - * - * Supports hot-swapping providers at runtime with a Promise-based - * mutex to prevent concurrent swap operations. The swap uses a - * "start new, then stop old" strategy so there is no gap in service. - * - * @internal - */ -export class ProviderManager { - private current: AgenticProvider; - private readonly factory: ProviderFactoryFn; - private readonly logger: LoggerService; - - /** Promise-based mutex: non-null when a swap is in progress. */ - private swapLock: Promise | null = null; - - /** Non-null when the initial provider failed to initialize. */ - private _initializationError: string | null = null; - - constructor( - initialProvider: AgenticProvider, - factory: ProviderFactoryFn, - logger: LoggerService, - initializationError?: string, - ) { - this.current = initialProvider; - this.factory = factory; - this.logger = logger; - this._initializationError = initializationError ?? null; - } - - /** The currently active provider. */ - get provider(): AgenticProvider { - return this.current; - } - - /** - * Non-null when the active provider failed to initialize at startup. - * Cleared after a successful provider swap. - */ - get initializationError(): string | null { - return this._initializationError; - } - - /** Whether a provider swap is currently in progress. */ - get isSwapping(): boolean { - return this.swapLock !== null; - } - - /** - * Hot-swap the active provider to a different type. - * - * The new provider is fully initialized before the old one is - * shut down. If initialization fails, the old provider remains - * active and the error is propagated to the caller. - * - * @param type - The provider type to switch to - * @throws Error if a swap is already in progress (409-style) - * @throws Error if the new provider fails to initialize - */ - async switchProvider(type: ProviderType): Promise { - if (this.swapLock !== null) { - throw new Error( - 'A provider swap is already in progress. Please wait and try again.', - ); - } - - if (type === this.current.id) { - this.logger.info(`Provider "${type}" is already active, skipping swap`); - return; - } - - const swapPromise = this.performSwap(type); - this.swapLock = swapPromise; - - try { - await swapPromise; - } finally { - this.swapLock = null; - } - } - - /** - * Internal swap implementation. Called only when the mutex is held. - */ - private async performSwap(type: ProviderType): Promise { - const oldId = this.current.id; - this.logger.info(`Swapping provider from "${oldId}" to "${type}"`); - - const next = this.factory(type); - - await next.initialize(); - await next.postInitialize(); - - const old = this.current; - this.current = next; - this._initializationError = null; - - this.logger.info( - `Provider swapped to "${type}" successfully, shutting down "${oldId}"`, - ); - - try { - await old.shutdown?.(); - } catch (error) { - this.logger.warn( - `Old provider "${oldId}" shutdown error (non-fatal): ${toErrorMessage( - error, - )}`, - ); - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/capabilityTypes.ts b/workspaces/augment/plugins/augment-backend/src/providers/capabilityTypes.ts deleted file mode 100644 index 913f17738fb..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/capabilityTypes.ts +++ /dev/null @@ -1,215 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { SyncResult } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { - DocumentInfo, - VectorStoreInfo, - ProviderStatus, - VectorStoreStatus, - MCPServerStatus, - SecurityMode, - EvaluationResult, -} from '../types'; - -import type { - ConversationListResult, - ConversationDetails, - InputItemsResult, - ConversationItemsResult, - ProcessedMessage, - ToolApproval, - ApprovalResult, -} from './llamastack/conversationTypes'; - -/** - * @public - */ -export interface ConversationCapability { - create(): Promise<{ conversationId: string }>; - list(limit?: number, after?: string): Promise; - get(responseId: string): Promise; - getInputs(responseId: string): Promise; - getByResponseChain(responseId: string): Promise; - getProcessedMessages(conversationId: string): Promise; - delete(responseId: string, conversationId?: string): Promise; - /** Best-effort deletion of a Llama Stack conversation container by ID. */ - deleteContainer?(conversationId: string): Promise; - submitApproval(approval: ToolApproval): Promise; -} - -/** - * @public - */ -export interface RAGCapability { - listDocuments(vectorStoreId?: string): Promise; - listVectorStores(): Promise; - getDefaultVectorStoreId(): string | undefined; - getActiveVectorStoreIds(): string[] | Promise; - syncDocuments(): Promise; - uploadDocument?( - fileName: string, - content: Buffer, - vectorStoreId?: string, - ): Promise<{ fileId: string; fileName: string; status: string }>; - deleteDocument?( - fileId: string, - vectorStoreId?: string, - ): Promise<{ success: boolean }>; - searchVectorStore?( - query: string, - maxResults?: number, - vectorStoreId?: string, - vectorStoreIds?: string[], - ): Promise<{ - query: string; - chunks: Array<{ - text: string; - score?: number; - fileId?: string; - fileName?: string; - vectorStoreId?: string; - }>; - vectorStoreId: string; - totalResults: number; - }>; - getVectorStoreConfig?(): Promise<{ - vectorStoreName: string; - embeddingModel: string; - embeddingDimension: number; - searchMode?: 'semantic' | 'keyword' | 'hybrid'; - bm25Weight?: number; - semanticWeight?: number; - chunkingStrategy: 'auto' | 'static'; - maxChunkSizeTokens: number; - chunkOverlapTokens: number; - fileSearchMaxResults?: number; - fileSearchScoreThreshold?: number; - } | null>; - createVectorStoreWithConfig?(overrides: Record): Promise<{ - vectorStoreId: string; - vectorStoreName: string; - created: boolean; - embeddingModel: string; - embeddingDimension?: number; - }>; - getVectorStoreStatus?(): Promise<{ - exists: boolean; - vectorStoreId?: string; - vectorStoreName?: string; - documentCount?: number; - embeddingModel?: string; - ready: boolean; - }>; - addVectorStoreId?(id: string): void; - removeVectorStoreId?(id: string): void; - deleteVectorStore?( - vectorStoreId: string, - ): Promise<{ success: boolean; filesDeleted: number }>; - updateVectorStore?( - vectorStoreId: string, - updates: Record, - ): Promise; - generateAnswer?( - query: string, - maxResults?: number, - vectorStoreId?: string, - vectorStoreIds?: string[], - ): Promise<{ - query: string; - answer: string; - chunks: Array<{ - text: string; - score?: number; - fileId?: string; - fileName?: string; - vectorStoreId?: string; - }>; - model: string; - totalResults: number; - }>; -} - -/** - * @public - */ -export interface SafetyCapability { - isEnabled(): boolean; - getStatus(): Promise; - checkInput(text: string): Promise; - checkOutput(text: string): Promise; -} - -/** - * @public - */ -export interface EvaluationCapability { - isEnabled(): boolean; - getStatus(): Promise; - evaluateResponse( - userMessage: string, - assistantResponse: string, - context?: string[], - ): Promise; -} - -/** - * @public - */ -export interface AgenticProviderStatus { - provider: ProviderStatus; - vectorStore: VectorStoreStatus; - mcpServers: MCPServerStatus[]; - securityMode: SecurityMode; - timestamp: string; - ready: boolean; - configurationErrors: string[]; - capabilities?: { - chat: boolean; - rag: { available: boolean; reason?: string }; - mcpTools: { available: boolean; reason?: string }; - agentCatalog?: boolean; - agentSelection?: boolean; - agentCards?: boolean; - }; -} - -/** - * @public - */ -export interface SafetyStatus { - enabled: boolean; - shields: string[]; - error?: string; -} - -/** - * @public - */ -export interface SafetyCheckResult { - safe: boolean; - violation?: string; - category?: string; -} - -/** - * @public - */ -export interface EvalStatus { - enabled: boolean; - scoringFunctions: string[]; - error?: string; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/factory.ts b/workspaces/augment/plugins/augment-backend/src/providers/factory.ts deleted file mode 100644 index 82c2bc3ece1..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/factory.ts +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Provider Factory - * - * Creates the correct AgenticProvider based on app-config.yaml. - * The provider type is read from `augment.provider` (default: 'llamastack'). - * - * Built-in providers are handled directly. External providers registered - * via the extension point are looked up from the dynamic factory registry. - */ - -import type { - LoggerService, - RootConfigService, - DatabaseService, - CacheService, -} from '@backstage/backend-plugin-api'; -import type { ProviderType } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import type { AgenticProvider } from './types'; -import type { AgenticProviderFactory } from '../extensions'; -import { ResponsesApiProvider } from './llamastack'; -import { KagentiProvider } from './kagenti'; - -export type { ProviderType }; - -/** - * Options for creating an AgenticProvider instance. - * @public - */ -export interface CreateProviderOptions { - logger: LoggerService; - config: RootConfigService; - database?: DatabaseService; - adminConfig?: AdminConfigService; - cache?: CacheService; -} - -/** - * Dynamically registered provider factories from extension modules. - * Populated at startup via {@link registerProviderFactory}. - */ -const dynamicFactories = new Map(); - -/** - * Register an external provider factory. - * - * Called during plugin initialization by the extension point wiring. - * - * @param id - The provider type identifier - * @param factory - Factory function that creates the provider instance - * @internal - */ -export function registerProviderFactory( - id: string, - factory: AgenticProviderFactory, -): void { - dynamicFactories.set(id, factory); -} - -/** - * Creates an AgenticProvider instance based on configuration. - * - * Reads `augment.provider` from app-config.yaml to determine which - * provider to instantiate. Defaults to 'llamastack' if not specified. - * Built-in providers are handled by a direct switch; extension-registered - * providers are resolved from the dynamic factory registry. - * - * @param options - Provider dependencies - * @param overrideType - Override the provider type (used by ProviderManager for hot-swap) - * @returns An uninitialized AgenticProvider instance - * @internal - */ -export function createProvider( - options: CreateProviderOptions, - overrideType?: ProviderType, -): AgenticProvider { - const { logger, config, database, adminConfig, cache } = options; - - const providerType: ProviderType = - overrideType ?? - (config.getOptionalString('augment.provider') || 'llamastack'); - - logger.info(`Creating agentic provider: ${providerType}`); - - switch (providerType) { - case 'llamastack': - return new ResponsesApiProvider({ - logger, - config, - database, - adminConfig, - }); - - case 'kagenti': - return new KagentiProvider({ - logger, - config, - adminConfig, - cache, - }); - - case 'googleadk': - throw new Error( - 'Google ADK provider is not yet implemented. ' + - 'Set augment.provider to "llamastack" in app-config.yaml.', - ); - - default: { - const externalFactory = dynamicFactories.get(providerType); - if (externalFactory) { - return externalFactory(options); - } - throw new Error( - `Unknown agentic provider: "${providerType}". ` + - `Check augment.provider in app-config.yaml. ` + - `Available providers: llamastack, kagenti, googleadk${ - dynamicFactories.size > 0 - ? `, ${[...dynamicFactories.keys()].join(', ')}` - : '' - }`, - ); - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/index.ts b/workspaces/augment/plugins/augment-backend/src/providers/index.ts deleted file mode 100644 index 7e2669fd9fb..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { createProvider, registerProviderFactory } from './factory'; -export type { ProviderType, CreateProviderOptions } from './factory'; -export { ProviderManager } from './ProviderManager'; -export type { ProviderFactoryFn } from './ProviderManager'; -export { - PROVIDER_REGISTRY, - registerProvider, - getProviderDescriptor, - getAllProviderDescriptors, - isValidProviderType, -} from './registry'; -export type { - AgenticProvider, - NormalizedStreamEvent, - AgenticProviderStatus, - ConversationCapability, - RAGCapability, - SafetyCapability, - EvaluationCapability, - ConversationSummary, - ConversationListResult, - ConversationDetails, - InputItemsResult, - ConversationItemsResult, - ToolApproval, - ApprovalResult, - SyncResult, - SafetyStatus, - SafetyCheckResult, - EvalStatus, -} from './types'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/KagentiAgentCardCache.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/KagentiAgentCardCache.ts deleted file mode 100644 index 33183c8eb8a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/KagentiAgentCardCache.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - LoggerService, - CacheService, -} from '@backstage/backend-plugin-api'; -import type { AgentCardResponse } from './client/types'; -import type { KagentiApiClient } from './client/KagentiApiClient'; -import type { KagentiConfig } from './config/KagentiConfigLoader'; -import { handleAgentCard } from '@kagenti/adk/core'; -import { agentCardSchema } from '@kagenti/adk'; - -export interface AgentCardCacheEntry { - card: AgentCardResponse; - demands: ReturnType['demands']; - resolveMetadata: ReturnType['resolveMetadata']; - fetchedAt: number; -} - -// TODO: this entire class will get reworked once upstream and/or downstream -// RHDH features around modeling agents drops -export class KagentiAgentCardCache { - private readonly logger: LoggerService; - private readonly cacheService: CacheService; - - constructor(logger: LoggerService, cacheService: CacheService) { - this.logger = logger; - this.cacheService = cacheService; - } - - async getAgentCardCached( - apiClient: KagentiApiClient, - config: KagentiConfig, - namespace: string, - name: string, - options?: { retries?: number }, - ): Promise { - const key = `agent-card:${namespace}/${name}`; - const cachedCard = (await this.cacheService.get(key)) as - | AgentCardResponse - | undefined; - - if (cachedCard) { - return this.buildEntry(cachedCard); - } - - const card = await apiClient.getAgentCard(namespace, name, options); - - if (config.validateResponses) { - const result = agentCardSchema.safeParse(card); - if (!result.success) { - const msg = `Agent card validation failed for ${namespace}/${name}: ${JSON.stringify(result.error.issues)}`; - this.logger.warn(msg); - throw new Error(msg); - } - } - - await this.cacheService.set(key, JSON.parse(JSON.stringify(card))); - return this.buildEntry(card); - } - - clear(): void { - // CacheService doesn't expose a bulk-clear; individual entries expire via TTL - } - - private buildEntry(card: AgentCardResponse): AgentCardCacheEntry { - let demands: AgentCardCacheEntry['demands'] = { - llmDemands: null, - embeddingDemands: null, - mcpDemands: null, - oauthDemands: null, - secretDemands: null, - settingsDemands: null, - formDemands: null, - }; - let resolveMetadata: AgentCardCacheEntry['resolveMetadata'] = - async () => ({}); - - try { - if (card.capabilities?.extensions?.length) { - const adkResult = handleAgentCard( - card as Parameters[0], - ); - demands = adkResult.demands; - resolveMetadata = adkResult.resolveMetadata; - } - } catch (err) { - this.logger.warn(`Failed to parse agent card demands: ${err}`); - } - - return { card, demands, resolveMetadata, fetchedAt: Date.now() }; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/KagentiProvider.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/KagentiProvider.test.ts deleted file mode 100644 index ed95079ef5c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/KagentiProvider.test.ts +++ /dev/null @@ -1,515 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ConfigReader } from '@backstage/config'; -import { createMockLogger } from '../../test-utils/mocks'; -import { KagentiProvider } from './KagentiProvider'; - -jest.mock('./client/KeycloakTokenManager', () => ({ - KeycloakTokenManager: jest.fn().mockImplementation(() => ({ - getToken: jest.fn().mockResolvedValue('mock-token'), - clearCache: jest.fn(), - })), -})); - -jest.mock('./client/KagentiApiClient', () => ({ - KagentiApiClient: jest.fn().mockImplementation(() => ({ - health: jest.fn().mockResolvedValue({ status: 'healthy' }), - ready: jest.fn().mockResolvedValue({ status: 'ready' }), - getFeatureFlags: jest.fn().mockResolvedValue({ - sandbox: false, - integrations: false, - triggers: false, - }), - listAgents: jest.fn().mockResolvedValue({ - items: [ - { - name: 'weather-bot', - namespace: 'team1', - description: 'Weather agent', - status: 'Running', - labels: { protocol: 'a2a' }, - workloadType: 'deployment', - }, - ], - }), - listNamespaces: jest.fn().mockResolvedValue({ namespaces: ['team1'] }), - getAgentCard: jest.fn().mockResolvedValue({ - name: 'weather-bot', - description: 'Weather agent', - version: '1.0', - url: 'https://weather-bot:8000', - streaming: true, - skills: [], - }), - chatSend: jest.fn().mockResolvedValue({ - content: 'It is sunny today.', - session_id: 'session-1', - is_complete: true, - }), - chatStream: jest - .fn() - .mockImplementation( - ( - _ns: string, - _name: string, - _msg: string, - _sid: string | undefined, - onLine: (l: string) => void, - ) => { - onLine(JSON.stringify({ content: 'Streaming...', session_id: 's1' })); - onLine(JSON.stringify({ done: true })); - return Promise.resolve(); - }, - ), - destroy: jest.fn(), - })), -})); - -jest.mock('./client/KagentiSandboxClient'); -jest.mock('./client/KagentiAdminClient'); - -function createProvider() { - const config = new ConfigReader({ - augment: { - kagenti: { - baseUrl: 'https://kagenti.example.com', - namespace: 'team1', - agentName: 'weather-bot', - auth: { - tokenEndpoint: 'https://kc.example.com/token', - clientId: 'client', - clientSecret: 'secret', - }, - }, - }, - }); - - return new KagentiProvider({ - logger: createMockLogger(), - config, - }); -} - -describe('KagentiProvider', () => { - it('has correct id and displayName', () => { - const p = createProvider(); - expect(p.id).toBe('kagenti'); - expect(p.displayName).toBe('Kagenti'); - }); - - it('has conversations but no other optional capabilities', () => { - const p = createProvider(); - expect(p.conversations).toBeDefined(); - expect(p.rag).toBeUndefined(); - expect(p.safety).toBeUndefined(); - expect(p.evaluation).toBeUndefined(); - }); - - it('initializes and reports healthy status', async () => { - const p = createProvider(); - await p.initialize(); - await p.postInitialize(); - - const status = await p.getStatus(); - expect(status.ready).toBe(true); - expect(status.provider.connected).toBe(true); - expect(status.capabilities?.chat).toBe(true); - expect(status.capabilities?.rag?.available).toBe(false); - }); - - it('returns empty models when no LlamaStack baseUrl is configured', async () => { - const p = createProvider(); - await p.initialize(); - - const models = await p.listModels!(); - expect(models).toHaveLength(0); - }); - - it('tests model by fetching agent card', async () => { - const p = createProvider(); - await p.initialize(); - - const result = await p.testModel!('team1/weather-bot'); - expect(result.connected).toBe(true); - expect(result.modelFound).toBe(true); - }); - - it('sends a chat message and returns response', async () => { - const p = createProvider(); - await p.initialize(); - - const response = await p.chat({ - messages: [{ role: 'user', content: 'What is the weather?' }], - }); - expect(response.role).toBe('assistant'); - expect(response.content).toBe('It is sunny today.'); - }); - - it('streams a chat response', async () => { - const p = createProvider(); - await p.initialize(); - - const events: Array> = []; - await p.chatStream({ messages: [{ role: 'user', content: 'Hello' }] }, e => - events.push(e as unknown as Record), - ); - - expect(events.length).toBeGreaterThan(0); - expect(events[0].type).toBe('stream.started'); - }); - - it('shuts down cleanly and clears caches', async () => { - const p = createProvider(); - await p.initialize(); - await expect(p.shutdown!()).resolves.toBeUndefined(); - }); -}); - -describe('KagentiProvider -- Agent Card Cache & Demand Resolution', () => { - it('caches agent card on first call and returns cached on second', async () => { - const p = createProvider(); - await p.initialize(); - - const entry1 = await p.getAgentCardCached('team1', 'weather-bot'); - expect(entry1.card).toBeDefined(); - expect(entry1.demands).toBeDefined(); - - const entry2 = await p.getAgentCardCached('team1', 'weather-bot'); - expect(entry2.card).toEqual(entry1.card); - expect(entry2.demands).toEqual(entry1.demands); - }); - - it('resolveMetadata returns empty object for agent without extension demands', async () => { - const p = createProvider(); - await p.initialize(); - - const entry = await p.getAgentCardCached('team1', 'weather-bot'); - const metadata = await entry.resolveMetadata({}); - expect(metadata).toEqual({}); - }); - - it('demands are all null for simple agent card', async () => { - const p = createProvider(); - await p.initialize(); - - const entry = await p.getAgentCardCached('team1', 'weather-bot'); - expect(entry.demands.llmDemands).toBeNull(); - expect(entry.demands.mcpDemands).toBeNull(); - expect(entry.demands.oauthDemands).toBeNull(); - expect(entry.demands.secretDemands).toBeNull(); - }); - - it('chatStream passes undefined metadata when agent has no demands', async () => { - const p = createProvider(); - await p.initialize(); - - const events: Array> = []; - await p.chatStream({ messages: [{ role: 'user', content: 'Hi' }] }, e => - events.push(e as unknown as Record), - ); - - expect(events.length).toBeGreaterThan(0); - expect(events[0].type).toBe('stream.started'); - }); -}); - -// ============================================================================= -// Additional coverage: Area 7a gaps -// ============================================================================= - -describe('KagentiProvider -- lifecycle guards', () => { - it('throws if methods called before initialize()', async () => { - const p = createProvider(); - await expect(p.getStatus()).rejects.toThrow( - 'initialize() must be called before use', - ); - await expect( - p.chat({ messages: [{ role: 'user', content: 'hi' }] }), - ).rejects.toThrow('initialize()'); - await expect(p.testModel!()).rejects.toThrow('initialize()'); - expect(() => p.getApiClient()).toThrow('initialize()'); - expect(() => p.getConfig()).toThrow('initialize()'); - }); -}); - -function getLatestMockApiClient() { - const { KagentiApiClient } = jest.requireMock('./client/KagentiApiClient'); - const results = KagentiApiClient.mock.results; - return results[results.length - 1].value; -} - -describe('KagentiProvider -- testModel failure path', () => { - it('returns connected:false for connection errors', async () => { - const p = createProvider(); - await p.initialize(); - - getLatestMockApiClient().getAgentCard.mockRejectedValueOnce( - new Error('ECONNREFUSED: connection refused'), - ); - - const result = await p.testModel!('team1/bad-agent'); - expect(result.connected).toBe(false); - expect(result.modelFound).toBe(false); - expect(result.error).toContain('ECONNREFUSED'); - }); - - it('returns connected:true for 404 (model not found but server reachable)', async () => { - const p = createProvider(); - await p.initialize(); - - getLatestMockApiClient().getAgentCard.mockRejectedValueOnce( - new Error( - 'Kagenti API error: GET /api/v1/chat/team1/missing/agent-card status 404 - Not Found', - ), - ); - - const result = await p.testModel!('team1/missing'); - expect(result.connected).toBe(true); - expect(result.modelFound).toBe(false); - }); -}); - -describe('KagentiProvider -- listModels via LlamaStack', () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - }); - - function createProviderWithLlamaStack() { - const config = new ConfigReader({ - augment: { - kagenti: { - baseUrl: 'https://kagenti.example.com', - namespace: 'team1', - agentName: 'weather-bot', - auth: { - tokenEndpoint: 'https://kc.example.com/token', - clientId: 'client', - clientSecret: 'secret', - }, - }, - llamaStack: { - baseUrl: 'https://llamastack.example.com', - model: 'meta-llama/Llama-3.3-8B-Instruct', - }, - }, - }); - return new KagentiProvider({ logger: createMockLogger(), config }); - } - - it('fetches models from LlamaStack /v1/models', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { id: 'meta-llama/Llama-3.3-8B-Instruct', owned_by: 'meta' }, - { id: 'gpt-120b', model_type: 'llm' }, - ], - }), - }); - - const p = createProviderWithLlamaStack(); - await p.initialize(); - - const models = await p.listModels!(); - expect(models).toHaveLength(2); - expect(models[0].id).toBe('meta-llama/Llama-3.3-8B-Instruct'); - expect(models[0].owned_by).toBe('meta'); - expect(models[1].id).toBe('gpt-120b'); - expect(models[1].model_type).toBe('llm'); - expect(global.fetch).toHaveBeenCalledWith( - 'https://llamastack.example.com/v1/models', - expect.objectContaining({ method: 'GET' }), - ); - }); - - it('returns empty array when LlamaStack is unreachable', async () => { - global.fetch = jest.fn().mockRejectedValue(new Error('ECONNREFUSED')); - - const p = createProviderWithLlamaStack(); - await p.initialize(); - - const models = await p.listModels!(); - expect(models).toHaveLength(0); - }); - - it('returns empty array when LlamaStack returns non-ok status', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - }); - - const p = createProviderWithLlamaStack(); - await p.initialize(); - - const models = await p.listModels!(); - expect(models).toHaveLength(0); - }); - - it('caches models for subsequent calls', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - data: [{ id: 'cached-model' }], - }), - }); - - const p = createProviderWithLlamaStack(); - await p.initialize(); - - const first = await p.listModels!(); - const second = await p.listModels!(); - expect(first).toEqual(second); - expect(global.fetch).toHaveBeenCalledTimes(1); - }); - - it('returns empty array when no LlamaStack baseUrl is configured', async () => { - const p = createProvider(); - await p.initialize(); - - const models = await p.listModels!(); - expect(models).toHaveLength(0); - }); -}); - -describe('KagentiProvider -- getEffectiveConfig', () => { - it('returns Kagenti baseUrl and LlamaStack model from YAML', async () => { - const config = new ConfigReader({ - augment: { - kagenti: { - baseUrl: 'https://kagenti.example.com', - namespace: 'team1', - agentName: 'weather-bot', - auth: { - tokenEndpoint: 'https://kc.example.com/token', - clientId: 'client', - clientSecret: 'secret', - }, - }, - llamaStack: { - baseUrl: 'https://llamastack.example.com', - model: 'meta-llama/Llama-3.3-8B-Instruct', - }, - }, - }); - - const p = new KagentiProvider({ logger: createMockLogger(), config }); - await p.initialize(); - - const ec = await p.getEffectiveConfig!(); - expect(ec.baseUrl).toBe('https://llamastack.example.com'); - expect(ec.kagentiBaseUrl).toBe('https://kagenti.example.com'); - expect(ec.model).toBe('meta-llama/Llama-3.3-8B-Instruct'); - expect(ec.toolChoice).toBe('auto'); - }); - - it('returns empty model when no LlamaStack config is present', async () => { - const p = createProvider(); - await p.initialize(); - - const ec = await p.getEffectiveConfig!(); - expect(ec.baseUrl).toBe('https://kagenti.example.com'); - expect(ec.model).toBe(''); - }); -}); - -describe('KagentiProvider -- session cache', () => { - it('stores and retrieves session context via CacheService', async () => { - const p = createProvider(); - await p.initialize(); - - // Hydrate three sessions - await p.hydrateSessionContext('bs-1', 'ctx-1', 'ns/a1'); - await p.hydrateSessionContext('bs-2', 'ctx-2', 'ns/a2'); - await p.hydrateSessionContext('bs-3', 'ctx-3', 'ns/a3'); - - // All three should be accessible - expect(await p.getSessionContextId('bs-1')).toBe('ctx-1'); - expect(await p.getSessionContextId('bs-2')).toBe('ctx-2'); - expect(await p.getSessionContextId('bs-3')).toBe('ctx-3'); - }); - - it('getSessionContextId returns undefined for unknown sessions', async () => { - const p = createProvider(); - await p.initialize(); - expect(await p.getSessionContextId('nonexistent')).toBeUndefined(); - }); -}); - -describe('KagentiProvider -- conversation capability contract', () => { - it('getProcessedMessages returns empty array', async () => { - const p = createProvider(); - await p.initialize(); - const messages = await p.conversations.getProcessedMessages('ctx-1'); - expect(messages).toEqual([]); - }); - - it('list throws with descriptive error', async () => { - const p = createProvider(); - await p.initialize(); - expect(() => p.conversations.list()).toThrow( - 'not supported by the Kagenti provider', - ); - }); - - it('create throws with descriptive error', async () => { - const p = createProvider(); - await p.initialize(); - expect(() => p.conversations.create()).toThrow( - 'not supported by the Kagenti provider', - ); - }); - - it('delete throws with descriptive error', async () => { - const p = createProvider(); - await p.initialize(); - expect(() => p.conversations.delete('some-id')).toThrow( - 'not supported by the Kagenti provider', - ); - }); -}); - -describe('KagentiProvider -- resolveAgent', () => { - it('parses ns/name model format', async () => { - const p = createProvider(); - await p.initialize(); - - const events: Array> = []; - await p.chatStream( - { - messages: [{ role: 'user', content: 'hello' }], - model: 'other-ns/other-agent', - } as any, - e => events.push(e as unknown as Record), - ); - - const chatStreamCall = getLatestMockApiClient().chatStream; - expect(chatStreamCall).toHaveBeenCalledWith( - 'other-ns', - 'other-agent', - 'hello', - undefined, - expect.any(Function), - undefined, - undefined, - ); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/KagentiProvider.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/KagentiProvider.ts deleted file mode 100644 index aac4183fdc1..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/KagentiProvider.ts +++ /dev/null @@ -1,808 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - LoggerService, - RootConfigService, - CacheService, -} from '@backstage/backend-plugin-api'; -import pLimit from 'p-limit'; -import type { - NormalizedStreamEvent, - ChatAgent, - KagentiAgentSummary, - PromptCapabilities, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { - isProviderScopedKey, - type AdminConfigKey, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { - AgenticProvider, - AgenticProviderStatus, - ConversationCapability, - ChatRequest as AugmentChatRequest, -} from '../types'; -import type { ChatResponse as AugmentChatResponse } from '../../types'; -import type { FeatureFlagsResponse } from './client/types'; -import { loadKagentiConfig } from './config/KagentiConfigLoader'; -import type { KagentiConfig } from './config/KagentiConfigLoader'; -import { KeycloakTokenManager } from './client/KeycloakTokenManager'; -import { KagentiApiClient } from './client/KagentiApiClient'; -import { KagentiSandboxClient } from './client/KagentiSandboxClient'; -import { KagentiAdminClient } from './client/KagentiAdminClient'; -import { KagentiStreamNormalizer } from './stream/KagentiStreamNormalizer'; -import { KagentiAgentCardCache } from './KagentiAgentCardCache'; -import type { AgentCardCacheEntry } from './KagentiAgentCardCache'; -import { submitApproval as submitApprovalImpl } from './kagentiApprovalHandler'; -import type { ApprovalRequest, ApprovalResult } from './kagentiApprovalHandler'; -import { buildKagentiConversationCapability } from './kagentiConversationCapability'; -import { buildMetaPrompt } from '../../services'; -import { getVisibleNamespaces } from './kagentiNamespaceUtils'; -import { - stripTrailingSlashes, - createNoopCache, - resolveAgent, - parseAgentId, - extractLastUserMessage, - buildA2AMetadata, - validateNamespace, -} from './kagentiHelpers'; -import type { KagentiProviderOptions } from './kagentiHelpers'; - -export type { KagentiProviderOptions }; - -export class KagentiProvider implements AgenticProvider { - readonly id = 'kagenti'; - readonly displayName = 'Kagenti'; - - private readonly logger: LoggerService; - private readonly rootConfig: RootConfigService; - private readonly adminConfig?: import('../../services/AdminConfigService').AdminConfigService; - private kagentiConfig: KagentiConfig | undefined; - private tokenManager: KeycloakTokenManager | undefined; - private apiClient: KagentiApiClient | undefined; - private activeBaseUrl: string | undefined; - private sandboxClient: KagentiSandboxClient | undefined; - private adminClient: KagentiAdminClient | undefined; - private featureFlags: FeatureFlagsResponse = { - sandbox: false, - integrations: false, - triggers: false, - }; - private static readonly MODELS_CACHE_TTL_MS = 60_000; - private static readonly SESSION_CACHE_TTL_MS = 24 * 60 * 60 * 1000; - private readonly cardCache: KagentiAgentCardCache; - private readonly sessionAgentCache: CacheService; - private readonly kagentiSessionCache: CacheService; - private readonly modelsCache: CacheService; - readonly conversations: ConversationCapability; - readonly rag = undefined; - readonly safety = undefined; - readonly evaluation = undefined; - - constructor(options: KagentiProviderOptions) { - this.logger = options.logger.child({ label: 'kagenti-provider' }); - this.rootConfig = options.config; - this.adminConfig = options.adminConfig; - const bc = options.cache; - this.sessionAgentCache = bc - ? bc.withOptions({ defaultTtl: KagentiProvider.SESSION_CACHE_TTL_MS }) - : createNoopCache(); - this.kagentiSessionCache = bc - ? bc.withOptions({ defaultTtl: KagentiProvider.SESSION_CACHE_TTL_MS }) - : createNoopCache(); - this.modelsCache = bc - ? bc.withOptions({ defaultTtl: KagentiProvider.MODELS_CACHE_TTL_MS }) - : createNoopCache(); - this.cardCache = new KagentiAgentCardCache( - this.logger, - bc ? bc.withOptions({ defaultTtl: 5 * 60 * 1000 }) : createNoopCache(), - ); - this.conversations = buildKagentiConversationCapability( - () => this.requireInitialized().apiClient, - this.logger, - ); - } - - private requireInitialized() { - if (!this.kagentiConfig || !this.tokenManager || !this.apiClient) - throw new Error('KagentiProvider.initialize() must be called before use'); - return { - config: this.kagentiConfig, - tokenManager: this.tokenManager, - apiClient: this.apiClient, - }; - } - - private async resolveKagentiBaseUrl(): Promise { - if (this.adminConfig) { - try { - const dbUrl = await this.adminConfig.getScopedValue( - 'kagentiBaseUrl', - 'kagenti', - ); - if (typeof dbUrl === 'string' && dbUrl) return dbUrl; - } catch { - /* fall through */ - } - } - return this.kagentiConfig?.baseUrl ?? ''; - } - - private async ensureClientUrl(): Promise { - const resolved = await this.resolveKagentiBaseUrl(); - if (!resolved || resolved === this.activeBaseUrl) return; - const { tokenManager } = this.requireInitialized(); - this.logger.info( - `Kagenti baseUrl changed to ${resolved}, rebuilding client`, - ); - this.activeBaseUrl = resolved; - this.apiClient = new KagentiApiClient({ - baseUrl: resolved, - tokenManager, - skipTlsVerify: this.kagentiConfig!.skipTlsVerify, - logger: this.logger, - requestTimeoutMs: this.kagentiConfig!.requestTimeoutMs, - streamTimeoutMs: this.kagentiConfig!.streamTimeoutMs, - maxRetries: this.kagentiConfig!.maxRetries, - retryBaseDelayMs: this.kagentiConfig!.retryBaseDelayMs, - }); - this.adminClient = new KagentiAdminClient(this.apiClient); - if (this.featureFlags.sandbox) - this.sandboxClient = new KagentiSandboxClient(this.apiClient); - } - - async initialize(): Promise { - this.kagentiConfig = loadKagentiConfig(this.rootConfig); - this.logger.info( - `Initializing Kagenti provider: ${this.kagentiConfig.baseUrl}`, - ); - if ( - this.kagentiConfig.showAllNamespaces && - !this.kagentiConfig.namespaces?.length - ) - this.logger.warn( - 'showAllNamespaces is enabled with no namespace allowlist', - ); - this.tokenManager = new KeycloakTokenManager({ - tokenEndpoint: this.kagentiConfig.auth.tokenEndpoint, - clientId: this.kagentiConfig.auth.clientId, - clientSecret: this.kagentiConfig.auth.clientSecret, - logger: this.logger, - skipTlsVerify: this.kagentiConfig.skipTlsVerify, - tokenExpiryBufferSeconds: this.kagentiConfig.tokenExpiryBufferSeconds, - }); - this.activeBaseUrl = this.kagentiConfig.baseUrl; - this.apiClient = new KagentiApiClient({ - baseUrl: this.activeBaseUrl, - tokenManager: this.tokenManager, - skipTlsVerify: this.kagentiConfig.skipTlsVerify, - logger: this.logger, - requestTimeoutMs: this.kagentiConfig.requestTimeoutMs, - streamTimeoutMs: this.kagentiConfig.streamTimeoutMs, - maxRetries: this.kagentiConfig.maxRetries, - retryBaseDelayMs: this.kagentiConfig.retryBaseDelayMs, - }); - try { - await this.tokenManager.getToken(); - this.logger.info('Keycloak token acquired successfully'); - } catch (e) { - this.logger.error( - `Failed to acquire Keycloak token: ${e instanceof Error ? e.message : e}`, - ); - } - try { - const h = await this.apiClient.health(); - this.logger.info(`Kagenti health: ${h.status}`); - } catch (e) { - this.logger.error( - `Cannot reach Kagenti at ${this.activeBaseUrl}: ${e instanceof Error ? e.message : e}`, - ); - } - try { - this.featureFlags = await this.apiClient.getFeatureFlags(); - } catch (e) { - this.logger.warn(`Could not fetch Kagenti feature flags: ${e}`); - } - const ov = this.kagentiConfig.featureOverrides; - if (ov.sandbox !== undefined) this.featureFlags.sandbox = ov.sandbox; - if (ov.integrations !== undefined) - this.featureFlags.integrations = ov.integrations; - if (ov.triggers !== undefined) this.featureFlags.triggers = ov.triggers; - this.adminClient = new KagentiAdminClient(this.apiClient); - if (this.featureFlags.sandbox) { - this.sandboxClient = new KagentiSandboxClient(this.apiClient); - this.logger.info('Sandbox client initialized'); - } - } - - async postInitialize(): Promise { - const { config, apiClient } = this.requireInitialized(); - try { - const agents = await apiClient.listAgents(config.namespace); - this.logger.info(`Discovered ${agents.items.length} Kagenti agents`); - } catch (err) { - this.logger.warn(`Failed to warm agent cache: ${err}`); - } - } - - async getStatus(): Promise { - const { config, apiClient } = this.requireInitialized(); - let connected = false; - let error: string | undefined; - try { - const h = await apiClient.health(); - const r = await apiClient.ready(); - connected = h.status === 'healthy' && r.status === 'ready'; - } catch (err) { - error = err instanceof Error ? err.message : String(err); - } - const securityMode = - (this.rootConfig.getOptionalString('augment.security.mode') as - | 'none' - | 'plugin-only' - | 'full' - | undefined) ?? 'plugin-only'; - return { - provider: { - id: 'kagenti', - model: config.agentName ?? `${config.namespace}/default`, - baseUrl: config.baseUrl, - connected, - error, - }, - vectorStore: { id: 'none', connected: false }, - mcpServers: [], - securityMode, - timestamp: new Date().toISOString(), - ready: connected, - configurationErrors: error ? [error] : [], - capabilities: { - chat: true, - rag: { available: false, reason: 'Kagenti does not provide RAG' }, - mcpTools: { - available: true, - reason: 'Kagenti manages MCP tools natively', - }, - agentCatalog: true, - agentSelection: true, - agentCards: true, - }, - }; - } - - async getEffectiveConfig(): Promise> { - await this.ensureClientUrl(); - const { config } = this.requireInitialized(); - const ls = this.rootConfig.getOptionalConfig('augment.llamaStack'); - let model = ls?.getOptionalString('model') ?? ''; - if (model === 'unused') model = ''; - if (!model) { - try { - const available = await this.listModels(); - model = - available.find( - m => m.id.includes('inference') && !m.id.includes('embed'), - )?.id ?? - available[0]?.id ?? - ''; - } catch { - /* */ - } - } - const result: Record = { - model, - baseUrl: ls?.getOptionalString('baseUrl') || config.baseUrl, - kagentiBaseUrl: this.activeBaseUrl || config.baseUrl, - systemPrompt: - this.rootConfig.getOptionalString('augment.systemPrompt') ?? '', - toolChoice: ls?.getOptionalString('toolChoice') ?? 'auto', - enableWebSearch: ls?.getOptionalBoolean('enableWebSearch') ?? false, - enableCodeInterpreter: - ls?.getOptionalBoolean('enableCodeInterpreter') ?? false, - safetyEnabled: ls?.getOptionalBoolean('safetyEnabled') ?? false, - inputShields: ls?.getOptionalStringArray('inputShields') ?? [], - outputShields: ls?.getOptionalStringArray('outputShields') ?? [], - evaluationEnabled: ls?.getOptionalBoolean('evaluationEnabled') ?? false, - scoringFunctions: ls?.getOptionalStringArray('scoringFunctions') ?? [], - }; - if (this.adminConfig) { - const dbKeys: AdminConfigKey[] = [ - 'model', - 'baseUrl', - 'kagentiBaseUrl', - 'systemPrompt', - 'toolChoice', - 'enableWebSearch', - 'enableCodeInterpreter', - 'mcpServers', - 'disabledMcpServerIds', - 'safetyEnabled', - 'inputShields', - 'outputShields', - 'evaluationEnabled', - 'scoringFunctions', - 'agents', - 'defaultAgent', - 'maxAgentTurns', - ]; - const values = await Promise.all( - dbKeys.map(k => - isProviderScopedKey(k) - ? this.adminConfig!.getScopedValue(k, 'kagenti') - : this.adminConfig!.get(k), - ), - ); - for (let i = 0; i < dbKeys.length; i++) { - if (values[i] !== undefined) result[dbKeys[i]] = values[i]; - } - } - return result; - } - - async chat(request: AugmentChatRequest): Promise { - await this.ensureClientUrl(); - const { apiClient } = this.requireInitialized(); - const { namespace, name } = this.doResolveAgent(request); - const userMessage = extractLastUserMessage(request); - const backstageSessionId = request.sessionId; - const storedSessionId = backstageSessionId - ? await this.kagentiSessionCache.get(backstageSessionId) - : undefined; - const a2a = await buildA2AMetadata( - namespace, - name, - (ns, n) => this.getAgentCardCached(ns, n), - this.logger, - ); - const merged = { - ...a2a, - ...(storedSessionId ? { contextId: storedSessionId } : {}), - }; - const response = await apiClient.chatSend( - namespace, - name, - userMessage, - storedSessionId, - Object.keys(merged).length > 0 ? merged : undefined, - ); - if (backstageSessionId && response.session_id) { - await this.kagentiSessionCache.set( - backstageSessionId, - response.session_id, - ); - await this.sessionAgentCache.set( - response.session_id, - `${namespace}/${name}`, - ); - } - return { - role: 'assistant', - content: response.content, - responseId: response.session_id, - }; - } - - async chatStream( - request: AugmentChatRequest, - onEvent: (event: NormalizedStreamEvent) => void, - signal?: AbortSignal, - ): Promise { - await this.ensureClientUrl(); - const { config, apiClient } = this.requireInitialized(); - const { namespace, name } = this.doResolveAgent(request); - const userMessage = extractLastUserMessage(request); - const normalizer = new KagentiStreamNormalizer( - config.verboseStreamLogging ? this.logger : undefined, - ); - const backstageSessionId = request.sessionId; - const agentId = `${namespace}/${name}`; - const storedContextId = backstageSessionId - ? await this.kagentiSessionCache.get(backstageSessionId) - : undefined; - const a2a = await buildA2AMetadata( - namespace, - name, - (ns, n) => this.getAgentCardCached(ns, n), - this.logger, - ); - const merged = { - ...a2a, - ...(storedContextId ? { contextId: storedContextId } : {}), - }; - let contentEventCount = 0; - try { - await apiClient.chatStream( - namespace, - name, - userMessage, - storedContextId, - (line: string) => { - const events = normalizer.normalize(line); - for (const event of events) { - if (normalizer.hasJsonRpcStreamingError) continue; - if (event.type === 'stream.started' && backstageSessionId) { - const ctxId = (event as { responseId?: string }).responseId; - if (ctxId) { - void this.kagentiSessionCache.set(backstageSessionId, ctxId); - void this.sessionAgentCache.set(ctxId, agentId); - } - } - if ( - event.type === 'stream.text.delta' || - event.type === 'stream.artifact' - ) - contentEventCount++; - onEvent(event); - } - }, - signal, - Object.keys(merged).length > 0 ? merged : undefined, - ); - } catch (err) { - if (signal?.aborted) return; - const errMsg = err instanceof Error ? err.message : String(err); - this.logger.error(`Kagenti chatStream failed for ${agentId}: ${errMsg}`); - if (/ECONNREFUSED|ENOTFOUND|ETIMEDOUT/i.test(errMsg)) - throw new Error( - `Cannot connect to Kagenti at ${this.activeBaseUrl}: ${errMsg}`, - ); - if (/status 401/i.test(errMsg)) - throw new Error( - `Kagenti rejected the request (401 Unauthorized): ${errMsg}`, - ); - throw err; - } - if (contentEventCount === 0 && !signal?.aborted) { - this.logger.info( - `Agent ${agentId} stream produced no content, falling back to non-streaming`, - ); - const response = await this.chat(request); - onEvent({ - type: 'stream.started', - responseId: response.responseId ?? `kagenti-fallback-${Date.now()}`, - }); - if (response.content) { - onEvent({ type: 'stream.text.delta', delta: response.content }); - onEvent({ type: 'stream.text.done', text: response.content }); - } - onEvent({ type: 'stream.completed' }); - } - } - - async listModels(): Promise< - Array<{ id: string; owned_by?: string; model_type?: string }> - > { - const cached = - await this.modelsCache.get< - Array<{ id: string; owned_by?: string; model_type?: string }> - >('kagenti:models'); - if (cached) return cached; - const llamaStackUrl = this.rootConfig.getOptionalString( - 'augment.llamaStack.baseUrl', - ); - if (!llamaStackUrl) return []; - try { - const res = await fetch( - `${stripTrailingSlashes(llamaStackUrl)}/v1/models`, - { - method: 'GET', - headers: { Accept: 'application/json' }, - signal: AbortSignal.timeout(10_000), - }, - ); - if (!res.ok) return []; - const json = (await res.json()) as { - data?: Array<{ - id: string; - owned_by?: string; - model_type?: string; - custom_metadata?: Record; - }>; - }; - const models = (json.data ?? []) - .filter(m => typeof m.id === 'string' && m.id.length > 0) - .map(m => ({ - id: m.id, - ...(m.owned_by ? { owned_by: m.owned_by } : {}), - ...((m.model_type ?? m.custom_metadata?.model_type) - ? { - model_type: (m.model_type ?? - m.custom_metadata?.model_type) as string, - } - : {}), - })); - await this.modelsCache.set('kagenti:models', models); - return models; - } catch { - return []; - } - } - - async listAgents(): Promise { - await this.ensureClientUrl(); - const { config, apiClient } = this.requireInitialized(); - const namespaces = await getVisibleNamespaces( - apiClient, - config, - this.logger, - ); - const allItems: KagentiAgentSummary[] = []; - const limit = pLimit(5); - const nsResults = await Promise.allSettled( - namespaces.map(ns => limit(() => apiClient.listAgents(ns))), - ); - for (let i = 0; i < nsResults.length; i++) { - const r = nsResults[i]; - if (r.status === 'fulfilled') { - for (const agent of r.value.items ?? []) allItems.push(agent); - } else { - this.logger.warn( - `Failed to list agents in namespace ${namespaces[i]}: ${r.reason instanceof Error ? r.reason.message : r.reason}`, - ); - } - } - if (allItems.length === 0 && config.agents?.length) { - for (const ref of config.agents) { - const { namespace: ns, name: n } = ref.includes('/') - ? { namespace: ref.split('/')[0], name: ref.split('/')[1] } - : { namespace: config.namespace, name: ref }; - try { - const card = await this.getAgentCardCached(ns, n); - allItems.push({ - name: n, - namespace: ns, - description: card.card?.description, - status: 'ready', - createdAt: new Date().toISOString(), - labels: {}, - }); - } catch { - /* skip */ - } - } - } - return allItems.map(agent => ({ - id: `${agent.namespace}/${agent.name}`, - name: agent.name, - description: agent.description, - status: agent.status, - isDefault: false, - providerType: 'kagenti', - createdAt: agent.createdAt, - framework: agent.labels?.framework, - protocols: (() => { - const p = agent.labels?.protocol; - if (!p) return undefined; - return Array.isArray(p) ? p : [p]; - })(), - source: 'kagenti', - namespace: agent.namespace, - })); - } - - async generateSystemPrompt( - description: string, - modelOverride?: string, - capabilities?: PromptCapabilities, - ): Promise { - const llamaStackUrl = this.rootConfig.getOptionalString( - 'augment.llamaStack.baseUrl', - ); - if (!llamaStackUrl) - throw new Error('augment.llamaStack.baseUrl is not configured'); - const ec = await this.getEffectiveConfig(); - const model = modelOverride || (ec.model as string) || 'default'; - const { instructions, input } = buildMetaPrompt( - description, - { - model, - baseUrl: llamaStackUrl, - systemPrompt: (ec.systemPrompt as string) || '', - vectorStoreIds: [], - vectorStoreName: 'default-vector-store', - embeddingModel: 'all-MiniLM-L6-v2', - embeddingDimension: 384, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 800, - chunkOverlapTokens: 50, - enableWebSearch: (ec.enableWebSearch as boolean) || false, - enableCodeInterpreter: (ec.enableCodeInterpreter as boolean) || false, - skipTlsVerify: false, - zdrMode: false, - verboseStreamLogging: false, - }, - capabilities, - ); - const res = await fetch( - `${stripTrailingSlashes(llamaStackUrl)}/v1/responses`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ input, instructions, model, store: false }), - signal: AbortSignal.timeout(60_000), - }, - ); - if (!res.ok) { - const body = await res.text().catch(() => ''); - throw new Error( - `Inference server returned ${res.status}: ${body.slice(0, 200)}`, - ); - } - const response = (await res.json()) as { - output: Array<{ - type: string; - content?: Array<{ type: string; text?: string }>; - }>; - }; - for (const item of response.output) { - if (item.type === 'message' && item.content) { - for (const block of item.content) { - if (block.type === 'output_text' && block.text) - return block.text.trim(); - } - } - } - throw new Error('LLM returned no text in the response'); - } - - async testModel( - model?: string, - overrideBaseUrl?: string, - ): Promise<{ - connected: boolean; - modelFound: boolean; - canGenerate: boolean; - error?: string; - }> { - const llamaStackUrl = - overrideBaseUrl || - this.rootConfig.getOptionalString('augment.llamaStack.baseUrl'); - if (llamaStackUrl && model) { - try { - const res = await fetch( - `${stripTrailingSlashes(llamaStackUrl)}/v1/models`, - { - method: 'GET', - headers: { Accept: 'application/json' }, - signal: AbortSignal.timeout(10_000), - }, - ); - if (!res.ok) - return { - connected: false, - modelFound: false, - canGenerate: false, - error: `LlamaStack returned ${res.status}`, - }; - const json = (await res.json()) as { data?: Array<{ id: string }> }; - const found = json.data?.some(m => m.id === model) ?? false; - return { - connected: true, - modelFound: found, - canGenerate: found, - error: found ? undefined : `Model "${model}" not found`, - }; - } catch (err) { - return { - connected: false, - modelFound: false, - canGenerate: false, - error: `LlamaStack connection failed: ${err instanceof Error ? err.message : err}`, - }; - } - } - const { config, apiClient } = this.requireInitialized(); - try { - const { namespace, name } = model - ? parseAgentId(model, config.namespace) - : { namespace: config.namespace, name: config.agentName ?? 'default' }; - await apiClient.getAgentCard(namespace, name); - return { connected: true, modelFound: true, canGenerate: true }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { - connected: !/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|timeout/i.test(msg), - modelFound: false, - canGenerate: false, - error: msg, - }; - } - } - - async shutdown(): Promise { - this.tokenManager?.clearCache(); - this.apiClient?.destroy(); - this.cardCache.clear(); - this.logger.info('Kagenti provider shut down'); - } - async getAgentCardCached( - ns: string, - name: string, - opts?: { retries?: number }, - ): Promise { - const { config, apiClient } = this.requireInitialized(); - return this.cardCache.getAgentCardCached(apiClient, config, ns, name, opts); - } - getApiClient(): KagentiApiClient { - return this.requireInitialized().apiClient; - } - getSandboxClient(): KagentiSandboxClient | undefined { - return this.sandboxClient; - } - getAdminClient(): KagentiAdminClient | undefined { - return this.adminClient; - } - getFeatureFlags(): FeatureFlagsResponse { - return this.featureFlags; - } - setUserContext(userRef: string): void { - this.apiClient?.setRequestContext({ userRef }); - } - getConfig(): KagentiConfig { - return this.requireInitialized().config; - } - getTokenManager(): KeycloakTokenManager { - return this.requireInitialized().tokenManager; - } - async getAuthToken(): Promise { - return this.requireInitialized().tokenManager.getToken(); - } - async hydrateSessionContext( - bsId: string, - ctxId: string, - agentId?: string, - ): Promise { - await this.kagentiSessionCache.set(bsId, ctxId); - if (agentId) await this.sessionAgentCache.set(ctxId, agentId); - } - async getSessionContextId(bsId: string): Promise { - return this.kagentiSessionCache.get(bsId); - } - async submitApproval(approval: ApprovalRequest): Promise { - const { config, apiClient } = this.requireInitialized(); - const agentId = await this.sessionAgentCache.get( - approval.responseId, - ); - const { namespace, name } = agentId - ? parseAgentId(agentId, config.namespace) - : this.doResolveAgent({ messages: [] }); - return submitApprovalImpl( - apiClient, - namespace, - name, - approval, - config.extensionBaseUrl, - ); - } - validateNamespace(namespace: string): void { - validateNamespace(namespace, this.requireInitialized().config); - } - - private doResolveAgent(request: AugmentChatRequest) { - const { config } = this.requireInitialized(); - return resolveAgent( - request, - config, - ns => this.validateNamespace(ns), - id => parseAgentId(id, config.namespace), - ); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiAdminClient.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiAdminClient.test.ts deleted file mode 100644 index eadf2b5e94c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiAdminClient.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { KagentiAdminClient } from './KagentiAdminClient'; - -describe('KagentiAdminClient', () => { - const mockApi = { - request: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - function createClient() { - return new KagentiAdminClient(mockApi as never); - } - - it('lists LLM models', async () => { - mockApi.request.mockResolvedValue([{ id: 'llama3', name: 'Llama 3' }]); - const client = createClient(); - const result = await client.listLlmModels(); - expect(mockApi.request).toHaveBeenCalledWith('GET', '/api/v1/models'); - expect(result).toHaveLength(1); - }); - - it('creates a team', async () => { - mockApi.request.mockResolvedValue({ teamId: 't1', namespace: 'ns1' }); - const client = createClient(); - await client.createTeam({ namespace: 'ns1' }); - expect(mockApi.request).toHaveBeenCalledWith('POST', '/api/v1/llm/teams', { - namespace: 'ns1', - }); - }); - - it('lists teams', async () => { - mockApi.request.mockResolvedValue([]); - const client = createClient(); - await client.listTeams(); - expect(mockApi.request).toHaveBeenCalledWith('GET', '/api/v1/llm/teams'); - }); - - it('gets a team by namespace', async () => { - mockApi.request.mockResolvedValue({ teamId: 't1', namespace: 'team1' }); - const client = createClient(); - await client.getTeam('team1'); - expect(mockApi.request).toHaveBeenCalledWith( - 'GET', - '/api/v1/llm/teams/team1', - ); - }); - - it('creates a key', async () => { - mockApi.request.mockResolvedValue({ success: true }); - const client = createClient(); - await client.createKey({ namespace: 'ns1', agentName: 'bot' }); - expect(mockApi.request).toHaveBeenCalledWith( - 'POST', - '/api/v1/llm/keys', - expect.objectContaining({ namespace: 'ns1' }), - ); - }); - - it('lists keys', async () => { - mockApi.request.mockResolvedValue([]); - const client = createClient(); - await client.listKeys(); - expect(mockApi.request).toHaveBeenCalledWith('GET', '/api/v1/llm/keys'); - }); - - it('deletes a key', async () => { - mockApi.request.mockResolvedValue({ success: true }); - const client = createClient(); - await client.deleteKey('ns1', 'bot'); - expect(mockApi.request).toHaveBeenCalledWith( - 'DELETE', - '/api/v1/llm/keys/ns1/bot', - ); - }); - - it('gets agent models', async () => { - mockApi.request.mockResolvedValue([{ model: 'llama3' }]); - const client = createClient(); - await client.getAgentModels('ns1', 'bot'); - expect(mockApi.request).toHaveBeenCalledWith( - 'GET', - '/api/v1/llm/agent-models/ns1/bot', - ); - }); - - it('lists integrations', async () => { - mockApi.request.mockResolvedValue({ items: [] }); - const client = createClient(); - await client.listIntegrations('ns1'); - expect(mockApi.request).toHaveBeenCalledWith( - 'GET', - '/api/v1/integrations?namespace=ns1', - ); - }); - - it('creates an integration', async () => { - mockApi.request.mockResolvedValue({ success: true }); - const client = createClient(); - await client.createIntegration({ name: 'int1', namespace: 'ns1' }); - expect(mockApi.request).toHaveBeenCalledWith( - 'POST', - '/api/v1/integrations', - expect.objectContaining({ name: 'int1' }), - ); - }); - - it('deletes an integration', async () => { - mockApi.request.mockResolvedValue({ success: true }); - const client = createClient(); - await client.deleteIntegration('ns1', 'int1'); - expect(mockApi.request).toHaveBeenCalledWith( - 'DELETE', - '/api/v1/integrations/ns1/int1', - ); - }); - - it('tests an integration', async () => { - mockApi.request.mockResolvedValue({ success: true }); - const client = createClient(); - await client.testIntegration('ns1', 'int1'); - expect(mockApi.request).toHaveBeenCalledWith( - 'POST', - '/api/v1/integrations/ns1/int1/test', - ); - }); - - it('creates a trigger', async () => { - mockApi.request.mockResolvedValue({ - sandbox_claim: 'claim-1', - namespace: 'ns1', - }); - const client = createClient(); - await client.createTrigger({ - type: 'cron', - namespace: 'ns1', - schedule: '*/5 * * * *', - }); - expect(mockApi.request).toHaveBeenCalledWith( - 'POST', - '/api/v1/sandbox/trigger', - expect.objectContaining({ type: 'cron' }), - ); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiAdminClient.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiAdminClient.ts deleted file mode 100644 index dea6e865721..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiAdminClient.ts +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { KagentiApiClient } from './KagentiApiClient'; -import type { - LlmModelResponse, - TeamCreateRequest, - TeamResponse, - KeyCreateRequest, - KeyInfo, - CreateIntegrationRequest, - IntegrationListResponse, - TriggerRequest, - TriggerResponse, -} from './types'; -import { API_PREFIX as API, encodePathSegment as e } from './utils'; - -/** - * Client for Kagenti admin/feature-flagged endpoints: - * models, LLM keys/teams, integrations, and triggers. - */ -export class KagentiAdminClient { - constructor(private readonly api: KagentiApiClient) {} - - // -- Models (sandbox flag) -------------------------------------------------- - - async listLlmModels(): Promise { - return this.api.request('GET', `${API}/models`); - } - - // -- LLM Teams (sandbox flag) ----------------------------------------------- - - async createTeam(body: TeamCreateRequest): Promise { - return this.api.request('POST', `${API}/llm/teams`, body); - } - - async listTeams(): Promise { - return this.api.request('GET', `${API}/llm/teams`); - } - - async getTeam(namespace: string): Promise { - return this.api.request('GET', `${API}/llm/teams/${e(namespace)}`); - } - - // -- LLM Keys (sandbox flag) ------------------------------------------------ - - async createKey(body: KeyCreateRequest): Promise> { - return this.api.request('POST', `${API}/llm/keys`, body); - } - - async listKeys(): Promise { - return this.api.request('GET', `${API}/llm/keys`); - } - - async deleteKey( - namespace: string, - agentName: string, - ): Promise> { - return this.api.request( - 'DELETE', - `${API}/llm/keys/${e(namespace)}/${e(agentName)}`, - ); - } - - async getAgentModels( - namespace: string, - agentName: string, - ): Promise[]> { - return this.api.request( - 'GET', - `${API}/llm/agent-models/${e(namespace)}/${e(agentName)}`, - ); - } - - // -- Integrations (integrations flag) --------------------------------------- - - async listIntegrations(namespace?: string): Promise { - const qs = namespace ? `?namespace=${e(namespace)}` : ''; - return this.api.request('GET', `${API}/integrations${qs}`); - } - - async getIntegration( - namespace: string, - name: string, - ): Promise> { - return this.api.request( - 'GET', - `${API}/integrations/${e(namespace)}/${e(name)}`, - ); - } - - async createIntegration( - body: CreateIntegrationRequest, - ): Promise> { - return this.api.request('POST', `${API}/integrations`, body); - } - - async updateIntegration( - namespace: string, - name: string, - patch: Record, - ): Promise> { - return this.api.request( - 'PUT', - `${API}/integrations/${e(namespace)}/${e(name)}`, - patch, - ); - } - - async deleteIntegration( - namespace: string, - name: string, - ): Promise> { - return this.api.request( - 'DELETE', - `${API}/integrations/${e(namespace)}/${e(name)}`, - ); - } - - async testIntegration( - namespace: string, - name: string, - ): Promise> { - return this.api.request( - 'POST', - `${API}/integrations/${e(namespace)}/${e(name)}/test`, - ); - } - - // -- Triggers (triggers flag) ----------------------------------------------- - - async createTrigger(body: TriggerRequest): Promise { - return this.api.request('POST', `${API}/sandbox/trigger`, body); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiApiClient.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiApiClient.test.ts deleted file mode 100644 index 78792a74924..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiApiClient.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as http from 'http'; -import { KagentiApiClient } from './KagentiApiClient'; -import { createMockLogger } from '../../../test-utils/mocks'; - -describe('KagentiApiClient', () => { - let server: http.Server; - let port: number; - - const mockTokenManager = { - getToken: jest.fn().mockResolvedValue('test-token'), - clearCache: jest.fn(), - }; - - beforeAll(async () => { - server = http.createServer((req, res) => { - if (req.url === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'healthy' })); - } else if (req.url === '/ready') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ready' })); - } else if (req.url === '/api/v1/config/features') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - sandbox: false, - integrations: false, - triggers: false, - }), - ); - } else if (req.url === '/api/v1/namespaces?enabled_only=true') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ namespaces: ['team1', 'team2'] })); - } else if (req.url === '/api/v1/agents?namespace=team1') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - items: [ - { - name: 'bot', - namespace: 'team1', - description: '', - status: 'Running', - labels: { protocol: 'a2a', framework: 'LangGraph' }, - workloadType: 'deployment', - }, - ], - }), - ); - } else if (req.url === '/api/v1/chat/team1/bot/agent-card') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - name: 'bot', - description: 'A bot', - version: '1.0', - url: 'https://bot:8000', - capabilities: { streaming: true, pushNotifications: false }, - skills: [], - }), - ); - } else if ( - req.url === '/api/v1/agents/parse-env' && - req.method === 'POST' - ) { - const chunks: Buffer[] = []; - req.on('data', c => chunks.push(c)); - req.on('end', () => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ envVars: [], warnings: [] })); - }); - } else if (req.url === '/api/v1/tools?namespace=team1') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ items: [] })); - } else if ( - req.url?.startsWith('/api/v1/tools/team1/my-tool/connect') && - req.method === 'POST' - ) { - const chunks: Buffer[] = []; - req.on('data', c => chunks.push(c)); - req.on('end', () => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - tools: [{ name: 'search', description: 'Search tool' }], - }), - ); - }); - } else if (req.url === '/api/v1/agents') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ items: [] })); - } else if (req.url === '/api/v1/tools') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ items: [] })); - } else { - res.writeHead(404); - res.end('Not found'); - } - }); - await new Promise(resolve => { - server.listen(0, () => { - const addr = server.address(); - port = typeof addr === 'object' && addr ? addr.port : 0; - resolve(); - }); - }); - }); - - afterAll(async () => { - await new Promise(resolve => server.close(() => resolve())); - }); - - function createClient() { - return new KagentiApiClient({ - baseUrl: `http://localhost:${port}`, - tokenManager: mockTokenManager as never, - logger: createMockLogger(), - }); - } - - it('checks health', async () => { - const client = createClient(); - const result = await client.health(); - expect(result.status).toBe('healthy'); - }); - - it('checks readiness', async () => { - const client = createClient(); - const result = await client.ready(); - expect(result.status).toBe('ready'); - }); - - it('gets feature flags', async () => { - const client = createClient(); - const result = await client.getFeatureFlags(); - expect(result).toEqual({ - sandbox: false, - integrations: false, - triggers: false, - }); - }); - - it('lists namespaces', async () => { - const client = createClient(); - const result = await client.listNamespaces(); - expect(result.namespaces).toEqual(['team1', 'team2']); - }); - - it('lists agents', async () => { - const client = createClient(); - const result = await client.listAgents('team1'); - expect(result.items).toHaveLength(1); - expect(result.items[0].name).toBe('bot'); - }); - - it('gets agent card', async () => { - const client = createClient(); - const result = await client.getAgentCard('team1', 'bot'); - expect(result.name).toBe('bot'); - expect(result.capabilities?.streaming).toBe(true); - }); - - it('parses env content', async () => { - const client = createClient(); - const result = await client.parseEnv('KEY=value'); - expect(result.envVars).toEqual([]); - }); - - it('lists tools', async () => { - const client = createClient(); - const result = await client.listTools('team1'); - expect(result.items).toEqual([]); - }); - - it('connects to MCP tool', async () => { - const client = createClient(); - const result = await client.connectTool('team1', 'my-tool'); - expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('search'); - }); - - it('lists all Shipwright builds', async () => { - const client = createClient(); - const result = await client.listAllBuilds(); - expect(result.items).toEqual([]); - }); - - it('throws on 404', async () => { - const client = createClient(); - await expect(client.getAgent('nonexist', 'nope')).rejects.toThrow( - /status 404/, - ); - }); -}); - -describe('KagentiApiClient -- retry logic', () => { - let retryServer: http.Server; - let retryPort: number; - let requestCount: number; - - const mockTokenManager = { - getToken: jest.fn().mockResolvedValue('test-token'), - getTokenForStreaming: jest.fn().mockResolvedValue('test-token'), - clearCache: jest.fn(), - }; - - beforeEach(() => { - requestCount = 0; - }); - - afterEach(async () => { - if (retryServer) { - await new Promise(resolve => retryServer.close(() => resolve())); - } - }); - - async function startRetryServer( - handler: (req: http.IncomingMessage, res: http.ServerResponse) => void, - ): Promise { - retryServer = http.createServer(handler); - return new Promise(resolve => { - retryServer.listen(0, () => { - const addr = retryServer.address(); - retryPort = typeof addr === 'object' && addr ? addr.port : 0; - resolve(retryPort); - }); - }); - } - - it('retries on 503 and succeeds on second attempt', async () => { - await startRetryServer((_req, res) => { - requestCount++; - if (requestCount === 1) { - res.writeHead(503, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ detail: 'Service unavailable' })); - } else { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok' })); - } - }); - - const client = new KagentiApiClient({ - baseUrl: `http://localhost:${retryPort}`, - tokenManager: mockTokenManager as never, - logger: createMockLogger(), - maxRetries: 2, - retryBaseDelayMs: 10, - }); - - const result = await client.requestWithRetry<{ status: string }>( - 'GET', - '/test', - ); - expect(result.status).toBe('ok'); - expect(requestCount).toBe(2); - }); - - it('clears token cache and retries on 401', async () => { - await startRetryServer((_req, res) => { - requestCount++; - if (requestCount === 1) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ detail: 'Unauthorized' })); - } else { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok' })); - } - }); - - const client = new KagentiApiClient({ - baseUrl: `http://localhost:${retryPort}`, - tokenManager: mockTokenManager as never, - logger: createMockLogger(), - maxRetries: 2, - retryBaseDelayMs: 10, - }); - - const result = await client.requestWithRetry<{ status: string }>( - 'GET', - '/test', - ); - expect(result.status).toBe('ok'); - expect(mockTokenManager.clearCache).toHaveBeenCalled(); - }); - - it('throws after exhausting all retries', async () => { - await startRetryServer((_req, res) => { - requestCount++; - res.writeHead(503, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ detail: 'Service unavailable' })); - }); - - const client = new KagentiApiClient({ - baseUrl: `http://localhost:${retryPort}`, - tokenManager: mockTokenManager as never, - logger: createMockLogger(), - maxRetries: 1, - retryBaseDelayMs: 10, - }); - - await expect(client.requestWithRetry('GET', '/test')).rejects.toThrow( - /status 503/, - ); - expect(requestCount).toBe(2); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiApiClient.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiApiClient.ts deleted file mode 100644 index ca3a4b05d91..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiApiClient.ts +++ /dev/null @@ -1,512 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as http from 'http'; -import * as https from 'https'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { KeycloakTokenManager } from './KeycloakTokenManager'; -import type { - HealthResponse, - AuthConfigResponse, - AuthStatusResponse, - UserInfoResponse, - FeatureFlagsResponse, - DashboardConfigResponse, - NamespaceListResponse, - AgentCardResponse, - ChatRequest, - ChatResponse, - AgentListResponse, - AgentDetailResponse, - RouteStatusResponse, - CreateAgentRequest, - CreateAgentResponse, - DeleteResponse, - ListMigratableAgentsResponse, - MigrateAgentRequest, - MigrateAgentResponse, - MigrateAllResponse, - ClusterBuildStrategiesResponse, - ShipwrightBuildListItem, - ShipwrightBuildListResponse, - ShipwrightBuildStatusResponse, - ShipwrightBuildRunStatusResponse, - ShipwrightBuildInfoResponse, - TriggerBuildRunResponse, - FinalizeShipwrightBuildRequest, - ParseEnvRequest, - ParseEnvResponse, - FetchEnvUrlRequest, - FetchEnvUrlResponse, - ToolListResponse, - ToolDetailResponse, - CreateToolRequest, - CreateToolResponse, - FinalizeToolBuildRequest, - MCPToolsResponse, - MCPInvokeRequest, - MCPInvokeResponse, - ContextHistoryListResponse, -} from './types'; -import { API_PREFIX, encodePathSegment as e } from './utils'; -import { - requestWithRetry as doRetry, - streamRequest as doStream, -} from './requestCore'; -import type { RequestCoreContext } from './requestCore'; - -export interface KagentiApiClientOptions { - baseUrl: string; - tokenManager: KeycloakTokenManager; - skipTlsVerify?: boolean; - logger: LoggerService; - requestTimeoutMs?: number; - streamTimeoutMs?: number; - maxRetries?: number; - retryBaseDelayMs?: number; -} -export interface KagentiRequestContext { - userRef?: string; -} - -function stripTrailingSlashes(s: string): string { - let end = s.length; - while (end > 0 && s[end - 1] === '/') end--; - return end === s.length ? s : s.slice(0, end); -} - -export class KagentiApiClient { - private readonly ctx: RequestCoreContext; - private _requestContext: KagentiRequestContext = {}; - - constructor(options: KagentiApiClientOptions) { - const baseUrl = stripTrailingSlashes(options.baseUrl); - const isHttps = baseUrl.startsWith('https'); - const httpAgent = isHttps - ? new https.Agent({ - keepAlive: true, - rejectUnauthorized: !(options.skipTlsVerify ?? false), - }) - : new http.Agent({ keepAlive: true }); - this.ctx = { - baseUrl, - isHttps, - httpAgent, - tokenManager: options.tokenManager, - logger: options.logger, - requestTimeoutMs: options.requestTimeoutMs ?? 30_000, - streamTimeoutMs: options.streamTimeoutMs ?? 300_000, - maxRetries: options.maxRetries ?? 3, - retryBaseDelayMs: options.retryBaseDelayMs ?? 1000, - getUserRef: () => this._requestContext.userRef, - }; - } - - setRequestContext(ctx: KagentiRequestContext): void { - this._requestContext = ctx; - } - destroy(): void { - this.ctx.httpAgent.destroy(); - } - - async request( - method: string, - path: string, - body?: unknown, - skipAuth = false, - ): Promise { - return doRetry(this.ctx, method, path, body, skipAuth); - } - async requestWithRetry( - method: string, - path: string, - body?: unknown, - skipAuth = false, - retries?: number, - ): Promise { - return doRetry(this.ctx, method, path, body, skipAuth, retries); - } - async streamRequest( - path: string, - body: unknown, - onLine: (line: string) => void, - signal?: AbortSignal, - ): Promise { - return doStream(this.ctx, path, body, onLine, signal); - } - - // -- Health/Auth/Config -- - async health(): Promise { - return this.request('GET', '/health', undefined, true); - } - async ready(): Promise { - return this.request('GET', '/ready', undefined, true); - } - async getAuthConfig(): Promise { - return this.request('GET', `${API_PREFIX}/auth/config`, undefined, true); - } - async getAuthStatus(): Promise { - return this.request('GET', `${API_PREFIX}/auth/status`); - } - async getUserInfo(): Promise { - return this.request('GET', `${API_PREFIX}/auth/userinfo`); - } - async getMe(): Promise { - return this.request('GET', `${API_PREFIX}/auth/me`); - } - async getFeatureFlags(): Promise { - return this.request('GET', `${API_PREFIX}/config/features`); - } - async getDashboards(): Promise { - return this.request('GET', `${API_PREFIX}/config/dashboards`); - } - async listNamespaces(enabledOnly = true): Promise { - return this.request( - 'GET', - `${API_PREFIX}/namespaces?enabled_only=${enabledOnly}`, - ); - } - - // -- Chat -- - async getAgentCard( - ns: string, - name: string, - opts?: { retries?: number }, - ): Promise { - return this.requestWithRetry( - 'GET', - `${API_PREFIX}/chat/${e(ns)}/${e(name)}/agent-card`, - undefined, - false, - opts?.retries, - ); - } - async chatSend( - ns: string, - name: string, - message: string, - sessionId?: string, - a2a?: { - metadata?: Record; - parts?: Array>; - contextId?: string; - }, - ): Promise { - const body: ChatRequest = { - message, - session_id: sessionId, - ...(a2a?.metadata && { metadata: a2a.metadata }), - ...(a2a?.parts && { parts: a2a.parts }), - ...(a2a?.contextId && { context_id: a2a.contextId }), - }; - return this.request( - 'POST', - `${API_PREFIX}/chat/${e(ns)}/${e(name)}/send`, - body, - ); - } - async chatStream( - ns: string, - name: string, - message: string, - sessionId: string | undefined, - onLine: (line: string) => void, - signal?: AbortSignal, - a2a?: { - metadata?: Record; - parts?: Array>; - contextId?: string; - }, - ): Promise { - const body: ChatRequest = { - message, - session_id: sessionId, - ...(a2a?.metadata && { metadata: a2a.metadata }), - ...(a2a?.parts && { parts: a2a.parts }), - ...(a2a?.contextId && { context_id: a2a.contextId }), - }; - return this.streamRequest( - `${API_PREFIX}/chat/${e(ns)}/${e(name)}/stream`, - body, - onLine, - signal, - ); - } - async listContextHistory( - contextId: string, - opts?: { limit?: number; pageToken?: string }, - ): Promise { - const p = new URLSearchParams(); - if (opts?.limit !== undefined) p.set('limit', String(opts.limit)); - if (opts?.pageToken) p.set('page_token', opts.pageToken); - const qs = p.toString() ? `?${p.toString()}` : ''; - return this.request( - 'GET', - `${API_PREFIX}/contexts/${e(contextId)}/history${qs}`, - ); - } - - // -- Agents -- - async listAgents(ns?: string): Promise { - return this.request( - 'GET', - `${API_PREFIX}/agents${ns ? `?namespace=${e(ns)}` : ''}`, - ); - } - async getAgent(ns: string, name: string): Promise { - return this.request('GET', `${API_PREFIX}/agents/${e(ns)}/${e(name)}`); - } - async getAgentRouteStatus( - ns: string, - name: string, - ): Promise { - return this.request( - 'GET', - `${API_PREFIX}/agents/${e(ns)}/${e(name)}/route-status`, - ); - } - async createAgent(body: CreateAgentRequest): Promise { - return this.request('POST', `${API_PREFIX}/agents`, body); - } - async deleteAgent(ns: string, name: string): Promise { - return this.request('DELETE', `${API_PREFIX}/agents/${e(ns)}/${e(name)}`); - } - async listMigratableAgents(): Promise { - return this.request('GET', `${API_PREFIX}/agents/migration/migratable`); - } - async migrateAgent( - ns: string, - name: string, - deleteOld = false, - ): Promise { - return this.request( - 'POST', - `${API_PREFIX}/agents/${e(ns)}/${e(name)}/migrate`, - { delete_old: deleteOld } as MigrateAgentRequest, - ); - } - async migrateAllAgents(opts?: { - namespace?: string; - dryRun?: boolean; - deleteOld?: boolean; - }): Promise { - const p = new URLSearchParams(); - if (opts?.namespace) p.set('namespace', opts.namespace); - if (opts?.dryRun !== undefined) p.set('dry_run', String(opts.dryRun)); - if (opts?.deleteOld !== undefined) - p.set('delete_old', String(opts.deleteOld)); - return this.request( - 'POST', - `${API_PREFIX}/agents/migration/migrate-all${p.toString() ? `?${p.toString()}` : ''}`, - ); - } - async listBuildStrategies(): Promise { - return this.request('GET', `${API_PREFIX}/agents/build-strategies`); - } - async listAgentBuilds(ns?: string): Promise { - const agents = await this.listAgents(ns); - const results = await Promise.allSettled( - agents.items.map(a => this.getAgentBuildInfo(a.namespace, a.name)), - ); - const items: ShipwrightBuildListItem[] = []; - results.forEach((r, i) => { - if (r.status === 'fulfilled') { - const a = agents.items[i]; - const info = r.value; - items.push({ - name: a.name, - namespace: a.namespace, - resourceType: 'agent', - registered: info.buildRegistered, - strategy: info.strategy, - gitUrl: info.gitUrl, - gitRevision: info.gitRevision, - contextDir: info.contextDir, - outputImage: info.outputImage, - creationTimestamp: a.createdAt, - }); - } - }); - return { items }; - } - async getAgentBuild( - ns: string, - name: string, - ): Promise { - return this.request( - 'GET', - `${API_PREFIX}/agents/${e(ns)}/${e(name)}/shipwright-build`, - ); - } - async getAgentBuildRun( - ns: string, - name: string, - ): Promise { - return this.request( - 'GET', - `${API_PREFIX}/agents/${e(ns)}/${e(name)}/shipwright-buildrun`, - ); - } - async triggerAgentBuildRun( - ns: string, - name: string, - ): Promise { - return this.request( - 'POST', - `${API_PREFIX}/agents/${e(ns)}/${e(name)}/shipwright-buildrun`, - ); - } - async getAgentBuildInfo( - ns: string, - name: string, - ): Promise { - return this.request( - 'GET', - `${API_PREFIX}/agents/${e(ns)}/${e(name)}/shipwright-build-info`, - ); - } - async finalizeAgentBuild( - ns: string, - name: string, - body?: FinalizeShipwrightBuildRequest, - ): Promise { - return this.request( - 'POST', - `${API_PREFIX}/agents/${e(ns)}/${e(name)}/finalize-shipwright-build`, - body ?? {}, - ); - } - async parseEnv(content: string): Promise { - return this.request('POST', `${API_PREFIX}/agents/parse-env`, { - content, - } as ParseEnvRequest); - } - async fetchEnvUrl(url: string): Promise { - return this.request('POST', `${API_PREFIX}/agents/fetch-env-url`, { - url, - } as FetchEnvUrlRequest); - } - - // -- Tools -- - async listTools(ns?: string): Promise { - return this.request( - 'GET', - `${API_PREFIX}/tools${ns ? `?namespace=${e(ns)}` : ''}`, - ); - } - async getTool(ns: string, name: string): Promise { - return this.request('GET', `${API_PREFIX}/tools/${e(ns)}/${e(name)}`); - } - async getToolRouteStatus( - ns: string, - name: string, - ): Promise { - return this.request( - 'GET', - `${API_PREFIX}/tools/${e(ns)}/${e(name)}/route-status`, - ); - } - async createTool(body: CreateToolRequest): Promise { - return this.request('POST', `${API_PREFIX}/tools`, body); - } - async deleteTool(ns: string, name: string): Promise { - return this.request('DELETE', `${API_PREFIX}/tools/${e(ns)}/${e(name)}`); - } - async listToolBuilds(ns?: string): Promise { - const tools = await this.listTools(ns); - const results = await Promise.allSettled( - tools.items.map(t => this.getToolBuildInfo(t.namespace, t.name)), - ); - const items: ShipwrightBuildListItem[] = []; - results.forEach((r, i) => { - if (r.status === 'fulfilled') { - const t = tools.items[i]; - const info = r.value; - items.push({ - name: t.name, - namespace: t.namespace, - resourceType: 'tool', - registered: info.buildRegistered, - strategy: info.strategy, - gitUrl: info.gitUrl, - gitRevision: info.gitRevision, - contextDir: info.contextDir, - outputImage: info.outputImage, - creationTimestamp: t.createdAt, - }); - } - }); - return { items }; - } - async getToolBuildInfo( - ns: string, - name: string, - ): Promise { - return this.request( - 'GET', - `${API_PREFIX}/tools/${e(ns)}/${e(name)}/shipwright-build-info`, - ); - } - async triggerToolBuildRun( - ns: string, - name: string, - ): Promise { - return this.request( - 'POST', - `${API_PREFIX}/tools/${e(ns)}/${e(name)}/shipwright-buildrun`, - ); - } - async finalizeToolBuild( - ns: string, - name: string, - body?: FinalizeToolBuildRequest, - ): Promise { - return this.request( - 'POST', - `${API_PREFIX}/tools/${e(ns)}/${e(name)}/finalize-shipwright-build`, - body ?? {}, - ); - } - async connectTool(ns: string, name: string): Promise { - return this.request( - 'POST', - `${API_PREFIX}/tools/${e(ns)}/${e(name)}/connect`, - ); - } - async invokeTool( - ns: string, - name: string, - toolName: string, - args: Record = {}, - ): Promise { - return this.request( - 'POST', - `${API_PREFIX}/tools/${e(ns)}/${e(name)}/invoke`, - { tool_name: toolName, arguments: args } as MCPInvokeRequest, - ); - } - async listAllBuilds(ns?: string): Promise { - const [a, t] = await Promise.all([ - this.listAgentBuilds(ns).catch(() => ({ - items: [] as ShipwrightBuildListItem[], - })), - this.listToolBuilds(ns).catch(() => ({ - items: [] as ShipwrightBuildListItem[], - })), - ]); - return { items: [...a.items, ...t.items] }; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiSandboxClient.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiSandboxClient.test.ts deleted file mode 100644 index c0112fc07e1..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiSandboxClient.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { KagentiSandboxClient } from './KagentiSandboxClient'; - -describe('KagentiSandboxClient', () => { - const mockApi = { - request: jest.fn(), - streamRequest: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - function createClient() { - return new KagentiSandboxClient(mockApi as never); - } - - it('lists sessions', async () => { - mockApi.request.mockResolvedValue({ items: [], total: 0 }); - const client = createClient(); - const result = await client.listSessions('ns1'); - expect(mockApi.request).toHaveBeenCalledWith( - 'GET', - '/api/v1/sandbox/ns1/sessions', - ); - expect(result).toEqual({ items: [], total: 0 }); - }); - - it('lists sessions with query params', async () => { - mockApi.request.mockResolvedValue({ items: [] }); - const client = createClient(); - await client.listSessions('ns1', { limit: 10, offset: 5, search: 'test' }); - expect(mockApi.request).toHaveBeenCalledWith( - 'GET', - expect.stringContaining('limit=10'), - ); - }); - - it('gets session detail', async () => { - mockApi.request.mockResolvedValue({ contextId: 'c1' }); - const client = createClient(); - await client.getSession('ns1', 'c1'); - expect(mockApi.request).toHaveBeenCalledWith( - 'GET', - '/api/v1/sandbox/ns1/sessions/c1', - ); - }); - - it('deletes a session', async () => { - mockApi.request.mockResolvedValue(undefined); - const client = createClient(); - await client.deleteSession('ns1', 'c1'); - expect(mockApi.request).toHaveBeenCalledWith( - 'DELETE', - '/api/v1/sandbox/ns1/sessions/c1', - ); - }); - - it('renames a session', async () => { - mockApi.request.mockResolvedValue({ title: 'New Title' }); - const client = createClient(); - await client.renameSession('ns1', 'c1', 'New Title'); - expect(mockApi.request).toHaveBeenCalledWith( - 'PUT', - '/api/v1/sandbox/ns1/sessions/c1/rename', - { title: 'New Title' }, - ); - }); - - it('sends sandbox chat', async () => { - mockApi.request.mockResolvedValue({ - content: 'reply', - context_id: 'c1', - status: 'completed', - }); - const client = createClient(); - await client.sandboxChat('ns1', 'Hello', { agentName: 'bot' }); - expect(mockApi.request).toHaveBeenCalledWith( - 'POST', - '/api/v1/sandbox/ns1/chat', - expect.objectContaining({ message: 'Hello', agent_name: 'bot' }), - ); - }); - - it('streams sandbox chat', async () => { - mockApi.streamRequest.mockResolvedValue(undefined); - const client = createClient(); - const onLine = jest.fn(); - await client.sandboxChatStream('ns1', 'Hi', undefined, onLine); - expect(mockApi.streamRequest).toHaveBeenCalledWith( - '/api/v1/sandbox/ns1/chat/stream', - expect.objectContaining({ message: 'Hi' }), - onLine, - undefined, - ); - }); - - it('gets sandbox defaults', async () => { - mockApi.request.mockResolvedValue({ name: 'sandbox-default' }); - const client = createClient(); - await client.getSandboxDefaults(); - expect(mockApi.request).toHaveBeenCalledWith( - 'GET', - '/api/v1/sandbox/defaults', - ); - }); - - it('creates a sandbox', async () => { - mockApi.request.mockResolvedValue({ success: true }); - const client = createClient(); - await client.createSandbox('ns1', { key: 'value' }); - expect(mockApi.request).toHaveBeenCalledWith( - 'POST', - '/api/v1/sandbox/ns1/create', - { key: 'value' }, - ); - }); - - it('lists sidecars', async () => { - mockApi.request.mockResolvedValue([]); - const client = createClient(); - await client.listSidecars('ns1', 'ctx1'); - expect(mockApi.request).toHaveBeenCalledWith( - 'GET', - '/api/v1/sandbox/ns1/sessions/ctx1/sidecars', - ); - }); - - it('enables a sidecar', async () => { - mockApi.request.mockResolvedValue({ - sidecarType: 'observer', - enabled: true, - }); - const client = createClient(); - await client.enableSidecar('ns1', 'ctx1', 'observer', { - auto_approve: true, - }); - expect(mockApi.request).toHaveBeenCalledWith( - 'POST', - '/api/v1/sandbox/ns1/sessions/ctx1/sidecars/observer/enable', - { auto_approve: true }, - ); - }); - - it('gets session token usage', async () => { - mockApi.request.mockResolvedValue({ totalTokens: 100 }); - const client = createClient(); - await client.getSessionTokenUsage('ns1', 'ctx1'); - expect(mockApi.request).toHaveBeenCalledWith( - 'GET', - '/api/v1/sandbox/ns1/token-usage/sessions/ctx1', - ); - }); - - it('gets storage stats', async () => { - mockApi.request.mockResolvedValue({ used: '1G' }); - const client = createClient(); - await client.getStorageStats('ns1', 'agent1'); - expect(mockApi.request).toHaveBeenCalledWith( - 'GET', - '/api/v1/sandbox/ns1/stats/agent1', - ); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiSandboxClient.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiSandboxClient.ts deleted file mode 100644 index 11a678f3b65..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KagentiSandboxClient.ts +++ /dev/null @@ -1,515 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { KagentiApiClient } from './KagentiApiClient'; -import type { - SandboxSessionListResponse, - SandboxSessionDetailResponse, - SessionChainResponse, - SandboxChatRequest, - SandboxChatResponse, - SandboxCreateRequest, - SandboxCreateResponse, - RenameRequest, - VisibilityRequest, - CleanupResponse, - SandboxAgentInfoResponse, - DirectoryListing, - FileContent, - PodStorageStats, - SidecarResponse, - EnableSidecarRequest, - SidecarConfigUpdateRequest, - SessionTokenUsageResponse, - SessionTreeUsageResponse, - PaginatedEventsResponse, - PaginatedTasksResponse, -} from './types'; -import { SANDBOX_PREFIX as P, encodePathSegment as e } from './utils'; -import * as fileOps from './sandboxFileOps'; -import * as sidecarOps from './sandboxSidecarOps'; - -export class KagentiSandboxClient { - constructor(private readonly api: KagentiApiClient) {} - - // -- Sessions --------------------------------------------------------------- - - async listSessions( - namespace: string, - opts?: { - limit?: number; - offset?: number; - search?: string; - agentName?: string; - }, - ): Promise { - const params = new URLSearchParams(); - if (opts?.limit !== undefined) params.set('limit', String(opts.limit)); - if (opts?.offset !== undefined) params.set('offset', String(opts.offset)); - if (opts?.search) params.set('search', opts.search); - if (opts?.agentName) params.set('agent_name', opts.agentName); - const qs = params.toString() ? `?${params.toString()}` : ''; - return this.api.request('GET', `${P}/${e(namespace)}/sessions${qs}`); - } - - async getSession( - namespace: string, - contextId: string, - ): Promise { - return this.api.request( - 'GET', - `${P}/${e(namespace)}/sessions/${e(contextId)}`, - ); - } - - async getSessionChain( - namespace: string, - contextId: string, - ): Promise { - return this.api.request( - 'GET', - `${P}/${e(namespace)}/sessions/${e(contextId)}/chain`, - ); - } - - async getSessionHistory( - namespace: string, - contextId: string, - ): Promise> { - return this.api.request( - 'GET', - `${P}/${e(namespace)}/sessions/${e(contextId)}/history`, - ); - } - - async deleteSession(namespace: string, contextId: string): Promise { - await this.api.request( - 'DELETE', - `${P}/${e(namespace)}/sessions/${e(contextId)}`, - ); - } - - async renameSession( - namespace: string, - contextId: string, - title: string, - ): Promise<{ title: string }> { - const body: RenameRequest = { title }; - return this.api.request( - 'PUT', - `${P}/${e(namespace)}/sessions/${e(contextId)}/rename`, - body, - ); - } - - async killSession( - namespace: string, - contextId: string, - ): Promise> { - return this.api.request( - 'POST', - `${P}/${e(namespace)}/sessions/${e(contextId)}/kill`, - ); - } - - async approveSession( - namespace: string, - contextId: string, - ): Promise> { - return this.api.request( - 'POST', - `${P}/${e(namespace)}/sessions/${e(contextId)}/approve`, - ); - } - - async denySession( - namespace: string, - contextId: string, - ): Promise> { - return this.api.request( - 'POST', - `${P}/${e(namespace)}/sessions/${e(contextId)}/deny`, - ); - } - - async setVisibility( - namespace: string, - contextId: string, - visibility: 'private' | 'namespace', - ): Promise<{ visibility: string }> { - const body: VisibilityRequest = { visibility }; - return this.api.request( - 'PUT', - `${P}/${e(namespace)}/sessions/${e(contextId)}/visibility`, - body, - ); - } - - async cleanupSessions( - namespace: string, - ttlMinutes?: number, - ): Promise { - const qs = ttlMinutes !== undefined ? `?ttl_minutes=${ttlMinutes}` : ''; - return this.api.request('POST', `${P}/${e(namespace)}/cleanup${qs}`); - } - - // -- Sandbox Agents --------------------------------------------------------- - - async listSandboxAgents( - namespace: string, - ): Promise { - return this.api.request('GET', `${P}/${e(namespace)}/agents`); - } - - async getSandboxAgentCard( - namespace: string, - agentName: string, - ): Promise> { - return this.api.request( - 'GET', - `${P}/${e(namespace)}/agent-card/${e(agentName)}`, - ); - } - - async getAgentPodStatus( - namespace: string, - agentName: string, - ): Promise> { - return this.api.request( - 'GET', - `${P}/${e(namespace)}/agents/${e(agentName)}/pod-status`, - ); - } - - async getPodMetrics( - namespace: string, - agentName: string, - ): Promise> { - return this.api.request( - 'GET', - `${P}/${e(namespace)}/pods/${e(agentName)}/metrics`, - ); - } - - async getPodEvents( - namespace: string, - agentName: string, - ): Promise> { - return this.api.request( - 'GET', - `${P}/${e(namespace)}/pods/${e(agentName)}/events`, - ); - } - - // -- Sandbox Chat ----------------------------------------------------------- - - async sandboxChat( - namespace: string, - message: string, - opts?: { sessionId?: string; agentName?: string; skill?: string }, - ): Promise { - const body: SandboxChatRequest = { - message, - session_id: opts?.sessionId, - agent_name: opts?.agentName, - skill: opts?.skill, - }; - return this.api.request('POST', `${P}/${e(namespace)}/chat`, body); - } - - async sandboxChatStream( - namespace: string, - message: string, - opts: - | { sessionId?: string; agentName?: string; skill?: string } - | undefined, - onLine: (line: string) => void, - signal?: AbortSignal, - ): Promise { - const body: SandboxChatRequest = { - message, - session_id: opts?.sessionId, - agent_name: opts?.agentName, - skill: opts?.skill, - }; - return this.api.streamRequest( - `${P}/${e(namespace)}/chat/stream`, - body, - onLine, - signal, - ); - } - - async subscribeSession( - namespace: string, - sessionId: string, - onLine: (line: string) => void, - signal?: AbortSignal, - ): Promise { - return this.api.streamRequest( - `${P}/${e(namespace)}/sessions/${e(sessionId)}/subscribe`, - {}, - onLine, - signal, - ); - } - - // -- Sandbox Deploy --------------------------------------------------------- - - async getSandboxDefaults(): Promise> { - return this.api.request('GET', `${P}/defaults`); - } - - async createSandbox( - namespace: string, - body: SandboxCreateRequest, - ): Promise { - return this.api.request('POST', `${P}/${e(namespace)}/create`, body); - } - - async deleteSandbox( - namespace: string, - name: string, - ): Promise> { - return this.api.request('DELETE', `${P}/${e(namespace)}/${e(name)}`); - } - - async getSandboxConfig( - namespace: string, - name: string, - ): Promise> { - return this.api.request('GET', `${P}/${e(namespace)}/${e(name)}/config`); - } - - async updateSandbox( - namespace: string, - name: string, - body: SandboxCreateRequest, - ): Promise { - return this.api.request('PUT', `${P}/${e(namespace)}/${e(name)}`, body); - } - - // -- Files (delegated) ------------------------------------------------------ - - browseFiles( - namespace: string, - agentName: string, - path = '/', - ): Promise { - return fileOps.browseFiles(this.api, namespace, agentName, path); - } - - listDirectory( - namespace: string, - agentName: string, - path = '/', - ): Promise { - return fileOps.listDirectory(this.api, namespace, agentName, path); - } - - getFileContent( - namespace: string, - agentName: string, - path: string, - ): Promise { - return fileOps.getFileContent(this.api, namespace, agentName, path); - } - - browseContextFiles( - namespace: string, - agentName: string, - contextId: string, - path = '/', - ): Promise { - return fileOps.browseContextFiles( - this.api, - namespace, - agentName, - contextId, - path, - ); - } - - getStorageStats( - namespace: string, - agentName: string, - ): Promise { - return fileOps.getStorageStats(this.api, namespace, agentName); - } - - // -- Sidecars (delegated) --------------------------------------------------- - - listSidecars( - namespace: string, - contextId: string, - ): Promise { - return sidecarOps.listSidecars(this.api, namespace, contextId); - } - - enableSidecar( - namespace: string, - contextId: string, - sidecarType: string, - opts?: EnableSidecarRequest, - ): Promise { - return sidecarOps.enableSidecar( - this.api, - namespace, - contextId, - sidecarType, - opts, - ); - } - - disableSidecar( - namespace: string, - contextId: string, - sidecarType: string, - ): Promise<{ status: string; sidecar_type: string }> { - return sidecarOps.disableSidecar( - this.api, - namespace, - contextId, - sidecarType, - ); - } - - updateSidecarConfig( - namespace: string, - contextId: string, - sidecarType: string, - config: SidecarConfigUpdateRequest, - ): Promise { - return sidecarOps.updateSidecarConfig( - this.api, - namespace, - contextId, - sidecarType, - config, - ); - } - - resetSidecar( - namespace: string, - contextId: string, - sidecarType: string, - ): Promise> { - return sidecarOps.resetSidecar(this.api, namespace, contextId, sidecarType); - } - - streamObservations( - namespace: string, - contextId: string, - sidecarType: string, - onLine: (line: string) => void, - signal?: AbortSignal, - ): Promise { - return sidecarOps.streamObservations( - this.api, - namespace, - contextId, - sidecarType, - onLine, - signal, - ); - } - - approveSidecar( - namespace: string, - contextId: string, - sidecarType: string, - msgId: string, - ): Promise<{ status: string; id: string }> { - return sidecarOps.approveSidecar( - this.api, - namespace, - contextId, - sidecarType, - msgId, - ); - } - - denySidecar( - namespace: string, - contextId: string, - sidecarType: string, - msgId: string, - ): Promise<{ status: string; id: string }> { - return sidecarOps.denySidecar( - this.api, - namespace, - contextId, - sidecarType, - msgId, - ); - } - - // -- Token Usage ------------------------------------------------------------ - - async getSessionTokenUsage( - namespace: string, - contextId: string, - ): Promise { - return this.api.request( - 'GET', - `${P}/${e(namespace)}/token-usage/sessions/${e(contextId)}`, - ); - } - - async getSessionTreeUsage( - namespace: string, - contextId: string, - ): Promise { - return this.api.request( - 'GET', - `${P}/${e(namespace)}/token-usage/sessions/${e(contextId)}/tree`, - ); - } - - // -- Events ----------------------------------------------------------------- - - async getEvents( - namespace: string, - contextId: string, - opts?: { taskId?: string; fromIndex?: number; limit?: number }, - ): Promise { - const params = new URLSearchParams(); - params.set('context_id', contextId); - if (opts?.taskId) params.set('task_id', opts.taskId); - if (opts?.fromIndex !== undefined) - params.set('from_index', String(opts.fromIndex)); - if (opts?.limit !== undefined) params.set('limit', String(opts.limit)); - return this.api.request( - 'GET', - `${P}/${e(namespace)}/events?${params.toString()}`, - ); - } - - async getPaginatedTasks( - namespace: string, - contextId: string, - opts?: { limit?: number; beforeId?: string }, - ): Promise { - const params = new URLSearchParams(); - params.set('context_id', contextId); - if (opts?.limit !== undefined) params.set('limit', String(opts.limit)); - if (opts?.beforeId) params.set('before_id', opts.beforeId); - return this.api.request( - 'GET', - `${P}/${e(namespace)}/tasks/paginated?${params.toString()}`, - ); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KeycloakTokenManager.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KeycloakTokenManager.test.ts deleted file mode 100644 index f8e97ab7651..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KeycloakTokenManager.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as http from 'http'; -import { KeycloakTokenManager } from './KeycloakTokenManager'; -import { createMockLogger } from '../../../test-utils/mocks'; - -describe('KeycloakTokenManager', () => { - let server: http.Server; - let serverPort: number; - let tokenCallCount: number; - - beforeAll(async () => { - tokenCallCount = 0; - server = http.createServer((req, res) => { - tokenCallCount++; - if (req.url === '/token' && req.method === 'POST') { - const chunks: Buffer[] = []; - req.on('data', c => chunks.push(c)); - req.on('end', () => { - const body = Buffer.concat(chunks).toString(); - if (body.includes('grant_type=client_credentials')) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - access_token: `token-${tokenCallCount}`, - expires_in: 300, - token_type: 'Bearer', - }), - ); - } else { - res.writeHead(400); - res.end('Bad request'); - } - }); - } else if (req.url === '/fail') { - res.writeHead(401); - res.end('Unauthorized'); - } else { - res.writeHead(404); - res.end('Not found'); - } - }); - await new Promise(resolve => { - server.listen(0, () => { - const addr = server.address(); - serverPort = typeof addr === 'object' && addr ? addr.port : 0; - resolve(); - }); - }); - }); - - afterAll(async () => { - await new Promise(resolve => server.close(() => resolve())); - }); - - beforeEach(() => { - tokenCallCount = 0; - }); - - it('acquires a token from the endpoint', async () => { - const mgr = new KeycloakTokenManager({ - tokenEndpoint: `http://localhost:${serverPort}/token`, - clientId: 'test-client', - clientSecret: 'test-secret', - logger: createMockLogger(), - }); - - const token = await mgr.getToken(); - expect(token).toBe('token-1'); - }); - - it('returns cached token on subsequent calls', async () => { - const mgr = new KeycloakTokenManager({ - tokenEndpoint: `http://localhost:${serverPort}/token`, - clientId: 'test-client', - clientSecret: 'test-secret', - logger: createMockLogger(), - }); - - const t1 = await mgr.getToken(); - const t2 = await mgr.getToken(); - expect(t1).toBe(t2); - expect(tokenCallCount).toBe(1); - }); - - it('deduplicates concurrent requests', async () => { - const mgr = new KeycloakTokenManager({ - tokenEndpoint: `http://localhost:${serverPort}/token`, - clientId: 'test-client', - clientSecret: 'test-secret', - logger: createMockLogger(), - }); - - const [t1, t2, t3] = await Promise.all([ - mgr.getToken(), - mgr.getToken(), - mgr.getToken(), - ]); - expect(t1).toBe(t2); - expect(t2).toBe(t3); - expect(tokenCallCount).toBe(1); - }); - - it('clears cache and re-acquires', async () => { - const mgr = new KeycloakTokenManager({ - tokenEndpoint: `http://localhost:${serverPort}/token`, - clientId: 'test-client', - clientSecret: 'test-secret', - logger: createMockLogger(), - }); - - await mgr.getToken(); - mgr.clearCache(); - const t2 = await mgr.getToken(); - expect(t2).toBe('token-2'); - expect(tokenCallCount).toBe(2); - }); - - it('rejects when the endpoint returns an error', async () => { - const mgr = new KeycloakTokenManager({ - tokenEndpoint: `http://localhost:${serverPort}/fail`, - clientId: 'test-client', - clientSecret: 'test-secret', - logger: createMockLogger(), - }); - - await expect(mgr.getToken()).rejects.toThrow(/status 401/); - }); - - it('clearCache during in-flight request prevents stale cache write', async () => { - const mgr = new KeycloakTokenManager({ - tokenEndpoint: `http://localhost:${serverPort}/token`, - clientId: 'test-client', - clientSecret: 'test-secret', - logger: createMockLogger(), - }); - - const firstTokenPromise = mgr.getToken(); - mgr.clearCache(); - const firstToken = await firstTokenPromise; - expect(firstToken).toBe('token-1'); - - const secondToken = await mgr.getToken(); - expect(secondToken).toBe('token-2'); - expect(tokenCallCount).toBe(2); - }); - - it('getTokenForStreaming forces refresh when remaining lifetime is insufficient', async () => { - const mgr = new KeycloakTokenManager({ - tokenEndpoint: `http://localhost:${serverPort}/token`, - clientId: 'test-client', - clientSecret: 'test-secret', - logger: createMockLogger(), - tokenExpiryBufferSeconds: 60, - }); - - const t1 = await mgr.getToken(); - expect(t1).toBe('token-1'); - expect(tokenCallCount).toBe(1); - - // Request a token with a minimum lifetime longer than the remaining validity. - // The token expires in 300s with 60s buffer = 240s effective. - // Requesting 999999ms > remaining → forces refresh. - const t2 = await mgr.getTokenForStreaming(999_999_000); - expect(t2).toBe('token-2'); - expect(tokenCallCount).toBe(2); - }); - - it('getTokenForStreaming returns cached token when lifetime is sufficient', async () => { - const mgr = new KeycloakTokenManager({ - tokenEndpoint: `http://localhost:${serverPort}/token`, - clientId: 'test-client', - clientSecret: 'test-secret', - logger: createMockLogger(), - tokenExpiryBufferSeconds: 60, - }); - - const t1 = await mgr.getToken(); - expect(t1).toBe('token-1'); - - // Request token with a very short minimum lifetime — should use cached - const t2 = await mgr.getTokenForStreaming(1000); - expect(t2).toBe('token-1'); - expect(tokenCallCount).toBe(1); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KeycloakTokenManager.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KeycloakTokenManager.ts deleted file mode 100644 index c71f4dfbe33..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/KeycloakTokenManager.ts +++ /dev/null @@ -1,223 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as https from 'https'; -import * as http from 'http'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { KeycloakTokenResponse } from './types'; - -const DEFAULT_TOKEN_EXPIRY_BUFFER_SECONDS = 60; -const MIN_TOKEN_LIFETIME_SECONDS = 10; -const TOKEN_REQUEST_TIMEOUT_MS = 15_000; - -export interface KeycloakTokenManagerOptions { - tokenEndpoint: string; - clientId: string; - clientSecret: string | (() => string); - logger: LoggerService; - skipTlsVerify?: boolean; - tokenExpiryBufferSeconds?: number; -} - -export class KeycloakTokenManager { - private readonly tokenEndpoint: string; - private readonly clientId: string; - private readonly secretAccessor: () => string; - private readonly logger: LoggerService; - private readonly skipTlsVerify: boolean; - private readonly tokenExpiryBufferSeconds: number; - - private cachedToken: string | undefined; - private expiresAt = 0; - private pendingRequest: Promise | undefined; - private generation = 0; - - constructor(options: KeycloakTokenManagerOptions) { - this.tokenEndpoint = options.tokenEndpoint; - this.clientId = options.clientId; - this.secretAccessor = - typeof options.clientSecret === 'function' - ? options.clientSecret - : () => options.clientSecret as string; - this.logger = options.logger; - this.skipTlsVerify = options.skipTlsVerify ?? false; - this.tokenExpiryBufferSeconds = - options.tokenExpiryBufferSeconds ?? DEFAULT_TOKEN_EXPIRY_BUFFER_SECONDS; - } - - async getToken(): Promise { - if (this.cachedToken && Date.now() < this.expiresAt) { - return this.cachedToken; - } - - if (this.pendingRequest) { - return this.pendingRequest; - } - - this.pendingRequest = this.acquireToken().finally(() => { - this.pendingRequest = undefined; - }); - - return this.pendingRequest; - } - - /** - * Get a token ensuring at least `minLifetimeMs` of remaining validity. - * Used before starting long-lived SSE streams where mid-stream refresh - * is not possible. - */ - async getTokenForStreaming(minLifetimeMs: number): Promise { - if (this.cachedToken && Date.now() + minLifetimeMs < this.expiresAt) { - return this.cachedToken; - } - this.cachedToken = undefined; - this.expiresAt = 0; - return this.getToken(); - } - - clearCache(): void { - this.generation++; - this.cachedToken = undefined; - this.expiresAt = 0; - this.pendingRequest = undefined; - } - - private async acquireToken(): Promise { - const startGen = this.generation; - const body = new URLSearchParams({ - grant_type: 'client_credentials', - client_id: this.clientId, - client_secret: this.secretAccessor(), - }).toString(); - - const url = new URL(this.tokenEndpoint); - const isHttps = url.protocol === 'https:'; - const transport = isHttps ? https : http; - - const requestOptions: https.RequestOptions = { - method: 'POST', - hostname: url.hostname, - port: url.port || (isHttps ? 443 : 80), - path: url.pathname + url.search, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': Buffer.byteLength(body), - }, - timeout: TOKEN_REQUEST_TIMEOUT_MS, - ...(isHttps && this.skipTlsVerify ? { rejectUnauthorized: false } : {}), - }; - - return new Promise((resolve, reject) => { - const req = transport.request(requestOptions, res => { - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('error', err => { - this.logger.error( - `Keycloak token response stream error: ${err.message}`, - ); - reject(err); - }); - res.on('end', () => { - const raw = Buffer.concat(chunks).toString('utf-8'); - if (!res.statusCode || res.statusCode >= 400) { - let detail = ''; - try { - detail = raw.substring(0, 500); - } catch { - /* ignore */ - } - this.logger.error( - `Keycloak token request failed: status ${res.statusCode} from ${this.tokenEndpoint}${detail ? ` - ${detail}` : ''}`, - ); - reject( - new Error( - `Keycloak token request failed with status ${res.statusCode} from ${this.tokenEndpoint}. ` + - `Verify the client "${this.clientId}" is configured for client_credentials grant in Keycloak.`, - ), - ); - return; - } - - try { - const parsed: KeycloakTokenResponse = JSON.parse(raw); - - if ( - !parsed.access_token || - typeof parsed.access_token !== 'string' - ) { - reject( - new Error('Keycloak returned empty or missing access_token'), - ); - return; - } - - const expiresIn = - typeof parsed.expires_in === 'number' && - Number.isFinite(parsed.expires_in) - ? parsed.expires_in - : MIN_TOKEN_LIFETIME_SECONDS + this.tokenExpiryBufferSeconds; - - if (this.generation !== startGen) { - resolve(parsed.access_token); - return; - } - - this.cachedToken = parsed.access_token; - - const effectiveLifetime = Math.max( - expiresIn - this.tokenExpiryBufferSeconds, - MIN_TOKEN_LIFETIME_SECONDS, - ); - this.expiresAt = Date.now() + effectiveLifetime * 1000; - - if (expiresIn <= this.tokenExpiryBufferSeconds) { - this.logger.warn( - `Keycloak token expires_in (${expiresIn}s) is <= buffer (${this.tokenExpiryBufferSeconds}s); using minimum lifetime of ${MIN_TOKEN_LIFETIME_SECONDS}s`, - ); - } else { - this.logger.debug( - `Acquired Keycloak token, expires in ${expiresIn}s`, - ); - } - resolve(parsed.access_token); - } catch { - reject(new Error('Failed to parse Keycloak token response')); - } - }); - }); - - req.on('timeout', () => { - req.destroy(); - this.logger.error( - `Keycloak token request timed out after ${TOKEN_REQUEST_TIMEOUT_MS}ms`, - ); - reject( - new Error( - `Keycloak token request timed out after ${TOKEN_REQUEST_TIMEOUT_MS}ms`, - ), - ); - }); - - req.on('error', err => { - this.logger.error(`Keycloak token request error: ${err.message}`); - reject(err); - }); - - req.write(body); - req.end(); - }); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/adminMethods.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/adminMethods.ts deleted file mode 100644 index 8ae43a1bbb0..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/adminMethods.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { KAGENTI_API_TIMEOUT_MS } from './requestCore'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/agentMethods.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/agentMethods.ts deleted file mode 100644 index 8ae43a1bbb0..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/agentMethods.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { KAGENTI_API_TIMEOUT_MS } from './requestCore'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/chatMethods.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/chatMethods.ts deleted file mode 100644 index 66875f004ef..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/chatMethods.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { KAGENTI_STREAM_TIMEOUT_MS } from './requestCore'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/requestCore.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/requestCore.ts deleted file mode 100644 index ab3ad9f4f76..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/requestCore.ts +++ /dev/null @@ -1,284 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as http from 'http'; -import * as https from 'https'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { KeycloakTokenManager } from './KeycloakTokenManager'; - -export const KAGENTI_API_TIMEOUT_MS = 30_000; -export const KAGENTI_STREAM_TIMEOUT_MS = 300_000; - -const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]); - -export interface RequestCoreContext { - baseUrl: string; - isHttps: boolean; - httpAgent: http.Agent | https.Agent; - tokenManager: KeycloakTokenManager; - logger: LoggerService; - requestTimeoutMs: number; - streamTimeoutMs: number; - maxRetries: number; - retryBaseDelayMs: number; - getUserRef: () => string | undefined; -} - -export async function doRequest( - ctx: RequestCoreContext, - method: string, - path: string, - body?: unknown, - skipAuth = false, -): Promise { - const headers: Record = { Accept: 'application/json' }; - if (!skipAuth) { - const token = await ctx.tokenManager.getToken(); - headers.Authorization = `Bearer ${token}`; - } - const userRef = ctx.getUserRef(); - if (userRef) headers['X-Backstage-User'] = userRef; - let payload: string | undefined; - if (body !== undefined) { - payload = JSON.stringify(body); - headers['Content-Type'] = 'application/json'; - headers['Content-Length'] = String(Buffer.byteLength(payload)); - } - const url = new URL(`${ctx.baseUrl}${path}`); - const transport = ctx.isHttps ? https : http; - - return new Promise((resolve, reject) => { - const req = transport.request( - { - method, - hostname: url.hostname, - port: url.port || (ctx.isHttps ? 443 : 80), - path: url.pathname + url.search, - headers, - agent: ctx.httpAgent, - timeout: ctx.requestTimeoutMs, - }, - res => { - const chunks: Buffer[] = []; - res.on('data', (c: Buffer) => chunks.push(c)); - res.on('error', err => reject(err)); - res.on('end', () => { - const raw = Buffer.concat(chunks).toString('utf-8'); - if (!res.statusCode || res.statusCode >= 400) { - let detail = ''; - try { - const p = JSON.parse(raw); - detail = p.detail || p.message || ''; - } catch { - /* */ - } - reject( - new Error( - `Kagenti API error: ${method} ${path} status ${res.statusCode}${detail ? ` - ${detail}` : ''}`, - ), - ); - return; - } - if (res.statusCode >= 300 && res.statusCode < 400) { - reject( - new Error( - `Kagenti API error: ${method} ${path} unexpected redirect status ${res.statusCode}`, - ), - ); - return; - } - if (res.statusCode === 204 || !raw.trim()) { - resolve({} as T); - return; - } - try { - resolve(JSON.parse(raw) as T); - } catch { - reject(new Error(`Failed to parse Kagenti response from ${path}`)); - } - }); - }, - ); - req.on('timeout', () => { - req.destroy(); - reject(new Error(`Kagenti API timeout: ${method} ${path}`)); - }); - req.on('error', reject); - if (payload) req.write(payload); - req.end(); - }); -} - -export async function requestWithRetry( - ctx: RequestCoreContext, - method: string, - path: string, - body?: unknown, - skipAuth = false, - retries?: number, -): Promise { - const maxAttempts = retries ?? ctx.maxRetries; - let lastError: Error | undefined; - for (let attempt = 0; attempt <= maxAttempts; attempt++) { - try { - return await doRequest(ctx, method, path, body, skipAuth); - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - const statusMatch = lastError.message.match(/status (\d+)/); - const status = statusMatch ? Number(statusMatch[1]) : 0; - if (status === 401 && attempt === 0) { - ctx.tokenManager.clearCache(); - ctx.logger.warn( - `Got 401, cleared token cache and retrying ${method} ${path}`, - ); - continue; - } - const isNetworkError = - status === 0 && - /ECONNRESET|ECONNREFUSED|EPIPE|ETIMEDOUT|EAI_AGAIN/i.test( - lastError.message, - ); - if ( - (!RETRYABLE_STATUS_CODES.has(status) && !isNetworkError) || - attempt === maxAttempts - ) - throw lastError; - const delay = ctx.retryBaseDelayMs * Math.pow(2, attempt); - ctx.logger.warn( - `Retrying ${method} ${path} after ${status} (attempt ${attempt + 1} of ${maxAttempts + 1}, delay ${delay}ms)`, - ); - await new Promise(r => setTimeout(r, delay)); - } - } - throw lastError ?? new Error(`Request failed after ${maxAttempts} attempts`); -} - -export async function streamRequest( - ctx: RequestCoreContext, - path: string, - body: unknown, - onLine: (line: string) => void, - signal?: AbortSignal, -): Promise { - const token = await ctx.tokenManager.getTokenForStreaming( - ctx.streamTimeoutMs || 300_000, - ); - const url = new URL(`${ctx.baseUrl}${path}`); - const transport = ctx.isHttps ? https : http; - const payload = JSON.stringify(body); - const userRef = ctx.getUserRef(); - - return new Promise((resolve, reject) => { - let cleanupAbort = () => {}; - const req = transport.request( - { - method: 'POST', - hostname: url.hostname, - port: url.port || (ctx.isHttps ? 443 : 80), - path: url.pathname + url.search, - headers: { - 'Content-Type': 'application/json', - 'Content-Length': String(Buffer.byteLength(payload)), - Authorization: `Bearer ${token}`, - Accept: 'text/event-stream', - ...(userRef && { 'X-Backstage-User': userRef }), - }, - agent: ctx.httpAgent, - timeout: ctx.streamTimeoutMs || 0, - }, - res => { - if (!res.statusCode || res.statusCode >= 300) { - const errChunks: Buffer[] = []; - res.on('data', (c: Buffer) => errChunks.push(c)); - res.on('end', () => { - const raw = Buffer.concat(errChunks).toString('utf-8'); - let detail = ''; - try { - const p = JSON.parse(raw); - detail = p.detail || p.message || ''; - } catch { - /* */ - } - reject( - new Error( - `Kagenti stream request failed: status ${res.statusCode}${detail ? ` - ${detail}` : ''}`, - ), - ); - }); - return; - } - let buffer = ''; - res.setEncoding('utf-8'); - res.on('data', (chunk: string) => { - buffer += chunk; - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.startsWith('data: ')) { - const data = trimmed.substring(6); - if (data !== '[DONE]') onLine(data); - } - } - }); - res.on('end', () => { - if (buffer.trim().startsWith('data: ')) { - const data = buffer.trim().substring(6); - if (data !== '[DONE]') onLine(data); - } - cleanupAbort(); - resolve(); - }); - res.on('error', err => { - cleanupAbort(); - reject(err); - }); - }, - ); - req.on('error', err => { - cleanupAbort(); - reject( - new Error( - `Kagenti stream connection error to ${url.href}: ${err instanceof Error ? err.message : String(err)}`, - ), - ); - }); - req.on('timeout', () => { - req.destroy(); - cleanupAbort(); - reject( - new Error( - `Kagenti stream request timed out after ${ctx.streamTimeoutMs}ms to ${url.href}`, - ), - ); - }); - if (signal) { - const onAbort = () => { - req.destroy(); - reject(new Error('Stream aborted by client')); - }; - if (signal.aborted) { - req.destroy(); - reject(new Error('Stream aborted by client')); - return; - } - signal.addEventListener('abort', onAbort, { once: true }); - cleanupAbort = () => signal.removeEventListener('abort', onAbort); - } - req.write(payload); - req.end(); - }); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/sandboxFileOps.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/sandboxFileOps.ts deleted file mode 100644 index 4fc51af5232..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/sandboxFileOps.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { KagentiApiClient } from './KagentiApiClient'; -import type { DirectoryListing, FileContent, PodStorageStats } from './types'; -import { SANDBOX_PREFIX as P, encodePathSegment as e } from './utils'; - -export function browseFiles( - api: KagentiApiClient, - namespace: string, - agentName: string, - path = '/', -): Promise { - return api.request( - 'GET', - `${P}/${e(namespace)}/files/${e(agentName)}?path=${e(path)}`, - ); -} - -export function listDirectory( - api: KagentiApiClient, - namespace: string, - agentName: string, - path = '/', -): Promise { - return api.request( - 'GET', - `${P}/${e(namespace)}/files/${e(agentName)}/list?path=${e(path)}`, - ); -} - -export function getFileContent( - api: KagentiApiClient, - namespace: string, - agentName: string, - path: string, -): Promise { - return api.request( - 'GET', - `${P}/${e(namespace)}/files/${e(agentName)}/content?path=${e(path)}`, - ); -} - -export function browseContextFiles( - api: KagentiApiClient, - namespace: string, - agentName: string, - contextId: string, - path = '/', -): Promise { - return api.request( - 'GET', - `${P}/${e(namespace)}/files/${e(agentName)}/${e(contextId)}?path=${e(path)}`, - ); -} - -export function getStorageStats( - api: KagentiApiClient, - namespace: string, - agentName: string, -): Promise { - return api.request('GET', `${P}/${e(namespace)}/stats/${e(agentName)}`); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/sandboxSidecarOps.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/sandboxSidecarOps.ts deleted file mode 100644 index d4b4ab5b64f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/sandboxSidecarOps.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { KagentiApiClient } from './KagentiApiClient'; -import type { - SidecarResponse, - EnableSidecarRequest, - SidecarConfigUpdateRequest, -} from './types'; -import { SANDBOX_PREFIX as P, encodePathSegment as e } from './utils'; - -export function listSidecars( - api: KagentiApiClient, - namespace: string, - contextId: string, -): Promise { - return api.request( - 'GET', - `${P}/${e(namespace)}/sessions/${e(contextId)}/sidecars`, - ); -} - -export function enableSidecar( - api: KagentiApiClient, - namespace: string, - contextId: string, - sidecarType: string, - opts?: EnableSidecarRequest, -): Promise { - return api.request( - 'POST', - `${P}/${e(namespace)}/sessions/${e(contextId)}/sidecars/${e(sidecarType)}/enable`, - opts, - ); -} - -export function disableSidecar( - api: KagentiApiClient, - namespace: string, - contextId: string, - sidecarType: string, -): Promise<{ status: string; sidecar_type: string }> { - return api.request( - 'POST', - `${P}/${e(namespace)}/sessions/${e(contextId)}/sidecars/${e(sidecarType)}/disable`, - ); -} - -export function updateSidecarConfig( - api: KagentiApiClient, - namespace: string, - contextId: string, - sidecarType: string, - config: SidecarConfigUpdateRequest, -): Promise { - return api.request( - 'PUT', - `${P}/${e(namespace)}/sessions/${e(contextId)}/sidecars/${e(sidecarType)}/config`, - config, - ); -} - -export function resetSidecar( - api: KagentiApiClient, - namespace: string, - contextId: string, - sidecarType: string, -): Promise> { - return api.request( - 'POST', - `${P}/${e(namespace)}/sessions/${e(contextId)}/sidecars/${e(sidecarType)}/reset`, - ); -} - -export function streamObservations( - api: KagentiApiClient, - namespace: string, - contextId: string, - sidecarType: string, - onLine: (line: string) => void, - signal?: AbortSignal, -): Promise { - return api.streamRequest( - `${P}/${e(namespace)}/sessions/${e(contextId)}/sidecars/${e(sidecarType)}/observations`, - {}, - onLine, - signal, - ); -} - -export function approveSidecar( - api: KagentiApiClient, - namespace: string, - contextId: string, - sidecarType: string, - msgId: string, -): Promise<{ status: string; id: string }> { - return api.request( - 'POST', - `${P}/${e(namespace)}/sessions/${e(contextId)}/sidecars/${e(sidecarType)}/approve/${e(msgId)}`, - ); -} - -export function denySidecar( - api: KagentiApiClient, - namespace: string, - contextId: string, - sidecarType: string, - msgId: string, -): Promise<{ status: string; id: string }> { - return api.request( - 'POST', - `${P}/${e(namespace)}/sessions/${e(contextId)}/sidecars/${e(sidecarType)}/deny/${e(msgId)}`, - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/toolMethods.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/toolMethods.ts deleted file mode 100644 index 8ae43a1bbb0..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/toolMethods.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { KAGENTI_API_TIMEOUT_MS } from './requestCore'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/types.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/types.ts deleted file mode 100644 index 4378a7b6830..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/types.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export type { - HealthResponse, - AuthConfigResponse, - AuthStatusResponse, - UserInfoResponse, - FeatureFlagsResponse, - DashboardConfigResponse, - NamespaceListResponse, - ChatRequest, - ChatResponse, - AgentCardResponse, - ResourceLabels, - AgentSummary, - AgentListResponse, - AgentDetailResponse, - RouteStatusResponse, - CreateAgentRequest, - CreateAgentResponse, - DeleteResponse, - MigratableAgentInfo, - ListMigratableAgentsResponse, - MigrateAgentRequest, - MigrateAgentResponse, - MigrateAllResponse, - ParseEnvRequest, - ParseEnvResponse, - FetchEnvUrlRequest, - FetchEnvUrlResponse, - LlmModelResponse, - TeamCreateRequest, - TeamResponse, - KeyCreateRequest, - KeyInfo, - IntegrationListResponse, - CreateIntegrationRequest, - TriggerRequest, - TriggerResponse, - ContextHistoryItem, - ContextHistoryMessage, - ContextHistoryArtifact, - ContextHistoryListResponse, - KeycloakTokenResponse, - SessionTokenUsageResponse, - SessionTreeUsageResponse, - PaginatedEventsResponse, - PaginatedTasksResponse, -} from './typesCore'; - -export type { - ClusterBuildStrategiesResponse, - FinalizeShipwrightBuildRequest, - ShipwrightBuildListItem, - ShipwrightBuildListResponse, - ShipwrightBuildStatusResponse, - BuildStatusCondition, - ShipwrightBuildRunStatusResponse, - ResourceConfigFromBuild, - ShipwrightBuildInfoResponse, - TriggerBuildRunResponse, - ToolSummary, - ToolListResponse, - ToolDetailResponse, - CreateToolRequest, - CreateToolResponse, - FinalizeToolBuildRequest, - MCPToolsResponse, - MCPInvokeRequest, - MCPInvokeResponse, -} from './typesBuildTools'; - -export type { - SandboxSessionListResponse, - SandboxSessionDetailResponse, - SandboxChatRequest, - SandboxChatResponse, - SandboxCreateRequest, - SandboxCreateResponse, - SandboxAgentInfoResponse, - RenameRequest, - VisibilityRequest, - CleanupResponse, - SessionChainResponse, - DirectoryListing, - FileContent, - PodStorageStats, - SidecarResponse, - EnableSidecarRequest, - SidecarConfigUpdateRequest, -} from './typesSandbox'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/typesBuildTools.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/typesBuildTools.ts deleted file mode 100644 index 7650415a528..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/typesBuildTools.ts +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - KagentiToolSummary, - KagentiToolDetail, - KagentiCreateToolRequest, - KagentiCreateToolResponse, - KagentiFinalizeToolBuildRequest, - KagentiMcpInvokeResponse, - KagentiEnvVar, - KagentiServicePort, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -// -- Shipwright Builds -------------------------------------------------------- - -export interface ClusterBuildStrategiesResponse { - strategies: Array<{ name: string; description?: string }>; -} - -export interface FinalizeShipwrightBuildRequest { - protocol?: string; - framework?: string; - envVars?: KagentiEnvVar[]; - servicePorts?: KagentiServicePort[]; - createHttpRoute?: boolean; - authBridgeEnabled?: boolean; - spireEnabled?: boolean; - imagePullSecret?: string; -} - -export interface ShipwrightBuildListItem { - name: string; - namespace: string; - resourceType?: string; - registered: boolean; - strategy?: string; - gitUrl?: string; - gitRevision?: string; - contextDir?: string; - outputImage?: string; - creationTimestamp?: string; -} - -export interface ShipwrightBuildListResponse { - items: ShipwrightBuildListItem[]; -} - -export interface ShipwrightBuildStatusResponse { - name: string; - namespace: string; - registered: boolean; - reason?: string; - message?: string; -} - -export interface BuildStatusCondition { - type: string; - status: string; - reason?: string; - message?: string; - lastTransitionTime?: string; -} - -export interface ShipwrightBuildRunStatusResponse { - name: string; - namespace: string; - buildName: string; - phase: string; - startTime?: string; - completionTime?: string; - outputImage?: string; - outputDigest?: string; - failureMessage?: string; - conditions: BuildStatusCondition[]; -} - -export interface ResourceConfigFromBuild { - protocol?: string; - framework?: string; - createHttpRoute?: boolean; - registrySecret?: string; - envVars?: KagentiEnvVar[]; - servicePorts?: KagentiServicePort[]; -} - -export interface ShipwrightBuildInfoResponse { - name: string; - namespace: string; - buildRegistered: boolean; - buildReason?: string; - buildMessage?: string; - outputImage: string; - strategy: string; - gitUrl: string; - gitRevision: string; - contextDir: string; - hasBuildRun: boolean; - buildRunName?: string; - buildRunPhase?: string; - buildRunStartTime?: string; - buildRunCompletionTime?: string; - buildRunOutputImage?: string; - buildRunOutputDigest?: string; - buildRunFailureMessage?: string; - agentConfig?: ResourceConfigFromBuild; - toolConfig?: ResourceConfigFromBuild; -} - -export interface TriggerBuildRunResponse { - success: boolean; - buildRunName: string; - namespace: string; - buildName: string; - message?: string; -} - -// -- Tools -------------------------------------------------------------------- - -export type ToolSummary = KagentiToolSummary; - -export interface ToolListResponse { - items: ToolSummary[]; -} - -export type ToolDetailResponse = KagentiToolDetail; - -export type CreateToolRequest = KagentiCreateToolRequest; - -export type CreateToolResponse = KagentiCreateToolResponse; - -export type FinalizeToolBuildRequest = KagentiFinalizeToolBuildRequest; - -export interface MCPToolsResponse { - tools: Array<{ - name: string; - description?: string; - input_schema?: Record; - }>; -} - -export interface MCPInvokeRequest { - tool_name: string; - arguments?: Record; -} - -export type MCPInvokeResponse = KagentiMcpInvokeResponse; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/typesCore.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/typesCore.ts deleted file mode 100644 index f31c4e35e17..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/typesCore.ts +++ /dev/null @@ -1,299 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { AgentCard as AdkAgentCard } from '@kagenti/adk'; -import type { - KagentiResourceLabels, - KagentiAgentSummary, - KagentiAgentDetail, - KagentiFeatureFlags, - KagentiDashboardConfig, - KagentiAuthConfig, - KagentiCreateAgentRequest, - KagentiCreateAgentResponse, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -// -- Health ------------------------------------------------------------------- - -export interface HealthResponse { - status: string; -} - -// -- Auth --------------------------------------------------------------------- - -export type AuthConfigResponse = KagentiAuthConfig; - -export interface AuthStatusResponse { - enabled: boolean; - authenticated: boolean; - keycloak_url?: string; - realm?: string; - client_id?: string; -} - -export interface UserInfoResponse { - username: string; - email?: string; - roles: string[]; - authenticated: boolean; -} - -// -- Config ------------------------------------------------------------------- - -export type FeatureFlagsResponse = KagentiFeatureFlags; - -export type DashboardConfigResponse = KagentiDashboardConfig; - -// -- Namespaces --------------------------------------------------------------- - -export interface NamespaceListResponse { - namespaces: string[]; -} - -// -- Chat --------------------------------------------------------------------- - -export interface ChatRequest { - message: string; - session_id?: string; - metadata?: Record; - parts?: Array>; - contextId?: string; -} - -export interface ChatResponse { - content: string; - session_id: string; - is_complete: boolean; -} - -export type AgentCardResponse = AdkAgentCard; - -// -- Agents ------------------------------------------------------------------- - -export type ResourceLabels = KagentiResourceLabels; - -export type AgentSummary = KagentiAgentSummary; - -export interface AgentListResponse { - items: AgentSummary[]; -} - -export type AgentDetailResponse = KagentiAgentDetail; - -export interface RouteStatusResponse { - hasRoute: boolean; -} - -export type CreateAgentRequest = KagentiCreateAgentRequest; - -export type CreateAgentResponse = KagentiCreateAgentResponse; - -export interface DeleteResponse { - success: boolean; - message: string; -} - -export interface MigratableAgentInfo { - name: string; - namespace: string; - status: string; - has_deployment: boolean; - labels: ResourceLabels; - description?: string; -} - -export interface ListMigratableAgentsResponse { - agents: MigratableAgentInfo[]; - total: number; - already_migrated: number; -} - -export interface MigrateAgentRequest { - delete_old?: boolean; -} - -export interface MigrateAgentResponse { - success: boolean; - migrated: boolean; - name: string; - namespace: string; - message: string; - deployment_created: boolean; - service_created: boolean; - agent_crd_deleted: boolean; -} - -export interface MigrateAllResponse { - namespace: string; - dry_run: boolean; - delete_old: boolean; - total: number; - migrated: Array>; - skipped: Array>; - failed: Array>; -} - -// -- Namespaces --------------------------------------------------------------- - -export interface ParseEnvRequest { - content: string; -} - -export interface ParseEnvResponse { - envVars: Array>; - warnings?: string[]; -} - -export interface FetchEnvUrlRequest { - url: string; -} - -export interface FetchEnvUrlResponse { - content: string; - url: string; -} - -// -- Models ------------------------------------------------------------------- - -export interface LlmModelResponse { - id: string; - name?: string; - [key: string]: unknown; -} - -// -- LLM Keys ---------------------------------------------------------------- - -export interface TeamCreateRequest { - namespace: string; - [key: string]: unknown; -} - -export interface TeamResponse { - teamId: string; - namespace: string; - [key: string]: unknown; -} - -export interface KeyCreateRequest { - namespace: string; - agentName: string; - [key: string]: unknown; -} - -export interface KeyInfo { - alias?: string; - agent?: string; - namespace?: string; - [key: string]: unknown; -} - -// -- Integrations ------------------------------------------------------------- - -export interface IntegrationListResponse { - [key: string]: unknown; -} - -export interface CreateIntegrationRequest { - name: string; - namespace: string; - [key: string]: unknown; -} - -// -- Triggers ----------------------------------------------------------------- - -export interface TriggerRequest { - type: 'cron' | 'webhook' | 'alert'; - namespace: string; - skill?: string; - schedule?: string; - ttl_hours?: number; - [key: string]: unknown; -} - -export interface TriggerResponse { - sandbox_claim?: string; - namespace: string; - [key: string]: unknown; -} - -// -- Contexts API ------------------------------------------------------------- - -export interface ContextHistoryItem { - id: string; - context_id: string; - created_at: string; - kind: 'artifact' | 'message'; - data: ContextHistoryMessage | ContextHistoryArtifact; -} - -export interface ContextHistoryMessage { - role: string; - parts: Array<{ - type?: string; - text?: string; - [key: string]: unknown; - }>; - metadata?: Record; - referenceTaskIds?: string[]; -} - -export interface ContextHistoryArtifact { - artifactId: string; - name?: string; - description?: string; - parts: Array<{ - type?: string; - text?: string; - [key: string]: unknown; - }>; - metadata?: Record; -} - -export interface ContextHistoryListResponse { - total_count: number; - has_more: boolean; - next_page_token: string | null; - items: ContextHistoryItem[]; -} - -// -- Keycloak Token ----------------------------------------------------------- - -export interface KeycloakTokenResponse { - access_token: string; - expires_in: number; - token_type: string; - scope?: string; -} - -// -- Token Usage -------------------------------------------------------------- - -export interface SessionTokenUsageResponse { - [key: string]: unknown; -} - -export interface SessionTreeUsageResponse { - [key: string]: unknown; -} - -// -- Events ------------------------------------------------------------------- - -export interface PaginatedEventsResponse { - [key: string]: unknown; -} - -export interface PaginatedTasksResponse { - [key: string]: unknown; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/typesSandbox.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/typesSandbox.ts deleted file mode 100644 index c1af45deca0..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/typesSandbox.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// -- Sandbox ------------------------------------------------------------------ - -export interface SandboxSessionListResponse { - items: Array<{ - contextId: string; - agentName?: string; - status: string; - title?: string; - visibility?: 'private' | 'namespace'; - createdAt?: string; - updatedAt?: string; - [key: string]: unknown; - }>; - total?: number; - limit?: number; - offset?: number; -} - -export interface SandboxSessionDetailResponse { - [key: string]: unknown; -} - -export interface SandboxChatRequest { - message: string; - session_id?: string; - agent_name?: string; - skill?: string; -} - -export interface SandboxChatResponse { - content: string; - context_id: string; - task_id?: string; - status: string; -} - -export interface SandboxCreateRequest { - [key: string]: unknown; -} - -export interface SandboxCreateResponse { - [key: string]: unknown; -} - -export interface SandboxAgentInfoResponse { - name: string; - namespace: string; - sessionCount?: number; - [key: string]: unknown; -} - -export interface RenameRequest { - title: string; -} - -export interface VisibilityRequest { - visibility: 'private' | 'namespace'; -} - -export interface CleanupResponse { - cleaned: number; -} - -export interface SessionChainResponse { - [key: string]: unknown; -} - -export interface DirectoryListing { - [key: string]: unknown; -} - -export interface FileContent { - [key: string]: unknown; -} - -export interface PodStorageStats { - [key: string]: unknown; -} - -// -- Sidecars ----------------------------------------------------------------- - -export interface SidecarResponse { - sidecarType: string; - enabled: boolean; - config?: Record; - [key: string]: unknown; -} - -export interface EnableSidecarRequest { - auto_approve?: boolean; - config?: Record; - agent_name?: string; -} - -export interface SidecarConfigUpdateRequest { - interval?: number; - counters?: Record; - thresholds?: Record; - auto_approve?: boolean; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/utils.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/utils.ts deleted file mode 100644 index 98d146e44a6..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/client/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export const API_PREFIX = '/api/v1'; -export const SANDBOX_PREFIX = `${API_PREFIX}/sandbox`; - -/** Shorthand for `encodeURIComponent` used in URL path segments. */ -export function encodePathSegment(s: string): string { - return encodeURIComponent(s); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/config/KagentiConfigLoader.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/config/KagentiConfigLoader.test.ts deleted file mode 100644 index 34ed266c825..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/config/KagentiConfigLoader.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ConfigReader } from '@backstage/config'; -import { loadKagentiConfig } from './KagentiConfigLoader'; - -function makeConfig(overrides: Record = {}) { - return new ConfigReader({ - augment: { - kagenti: { - baseUrl: 'https://kagenti.example.com', - auth: { - tokenEndpoint: - 'https://keycloak.example.com/realms/kagenti/protocol/openid-connect/token', - clientId: 'kagenti-client', - clientSecret: 'secret-123', - }, - ...overrides, - }, - }, - }); -} - -describe('loadKagentiConfig', () => { - it('loads a valid full config with defaults', () => { - const result = loadKagentiConfig(makeConfig()); - expect(result).toEqual({ - baseUrl: 'https://kagenti.example.com', - namespace: 'default', - namespaces: undefined, - showAllNamespaces: true, - agentName: undefined, - agents: undefined, - skipTlsVerify: false, - verboseStreamLogging: false, - validateResponses: false, - requestTimeoutMs: 30_000, - streamTimeoutMs: 300_000, - maxRetries: 3, - retryBaseDelayMs: 1000, - tokenExpiryBufferSeconds: 60, - extensionBaseUrl: 'https://a2a-extensions.adk.kagenti.dev', - dashboards: { - mcpInspector: undefined, - mcpProxy: undefined, - traces: undefined, - network: undefined, - keycloakConsole: undefined, - domainName: undefined, - }, - sandbox: { - sessionTtlMinutes: undefined, - defaultSkill: undefined, - sidecar: { autoApprove: false }, - }, - migration: { deleteOld: false, dryRun: false }, - pagination: { defaultLimit: 50, maxLimit: 200 }, - featureOverrides: { - sandbox: undefined, - integrations: undefined, - triggers: undefined, - }, - auth: { - tokenEndpoint: - 'https://keycloak.example.com/realms/kagenti/protocol/openid-connect/token', - clientId: 'kagenti-client', - clientSecret: 'secret-123', - }, - }); - }); - - it('strips trailing slashes from baseUrl', () => { - const result = loadKagentiConfig( - makeConfig({ baseUrl: 'https://kagenti.example.com///' }), - ); - expect(result.baseUrl).toBe('https://kagenti.example.com'); - }); - - it('accepts optional overrides', () => { - const result = loadKagentiConfig( - makeConfig({ - namespace: 'team1', - agentName: 'weather-bot', - skipTlsVerify: true, - }), - ); - expect(result.namespace).toBe('team1'); - expect(result.agentName).toBe('weather-bot'); - expect(result.skipTlsVerify).toBe(true); - }); - - it('loads new gap fields from config', () => { - const result = loadKagentiConfig( - makeConfig({ - namespaces: ['ns1', 'ns2'], - showAllNamespaces: false, - agents: ['ns1/bot1', 'ns2/bot2'], - verboseStreamLogging: true, - dashboards: { - mcpInspector: 'https://mcp.example.com', - traces: 'https://traces.example.com', - }, - sandbox: { - sessionTtlMinutes: 120, - defaultSkill: 'coding', - sidecar: { autoApprove: true }, - }, - migration: { deleteOld: true, dryRun: true }, - pagination: { defaultLimit: 25, maxLimit: 100 }, - }), - ); - expect(result.namespaces).toEqual(['ns1', 'ns2']); - expect(result.showAllNamespaces).toBe(false); - expect(result.agents).toEqual(['ns1/bot1', 'ns2/bot2']); - expect(result.verboseStreamLogging).toBe(true); - expect(result.dashboards.mcpInspector).toBe('https://mcp.example.com'); - expect(result.dashboards.traces).toBe('https://traces.example.com'); - expect(result.dashboards.mcpProxy).toBeUndefined(); - expect(result.sandbox.sessionTtlMinutes).toBe(120); - expect(result.sandbox.defaultSkill).toBe('coding'); - expect(result.sandbox.sidecar.autoApprove).toBe(true); - expect(result.migration.deleteOld).toBe(true); - expect(result.migration.dryRun).toBe(true); - expect(result.pagination.defaultLimit).toBe(25); - expect(result.pagination.maxLimit).toBe(100); - }); - - it('throws when augment.kagenti is missing', () => { - const config = new ConfigReader({ augment: {} }); - expect(() => loadKagentiConfig(config)).toThrow(/Missing required config/); - }); - - it('throws when baseUrl is missing', () => { - const config = new ConfigReader({ - augment: { - kagenti: { - auth: { - tokenEndpoint: 'https://kc.example.com/token', - clientId: 'c', - clientSecret: 's', - }, - }, - }, - }); - expect(() => loadKagentiConfig(config)).toThrow(/baseUrl/); - }); - - it('throws when auth block is missing', () => { - const config = new ConfigReader({ - augment: { kagenti: { baseUrl: 'https://k.example.com' } }, - }); - expect(() => loadKagentiConfig(config)).toThrow(/auth/); - }); - - it('throws when auth.clientSecret is missing', () => { - const config = new ConfigReader({ - augment: { - kagenti: { - baseUrl: 'https://k.example.com', - auth: { - tokenEndpoint: 'https://kc.example.com/token', - clientId: 'c', - }, - }, - }, - }); - expect(() => loadKagentiConfig(config)).toThrow(/clientSecret/); - }); - - it('throws when retryBaseDelayMs is zero', () => { - expect(() => - loadKagentiConfig(makeConfig({ retryBaseDelayMs: 0 })), - ).toThrow(/retryBaseDelayMs must be positive/); - }); - - it('throws when tokenExpiryBufferSeconds is zero', () => { - expect(() => - loadKagentiConfig(makeConfig({ tokenExpiryBufferSeconds: 0 })), - ).toThrow(/tokenExpiryBufferSeconds must be positive/); - }); - - it('throws when pagination.defaultLimit is zero', () => { - expect(() => - loadKagentiConfig( - makeConfig({ pagination: { defaultLimit: 0, maxLimit: 100 } }), - ), - ).toThrow(/defaultLimit must be positive/); - }); - - it('throws when pagination.maxLimit < defaultLimit', () => { - expect(() => - loadKagentiConfig( - makeConfig({ pagination: { defaultLimit: 50, maxLimit: 10 } }), - ), - ).toThrow(/maxLimit must be >= defaultLimit/); - }); - - it('accepts custom extensionBaseUrl', () => { - const result = loadKagentiConfig( - makeConfig({ extensionBaseUrl: 'https://custom.example.com/ext/' }), - ); - expect(result.extensionBaseUrl).toBe('https://custom.example.com/ext'); - }); - - it('uses default extensionBaseUrl when not specified', () => { - const result = loadKagentiConfig(makeConfig()); - expect(result.extensionBaseUrl).toBe( - 'https://a2a-extensions.adk.kagenti.dev', - ); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/config/KagentiConfigLoader.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/config/KagentiConfigLoader.ts deleted file mode 100644 index 07efd650ae8..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/config/KagentiConfigLoader.ts +++ /dev/null @@ -1,251 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { RootConfigService } from '@backstage/backend-plugin-api'; -import { InputError } from '@backstage/errors'; - -function stripTrailingSlashes(s: string): string { - let end = s.length; - while (end > 0 && s[end - 1] === '/') end--; - return end === s.length ? s : s.slice(0, end); -} - -export interface KagentiDashboardOverrides { - readonly mcpInspector?: string; - readonly mcpProxy?: string; - readonly traces?: string; - readonly network?: string; - readonly keycloakConsole?: string; - readonly domainName?: string; -} - -export interface KagentiSandboxDefaults { - readonly sessionTtlMinutes?: number; - readonly defaultSkill?: string; - readonly sidecar: { - readonly autoApprove: boolean; - }; -} - -export interface KagentiMigrationDefaults { - readonly deleteOld: boolean; - readonly dryRun: boolean; -} - -export interface KagentiPaginationDefaults { - readonly defaultLimit: number; - readonly maxLimit: number; -} - -export interface KagentiFeatureOverrides { - readonly sandbox?: boolean; - readonly integrations?: boolean; - readonly triggers?: boolean; -} - -export interface KagentiConfig { - readonly baseUrl: string; - readonly namespace: string; - readonly namespaces?: readonly string[]; - readonly showAllNamespaces: boolean; - readonly agentName?: string; - readonly agents?: readonly string[]; - readonly skipTlsVerify: boolean; - readonly verboseStreamLogging: boolean; - readonly validateResponses: boolean; - readonly requestTimeoutMs: number; - readonly streamTimeoutMs: number; - readonly maxRetries: number; - readonly retryBaseDelayMs: number; - readonly tokenExpiryBufferSeconds: number; - readonly extensionBaseUrl: string; - readonly dashboards: KagentiDashboardOverrides; - readonly sandbox: KagentiSandboxDefaults; - readonly migration: KagentiMigrationDefaults; - readonly pagination: KagentiPaginationDefaults; - readonly featureOverrides: KagentiFeatureOverrides; - readonly auth: { - readonly tokenEndpoint: string; - readonly clientId: string; - readonly clientSecret: string; - }; -} - -export function loadKagentiConfig(config: RootConfigService): KagentiConfig { - const kagenti = config.getOptionalConfig('augment.kagenti'); - if (!kagenti) { - throw new InputError( - 'Missing required config: augment.kagenti. ' + - 'Configure baseUrl and auth settings in app-config.yaml.', - ); - } - - const baseUrl = kagenti.getOptionalString('baseUrl'); - if (!baseUrl) { - throw new InputError('Missing required config: augment.kagenti.baseUrl'); - } - try { - // eslint-disable-next-line no-new - new URL(baseUrl); - } catch { - throw new InputError( - `Invalid augment.kagenti.baseUrl: "${baseUrl}" is not a valid URL. ` + - 'Expected format: https://kagenti-api.example.com', - ); - } - - const authConfig = kagenti.getOptionalConfig('auth'); - if (!authConfig) { - throw new InputError( - 'Missing required config: augment.kagenti.auth. ' + - 'Configure tokenEndpoint, clientId, and clientSecret.', - ); - } - - const tokenEndpoint = authConfig.getOptionalString('tokenEndpoint'); - if (!tokenEndpoint) { - throw new InputError( - 'Missing required config: augment.kagenti.auth.tokenEndpoint', - ); - } - try { - // eslint-disable-next-line no-new - new URL(tokenEndpoint); - } catch { - throw new InputError( - `Invalid augment.kagenti.auth.tokenEndpoint: "${tokenEndpoint}" is not a valid URL. ` + - 'Expected format: https://keycloak.example.com/realms/kagenti/protocol/openid-connect/token', - ); - } - - const clientId = authConfig.getOptionalString('clientId'); - if (!clientId) { - throw new InputError( - 'Missing required config: augment.kagenti.auth.clientId', - ); - } - - const clientSecret = authConfig.getOptionalString('clientSecret'); - if (!clientSecret) { - throw new InputError( - 'Missing required config: augment.kagenti.auth.clientSecret', - ); - } - - const requestTimeoutMs = - kagenti.getOptionalNumber('requestTimeoutMs') ?? 30_000; - const streamTimeoutMs = - kagenti.getOptionalNumber('streamTimeoutMs') ?? 300_000; - const maxRetries = kagenti.getOptionalNumber('maxRetries') ?? 3; - const retryBaseDelayMs = - kagenti.getOptionalNumber('retryBaseDelayMs') ?? 1000; - const tokenExpiryBufferSeconds = - kagenti.getOptionalNumber('tokenExpiryBufferSeconds') ?? 60; - const extensionBaseUrl = stripTrailingSlashes( - kagenti.getOptionalString('extensionBaseUrl') ?? - 'https://a2a-extensions.adk.kagenti.dev', - ); - - if (requestTimeoutMs <= 0) { - throw new InputError('augment.kagenti.requestTimeoutMs must be positive'); - } - if (streamTimeoutMs <= 0) { - throw new InputError('augment.kagenti.streamTimeoutMs must be positive'); - } - if (maxRetries < 0) { - throw new InputError('augment.kagenti.maxRetries must be non-negative'); - } - if (retryBaseDelayMs <= 0) { - throw new InputError('augment.kagenti.retryBaseDelayMs must be positive'); - } - if (tokenExpiryBufferSeconds <= 0) { - throw new InputError( - 'augment.kagenti.tokenExpiryBufferSeconds must be positive', - ); - } - - const namespacesRaw = kagenti.getOptionalStringArray('namespaces'); - - const dashboardsCfg = kagenti.getOptionalConfig('dashboards'); - const sandboxCfg = kagenti.getOptionalConfig('sandbox'); - const sidecarCfg = sandboxCfg?.getOptionalConfig('sidecar'); - const migrationCfg = kagenti.getOptionalConfig('migration'); - const paginationCfg = kagenti.getOptionalConfig('pagination'); - const featureOverridesCfg = kagenti.getOptionalConfig('featureOverrides'); - - const paginationDefaultLimit = - paginationCfg?.getOptionalNumber('defaultLimit') ?? 50; - const paginationMaxLimit = - paginationCfg?.getOptionalNumber('maxLimit') ?? 200; - if (paginationDefaultLimit <= 0) { - throw new InputError( - 'augment.kagenti.pagination.defaultLimit must be positive', - ); - } - if (paginationMaxLimit < paginationDefaultLimit) { - throw new InputError( - 'augment.kagenti.pagination.maxLimit must be >= defaultLimit', - ); - } - - return { - baseUrl: stripTrailingSlashes(baseUrl), - namespace: kagenti.getOptionalString('namespace') ?? 'default', - namespaces: namespacesRaw ?? undefined, - showAllNamespaces: kagenti.getOptionalBoolean('showAllNamespaces') ?? true, - agentName: kagenti.getOptionalString('agentName'), - agents: kagenti.getOptionalStringArray('agents') ?? undefined, - skipTlsVerify: kagenti.getOptionalBoolean('skipTlsVerify') ?? false, - verboseStreamLogging: - kagenti.getOptionalBoolean('verboseStreamLogging') ?? false, - validateResponses: kagenti.getOptionalBoolean('validateResponses') ?? false, - requestTimeoutMs, - streamTimeoutMs, - maxRetries, - retryBaseDelayMs, - tokenExpiryBufferSeconds, - extensionBaseUrl, - dashboards: { - mcpInspector: dashboardsCfg?.getOptionalString('mcpInspector'), - mcpProxy: dashboardsCfg?.getOptionalString('mcpProxy'), - traces: dashboardsCfg?.getOptionalString('traces'), - network: dashboardsCfg?.getOptionalString('network'), - keycloakConsole: dashboardsCfg?.getOptionalString('keycloakConsole'), - domainName: dashboardsCfg?.getOptionalString('domainName'), - }, - sandbox: { - sessionTtlMinutes: sandboxCfg?.getOptionalNumber('sessionTtlMinutes'), - defaultSkill: sandboxCfg?.getOptionalString('defaultSkill'), - sidecar: { - autoApprove: sidecarCfg?.getOptionalBoolean('autoApprove') ?? false, - }, - }, - migration: { - deleteOld: migrationCfg?.getOptionalBoolean('deleteOld') ?? false, - dryRun: migrationCfg?.getOptionalBoolean('dryRun') ?? false, - }, - pagination: { - defaultLimit: paginationDefaultLimit, - maxLimit: paginationMaxLimit, - }, - featureOverrides: { - sandbox: featureOverridesCfg?.getOptionalBoolean('sandbox'), - integrations: featureOverridesCfg?.getOptionalBoolean('integrations'), - triggers: featureOverridesCfg?.getOptionalBoolean('triggers'), - }, - auth: { tokenEndpoint, clientId, clientSecret }, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/index.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/index.ts deleted file mode 100644 index babc96ae435..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { KagentiProvider } from './KagentiProvider'; -export type { KagentiProviderOptions } from './KagentiProvider'; -export { KagentiApiClient } from './client/KagentiApiClient'; -export { KagentiSandboxClient } from './client/KagentiSandboxClient'; -export { KagentiAdminClient } from './client/KagentiAdminClient'; -export { KeycloakTokenManager } from './client/KeycloakTokenManager'; -export { - KagentiStreamNormalizer, - normalizeKagentiEvent, -} from './stream/KagentiStreamNormalizer'; -export { loadKagentiConfig } from './config/KagentiConfigLoader'; -export type { KagentiConfig } from './config/KagentiConfigLoader'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiAgentListing.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiAgentListing.ts deleted file mode 100644 index 911b190134a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiAgentListing.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { KAGENTI_PROVIDER_ID } from './kagentiHelpers'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiApprovalHandler.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiApprovalHandler.ts deleted file mode 100644 index fb511135d3e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiApprovalHandler.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { KagentiApiClient } from './client/KagentiApiClient'; -import { KagentiStreamNormalizer } from './stream/KagentiStreamNormalizer'; - -const DEFAULT_EXTENSION_BASE = 'https://a2a-extensions.adk.kagenti.dev'; - -function secretsUri(base?: string): string { - return `${base ?? DEFAULT_EXTENSION_BASE}/auth/secrets/v1`; -} - -function oauthUri(base?: string): string { - return `${base ?? DEFAULT_EXTENSION_BASE}/auth/oauth/v1`; -} - -export interface ApprovalRequest { - responseId: string; - callId: string; - approved: boolean; - toolName?: string; - toolArguments?: string; - reason?: string; -} - -export interface ApprovalResult { - content: string; - responseId: string; - toolExecuted: boolean; - toolOutput?: string; - pendingApproval?: { - approvalRequestId: string; - toolName: string; - serverLabel?: string; - arguments?: string; - }; - handoff?: { fromAgent: string; toAgent: string }; -} - -function buildApprovalMetadata( - approval: ApprovalRequest, - extensionBaseUrl?: string, -): Record { - if (approval.toolName === 'secrets_response' && approval.toolArguments) { - let secretValues: Record; - try { - secretValues = JSON.parse(approval.toolArguments) as Record< - string, - string - >; - } catch { - throw new Error('Invalid JSON in secrets toolArguments'); - } - const fulfillments: Record = {}; - for (const [key, value] of Object.entries(secretValues)) { - fulfillments[key] = { secret: value }; - } - return { - [secretsUri(extensionBaseUrl)]: { secret_fulfillments: fulfillments }, - }; - } - - if (approval.toolName === 'oauth_confirm') { - return { - [oauthUri(extensionBaseUrl)]: { data: { redirect_uri: 'confirmed' } }, - }; - } - - let parsedArgs: unknown; - if (approval.toolArguments) { - try { - parsedArgs = JSON.parse(approval.toolArguments); - } catch { - throw new Error('Invalid JSON in approval toolArguments'); - } - } - return { - approval: { - callId: approval.callId, - approved: approval.approved, - toolName: approval.toolName, - ...(approval.reason && { reason: approval.reason }), - toolArguments: parsedArgs, - }, - }; -} - -export async function submitApproval( - apiClient: KagentiApiClient, - namespace: string, - name: string, - approval: ApprovalRequest, - extensionBaseUrl?: string, -): Promise { - const contextId = approval.responseId; - const approvalMessage = approval.approved - ? `Approved: ${approval.toolName ?? 'tool call'}` - : `Rejected: ${approval.toolName ?? 'tool call'}`; - - const metadata = buildApprovalMetadata(approval, extensionBaseUrl); - const normalizer = new KagentiStreamNormalizer(); - - let content = ''; - let pendingApproval: ApprovalResult['pendingApproval']; - let handoff: ApprovalResult['handoff']; - let toolExecuted = false; - let toolOutput: string | undefined; - - await apiClient.chatStream( - namespace, - name, - approvalMessage, - undefined, - (line: string) => { - const events = normalizer.normalize(line); - for (const event of events) { - if (event.type === 'stream.text.delta') { - content += event.delta; - } else if (event.type === 'stream.tool.completed') { - toolExecuted = true; - toolOutput = (event as { output?: string }).output; - } else if (event.type === 'stream.tool.approval') { - pendingApproval = { - approvalRequestId: event.callId, - toolName: event.name, - arguments: event.arguments, - }; - } else if (event.type === 'stream.agent.handoff') { - const he = event as { fromAgent?: string; toAgent?: string }; - handoff = { - fromAgent: he.fromAgent ?? 'unknown', - toAgent: he.toAgent ?? 'unknown', - }; - } - } - }, - undefined, - { contextId, metadata }, - ); - - return { - content, - responseId: contextId, - toolExecuted, - toolOutput, - pendingApproval, - handoff, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiChatHandlers.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiChatHandlers.ts deleted file mode 100644 index 911b190134a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiChatHandlers.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { KAGENTI_PROVIDER_ID } from './kagentiHelpers'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiConversationCapability.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiConversationCapability.ts deleted file mode 100644 index f373fd80c2e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiConversationCapability.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ConversationCapability } from '../types'; -import type { ProcessedMessage } from '../types'; -import type { KagentiApiClient } from './client/KagentiApiClient'; - -/** - * The Kagenti API does not expose a conversation history endpoint - * (there is no GET /api/v1/contexts/{id}/history in the OpenAPI spec). - * Conversation history is served from the local `augment_session_messages` - * table by `sessionRoutes.ts`. This capability returns an empty array - * for `getProcessedMessages` as a safe no-op fallback. - */ -export function buildKagentiConversationCapability( - _getApiClient: () => KagentiApiClient, - logger: LoggerService, -): ConversationCapability { - const notSupported = (method: string) => () => { - throw new Error(`${method} is not supported by the Kagenti provider`); - }; - - return { - getProcessedMessages: async ( - contextId: string, - ): Promise => { - logger.info( - `Kagenti getProcessedMessages called for context ${contextId} — ` + - `the Kagenti API has no history endpoint; history is served from the local message store`, - ); - return []; - }, - submitApproval: notSupported( - 'submitApproval', - ) as ConversationCapability['submitApproval'], - create: notSupported('create') as ConversationCapability['create'], - list: notSupported('list') as ConversationCapability['list'], - get: notSupported('get') as ConversationCapability['get'], - getInputs: notSupported('getInputs') as ConversationCapability['getInputs'], - getByResponseChain: notSupported( - 'getByResponseChain', - ) as ConversationCapability['getByResponseChain'], - delete: notSupported('delete') as ConversationCapability['delete'], - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiHelpers.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiHelpers.ts deleted file mode 100644 index 20c3227e7a0..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiHelpers.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - LoggerService, - RootConfigService, - CacheService, -} from '@backstage/backend-plugin-api'; - -export const KAGENTI_PROVIDER_ID = 'kagenti'; -export const KAGENTI_DISPLAY_NAME = 'Kagenti'; -import { InputError } from '@backstage/errors'; -import type { ChatRequest as AugmentChatRequest } from '../types'; -import type { KagentiConfig } from './config/KagentiConfigLoader'; -import type { AgentCardCacheEntry } from './KagentiAgentCardCache'; - -export interface KagentiProviderOptions { - logger: LoggerService; - config: RootConfigService; - adminConfig?: import('../../services/AdminConfigService').AdminConfigService; - cache?: CacheService; -} - -export function stripTrailingSlashes(s: string): string { - let end = s.length; - while (end > 0 && s[end - 1] === '/') end--; - return end === s.length ? s : s.slice(0, end); -} - -export function createNoopCache(): CacheService { - const store = new Map(); - return { - async get(key: string): Promise { - const entry = store.get(key); - if (!entry) return undefined; - if (Date.now() > entry.expiresAt) { - store.delete(key); - return undefined; - } - return entry.value as T; - }, - async set( - key: string, - value: unknown, - options?: { ttl?: number }, - ): Promise { - store.set(key, { - value, - expiresAt: Date.now() + (options?.ttl ?? 24 * 60 * 60 * 1000), - }); - }, - async delete(key: string): Promise { - store.delete(key); - }, - withOptions(_options: { defaultTtl?: number }): CacheService { - return createNoopCache(); - }, - }; -} - -export function resolveAgent( - request: AugmentChatRequest, - config: KagentiConfig, - nsValidator: (ns: string) => void, - idParser: (id: string) => { namespace: string; name: string }, -): { namespace: string; name: string } { - const model = - 'model' in request ? (request.model as string | undefined) : undefined; - if (model && model.includes('/')) { - const parsed = idParser(model); - nsValidator(parsed.namespace); - return parsed; - } - return { - namespace: config.namespace, - name: config.agentName ?? model ?? 'default', - }; -} - -export function parseAgentId( - agentId: string, - defaultNamespace: string, -): { namespace: string; name: string } { - const slashIdx = agentId.indexOf('/'); - if (slashIdx > 0) - return { - namespace: agentId.substring(0, slashIdx), - name: agentId.substring(slashIdx + 1), - }; - return { namespace: defaultNamespace, name: agentId }; -} - -export function extractLastUserMessage(request: AugmentChatRequest): string { - if (request.messages) { - for (let i = request.messages.length - 1; i >= 0; i--) { - if (request.messages[i].role === 'user') - return request.messages[i].content || ''; - } - } - return ''; -} - -export async function buildA2AMetadata( - namespace: string, - name: string, - getAgentCardCached: (ns: string, n: string) => Promise, - logger: LoggerService, -): Promise< - | { - metadata?: Record; - parts?: Array>; - contextId?: string; - } - | undefined -> { - try { - const entry = await getAgentCardCached(namespace, name); - const hasDemands = Object.values(entry.demands).some(d => d !== null); - if (!hasDemands) return undefined; - const metadata = await entry.resolveMetadata({}); - if (!metadata || Object.keys(metadata).length === 0) return undefined; - return { metadata }; - } catch (err) { - logger.debug( - `Could not build A2A metadata for ${namespace}/${name}: ${err}`, - ); - return undefined; - } -} - -export function validateNamespace( - namespace: string, - config: KagentiConfig, -): void { - if (config.namespaces?.length) { - if (!new Set(config.namespaces).has(namespace)) - throw new InputError( - `Namespace "${namespace}" is not in the configured allow-list`, - ); - return; - } - if (!config.showAllNamespaces && namespace !== config.namespace) - throw new InputError( - `Namespace "${namespace}" is not accessible (default: "${config.namespace}")`, - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiInitialization.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiInitialization.ts deleted file mode 100644 index b6c82032a4d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiInitialization.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { KAGENTI_PROVIDER_ID, KAGENTI_DISPLAY_NAME } from './kagentiHelpers'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiModelOps.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiModelOps.ts deleted file mode 100644 index 911b190134a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiModelOps.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { KAGENTI_PROVIDER_ID } from './kagentiHelpers'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiNamespaceUtils.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiNamespaceUtils.ts deleted file mode 100644 index cedce1e2d78..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiNamespaceUtils.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { KagentiApiClient } from './client/KagentiApiClient'; -import type { KagentiConfig } from './config/KagentiConfigLoader'; - -/** - * Resolves the list of Kagenti namespaces visible to this Backstage instance - * based on the allow-list, showAllNamespaces flag, and the default namespace. - * - * Shared between KagentiProvider.listModels and kagentiRoutes agent/tool list handlers. - */ -export async function getVisibleNamespaces( - apiClient: KagentiApiClient, - config: KagentiConfig, - logger: LoggerService, -): Promise { - try { - const nsResp = await apiClient.listNamespaces(); - let namespaces = nsResp.namespaces; - - if (config.namespaces?.length) { - const allowSet = new Set(config.namespaces); - namespaces = namespaces.filter(ns => allowSet.has(ns)); - } else if (!config.showAllNamespaces) { - namespaces = [config.namespace]; - } - - return namespaces; - } catch (err) { - logger.warn( - `Failed to list namespaces, falling back to default: ${err instanceof Error ? err.message : err}`, - ); - return [config.namespace]; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiRagCapability.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiRagCapability.ts deleted file mode 100644 index 47ed75a8ca4..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/kagentiRagCapability.ts +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - LoggerService, - RootConfigService, -} from '@backstage/backend-plugin-api'; -import type { SyncResult } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { RAGCapability } from '../types'; -import { ResponsesApiClient, VectorStoreService } from '../../services'; -import type { LlamaStackConfig } from '../../types'; -import { - DEFAULT_EMBEDDING_MODEL, - DEFAULT_EMBEDDING_DIMENSION, - DEFAULT_VECTOR_STORE_NAME, - DEFAULT_CHUNK_SIZE, - DEFAULT_CHUNK_OVERLAP, -} from '../../constants'; - -/** - * Build a RAG capability for the Kagenti provider using the - * augment.llamaStack config section for vector store operations. - * - * Returns undefined if llamaStack is not configured. - */ -export function buildKagentiRag( - rootConfig: RootConfigService, - logger: LoggerService, -): { rag: RAGCapability; vectorStoreService: VectorStoreService } | undefined { - const ls = rootConfig.getOptionalConfig('augment.llamaStack'); - const baseUrl = ls?.getOptionalString('baseUrl'); - - if (!baseUrl) { - logger.info( - 'augment.llamaStack.baseUrl not configured; RAG/vector stores unavailable for Kagenti provider', - ); - return undefined; - } - - const skipTlsVerify = ls?.getOptionalBoolean('skipTlsVerify') ?? false; - const token = ls?.getOptionalString('token'); - - const client = new ResponsesApiClient( - { baseUrl, token, skipTlsVerify }, - logger, - ); - - let vectorStoreIds: string[] = []; - const idsArray = ls?.getOptionalStringArray('vectorStoreIds'); - if (idsArray && idsArray.length > 0) { - vectorStoreIds = idsArray; - } else { - const singleId = ls?.getOptionalString('vectorStoreId'); - if (singleId) vectorStoreIds = [singleId]; - } - - const llamaStackConfig: LlamaStackConfig = { - baseUrl, - vectorStoreIds, - vectorStoreName: - ls?.getOptionalString('vectorStoreName') ?? DEFAULT_VECTOR_STORE_NAME, - embeddingModel: - ls?.getOptionalString('embeddingModel') ?? DEFAULT_EMBEDDING_MODEL, - embeddingDimension: - ls?.getOptionalNumber('embeddingDimension') ?? - DEFAULT_EMBEDDING_DIMENSION, - model: ls?.getOptionalString('model') ?? '', - token, - skipTlsVerify, - chunkingStrategy: - (ls?.getOptionalString('chunkingStrategy') as 'auto' | 'static') ?? - 'auto', - maxChunkSizeTokens: - ls?.getOptionalNumber('maxChunkSizeTokens') ?? DEFAULT_CHUNK_SIZE, - chunkOverlapTokens: - ls?.getOptionalNumber('chunkOverlapTokens') ?? DEFAULT_CHUNK_OVERLAP, - searchMode: - (ls?.getOptionalString('searchMode') as - | 'semantic' - | 'keyword' - | 'hybrid') ?? 'semantic', - }; - - const vectorStore = new VectorStoreService(client, llamaStackConfig, logger); - - logger.info( - `Kagenti RAG initialized: LlamaStack at ${baseUrl}, ${vectorStoreIds.length} configured store(s)`, - ); - - const rag: RAGCapability = { - listDocuments: (vectorStoreId?: string) => - vectorStore.listDocuments(vectorStoreId), - - listVectorStores: () => vectorStore.listVectorStores(), - - getDefaultVectorStoreId: () => llamaStackConfig.vectorStoreIds[0], - - getActiveVectorStoreIds: () => - Promise.resolve([...llamaStackConfig.vectorStoreIds]), - - syncDocuments: (): Promise => - Promise.resolve({ - added: 0, - removed: 0, - updated: 0, - failed: 0, - unchanged: 0, - errors: [], - }), - - uploadDocument: ( - fileName: string, - content: Buffer, - vectorStoreId?: string, - ) => - vectorStore - .uploadDocuments( - [{ fileName, content: content.toString('utf-8') }], - vectorStoreId, - ) - .then(result => { - const first = result.uploaded[0]; - if (!first) { - const err = result.failed[0]; - throw new Error(err?.error ?? 'Upload failed'); - } - return { - fileId: first.id, - fileName: first.fileName, - status: first.status, - }; - }), - - deleteDocument: (fileId: string, vectorStoreId?: string) => - vectorStore.deleteDocument(fileId, vectorStoreId), - - searchVectorStore: async ( - query: string, - maxResults?: number, - vectorStoreId?: string, - storeIds?: string[], - ) => { - const ids = - storeIds ?? - (vectorStoreId ? [vectorStoreId] : llamaStackConfig.vectorStoreIds); - if (ids.length === 0) { - return { query, chunks: [], vectorStoreId: '', totalResults: 0 }; - } - const limit = maxResults ?? 10; - const allChunks = ( - await Promise.all( - ids.map(id => - vectorStore.searchSingle(id, query, limit).catch(() => []), - ), - ) - ).flat(); - allChunks.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); - const trimmed = allChunks.slice(0, limit); - return { - query, - chunks: trimmed, - vectorStoreId: ids[0], - totalResults: trimmed.length, - }; - }, - - getVectorStoreConfig: () => - Promise.resolve({ - vectorStoreName: llamaStackConfig.vectorStoreName, - embeddingModel: llamaStackConfig.embeddingModel, - embeddingDimension: - llamaStackConfig.embeddingDimension ?? DEFAULT_EMBEDDING_DIMENSION, - searchMode: llamaStackConfig.searchMode, - chunkingStrategy: llamaStackConfig.chunkingStrategy, - maxChunkSizeTokens: llamaStackConfig.maxChunkSizeTokens, - chunkOverlapTokens: llamaStackConfig.chunkOverlapTokens, - }), - - createVectorStoreWithConfig: (overrides: Record) => - vectorStore.createWithConfig(llamaStackConfig, overrides), - - getVectorStoreStatus: async () => { - const storeId = llamaStackConfig.vectorStoreIds[0]; - if (!storeId) { - return { exists: false, ready: false }; - } - try { - const stores = await vectorStore.listVectorStores(); - const found = stores.find(s => s.id === storeId); - return { - exists: !!found, - vectorStoreId: storeId, - vectorStoreName: found?.name, - documentCount: found?.fileCount, - ready: found?.status === 'completed', - }; - } catch { - return { exists: false, vectorStoreId: storeId, ready: false }; - } - }, - - addVectorStoreId: (id: string) => { - if (!llamaStackConfig.vectorStoreIds.includes(id)) { - llamaStackConfig.vectorStoreIds.push(id); - vectorStore.updateConfig(llamaStackConfig); - } - }, - - removeVectorStoreId: (id: string) => { - const idx = llamaStackConfig.vectorStoreIds.indexOf(id); - if (idx >= 0) { - llamaStackConfig.vectorStoreIds.splice(idx, 1); - vectorStore.updateConfig(llamaStackConfig); - } - }, - - deleteVectorStore: (vectorStoreId: string) => - vectorStore.deleteVectorStore(vectorStoreId), - - updateVectorStore: ( - vectorStoreId: string, - updates: { name?: string; metadata?: Record }, - ) => vectorStore.updateVectorStore(vectorStoreId, updates), - }; - - return { rag, vectorStoreService: vectorStore }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/KagentiStreamNormalizer.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/KagentiStreamNormalizer.test.ts deleted file mode 100644 index 9bd7265a35b..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/KagentiStreamNormalizer.test.ts +++ /dev/null @@ -1,849 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - KagentiStreamNormalizer, - normalizeKagentiEvent, -} from './KagentiStreamNormalizer'; - -// Extension URIs used in metadata -const TRAJECTORY_URI = - 'https://a2a-extensions.adk.kagenti.dev/ui/trajectory/v1'; -const CITATION_URI = 'https://a2a-extensions.adk.kagenti.dev/ui/citation/v1'; -const ERROR_URI = 'https://a2a-extensions.adk.kagenti.dev/ui/error/v1'; -const FORM_REQUEST_URI = - 'https://a2a-extensions.adk.kagenti.dev/ui/form_request/v1'; -const APPROVAL_URI = - 'https://a2a-extensions.adk.kagenti.dev/interactions/approval/v1'; -const OAUTH_URI = 'https://a2a-extensions.adk.kagenti.dev/auth/oauth/v1'; -const SECRETS_URI = 'https://a2a-extensions.adk.kagenti.dev/auth/secrets/v1'; - -function makeStatusUpdate( - state: string, - opts?: { - taskId?: string; - contextId?: string; - text?: string; - metadata?: Record; - rootMetadata?: Record; - }, -) { - return JSON.stringify({ - statusUpdate: { - taskId: opts?.taskId ?? 'task-1', - contextId: opts?.contextId ?? 'ctx-1', - ...(opts?.rootMetadata && { metadata: opts.rootMetadata }), - status: { - state, - ...(opts?.text || opts?.metadata - ? { - message: { - messageId: 'msg-1', - role: 'ROLE_AGENT', - parts: opts?.text ? [{ text: opts.text }] : [], - ...(opts?.metadata && { metadata: opts.metadata }), - }, - } - : {}), - }, - }, - }); -} - -// ============================================================================= -// Legacy format backward compatibility -// ============================================================================= - -describe('KagentiStreamNormalizer -- legacy format', () => { - it('emits stream.started on first event', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - JSON.stringify({ session_id: 's1', content: 'Hi' }), - ); - expect(events[0]).toEqual({ type: 'stream.started', responseId: 's1' }); - expect(events[1]).toEqual({ type: 'stream.text.delta', delta: 'Hi' }); - }); - - it('does not emit stream.started twice', () => { - const n = new KagentiStreamNormalizer(); - n.normalize(JSON.stringify({ session_id: 's1', content: 'a' })); - const events = n.normalize(JSON.stringify({ content: 'b' })); - expect(events.find(e => e.type === 'stream.started')).toBeUndefined(); - }); - - it('maps status event to text delta', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - JSON.stringify({ event: { type: 'status', message: 'Processing...' } }), - ); - expect(events).toContainEqual({ - type: 'stream.text.delta', - delta: 'Processing...', - }); - }); - - it('maps artifact event to text delta', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - JSON.stringify({ event: { type: 'artifact' }, content: 'Result data' }), - ); - expect(events).toContainEqual({ - type: 'stream.text.delta', - delta: 'Result data', - }); - }); - - it('emits text.done and completed on final event', () => { - const n = new KagentiStreamNormalizer(); - n.normalize(JSON.stringify({ content: 'Hello ' })); - const events = n.normalize( - JSON.stringify({ - event: { final: true, state: 'COMPLETED' }, - content: 'World', - }), - ); - expect(events).toContainEqual({ - type: 'stream.text.done', - text: 'Hello World', - }); - expect(events).toContainEqual({ type: 'stream.completed' }); - }); - - it('emits completed on done:true', () => { - const n = new KagentiStreamNormalizer(); - n.normalize(JSON.stringify({ content: 'x' })); - const events = n.normalize(JSON.stringify({ done: true })); - expect(events).toContainEqual({ type: 'stream.text.done', text: 'x' }); - expect(events).toContainEqual({ type: 'stream.completed' }); - }); - - it('emits error on error field', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize(JSON.stringify({ error: 'Something broke' })); - expect(events).toContainEqual({ - type: 'stream.error', - error: 'Something broke', - code: 'kagenti_error', - }); - expect(events).toContainEqual({ type: 'stream.completed' }); - }); - - it('emits error on FAILED state', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - JSON.stringify({ event: { state: 'FAILED', message: 'Timeout' } }), - ); - expect(events).toContainEqual({ - type: 'stream.error', - error: 'Timeout', - code: 'kagenti_task_failed', - }); - }); - - it('does not emit completed twice', () => { - const n = new KagentiStreamNormalizer(); - n.normalize(JSON.stringify({ content: 'a' })); - n.normalize(JSON.stringify({ done: true })); - const events = n.normalize(JSON.stringify({ done: true })); - expect(events.filter(e => e.type === 'stream.completed')).toHaveLength(0); - }); - - it('handles unparseable JSON', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize('not valid json'); - expect(events[0]).toEqual( - expect.objectContaining({ type: 'stream.error' }), - ); - }); - - it('falls back to context_id for responseId', () => { - const events = normalizeKagentiEvent( - JSON.stringify({ context_id: 'ctx-123', content: 'data' }), - ); - expect(events[0]).toEqual({ - type: 'stream.started', - responseId: 'ctx-123', - }); - }); -}); - -// ============================================================================= -// A2A Protocol: All 9 Task States -// ============================================================================= - -describe('KagentiStreamNormalizer -- A2A task states', () => { - it('TASK_STATE_SUBMITTED is a no-op (only emits started)', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize(makeStatusUpdate('TASK_STATE_SUBMITTED')); - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ type: 'stream.started', responseId: 'ctx-1' }); - }); - - it('TASK_STATE_UNSPECIFIED is a no-op (only emits started)', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize(makeStatusUpdate('TASK_STATE_UNSPECIFIED')); - expect(events).toHaveLength(1); - expect(events[0].type).toBe('stream.started'); - }); - - it('TASK_STATE_WORKING emits reasoning delta for all text', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_WORKING', { text: 'Thinking...' }), - ); - expect(events).toContainEqual({ - type: 'stream.reasoning.delta', - delta: 'Thinking...', - }); - }); - - it('TASK_STATE_COMPLETED emits text.done and completed', () => { - const n = new KagentiStreamNormalizer(); - // WORKING text goes to reasoning, not accumulated text - n.normalize(makeStatusUpdate('TASK_STATE_WORKING', { text: 'Part 1. ' })); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_COMPLETED', { text: 'Part 2.' }), - ); - expect(events).toContainEqual({ - type: 'stream.text.delta', - delta: 'Part 2.', - }); - expect(events).toContainEqual({ - type: 'stream.text.done', - text: 'Part 2.', - }); - expect(events).toContainEqual({ type: 'stream.completed' }); - }); - - it('TASK_STATE_FAILED emits error and completed', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_FAILED', { text: 'Out of memory' }), - ); - expect(events).toContainEqual( - expect.objectContaining({ - type: 'stream.error', - error: 'Out of memory', - code: 'kagenti_task_failed', - }), - ); - expect(events).toContainEqual({ type: 'stream.completed' }); - }); - - it('TASK_STATE_CANCELED emits completed with no text', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize(makeStatusUpdate('TASK_STATE_CANCELED')); - expect(events).toContainEqual({ type: 'stream.completed' }); - expect(events.find(e => e.type === 'stream.text.done')).toBeUndefined(); - }); - - it('TASK_STATE_REJECTED emits error with code kagenti_rejected', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_REJECTED', { text: 'Not allowed' }), - ); - expect(events).toContainEqual({ - type: 'stream.error', - error: 'Not allowed', - code: 'kagenti_rejected', - }); - expect(events).toContainEqual({ type: 'stream.completed' }); - }); - - it('TASK_STATE_REJECTED without text uses default message', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize(makeStatusUpdate('TASK_STATE_REJECTED')); - expect(events).toContainEqual( - expect.objectContaining({ - type: 'stream.error', - error: 'Agent rejected the request', - code: 'kagenti_rejected', - }), - ); - }); -}); - -// ============================================================================= -// A2A Protocol: INPUT_REQUIRED (forms, approvals, text input) -// ============================================================================= - -describe('KagentiStreamNormalizer -- INPUT_REQUIRED', () => { - it('emits stream.form.request for form extension', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_INPUT_REQUIRED', { - metadata: { - [FORM_REQUEST_URI]: { - title: 'Feedback', - fields: [{ type: 'text', id: 'comment', label: 'Comment' }], - }, - }, - }), - ); - const formEvent = events.find(e => e.type === 'stream.form.request'); - expect(formEvent).toBeDefined(); - expect(formEvent).toEqual( - expect.objectContaining({ - type: 'stream.form.request', - taskId: 'task-1', - contextId: 'ctx-1', - }), - ); - }); - - it('emits stream.tool.approval for approval extension', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_INPUT_REQUIRED', { - metadata: { - [APPROVAL_URI]: { - action: 'generic', - title: 'Delete file?', - description: 'This will permanently remove the file.', - }, - }, - }), - ); - const approvalEvent = events.find(e => e.type === 'stream.tool.approval'); - expect(approvalEvent).toBeDefined(); - }); - - it('emits text delta for plain text input required', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_INPUT_REQUIRED', { - text: 'Please provide more details', - }), - ); - expect(events).toContainEqual({ - type: 'stream.text.delta', - delta: 'Please provide more details', - }); - }); -}); - -// ============================================================================= -// A2A Protocol: AUTH_REQUIRED (OAuth, secrets) -// ============================================================================= - -describe('KagentiStreamNormalizer -- AUTH_REQUIRED', () => { - it('emits stream.auth.required for OAuth', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_AUTH_REQUIRED', { - metadata: { - [OAUTH_URI]: { - authorization_endpoint_url: 'https://auth.example.com/authorize', - }, - }, - }), - ); - const authEvent = events.find(e => e.type === 'stream.auth.required'); - expect(authEvent).toBeDefined(); - expect(authEvent).toEqual( - expect.objectContaining({ - type: 'stream.auth.required', - authType: 'oauth', - url: 'https://auth.example.com/authorize', - }), - ); - }); - - it('emits stream.auth.required for secrets', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_AUTH_REQUIRED', { - metadata: { - [SECRETS_URI]: { - secret_demands: { - api_key: { - name: 'api_key', - description: 'API key for external service', - }, - }, - }, - }, - }), - ); - const authEvent = events.find(e => e.type === 'stream.auth.required'); - expect(authEvent).toBeDefined(); - expect(authEvent).toEqual( - expect.objectContaining({ - type: 'stream.auth.required', - authType: 'secret', - }), - ); - }); -}); - -// ============================================================================= -// A2A Protocol: Artifact Updates -// ============================================================================= - -describe('KagentiStreamNormalizer -- artifact updates', () => { - it('emits stream.artifact for artifact update with text parts', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - JSON.stringify({ - artifactUpdate: { - taskId: 'task-1', - contextId: 'ctx-1', - artifact: { - artifactId: 'art-1', - name: 'output.py', - description: 'Generated code', - parts: [{ text: 'print("hello")' }], - }, - append: false, - lastChunk: true, - }, - }), - ); - - expect(events).toContainEqual( - expect.objectContaining({ - type: 'stream.artifact', - artifactId: 'art-1', - name: 'output.py', - content: 'print("hello")', - lastChunk: true, - }), - ); - expect(events).toContainEqual({ - type: 'stream.text.delta', - delta: 'print("hello")', - }); - expect(events).toContainEqual({ type: 'stream.completed' }); - }); - - it('does not emit completed for non-last artifact chunks', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - JSON.stringify({ - artifactUpdate: { - taskId: 'task-1', - contextId: 'ctx-1', - artifact: { - artifactId: 'art-1', - parts: [{ text: 'chunk1' }], - }, - append: true, - lastChunk: false, - }, - }), - ); - expect(events.find(e => e.type === 'stream.completed')).toBeUndefined(); - }); -}); - -// ============================================================================= -// A2A Protocol: UI Extensions (trajectory, citation, structured error) -// ============================================================================= - -describe('KagentiStreamNormalizer -- UI extensions', () => { - it('extracts trajectory metadata as reasoning deltas', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_WORKING', { - text: 'Processing', - metadata: { - [TRAJECTORY_URI]: [ - { title: 'Step 1', content: 'Analyzing input' }, - { title: 'Step 2', content: 'Generating response' }, - ], - }, - }), - ); - const reasoningEvents = events.filter( - e => e.type === 'stream.reasoning.delta', - ); - // 3 reasoning events: 1 from WORKING text + 2 from trajectory steps - expect(reasoningEvents).toHaveLength(3); - expect(reasoningEvents[0]).toEqual({ - type: 'stream.reasoning.delta', - delta: 'Processing', - }); - expect(reasoningEvents[1]).toEqual({ - type: 'stream.reasoning.delta', - delta: 'Step 1: Analyzing input\n', - }); - }); - - it('extracts citation metadata', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_WORKING', { - text: 'Answer', - metadata: { - [CITATION_URI]: [ - { url: 'https://docs.example.com', title: 'API Docs' }, - ], - }, - }), - ); - const citationEvent = events.find(e => e.type === 'stream.citation'); - expect(citationEvent).toBeDefined(); - expect(citationEvent).toEqual({ - type: 'stream.citation', - citations: [{ url: 'https://docs.example.com', title: 'API Docs' }], - }); - }); - - it('extracts structured error metadata on FAILED', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_FAILED', { - metadata: { - [ERROR_URI]: { - error: { - title: 'Rate Limit', - message: 'Too many requests', - }, - context: { retryAfter: 30 }, - }, - }, - }), - ); - expect(events).toContainEqual( - expect.objectContaining({ - type: 'stream.error', - error: 'Too many requests', - code: 'kagenti_task_failed', - title: 'Rate Limit', - context: { retryAfter: 30 }, - }), - ); - }); -}); - -// ============================================================================= -// JSONRPC Errors -// ============================================================================= - -describe('KagentiStreamNormalizer -- JSONRPC errors', () => { - it('maps known JSONRPC error codes to human-readable messages', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - JSON.stringify({ - error: { - code: -32001, - message: 'task xyz not found', - }, - }), - ); - expect(events).toContainEqual( - expect.objectContaining({ - type: 'stream.error', - error: 'Task not found: task xyz not found', - code: 'jsonrpc_32001', - }), - ); - expect(events).toContainEqual({ type: 'stream.completed' }); - }); - - it('uses raw message for unknown JSONRPC error codes (no duplication)', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - JSON.stringify({ - error: { - code: -99999, - message: 'unknown error', - }, - }), - ); - expect(events).toContainEqual( - expect.objectContaining({ - type: 'stream.error', - error: 'unknown error', - }), - ); - }); - - it('sets hasJsonRpcStreamingError flag on -32603', () => { - const n = new KagentiStreamNormalizer(); - expect(n.hasJsonRpcStreamingError).toBe(false); - n.normalize( - JSON.stringify({ - error: { - code: -32603, - message: 'Streaming is not supported by the agent', - }, - }), - ); - expect(n.hasJsonRpcStreamingError).toBe(true); - }); - - it('does not set hasJsonRpcStreamingError flag on other JSONRPC errors', () => { - const n = new KagentiStreamNormalizer(); - n.normalize( - JSON.stringify({ - error: { - code: -32001, - message: 'task not found', - }, - }), - ); - expect(n.hasJsonRpcStreamingError).toBe(false); - }); -}); - -// ============================================================================= -// Edge cases (7b test gap coverage) -// ============================================================================= - -describe('KagentiStreamNormalizer -- edge cases', () => { - it('unknown A2A state falls through to default (extracts text and completes)', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_UNKNOWN_FUTURE', { text: 'some text' }), - ); - expect(events).toContainEqual({ - type: 'stream.text.delta', - delta: 'some text', - }); - expect(events).toContainEqual({ type: 'stream.completed' }); - }); - - it('handleInteractiveState catch path logs and falls back to text', () => { - const mockLogger = { - warn: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - error: jest.fn(), - child: jest.fn().mockReturnThis(), - } as any; - const n = new KagentiStreamNormalizer(mockLogger); - // INPUT_REQUIRED with metadata that will cause handleTaskStatusUpdate to fail - // when there's no recognized extension URI, it may or may not throw depending on ADK version. - // We test by providing text that falls through to the catch path. - const events = n.normalize( - makeStatusUpdate('TASK_STATE_INPUT_REQUIRED', { text: 'Please respond' }), - ); - expect(events).toContainEqual({ - type: 'stream.text.delta', - delta: 'Please respond', - }); - }); - - it('artifact with no text parts emits no artifact event', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - JSON.stringify({ - artifactUpdate: { - taskId: 'task-1', - contextId: 'ctx-1', - artifact: { - artifactId: 'art-1', - parts: [{ data: 'binary-data-here' }], - }, - lastChunk: false, - }, - }), - ); - expect(events.find(e => e.type === 'stream.artifact')).toBeUndefined(); - }); - - it('artifact with multiple text parts joins them', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - JSON.stringify({ - artifactUpdate: { - taskId: 'task-1', - contextId: 'ctx-1', - artifact: { - artifactId: 'art-1', - parts: [{ text: 'Hello ' }, { text: 'World' }], - }, - lastChunk: true, - }, - }), - ); - const artifactEvent = events.find(e => e.type === 'stream.artifact'); - expect(artifactEvent).toEqual( - expect.objectContaining({ - type: 'stream.artifact', - content: 'Hello World', - }), - ); - }); - - it('verbose logging emits debug log when events are produced', () => { - const mockLogger = { - warn: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - error: jest.fn(), - child: jest.fn().mockReturnThis(), - } as any; - const n = new KagentiStreamNormalizer(mockLogger); - n.normalize(JSON.stringify({ content: 'hello', session_id: 's1' })); - expect(mockLogger.debug).toHaveBeenCalledWith( - expect.stringContaining('KagentiStream:'), - expect.any(Object), - ); - }); - - it('TASK_STATE_FAILED without error extension falls back to status text', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_FAILED', { text: 'Connection lost' }), - ); - expect(events).toContainEqual( - expect.objectContaining({ - type: 'stream.error', - error: 'Connection lost', - code: 'kagenti_task_failed', - }), - ); - }); - - it('TASK_STATE_FAILED without any message uses default error text', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize(makeStatusUpdate('TASK_STATE_FAILED')); - expect(events).toContainEqual( - expect.objectContaining({ - type: 'stream.error', - error: 'Agent task failed', - code: 'kagenti_task_failed', - }), - ); - }); - - it('TASK_STATE_WORKING emits reasoning delta for short text (no threshold)', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_WORKING', { - text: 'Starting pipeline...', - }), - ); - expect(events).toContainEqual({ - type: 'stream.reasoning.delta', - delta: 'Starting pipeline...', - }); - }); - - it('TASK_STATE_WORKING does not accumulate text (reasoning only)', () => { - const n = new KagentiStreamNormalizer(); - n.normalize(makeStatusUpdate('TASK_STATE_WORKING', { text: 'Step 1' })); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_COMPLETED', { text: 'Result' }), - ); - expect(events).toContainEqual({ - type: 'stream.text.done', - text: 'Result', - }); - }); - - it('empty citation array is not emitted', () => { - const n = new KagentiStreamNormalizer(); - const events = n.normalize( - makeStatusUpdate('TASK_STATE_WORKING', { - text: 'x', - metadata: { - [CITATION_URI]: [], - }, - }), - ); - expect(events.find(e => e.type === 'stream.citation')).toBeUndefined(); - }); -}); - -// ============================================================================= -// Agent handoff tests -// ============================================================================= - -describe('agent handoff events', () => { - it('emits stream.agent.handoff when agent_name changes', () => { - const n = new KagentiStreamNormalizer(); - const first = n.normalize( - makeStatusUpdate('TASK_STATE_WORKING', { - text: 'Hello from Agent A', - rootMetadata: { agent_name: 'AgentA' }, - }), - ); - const handoffInFirst = first.find(e => e.type === 'stream.agent.handoff'); - expect(handoffInFirst).toBeDefined(); - expect((handoffInFirst as { toAgent: string }).toAgent).toBe('AgentA'); - - const second = n.normalize( - makeStatusUpdate('TASK_STATE_WORKING', { - text: 'Hello from Agent B', - rootMetadata: { agent_name: 'AgentB' }, - }), - ); - const handoffInSecond = second.find(e => e.type === 'stream.agent.handoff'); - expect(handoffInSecond).toBeDefined(); - expect((handoffInSecond as { fromAgent?: string }).fromAgent).toBe( - 'AgentA', - ); - expect((handoffInSecond as { toAgent: string }).toAgent).toBe('AgentB'); - }); - - it('does not emit handoff when agent_name stays the same', () => { - const n = new KagentiStreamNormalizer(); - n.normalize( - makeStatusUpdate('TASK_STATE_WORKING', { - text: 'first', - rootMetadata: { agent_name: 'AgentA' }, - }), - ); - const second = n.normalize( - makeStatusUpdate('TASK_STATE_WORKING', { - text: 'second', - rootMetadata: { agent_name: 'AgentA' }, - }), - ); - expect(second.find(e => e.type === 'stream.agent.handoff')).toBeUndefined(); - }); - - it('emits handoff via trajectory extension metadata', () => { - const n = new KagentiStreamNormalizer(); - n.normalize( - makeStatusUpdate('TASK_STATE_WORKING', { - text: 'start', - rootMetadata: { agent_name: 'Root' }, - }), - ); - const second = n.normalize( - makeStatusUpdate('TASK_STATE_WORKING', { - metadata: { - [TRAJECTORY_URI]: [ - { agent_name: 'SpecialistAgent', title: 'Handling request' }, - ], - }, - }), - ); - const handoff = second.find(e => e.type === 'stream.agent.handoff'); - expect(handoff).toBeDefined(); - expect((handoff as { toAgent: string }).toAgent).toBe('SpecialistAgent'); - }); -}); - -// ============================================================================= -// Stateless convenience function -// ============================================================================= - -describe('normalizeKagentiEvent', () => { - it('works for a single A2A status update', () => { - const events = normalizeKagentiEvent( - makeStatusUpdate('TASK_STATE_COMPLETED', { text: 'Done' }), - ); - expect(events[0].type).toBe('stream.started'); - expect(events).toContainEqual({ type: 'stream.text.delta', delta: 'Done' }); - expect(events).toContainEqual({ type: 'stream.text.done', text: 'Done' }); - expect(events).toContainEqual({ type: 'stream.completed' }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/KagentiStreamNormalizer.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/KagentiStreamNormalizer.ts deleted file mode 100644 index 3ddd692b047..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/KagentiStreamNormalizer.ts +++ /dev/null @@ -1,386 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { NormalizedStreamEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { handleInteractiveState } from './streamInteractiveHandler'; -import type { StatusUpdate } from './streamInteractiveHandler'; -import { - extractUiExtensions, - extractStructuredError, -} from './streamUiExtensions'; - -const JSONRPC_ERROR_MESSAGES: Record = { - [-32700]: 'Parse error', - [-32600]: 'Invalid request', - [-32601]: 'Method not found', - [-32602]: 'Invalid parameters', - [-32603]: 'Internal error', - [-32001]: 'Task not found', - [-32002]: 'Task not cancelable', - [-32003]: 'Push notifications not supported', - [-32004]: 'Unsupported operation', - [-32005]: 'Content type not supported', - [-32006]: 'Invalid agent response', - [-32007]: 'Extended card not configured', -}; - -interface KagentiStreamPayload { - statusUpdate?: StatusUpdate; - artifactUpdate?: { - taskId: string; - contextId: string; - artifact: { - artifactId: string; - name?: string; - description?: string; - parts: Array>; - metadata?: Record; - }; - append?: boolean; - lastChunk?: boolean; - }; - error?: - | { code: number; message: string; data?: Record } - | string; - content?: string; - session_id?: string; - context_id?: string; - event?: { - type?: string; - state?: string; - taskId?: string; - final?: boolean; - message?: string | Record; - [key: string]: unknown; - }; - done?: boolean; - is_complete?: boolean; -} - -export class KagentiStreamNormalizer { - private started = false; - private completed = false; - private accumulatedText = ''; - private readonly verboseLogger?: LoggerService; - private _hasJsonRpcStreamingError = false; - private currentGroupId: string | undefined; - - constructor(verboseLogger?: LoggerService) { - this.verboseLogger = verboseLogger; - } - - get hasJsonRpcStreamingError(): boolean { - return this._hasJsonRpcStreamingError; - } - - normalize(jsonLine: string): NormalizedStreamEvent[] { - let payload: KagentiStreamPayload; - try { - payload = JSON.parse(jsonLine); - } catch { - this.verboseLogger?.warn( - `Unparseable SSE line from Kagenti, skipping: ${jsonLine.substring(0, 200)}`, - ); - return [ - { - type: 'stream.error', - error: 'Unparseable SSE data received from Kagenti', - }, - ]; - } - - const events: NormalizedStreamEvent[] = []; - - if (payload.statusUpdate) { - this.handleStatusUpdate(payload.statusUpdate, events); - } else if (payload.artifactUpdate) { - this.handleArtifactUpdate(payload.artifactUpdate, events); - } else if ( - typeof payload.error === 'object' && - payload.error?.code !== undefined - ) { - this.handleJsonRpcError(payload.error, events); - } else { - this.handleLegacyInline(payload, events); - } - - if (this.verboseLogger && events.length > 0) { - this.verboseLogger.debug( - `KagentiStream: ${events.map(e => e.type).join(', ')}`, - { raw: jsonLine.substring(0, 500) }, - ); - } - return events; - } - - private ensureStarted( - events: NormalizedStreamEvent[], - responseId?: string, - ): void { - if (!this.started) { - this.started = true; - events.push({ - type: 'stream.started', - responseId: responseId ?? 'kagenti', - }); - } - } - - private emitCompleted(events: NormalizedStreamEvent[]): void { - if (!this.completed) { - this.completed = true; - if (this.accumulatedText) - events.push({ type: 'stream.text.done', text: this.accumulatedText }); - events.push({ type: 'stream.completed' }); - } - } - - private handleStatusUpdate( - update: StatusUpdate, - events: NormalizedStreamEvent[], - ): void { - this.ensureStarted(events, update.contextId); - this.detectAgentHandoff(update.metadata, events); - const state = update.status.state; - - switch (state) { - case 'TASK_STATE_SUBMITTED': - case 'TASK_STATE_UNSPECIFIED': - break; - case 'TASK_STATE_WORKING': { - this.extractTextAsReasoning(update.status, events); - const gRef1 = { value: this.currentGroupId }; - extractUiExtensions(update.status.message?.metadata, events, gRef1); - this.currentGroupId = gRef1.value; - break; - } - case 'TASK_STATE_COMPLETED': { - this.extractTextFromStatus(update.status, events); - const gRef2 = { value: this.currentGroupId }; - extractUiExtensions(update.status.message?.metadata, events, gRef2); - this.currentGroupId = gRef2.value; - this.emitCompleted(events); - break; - } - case 'TASK_STATE_FAILED': - extractStructuredError( - update.status.message?.metadata, - events, - this.extractStatusText(update.status) || 'Agent task failed', - ); - this.emitCompleted(events); - break; - case 'TASK_STATE_CANCELED': - this.emitCompleted(events); - break; - case 'TASK_STATE_REJECTED': - events.push({ - type: 'stream.error', - error: - this.extractStatusText(update.status) || - 'Agent rejected the request', - code: 'kagenti_rejected', - }); - this.emitCompleted(events); - break; - case 'TASK_STATE_INPUT_REQUIRED': - case 'TASK_STATE_AUTH_REQUIRED': { - const ref = { value: this.accumulatedText }; - handleInteractiveState( - update, - events, - ref, - s => this.extractStatusText(s), - this.verboseLogger, - ); - this.accumulatedText = ref.value; - break; - } - default: - this.verboseLogger?.warn( - `Unknown A2A task state "${state}", treating as terminal`, - ); - this.extractTextFromStatus(update.status, events); - this.emitCompleted(events); - break; - } - } - - private handleArtifactUpdate( - update: NonNullable, - events: NormalizedStreamEvent[], - ): void { - this.ensureStarted(events, update.contextId); - const textParts = (update.artifact.parts ?? []) - .filter(p => 'text' in p) - .map(p => (p as { text: string }).text); - const content = textParts.join(''); - if (content) { - events.push({ - type: 'stream.artifact', - artifactId: update.artifact.artifactId, - name: update.artifact.name, - description: update.artifact.description, - content, - append: update.append, - lastChunk: update.lastChunk, - }); - this.accumulatedText += content; - events.push({ type: 'stream.text.delta', delta: content }); - } - if (update.lastChunk) this.emitCompleted(events); - } - - private handleJsonRpcError( - error: { code: number; message: string }, - events: NormalizedStreamEvent[], - ): void { - this.ensureStarted(events); - if (error.code === -32603) this._hasJsonRpcStreamingError = true; - const humanMessage = JSONRPC_ERROR_MESSAGES[error.code]; - events.push({ - type: 'stream.error', - error: humanMessage ? `${humanMessage}: ${error.message}` : error.message, - code: `jsonrpc_${Math.abs(error.code)}`, - }); - this.emitCompleted(events); - } - - private detectAgentHandoff( - metadata: Record | undefined, - events: NormalizedStreamEvent[], - ): void { - if (!metadata) return; - const agentId = - metadata.agent_name ?? metadata.group_id ?? metadata.agent_id; - if (typeof agentId === 'string' && agentId !== this.currentGroupId) { - const fromAgent = this.currentGroupId; - this.currentGroupId = agentId; - events.push({ - type: 'stream.agent.handoff', - fromAgent, - toAgent: agentId, - } as NormalizedStreamEvent); - } - } - - private extractTextFromStatus( - status: StatusUpdate['status'], - events: NormalizedStreamEvent[], - ): void { - const text = this.extractStatusText(status); - if (text) { - this.accumulatedText += text; - events.push({ type: 'stream.text.delta', delta: text }); - } - } - - private extractTextAsReasoning( - status: StatusUpdate['status'], - events: NormalizedStreamEvent[], - ): void { - const text = this.extractStatusText(status); - if (text) events.push({ type: 'stream.reasoning.delta', delta: text }); - } - - private extractStatusText(status: StatusUpdate['status']): string { - if (!status.message) return ''; - const parts = status.message.parts; - if (Array.isArray(parts)) - return parts - .filter(p => 'text' in p) - .map(p => (p as { text: string }).text) - .join(''); - return ''; - } - - private handleLegacyInline( - payload: KagentiStreamPayload, - events: NormalizedStreamEvent[], - ): void { - this.ensureStarted(events, payload.session_id ?? payload.context_id); - if (typeof payload.error === 'string') { - events.push({ - type: 'stream.error', - error: payload.error, - code: 'kagenti_error', - }); - this.emitCompleted(events); - return; - } - const evt = payload.event; - const normalizedState = evt?.state?.toUpperCase(); - if (normalizedState === 'FAILED') { - const msg = - typeof evt?.message === 'string' ? evt.message : 'Agent task failed'; - events.push({ - type: 'stream.error', - error: msg, - code: 'kagenti_task_failed', - }); - this.emitCompleted(events); - return; - } - if (evt?.type === 'status' && evt.message) { - const text = - typeof evt.message === 'string' - ? evt.message - : JSON.stringify(evt.message); - if (text) { - if (normalizedState === 'WORKING') { - events.push({ type: 'stream.reasoning.delta', delta: text }); - } else { - this.accumulatedText += text; - events.push({ type: 'stream.text.delta', delta: text }); - } - } - } - if (evt?.type === 'artifact' && payload.content) { - const artifactName = - typeof (evt as Record).name === 'string' - ? ((evt as Record).name as string) - : undefined; - events.push({ - type: 'stream.artifact', - artifactId: `legacy-${Date.now()}`, - name: artifactName, - content: payload.content, - append: false, - lastChunk: true, - }); - this.accumulatedText += payload.content; - events.push({ type: 'stream.text.delta', delta: payload.content }); - } - const handledContent = - evt?.type === 'artifact' || (evt?.type === 'status' && evt.message); - if (!handledContent && payload.content) { - this.accumulatedText += payload.content; - events.push({ type: 'stream.text.delta', delta: payload.content }); - } - const isFinal = evt?.final === true || normalizedState === 'COMPLETED'; - const isDone = payload.done === true || payload.is_complete === true; - if ((isFinal || isDone) && !this.completed) { - this.emitCompleted(events); - } - } -} - -export function normalizeKagentiEvent( - jsonLine: string, -): NormalizedStreamEvent[] { - return new KagentiStreamNormalizer().normalize(jsonLine); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamInteractiveHandler.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamInteractiveHandler.ts deleted file mode 100644 index f5b3bc35824..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamInteractiveHandler.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { - NormalizedStreamEvent, - StreamFormDescriptor, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { - handleTaskStatusUpdate, - TaskStatusUpdateType, -} from '@kagenti/adk/core'; -import type { TaskStatusUpdateEvent } from '@kagenti/adk'; - -export interface StatusUpdate { - taskId: string; - contextId: string; - status: { - state: string; - message?: { - messageId?: string; - parts?: Array>; - metadata?: Record; - [key: string]: unknown; - }; - timestamp?: string; - }; - metadata?: Record; -} - -export function handleInteractiveState( - update: StatusUpdate, - events: NormalizedStreamEvent[], - accumulatedTextRef: { value: string }, - extractStatusText: (status: StatusUpdate['status']) => string, - verboseLogger?: LoggerService, -): void { - try { - const a2aEvent: TaskStatusUpdateEvent = { - taskId: update.taskId, - contextId: update.contextId, - status: { - state: update.status.state as TaskStatusUpdateEvent['status']['state'], - message: update.status - .message as TaskStatusUpdateEvent['status']['message'], - timestamp: update.status.timestamp, - }, - metadata: update.metadata, - }; - - const results = handleTaskStatusUpdate(a2aEvent); - - for (const result of results) { - switch (result.type) { - case TaskStatusUpdateType.FormRequired: - events.push({ - type: 'stream.form.request', - taskId: update.taskId, - contextId: update.contextId, - form: result.form as unknown as StreamFormDescriptor, - }); - break; - case TaskStatusUpdateType.ApprovalRequired: { - const approvalId = (result as { id?: string }).id ?? update.taskId; - events.push({ - type: 'stream.tool.approval', - callId: approvalId, - name: 'agent-approval', - arguments: JSON.stringify(result.request), - responseId: update.contextId, - }); - break; - } - case TaskStatusUpdateType.OAuthRequired: - events.push({ - type: 'stream.auth.required', - taskId: update.taskId, - authType: 'oauth', - url: result.url, - }); - break; - case TaskStatusUpdateType.SecretRequired: - events.push({ - type: 'stream.auth.required', - taskId: update.taskId, - authType: 'secret', - demands: result.demands as { - secrets?: Array<{ name: string; description?: string }>; - [key: string]: unknown; - }, - }); - break; - case TaskStatusUpdateType.TextInputRequired: - if (result.text) { - accumulatedTextRef.value += result.text; - events.push({ type: 'stream.text.delta', delta: result.text }); - } - break; - default: - break; - } - } - - if (results.length === 0) { - const text = extractStatusText(update.status); - if (text) { - accumulatedTextRef.value += text; - events.push({ type: 'stream.text.delta', delta: text }); - } - } - } catch (err) { - verboseLogger?.warn( - `handleTaskStatusUpdate failed for state ${update.status.state}, falling back to text: ${err}`, - ); - const text = extractStatusText(update.status); - if (text) { - accumulatedTextRef.value += text; - events.push({ type: 'stream.text.delta', delta: text }); - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamLegacyHandler.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamLegacyHandler.ts deleted file mode 100644 index 9f941b0ddb0..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamLegacyHandler.ts +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { NormalizedStreamEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -export interface LegacyPayload { - content?: string; - session_id?: string; - context_id?: string; - error?: - | string - | { code: number; message: string; data?: Record }; - event?: { - type?: string; - state?: string; - taskId?: string; - final?: boolean; - message?: string | Record; - [key: string]: unknown; - }; - done?: boolean; - is_complete?: boolean; -} - -export function handleLegacyPayload( - payload: LegacyPayload, - events: NormalizedStreamEvent[], - accumulatedTextRef: { value: string }, - ensureStarted: (events: NormalizedStreamEvent[], responseId?: string) => void, - emitCompleted: (events: NormalizedStreamEvent[]) => void, - completed: boolean, -): void { - ensureStarted(events, payload.session_id ?? payload.context_id); - - if (typeof payload.error === 'string') { - events.push({ - type: 'stream.error', - error: payload.error, - code: 'kagenti_error', - }); - emitCompleted(events); - return; - } - - const evt = payload.event; - const normalizedState = evt?.state?.toUpperCase(); - - if (normalizedState === 'FAILED') { - const msg = - typeof evt?.message === 'string' ? evt.message : 'Agent task failed'; - events.push({ - type: 'stream.error', - error: msg, - code: 'kagenti_task_failed', - }); - emitCompleted(events); - return; - } - - if (evt?.type === 'status' && evt.message) { - const text = - typeof evt.message === 'string' - ? evt.message - : JSON.stringify(evt.message); - if (text) { - if (normalizedState === 'WORKING') { - events.push({ type: 'stream.reasoning.delta', delta: text }); - } else { - accumulatedTextRef.value += text; - events.push({ type: 'stream.text.delta', delta: text }); - } - } - } - - if (evt?.type === 'artifact' && payload.content) { - const artifactName = - typeof (evt as Record).name === 'string' - ? ((evt as Record).name as string) - : undefined; - events.push({ - type: 'stream.artifact', - artifactId: `legacy-${Date.now()}`, - name: artifactName, - content: payload.content, - append: false, - lastChunk: true, - }); - accumulatedTextRef.value += payload.content; - events.push({ type: 'stream.text.delta', delta: payload.content }); - } - - const handledContent = - evt?.type === 'artifact' || (evt?.type === 'status' && evt.message); - if (!handledContent && payload.content) { - accumulatedTextRef.value += payload.content; - events.push({ type: 'stream.text.delta', delta: payload.content }); - } - - const isFinal = evt?.final === true || normalizedState === 'COMPLETED'; - const isDone = payload.done === true || payload.is_complete === true; - - if ((isFinal || isDone) && !completed) { - emitCompleted(events); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamTypes.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamTypes.ts deleted file mode 100644 index 1033cd1b08e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamTypes.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export interface KagentiStreamEvent { - id?: string; - status?: { - state?: string; - message?: { role?: string; parts?: Array<{ text?: string }> }; - }; - result?: { - status?: string; - message?: { role?: string; parts?: Array<{ text?: string }> }; - artifacts?: unknown[]; - }; - error?: { message?: string; code?: string }; - [key: string]: unknown; -} - -export interface KagentiStreamState { - textBuffer: string; - reasoningBuffer: string; - lastEventType: string | null; - responseId: string | undefined; - taskId: string | undefined; -} - -export function createInitialStreamState(): KagentiStreamState { - return { - textBuffer: '', - reasoningBuffer: '', - lastEventType: null, - responseId: undefined, - taskId: undefined, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamUiExtensions.ts b/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamUiExtensions.ts deleted file mode 100644 index 57fb24dc5be..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/kagenti/stream/streamUiExtensions.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - NormalizedStreamEvent, - StreamCitationReference, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { - TRAJECTORY_EXTENSION_URI, - ERROR_EXTENSION_URI, - CITATION_EXTENSION_URI, -} from '@kagenti/adk/extensions'; - -export function extractUiExtensions( - metadata: Record | undefined, - events: NormalizedStreamEvent[], - currentGroupIdRef: { value: string | undefined }, -): void { - if (!metadata) return; - - const trajectory = metadata[TRAJECTORY_EXTENSION_URI]; - if (Array.isArray(trajectory)) { - for (const step of trajectory) { - const s = step as Record; - const groupId = s.group_id ?? s.agent_name ?? s.agent; - if (typeof groupId === 'string' && groupId !== currentGroupIdRef.value) { - const fromAgent = currentGroupIdRef.value; - currentGroupIdRef.value = groupId; - events.push({ - type: 'stream.agent.handoff', - fromAgent, - toAgent: groupId, - } as NormalizedStreamEvent); - } - const title = s.title; - const content = s.content; - const text = [title, content].filter(Boolean).join(': '); - if (text) - events.push({ type: 'stream.reasoning.delta', delta: `${text}\n` }); - } - } - - const citation = metadata[CITATION_EXTENSION_URI]; - if (Array.isArray(citation) && citation.length > 0) { - events.push({ - type: 'stream.citation', - citations: citation as StreamCitationReference[], - }); - } -} - -export function extractStructuredError( - metadata: Record | undefined, - events: NormalizedStreamEvent[], - fallbackText: string, -): void { - const errorMeta = metadata?.[ERROR_EXTENSION_URI] as - | { - error?: { title?: string; message?: string }; - context?: Record; - } - | undefined; - - if (errorMeta?.error) { - events.push({ - type: 'stream.error', - error: errorMeta.error.message ?? 'Agent task failed', - code: 'kagenti_task_failed', - title: errorMeta.error.title, - context: errorMeta.context, - }); - } else { - events.push({ - type: 'stream.error', - error: fallbackText || 'Agent task failed', - code: 'kagenti_task_failed', - }); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/AgentGraphManager.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/AgentGraphManager.test.ts deleted file mode 100644 index bda70dc8f40..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/AgentGraphManager.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { AgentGraphManager } from './AgentGraphManager'; -import type { ConfigResolutionService } from './ConfigResolutionService'; -import type { ConfigLoader } from './ConfigLoader'; -import type { EffectiveConfig } from '../../types'; -import type { LoggerService } from '@backstage/backend-plugin-api'; - -function mockLogger(): LoggerService { - return { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - child: jest.fn().mockReturnThis(), - } as unknown as LoggerService; -} - -function baseEffectiveConfig( - overrides: Partial = {}, -): EffectiveConfig { - return { - model: 'test-model', - baseUrl: 'http://localhost:8321', - systemPrompt: 'You are helpful.', - enableWebSearch: false, - enableCodeInterpreter: false, - vectorStoreIds: [], - vectorStoreName: 'test', - embeddingModel: 'test-embed', - embeddingDimension: 384, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 800, - chunkOverlapTokens: 400, - skipTlsVerify: false, - zdrMode: false, - verboseStreamLogging: false, - ...overrides, - }; -} - -function mockConfigResolution( - config: EffectiveConfig, -): ConfigResolutionService { - return { - resolve: jest.fn().mockResolvedValue(config), - invalidateCache: jest.fn(), - } as unknown as ConfigResolutionService; -} - -function mockConfigLoader( - agentConfigs?: ReturnType, -): ConfigLoader { - return { - loadAgentConfigs: jest.fn().mockReturnValue(agentConfigs ?? null), - } as unknown as ConfigLoader; -} - -describe('AgentGraphManager', () => { - it('returns single-agent fallback when no agents configured', async () => { - const config = baseEffectiveConfig(); - const manager = new AgentGraphManager( - mockConfigResolution(config), - mockConfigLoader(), - mockLogger(), - ); - - const snapshot = await manager.getSnapshot(); - - expect(snapshot.agents.size).toBe(1); - expect(snapshot.agents.has('default')).toBe(true); - expect(snapshot.defaultAgentKey).toBe('default'); - expect(snapshot.agents.get('default')!.config.name).toBe('Assistant'); - }); - - it('resolves multi-agent config from EffectiveConfig', async () => { - const config = baseEffectiveConfig({ - agents: { - router: { - name: 'Router', - instructions: 'Route messages.', - handoffs: ['specialist'], - }, - specialist: { - name: 'Specialist', - instructions: 'Handle domain queries.', - handoffDescription: 'Domain expert.', - }, - }, - defaultAgent: 'router', - maxAgentTurns: 5, - }); - - const manager = new AgentGraphManager( - mockConfigResolution(config), - mockConfigLoader(), - mockLogger(), - ); - - const snapshot = await manager.getSnapshot(); - - expect(snapshot.agents.size).toBe(2); - expect(snapshot.defaultAgentKey).toBe('router'); - expect(snapshot.maxTurns).toBe(5); - }); - - it('caches snapshot across calls', async () => { - const configResolution = mockConfigResolution(baseEffectiveConfig()); - const manager = new AgentGraphManager( - configResolution, - mockConfigLoader(), - mockLogger(), - ); - - const snapshot1 = await manager.getSnapshot(); - const snapshot2 = await manager.getSnapshot(); - - expect(snapshot1).toBe(snapshot2); - expect(configResolution.resolve).toHaveBeenCalledTimes(1); - }); - - it('re-resolves after invalidation', async () => { - const config1 = baseEffectiveConfig({ systemPrompt: 'Prompt v1' }); - const config2 = baseEffectiveConfig({ - agents: { - agent1: { name: 'Agent 1', instructions: 'First agent.' }, - }, - defaultAgent: 'agent1', - }); - - const resolve = jest - .fn() - .mockResolvedValueOnce(config1) - .mockResolvedValueOnce(config2); - - const configResolution = { - resolve, - invalidateCache: jest.fn(), - } as unknown as ConfigResolutionService; - const manager = new AgentGraphManager( - configResolution, - mockConfigLoader(), - mockLogger(), - ); - - const snapshot1 = await manager.getSnapshot(); - expect(snapshot1.agents.has('default')).toBe(true); - - // Advance past the debounce window so invalidation triggers re-resolve - jest.spyOn(Date, 'now').mockReturnValue(Date.now() + 1000); - - manager.invalidate(); - const snapshot2 = await manager.getSnapshot(); - expect(snapshot2.agents.has('agent1')).toBe(true); - expect(snapshot2).not.toBe(snapshot1); - expect(resolve).toHaveBeenCalledTimes(2); - - jest.restoreAllMocks(); - }); - - it('falls back to single agent in ZDR mode with multi-agent config', async () => { - const logger = mockLogger(); - const config = baseEffectiveConfig({ - zdrMode: true, - agents: { - router: { name: 'Router', instructions: 'Route.' }, - }, - defaultAgent: 'router', - }); - - const manager = new AgentGraphManager( - mockConfigResolution(config), - mockConfigLoader(), - logger, - ); - - const snapshot = await manager.getSnapshot(); - - expect(snapshot.agents.has('default')).toBe(true); - expect(snapshot.maxTurns).toBe(1); - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining('Cannot use multi-agent mode with zdrMode=true'), - ); - }); - - it('falls back to first agent when default agent is missing', async () => { - const logger = mockLogger(); - const config = baseEffectiveConfig({ - agents: { - agent1: { name: 'Agent 1', instructions: 'Hello.' }, - }, - defaultAgent: 'nonexistent', - }); - - const manager = new AgentGraphManager( - mockConfigResolution(config), - mockConfigLoader(), - logger, - ); - - const snapshot = await manager.getSnapshot(); - - expect(snapshot.agents.has('agent1')).toBe(true); - expect(snapshot.defaultAgentKey).toBe('agent1'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('nonexistent'), - ); - }); - - it('shares in-flight resolution across concurrent calls', async () => { - let resolveCount = 0; - const resolve = jest.fn().mockImplementation(() => { - resolveCount++; - return Promise.resolve(baseEffectiveConfig()); - }); - - const configResolution = { - resolve, - invalidateCache: jest.fn(), - } as unknown as ConfigResolutionService; - const manager = new AgentGraphManager( - configResolution, - mockConfigLoader(), - mockLogger(), - ); - - const [s1, s2, s3] = await Promise.all([ - manager.getSnapshot(), - manager.getSnapshot(), - manager.getSnapshot(), - ]); - - expect(s1).toBe(s2); - expect(s2).toBe(s3); - expect(resolveCount).toBe(1); - }); - - it('does not cache stale snapshot if invalidated during resolution', async () => { - const config1 = baseEffectiveConfig({ systemPrompt: 'v1' }); - const config2 = baseEffectiveConfig({ - agents: { a: { name: 'A', instructions: 'Agent A.' } }, - defaultAgent: 'a', - }); - - let callCount = 0; - const manager = new AgentGraphManager( - { - resolve: jest.fn().mockImplementation(async () => { - callCount++; - if (callCount === 1) { - manager.invalidate(); - return config1; - } - return config2; - }), - invalidateCache: jest.fn(), - } as unknown as ConfigResolutionService, - mockConfigLoader(), - mockLogger(), - ); - - const snapshot1 = await manager.getSnapshot(); - expect(snapshot1.agents.has('default')).toBe(true); - - const snapshot2 = await manager.getSnapshot(); - expect(snapshot2.agents.has('a')).toBe(true); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/AgentGraphManager.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/AgentGraphManager.ts deleted file mode 100644 index 4d95218fd1f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/AgentGraphManager.ts +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ConfigResolutionService } from './ConfigResolutionService'; -import type { ConfigLoader } from './ConfigLoader'; -import { - resolveAgentGraph, - type AgentGraphSnapshot, -} from '../responses-api/agents/agentGraph'; -import { DEFAULT_SYSTEM_PROMPT } from '../../constants'; - -/** - * Manages the agent graph lifecycle with generation-based caching. - * - * Follows the OpenAI Agents SDK pattern: agents are resolved per-request, - * not frozen at startup. The manager produces an AgentGraphSnapshot from - * the effective config (YAML + DB overrides) and caches it until - * invalidated by an admin config change. - * - * Flow: - * 1. Admin saves agents → `invalidate()` increments generation - * 2. Next chat request → `getSnapshot()` detects stale cache → re-resolves - * 3. Snapshot is passed to `AdkOrchestrator.chat()` / `chatStream()` — immutable for that run - */ -export class AgentGraphManager { - private cachedSnapshot: AgentGraphSnapshot | null = null; - private generation = 0; - private snapshotGeneration = -1; - private inflightResolve: Promise | null = null; - private lastResolutionError: string | null = null; - private lastResolveTimestamp = 0; - private static readonly MIN_RESOLVE_INTERVAL_MS = 500; - - constructor( - private readonly configResolution: ConfigResolutionService, - private readonly configLoader: ConfigLoader, - private readonly logger: LoggerService, - ) {} - - getLastResolutionError(): string | null { - return this.lastResolutionError; - } - - /** - * Bump the generation counter so the next `getSnapshot()` re-resolves. - * Called by ResponsesApiCoordinator.invalidateRuntimeConfig(). - */ - invalidate(): void { - this.generation++; - } - - /** - * Get the current agent graph snapshot. Re-resolves from EffectiveConfig - * only when the generation has changed (i.e., after invalidation). - * Concurrent callers share a single in-flight resolution. - */ - async getSnapshot(): Promise { - if (this.cachedSnapshot && this.snapshotGeneration === this.generation) { - return this.cachedSnapshot; - } - - // Debounce: if we have a cached snapshot that was valid (successfully - // cached) and resolved very recently, return it to prevent thrashing - // under rapid admin save cycles. Only applies when the snapshot was - // actually cached (snapshotGeneration was updated), not when caching - // was skipped due to a mid-resolution invalidation. - if ( - this.cachedSnapshot && - this.snapshotGeneration >= 0 && - this.generation - this.snapshotGeneration <= 2 - ) { - const elapsed = Date.now() - this.lastResolveTimestamp; - if (elapsed < AgentGraphManager.MIN_RESOLVE_INTERVAL_MS) { - return this.cachedSnapshot; - } - } - - if (this.inflightResolve !== null) { - return this.inflightResolve; - } - - const targetGen = this.generation; - - this.inflightResolve = this.resolveSnapshot(targetGen).finally(() => { - this.inflightResolve = null; - }); - - return this.inflightResolve; - } - - private async resolveSnapshot( - targetGen: number, - ): Promise { - const config = await this.configResolution.resolve(); - - const isZdr = config.zdrMode ?? false; - - // Agents can come from two sources: - // 1. EffectiveConfig (YAML + DB merge via RuntimeConfigResolver) - // 2. ConfigLoader.loadAgentConfigs() (YAML-only fallback when - // no RuntimeConfigResolver exists, e.g. no AdminConfigService) - let agents = config.agents; - let defaultAgent = config.defaultAgent; - let maxAgentTurns = config.maxAgentTurns; - - if (!agents || Object.keys(agents).length === 0) { - const yamlAgents = this.configLoader.loadAgentConfigs(); - if (yamlAgents) { - agents = yamlAgents.agents; - defaultAgent = yamlAgents.defaultAgent; - maxAgentTurns = yamlAgents.maxAgentTurns; - } - } - - const hasAgents = agents && Object.keys(agents).length > 0; - - if (hasAgents && isZdr) { - const errorMsg = - 'Cannot use multi-agent mode with zdrMode=true. ' + - 'Multi-agent requires store:true for previous_response_id chaining. ' + - 'Falling back to auto-synthesized single agent.'; - this.logger.error(`[AgentGraphManager] ${errorMsg}`); - this.lastResolutionError = errorMsg; - return this.buildAndCache( - this.buildSingleAgentFallback(config.systemPrompt, 1), - targetGen, - ); - } - - if (hasAgents) { - try { - const snapshot = resolveAgentGraph( - agents!, - defaultAgent!, - maxAgentTurns, - this.logger, - ); - this.lastResolutionError = null; - return this.buildAndCache(snapshot, targetGen); - } catch (error) { - const errorMsg = `Failed to resolve agent graph: ${String(error)}. Falling back to single-agent mode.`; - this.logger.error(`[AgentGraphManager] ${errorMsg}`); - this.lastResolutionError = errorMsg; - return this.buildAndCache( - this.buildSingleAgentFallback( - config.systemPrompt, - isZdr ? 1 : (maxAgentTurns ?? 10), - ), - targetGen, - ); - } - } - - this.lastResolutionError = null; - const maxTurns = isZdr ? 1 : (maxAgentTurns ?? 10); - this.logger.info( - `[AgentGraphManager] No agents configured — auto-synthesized default agent (maxTurns=${maxTurns}, zdr=${isZdr})`, - ); - return this.buildAndCache( - this.buildSingleAgentFallback(config.systemPrompt, maxTurns), - targetGen, - ); - } - - private buildAndCache( - snapshot: AgentGraphSnapshot, - targetGen: number, - ): AgentGraphSnapshot { - if (this.generation === targetGen) { - this.cachedSnapshot = snapshot; - this.snapshotGeneration = targetGen; - } - this.lastResolveTimestamp = Date.now(); - return snapshot; - } - - private buildSingleAgentFallback( - systemPrompt: string | undefined, - maxTurns: number, - ): AgentGraphSnapshot { - return resolveAgentGraph( - { - default: { - name: 'Assistant', - instructions: systemPrompt || DEFAULT_SYSTEM_PROMPT, - enableRAG: true, - }, - }, - 'default', - maxTurns, - this.logger, - ); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalContinuationExecutor.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalContinuationExecutor.test.ts deleted file mode 100644 index 4e2f8d8c965..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalContinuationExecutor.test.ts +++ /dev/null @@ -1,509 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { ResponsesApiClient } from './ResponsesApiClient'; -import type { MCPServerConfig } from '../../types'; -import { - executeApprovalContinuation, - type ApprovalContinuationDeps, - type ApprovalContinuationParams, -} from './ApprovalContinuationExecutor'; -import { createMockLogger } from '../../test-utils/mocks'; - -function createMockClient(): ResponsesApiClient & { request: jest.Mock } { - const request = jest.fn(); - return { request } as unknown as ResponsesApiClient & { request: jest.Mock }; -} - -function createMockMcpAuth(): { - getServerHeaders: jest.Mock; - getApiApprovalConfig: jest.Mock; -} { - return { - getServerHeaders: jest.fn().mockResolvedValue({}), - getApiApprovalConfig: jest.fn().mockReturnValue('never'), - }; -} - -function createBaseDeps( - overrides?: Partial, -): ApprovalContinuationDeps { - const mockClient = createMockClient(); - const mcpAuth = createMockMcpAuth(); - return { - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - mcpAuth: mcpAuth as unknown as ApprovalContinuationDeps['mcpAuth'], - mcpServers: [], - getConversationForResponse: jest.fn().mockResolvedValue(undefined), - registerResponse: jest.fn().mockResolvedValue(undefined), - logger: createMockLogger(), - ...overrides, - }; -} - -function createBaseParams( - overrides?: Partial, -): ApprovalContinuationParams { - return { - responseId: 'resp-1', - approvalRequestId: 'apr-1', - approved: true, - toolName: 'projects_list', - toolArguments: '{"org":"acme"}', - attempt: 0, - maxAttempts: 3, - ...overrides, - }; -} - -describe('executeApprovalContinuation', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('1. Successful approval flow', () => { - it('approved tool call executes and returns response', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [ - { - type: 'mcp_call', - name: 'projects_list', - output: '{"projects":["alpha","beta"]}', - }, - { - type: 'message', - content: [{ type: 'output_text', text: 'Here are your projects.' }], - }, - ], - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: true, - toolName: 'projects_list', - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(result.toolExecuted).toBe(true); - expect(result.responseId).toBe('resp-2'); - expect(result.content).toBe('Here are your projects.'); - expect(result.toolOutput).toBe('{"projects":["alpha","beta"]}'); - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/responses', - expect.objectContaining({ - method: 'POST', - body: expect.any(String), - }), - ); - }); - - it('uses conversationId when provided', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [{ type: 'mcp_call', name: 'projects_list', output: 'ok' }], - }); - - const registerResponse = jest.fn().mockResolvedValue(undefined); - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - registerResponse, - }); - const params = createBaseParams({ - conversationId: 'conv-123', - approved: true, - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(result.responseId).toBe('resp-2'); - expect(registerResponse).toHaveBeenCalledWith('conv-123', 'resp-2'); - }); - - it('resolves conversationId from registry when not provided', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [{ type: 'mcp_call', name: 'projects_list', output: 'ok' }], - }); - - const getConversationForResponse = jest - .fn() - .mockResolvedValue('conv-from-registry'); - const registerResponse = jest.fn().mockResolvedValue(undefined); - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - getConversationForResponse, - registerResponse, - }); - const params = createBaseParams({ approved: true }); - - await executeApprovalContinuation(deps, params); - - expect(getConversationForResponse).toHaveBeenCalledWith('resp-1'); - expect(registerResponse).toHaveBeenCalledWith( - 'conv-from-registry', - 'resp-2', - ); - }); - }); - - describe('2. Rejected tool call', () => { - it('denied tool call returns appropriate response without executing', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [], - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: false, - toolName: 'deploy_app', - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(result.toolExecuted).toBe(false); - expect(result.responseId).toBe('resp-2'); - expect(result.content).toContain('Tool Rejected'); - expect(result.content).toContain('deploy_app'); - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/responses', - expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('"approve":false'), - }), - ); - }); - }); - - describe('3. Auto-reapproval', () => { - it('auto-approves chained approval request for same tool (recursive)', async () => { - const mockClient = createMockClient(); - mockClient.request - .mockResolvedValueOnce({ - id: 'resp-2', - output: [ - { - type: 'mcp_approval_request', - id: 'apr-2', - name: 'projects_list', - server_label: 'ocp-mcp', - arguments: '{"org":"other"}', - }, - ], - }) - .mockResolvedValueOnce({ - id: 'resp-3', - output: [ - { - type: 'mcp_call', - name: 'projects_list', - output: '{"projects":["x","y"]}', - }, - { - type: 'message', - content: [{ type: 'output_text', text: 'Done.' }], - }, - ], - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: true, - toolName: 'projects_list', - attempt: 0, - maxAttempts: 3, - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(mockClient.request).toHaveBeenCalledTimes(2); - expect(result.toolExecuted).toBe(true); - expect(result.responseId).toBe('resp-3'); - expect(result.content).toBe('Done.'); - }); - - it('stops auto-reapproval when maxAttempts reached', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [ - { - type: 'mcp_approval_request', - id: 'apr-2', - name: 'projects_list', - arguments: '{}', - }, - ], - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: true, - toolName: 'projects_list', - attempt: 3, - maxAttempts: 3, - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(mockClient.request).toHaveBeenCalledTimes(1); - expect(result.pendingApproval).toBeDefined(); - expect(result.pendingApproval?.approvalRequestId).toBe('apr-2'); - }); - - it('does not auto-reapprove when chained tool is different', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [ - { - type: 'mcp_approval_request', - id: 'apr-2', - name: 'deploy_app', - arguments: '{}', - }, - ], - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: true, - toolName: 'projects_list', - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(mockClient.request).toHaveBeenCalledTimes(1); - expect(result.pendingApproval).toBeDefined(); - expect(result.pendingApproval?.toolName).toBe('deploy_app'); - }); - - it('does not auto-reapprove when user originally rejected', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [ - { - type: 'mcp_approval_request', - id: 'apr-2', - name: 'projects_list', - arguments: '{}', - }, - ], - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: false, - toolName: 'projects_list', - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(mockClient.request).toHaveBeenCalledTimes(1); - expect(result.toolExecuted).toBe(false); - expect(result.content).toContain('Tool Rejected'); - }); - }); - - describe('4. Error handling', () => { - it('returns error content when API call fails (approved)', async () => { - const mockClient = createMockClient(); - mockClient.request.mockRejectedValue(new Error('Connection refused')); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: true, - toolName: 'projects_list', - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(result.toolExecuted).toBe(false); - expect(result.responseId).toBe('resp-1'); - expect(result.content).toContain('Failed to execute tool'); - expect(result.content).toContain('projects_list'); - expect(result.content).toContain('Connection refused'); - }); - - it('returns error content when API call fails (rejected)', async () => { - const mockClient = createMockClient(); - mockClient.request.mockRejectedValue(new Error('Network timeout')); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: false, - toolName: 'deploy_app', - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(result.toolExecuted).toBe(false); - expect(result.content).toContain('Failed to reject tool'); - expect(result.content).toContain('Network timeout'); - }); - - it('uses "unknown" when toolName is undefined in error', async () => { - const mockClient = createMockClient(); - mockClient.request.mockRejectedValue(new Error('Server error')); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: true, - toolName: undefined, - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(result.content).toContain('unknown'); - expect(result.content).toContain('Server error'); - }); - }); - - describe('5. MCP tool building', () => { - const directModeServer: MCPServerConfig = { - id: 'srv-1', - name: 'Server 1', - type: 'streamable-http', - url: 'http://mcp.example.com/direct', - }; - - it('direct mode: uses server URL and calls getServerHeaders', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [{ type: 'mcp_call', name: 'projects_list', output: 'ok' }], - }); - - const mcpAuth = createMockMcpAuth(); - mcpAuth.getServerHeaders.mockResolvedValue({ - Authorization: 'Bearer token123', - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - mcpServers: [directModeServer], - mcpAuth: mcpAuth as unknown as ApprovalContinuationDeps['mcpAuth'], - }); - const params = createBaseParams(); - - await executeApprovalContinuation(deps, params); - - const body = JSON.parse(mockClient.request.mock.calls[0][1].body); - expect(body.tools).toHaveLength(1); - expect(body.tools[0]).toMatchObject({ - type: 'mcp', - server_url: 'http://mcp.example.com/direct', - server_label: 'srv-1', - headers: { Authorization: 'Bearer token123' }, - }); - expect(mcpAuth.getServerHeaders).toHaveBeenCalledWith(directModeServer); - }); - - it('uses require_approval never for approval continuation (user already approved)', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [{ type: 'mcp_call', name: 'projects_list', output: 'ok' }], - }); - - const mcpAuth = createMockMcpAuth(); - mcpAuth.getApiApprovalConfig.mockReturnValue('always'); - - const serverWithApproval: MCPServerConfig = { - ...directModeServer, - requireApproval: 'always', - }; - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - mcpServers: [serverWithApproval], - mcpAuth: mcpAuth as unknown as ApprovalContinuationDeps['mcpAuth'], - }); - const params = createBaseParams(); - - await executeApprovalContinuation(deps, params); - - const body = JSON.parse(mockClient.request.mock.calls[0][1].body); - expect(body.tools[0].require_approval).toBe('never'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalContinuationExecutor.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalContinuationExecutor.ts deleted file mode 100644 index 325fa93b61e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalContinuationExecutor.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/tools/ApprovalContinuationExecutor' instead */ -export * from '../responses-api/tools/ApprovalContinuationExecutor'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalHandler.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalHandler.test.ts deleted file mode 100644 index c5918e9034a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalHandler.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { buildApprovalRequest, parseApprovalResponse } from './ApprovalHandler'; -import { createMockLogger } from '../../test-utils/mocks'; - -const mockLogger = createMockLogger(); - -const BASE_OPTS = { - model: 'test-model', - responseId: 'resp-1', - approvalRequestId: 'apr-1', - toolName: 'projects_list', - toolArguments: '{"org":"acme"}', - tools: [{ type: 'mcp', server_url: 'https://mcp' }], -}; - -describe('buildApprovalRequest', () => { - it('sends native mcp_approval_response with approve: true when approved', () => { - const result = buildApprovalRequest({ ...BASE_OPTS, approved: true }); - - expect(result.input).toEqual([ - { - type: 'mcp_approval_response', - approval_request_id: 'apr-1', - approve: true, - }, - ]); - expect(result.model).toBe('test-model'); - expect(result.tools).toEqual([ - { type: 'mcp', server_url: 'https://mcp', require_approval: 'never' }, - ]); - expect(result.store).toBe(true); - expect(result.previous_response_id).toBe('resp-1'); - expect(result).not.toHaveProperty('conversation'); - expect(result.tool_choice).toBe('required'); - }); - - it('sends native mcp_approval_response with approve: false when rejected', () => { - const result = buildApprovalRequest({ ...BASE_OPTS, approved: false }); - - expect(result.input).toEqual([ - { - type: 'mcp_approval_response', - approval_request_id: 'apr-1', - approve: false, - }, - ]); - expect(result.store).toBe(true); - expect(result.previous_response_id).toBe('resp-1'); - }); - - it('always uses previous_response_id', () => { - const result = buildApprovalRequest({ - ...BASE_OPTS, - approved: true, - }); - - expect(result.previous_response_id).toBe('resp-1'); - expect(result).not.toHaveProperty('conversation'); - }); - - it('includes tools array for both approval and rejection', () => { - const approved = buildApprovalRequest({ ...BASE_OPTS, approved: true }); - const rejected = buildApprovalRequest({ ...BASE_OPTS, approved: false }); - - const expectedTools = [ - { type: 'mcp', server_url: 'https://mcp', require_approval: 'never' }, - ]; - expect(approved.tools).toEqual(expectedTools); - expect(rejected.tools).toEqual(expectedTools); - }); -}); - -describe('parseApprovalResponse', () => { - it('returns rejection message when not approved', () => { - const result = parseApprovalResponse( - { id: 'resp-2', output: [] }, - false, - 'projects_list', - mockLogger, - ); - - expect(result.content).toContain('Tool Rejected'); - expect(result.content).toContain('projects_list'); - expect(result.toolExecuted).toBe(false); - }); - - it('extracts successful tool execution from mcp_call output', () => { - const result = parseApprovalResponse( - { - id: 'resp-2', - output: [ - { - type: 'mcp_call', - name: 'projects_list', - output: '{"projects":["alpha"]}', - }, - { - type: 'message', - content: [{ type: 'output_text', text: 'Here are your projects.' }], - }, - ], - }, - true, - 'projects_list', - mockLogger, - ); - - expect(result.toolExecuted).toBe(true); - expect(result.toolOutput).toBe('{"projects":["alpha"]}'); - expect(result.content).toBe('Here are your projects.'); - expect(result.responseId).toBe('resp-2'); - }); - - it('uses default success message when LLM message is trivial', () => { - const result = parseApprovalResponse( - { - id: 'resp-2', - output: [ - { type: 'mcp_call', name: 'run_query', output: '42' }, - { type: 'message', content: [{ type: 'output_text', text: 'Ok' }] }, - ], - }, - true, - 'run_query', - mockLogger, - ); - - expect(result.content).toContain('executed successfully'); - }); - - it('reports tool error when mcp_call has error field', () => { - const result = parseApprovalResponse( - { - id: 'resp-2', - output: [ - { type: 'mcp_call', name: 'deploy', error: 'permission denied' }, - ], - }, - true, - 'deploy', - mockLogger, - ); - - expect(result.content).toContain('Tool Failed'); - expect(result.content).toContain('permission denied'); - expect(result.toolExecuted).toBe(true); - }); - - it('handles approved with no tool output', () => { - const result = parseApprovalResponse( - { id: 'resp-2', output: [] }, - true, - 'some_tool', - mockLogger, - ); - - expect(result.content).toContain('approved but no output received'); - expect(result.toolExecuted).toBe(false); - }); - - it('uses "unknown" when toolName is undefined', () => { - const result = parseApprovalResponse( - { id: 'resp-2', output: [] }, - false, - undefined, - mockLogger, - ); - - expect(result.content).toContain('unknown'); - }); - - it('strips proxy prefix from tool name in user-facing content', () => { - const result = parseApprovalResponse( - { - id: 'resp-2', - output: [ - { - type: 'mcp_call', - name: 'ocp_mcp__projects_list', - server_label: 'ocp-mcp', - output: '{"projects":["ns1"]}', - }, - { - type: 'message', - content: [{ type: 'output_text', text: 'Ok' }], - }, - ], - }, - true, - 'ocp_mcp__projects_list', - mockLogger, - ); - - expect(result.content).toContain('projects_list'); - expect(result.content).not.toContain('ocp_mcp__'); - expect(result.toolExecuted).toBe(true); - }); - - it('preserves prefixed name in rejection when no server_label available', () => { - const result = parseApprovalResponse( - { id: 'resp-2', output: [] }, - false, - 'aap_mcp__job_templates_list', - mockLogger, - ); - - expect(result.content).toContain('Tool Rejected'); - expect(result.content).toContain('aap_mcp__job_templates_list'); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalHandler.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalHandler.ts deleted file mode 100644 index 81bb1f5a84f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ApprovalHandler.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/tools/ApprovalHandler' instead */ -export * from '../responses-api/tools/ApprovalHandler'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendApprovalHandler.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendApprovalHandler.test.ts deleted file mode 100644 index cf7003dabc2..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendApprovalHandler.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { BackendApprovalHandler } from './BackendApprovalHandler'; -import { createMockLogger } from '../../test-utils/mocks'; -import type { PendingBackendToolApproval } from './BackendApprovalStore'; - -function createMockDeps() { - const mockChatService = { - continueFunctionCallOutput: jest.fn().mockResolvedValue({ - responseId: 'resp-new', - text: 'Tool result processed', - functionCalls: [], - }), - }; - - const pending: PendingBackendToolApproval = { - responseId: 'resp-1', - callId: 'call-1', - functionName: 'ocp-mcp__namespaces_list', - argumentsJson: '{"all":true}', - serverId: 'ocp-mcp', - serverUrl: 'https://mcp.example.com', - originalToolName: 'namespaces_list', - conversationId: 'conv_abc123', - createdAt: Date.now(), - }; - - const mockApprovalStore = { - get: jest.fn().mockReturnValue(pending), - store: jest.fn(), - remove: jest.fn(), - listPending: jest.fn().mockReturnValue([]), - }; - - const mockExecutor = { - executeTool: jest.fn().mockResolvedValue('ns1\nns2\nns3'), - ensureToolsDiscovered: jest.fn().mockResolvedValue([]), - getToolServerInfo: jest.fn().mockReturnValue(undefined), - }; - - let handlerFn: - | ((approval: { - responseId: string; - callId: string; - approved: boolean; - toolName?: string; - toolArguments?: string; - }) => Promise) - | null = null; - - const mockFacade = { - setBackendApprovalHandler: jest - .fn() - .mockImplementation((_store: unknown, fn: typeof handlerFn) => { - handlerFn = fn; - }), - }; - - const mockClient = { request: jest.fn(), streamRequest: jest.fn() }; - const mockClientManager = { - getExistingClient: jest.fn().mockReturnValue(mockClient), - }; - - const mockConfigResolution = { - getLlamaStackConfig: jest.fn().mockReturnValue({ model: 'test-model' }), - getResolver: jest.fn().mockReturnValue({ - getCachedConfig: jest.fn().mockReturnValue({ - model: 'test-model', - guardrails: undefined, - safetyIdentifier: undefined, - }), - }), - }; - - const mockConversations = { - registerResponse: jest.fn(), - }; - - return { - deps: { - conversationFacade: mockFacade as any, - backendApprovalStore: mockApprovalStore as any, - backendToolExecutor: mockExecutor as any, - chatService: mockChatService as any, - clientManager: mockClientManager as any, - configResolution: mockConfigResolution as any, - getConversations: () => mockConversations as any, - getAgentGraphManager: () => null, - getMcpServers: () => [], - logger: createMockLogger(), - }, - mocks: { - chatService: mockChatService, - approvalStore: mockApprovalStore, - executor: mockExecutor, - facade: mockFacade, - conversations: mockConversations, - pending, - }, - getHandler: () => handlerFn!, - }; -} - -describe('BackendApprovalHandler', () => { - it('passes functionCall and conversationId to continueFunctionCallOutput on approval', async () => { - const { deps, mocks, getHandler } = createMockDeps(); - const handler = new BackendApprovalHandler(deps); - handler.initialize(); - - const handlerFn = getHandler(); - await handlerFn({ - responseId: 'resp-1', - callId: 'call-1', - approved: true, - toolName: 'namespaces_list', - toolArguments: '{"all":true}', - }); - - expect(mocks.chatService.continueFunctionCallOutput).toHaveBeenCalledWith( - expect.objectContaining({ - callId: 'call-1', - previousResponseId: 'resp-1', - functionCall: { - name: 'ocp-mcp__namespaces_list', - arguments: '{"all":true}', - }, - conversationId: 'conv_abc123', - }), - ); - }); - - it('passes functionCall to continueFunctionCallOutput on rejection', async () => { - const { deps, mocks, getHandler } = createMockDeps(); - const handler = new BackendApprovalHandler(deps); - handler.initialize(); - - const handlerFn = getHandler(); - await handlerFn({ - responseId: 'resp-1', - callId: 'call-1', - approved: false, - toolName: 'namespaces_list', - }); - - const call = mocks.chatService.continueFunctionCallOutput.mock.calls[0][0]; - expect(call.functionCall).toEqual({ - name: 'ocp-mcp__namespaces_list', - arguments: '{"all":true}', - }); - expect(call.conversationId).toBe('conv_abc123'); - expect(JSON.parse(call.output)).toEqual( - expect.objectContaining({ error: expect.stringContaining('rejected') }), - ); - }); - - it('registers response in conversation when conversationId is present', async () => { - const { deps, mocks, getHandler } = createMockDeps(); - const handler = new BackendApprovalHandler(deps); - handler.initialize(); - - const handlerFn = getHandler(); - await handlerFn({ - responseId: 'resp-1', - callId: 'call-1', - approved: true, - toolName: 'namespaces_list', - }); - - expect(mocks.conversations.registerResponse).toHaveBeenCalledWith( - 'conv_abc123', - 'resp-new', - ); - }); - - it('handles tool execution failure gracefully', async () => { - const { deps, mocks, getHandler } = createMockDeps(); - mocks.executor.executeTool.mockRejectedValue(new Error('MCP server down')); - const handler = new BackendApprovalHandler(deps); - handler.initialize(); - - const handlerFn = getHandler(); - const result = await handlerFn({ - responseId: 'resp-1', - callId: 'call-1', - approved: true, - toolName: 'namespaces_list', - }); - - const call = mocks.chatService.continueFunctionCallOutput.mock.calls[0][0]; - expect(JSON.parse(call.output)).toEqual( - expect.objectContaining({ - error: expect.stringContaining('MCP server down'), - }), - ); - expect(result).toEqual(expect.objectContaining({ toolExecuted: true })); - }); - - it('handles missing approval store entry gracefully', async () => { - const { deps, mocks, getHandler } = createMockDeps(); - mocks.approvalStore.get.mockReturnValue(undefined); - - const handler = new BackendApprovalHandler(deps); - handler.initialize(); - - const handlerFn = getHandler(); - const result = await handlerFn({ - responseId: 'resp-1', - callId: 'call-1', - approved: true, - toolName: 'namespaces_list', - toolArguments: '{"all":true}', - }); - - expect(mocks.executor.executeTool).toHaveBeenCalled(); - expect(result).toEqual(expect.objectContaining({ toolExecuted: true })); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendApprovalHandler.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendApprovalHandler.ts deleted file mode 100644 index 1ebbdc9925c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendApprovalHandler.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { BackendApprovalStore } from './BackendApprovalStore'; -import type { BackendToolExecutor } from './BackendToolExecutor'; -import type { ConversationFacade } from './ConversationFacade'; -import type { ResponsesApiService } from './ResponsesApiService'; -import type { ClientManager } from './ClientManager'; -import type { ConfigResolutionService } from './ConfigResolutionService'; -import type { ConversationService } from './ConversationService'; -import type { AgentGraphManager } from './AgentGraphManager'; -import type { MCPServerConfig } from '../../types'; -import { toErrorMessage } from '../../services/utils'; -import { continueWithFunctionCallOutput } from './approvalContinuation'; -import type { ContinuationDeps } from './approvalContinuation'; - -export class BackendApprovalHandler { - constructor( - private readonly deps: { - conversationFacade: ConversationFacade; - backendApprovalStore: BackendApprovalStore; - backendToolExecutor: BackendToolExecutor; - chatService: ResponsesApiService; - clientManager: ClientManager; - configResolution: ConfigResolutionService; - getConversations: () => ConversationService | null; - getAgentGraphManager?: () => AgentGraphManager | null; - getMcpServers: () => MCPServerConfig[]; - logger: LoggerService; - }, - ) {} - - initialize(): void { - const { - conversationFacade, - backendApprovalStore, - backendToolExecutor, - logger, - } = this.deps; - - const continuationDeps: ContinuationDeps = { - backendApprovalStore, - backendToolExecutor, - chatService: this.deps.chatService, - clientManager: this.deps.clientManager, - configResolution: this.deps.configResolution, - getConversations: this.deps.getConversations, - getAgentGraphManager: this.deps.getAgentGraphManager, - getMcpServers: this.deps.getMcpServers, - logger, - }; - - conversationFacade.setBackendApprovalHandler( - backendApprovalStore, - async approval => { - const pending = backendApprovalStore.get( - approval.responseId, - approval.callId, - ); - const functionName = pending?.functionName ?? approval.toolName ?? ''; - const argsJson = - pending?.argumentsJson ?? approval.toolArguments ?? '{}'; - - const functionCall = { name: functionName, arguments: argsJson }; - - if (!approval.approved) { - const rejectionOutput = JSON.stringify({ - error: `Tool "${approval.toolName}" was rejected by the user.`, - }); - return continueWithFunctionCallOutput( - continuationDeps, - approval.responseId, - approval.callId, - rejectionOutput, - pending?.conversationId, - functionCall, - pending?.agentKey, - ); - } - - let output: string; - try { - output = await backendToolExecutor.executeTool( - functionName, - argsJson, - ); - } catch (error) { - output = JSON.stringify({ - error: `Tool execution failed: ${toErrorMessage(error)}`, - }); - } - - return continueWithFunctionCallOutput( - continuationDeps, - approval.responseId, - approval.callId, - output, - pending?.conversationId, - functionCall, - pending?.agentKey, - ); - }, - ); - - logger.info('[BackendApproval] Backend tool approval handler initialized'); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendApprovalStore.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendApprovalStore.ts deleted file mode 100644 index 49cc1533a4f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendApprovalStore.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/tools/BackendApprovalStore' instead */ -export * from '../responses-api/tools/BackendApprovalStore'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendToolEvents.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendToolEvents.test.ts deleted file mode 100644 index 107bf58374e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendToolEvents.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Integration test verifying the full backend-tool-execution-to-UI-rendering - * pipeline. Validates that: - * 1. AdkOrchestrator emits stream.tool.started/completed/failed events - * 2. ResponsesApiProvider forwards them (since they start with 'stream.') - * 3. The StreamingMessage reducer produces correct toolCalls state - */ - -import { createMockLogger } from '../../test-utils/mocks'; -import { sanitizeToolsForServer } from './ToolsBuilder'; -import type { ResponsesApiTool } from '../../types'; -import type { CapabilityInfo } from '../responses-api/types'; - -describe('Backend Tool Events', () => { - it('events with stream. prefix pass through ResponsesApiProvider forwarding logic', () => { - const events = [ - { type: 'stream.backend_tool.executing', toolCount: 1, tools: ['t1'] }, - { type: 'stream.tool.started', callId: 'c1', name: 't1' }, - { type: 'stream.tool.completed', callId: 'c1', name: 't1', output: 'ok' }, - { type: 'stream.tool.failed', callId: 'c2', name: 't2', error: 'fail' }, - ]; - - for (const event of events) { - expect(event.type.startsWith('stream.')).toBe(true); - } - }); - - it('stream.tool.started includes arguments field', () => { - const event = { - type: 'stream.tool.started', - callId: 'call-123', - name: 'list_pods', - serverLabel: 'ocp-mcp', - arguments: '{"namespace": "default"}', - }; - expect(event.arguments).toBe('{"namespace": "default"}'); - expect(event.serverLabel).toBe('ocp-mcp'); - }); -}); - -describe('Direct-mode MCP tool display regression', () => { - const logger = createMockLogger(); - - it('sanitizeToolsForServer does not alter mcp tools', () => { - const mcpTool: ResponsesApiTool = { - type: 'mcp', - server_url: 'https://mcp.example.com', - server_label: 'my-mcp', - require_approval: 'never', - headers: { Authorization: 'Bearer tok' }, - allowed_tools: ['tool_a', 'tool_b'], - }; - - const caps: CapabilityInfo = { - functionTools: true, - strictField: false, - maxOutputTokens: false, - mcpTools: true, - parallelToolCalls: true, - truncation: true, - }; - - const result = sanitizeToolsForServer([mcpTool], caps, logger); - expect(result).toEqual([mcpTool]); - }); - - it('sanitizeToolsForServer does not alter non-function tool types', () => { - const tools: ResponsesApiTool[] = [ - { type: 'web_search' }, - { type: 'code_interpreter' }, - { type: 'file_search', vector_store_ids: ['vs-1'] }, - ]; - - const caps: CapabilityInfo = { - functionTools: true, - strictField: false, - maxOutputTokens: true, - mcpTools: true, - parallelToolCalls: true, - truncation: true, - }; - - const result = sanitizeToolsForServer(tools, caps, logger); - expect(result).toEqual(tools); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendToolExecutor.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendToolExecutor.ts deleted file mode 100644 index beb88a68d24..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BackendToolExecutor.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/tools/BackendToolExecutor' instead */ -export * from '../responses-api/tools/BackendToolExecutor'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BrandingConfigLoader.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BrandingConfigLoader.ts deleted file mode 100644 index 8d0e91edd44..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/BrandingConfigLoader.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './config/BrandingConfigLoader'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/CapabilityBuilders.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/CapabilityBuilders.test.ts deleted file mode 100644 index e417fac7947..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/CapabilityBuilders.test.ts +++ /dev/null @@ -1,830 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - buildConversationsCapability, - buildRagCapability, - buildSafetyCapability, - buildEvaluationCapability, -} from './CapabilityBuilders'; -import type { ConversationFacade } from './ConversationFacade'; -import type { VectorStoreFacade } from './VectorStoreFacade'; -import type { SafetyService } from './SafetyService'; -import type { EvaluationService } from './EvaluationService'; -import type { ProcessedMessage } from '../types'; -import { - FileFormat, - type DocumentInfo, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -function createMockConversationFacade(): jest.Mocked { - return { - createConversation: jest.fn().mockResolvedValue('conv_123'), - listConversations: jest.fn().mockResolvedValue({ - conversations: [], - hasMore: false, - }), - getConversation: jest.fn().mockResolvedValue(null), - getConversationInputs: jest.fn().mockResolvedValue({ - items: [], - hasMore: false, - }), - deleteConversation: jest.fn().mockResolvedValue(true), - getProcessedMessages: jest.fn().mockResolvedValue([]), - walkResponseChain: jest.fn().mockResolvedValue([]), - continueAfterApproval: jest.fn().mockResolvedValue({ - responseId: 'r1', - output: [], - }), - } as unknown as jest.Mocked; -} - -function createMockVectorStoreFacade(): jest.Mocked { - return { - listDocuments: jest.fn().mockResolvedValue([]), - listVectorStores: jest.fn().mockResolvedValue([]), - getDefaultVectorStoreId: jest.fn().mockReturnValue('vs_123'), - getActiveVectorStoreIds: jest.fn().mockResolvedValue(['vs_123']), - syncDocuments: jest.fn().mockResolvedValue({ - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [], - }), - uploadDocument: jest.fn().mockResolvedValue({ - fileId: 'f1', - fileName: 'doc.txt', - status: 'completed', - }), - deleteDocument: jest.fn().mockResolvedValue({ success: true }), - searchVectorStore: jest.fn().mockResolvedValue({ - query: 'test', - chunks: [], - vectorStoreId: 'vs_123', - totalResults: 0, - }), - getVectorStoreConfig: jest.fn().mockResolvedValue(null), - createVectorStoreWithConfig: jest.fn().mockResolvedValue({ - vectorStoreId: 'vs_new', - vectorStoreName: 'new-store', - created: true, - embeddingModel: 'model', - embeddingDimension: 384, - }), - getVectorStoreStatus: jest.fn().mockResolvedValue({ - exists: true, - vectorStoreId: 'vs_123', - ready: true, - }), - addVectorStoreId: jest.fn(), - removeVectorStoreId: jest.fn(), - deleteVectorStore: jest.fn().mockResolvedValue({ - success: true, - filesDeleted: 0, - }), - } as unknown as jest.Mocked; -} - -function createMockSafetyService(): jest.Mocked { - return { - isEnabled: jest.fn().mockReturnValue(true), - getAvailableShields: jest.fn().mockReturnValue([ - { - identifier: 'shield1', - provider_id: 'p1', - provider_resource_id: 'pr1', - type: 'llama-guard', - }, - { - identifier: 'shield2', - provider_id: 'p2', - provider_resource_id: 'pr2', - type: 'prompt-guard', - }, - ]), - checkInput: jest.fn().mockResolvedValue(undefined), - checkOutput: jest.fn().mockResolvedValue(undefined), - } as unknown as jest.Mocked; -} - -function createMockEvaluationService(): jest.Mocked { - return { - isEnabled: jest.fn().mockReturnValue(true), - getAvailableScoringFunctions: jest.fn().mockReturnValue([ - { - identifier: 'score1', - provider_id: 'p1', - provider_resource_id: 'pr1', - type: 'llm-as-judge', - }, - { - identifier: 'score2', - provider_id: 'p2', - provider_resource_id: 'pr2', - type: 'regex', - }, - ]), - scoreResponse: jest.fn().mockResolvedValue({ - overallScore: 0.9, - scores: { score1: 0.9 }, - passedThreshold: true, - qualityLevel: 'good', - evaluatedAt: new Date().toISOString(), - }), - } as unknown as jest.Mocked; -} - -describe('CapabilityBuilders', () => { - describe('buildConversationsCapability', () => { - let mockFacade: jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - mockFacade = createMockConversationFacade(); - }); - - it('create delegates to facade.createConversation', async () => { - const cap = buildConversationsCapability(mockFacade); - const result = await cap.create(); - expect(mockFacade.createConversation).toHaveBeenCalledTimes(1); - expect(result).toEqual({ conversationId: 'conv_123' }); - }); - - it('list delegates to facade.listConversations with limit, desc, after', async () => { - const listResult = { - conversations: [ - { - responseId: 'r1', - conversationId: 'c1', - createdAt: new Date(0), - preview: 'Chat', - model: 'model', - status: 'completed' as const, - }, - ], - hasMore: true, - }; - mockFacade.listConversations.mockResolvedValue(listResult); - - const cap = buildConversationsCapability(mockFacade); - const result = await cap.list(10, 'after_1'); - - expect(mockFacade.listConversations).toHaveBeenCalledWith( - 10, - 'desc', - 'after_1', - ); - expect(result).toEqual(listResult); - }); - - it('get delegates to facade.getConversation and returns details', async () => { - const details = { - id: 'c1', - createdAt: new Date(0), - model: 'model', - status: 'completed' as const, - input: 'hello', - output: [], - }; - mockFacade.getConversation.mockResolvedValue(details); - - const cap = buildConversationsCapability(mockFacade); - const result = await cap.get('r1'); - - expect(mockFacade.getConversation).toHaveBeenCalledWith('r1'); - expect(result).toEqual(details); - }); - - it('get throws when facade.getConversation returns null', async () => { - mockFacade.getConversation.mockResolvedValue(null); - const cap = buildConversationsCapability(mockFacade); - await expect(cap.get('r1')).rejects.toThrow('Conversation not found: r1'); - }); - - it('getInputs delegates to facade.getConversationInputs', async () => { - const inputs = { - items: [{ type: 'message', role: 'user', content: 'hi' }], - hasMore: false, - }; - mockFacade.getConversationInputs.mockResolvedValue(inputs); - - const cap = buildConversationsCapability(mockFacade); - const result = await cap.getInputs('r1'); - - expect(mockFacade.getConversationInputs).toHaveBeenCalledWith('r1'); - expect(result).toEqual(inputs); - }); - - it('getByResponseChain delegates to facade.walkResponseChain and maps items', async () => { - mockFacade.walkResponseChain.mockResolvedValue([ - { role: 'user', text: 'hello' }, - { role: 'assistant', text: 'hi' }, - ]); - - const cap = buildConversationsCapability(mockFacade); - // eslint-disable-next-line testing-library/no-await-sync-queries - const result = await cap.getByResponseChain('r1'); - - expect(mockFacade.walkResponseChain).toHaveBeenCalledWith('r1'); - expect(result.items).toEqual([ - { type: 'message', role: 'user', content: 'hello' }, - { type: 'message', role: 'assistant', content: 'hi' }, - ]); - }); - - it('getProcessedMessages delegates to facade.getProcessedMessages', async () => { - const messages: ProcessedMessage[] = [ - { role: 'user', text: 'hi' }, - { role: 'assistant', text: 'hello' }, - ]; - mockFacade.getProcessedMessages.mockResolvedValue(messages); - - const cap = buildConversationsCapability(mockFacade); - const result = await cap.getProcessedMessages('c1'); - - expect(mockFacade.getProcessedMessages).toHaveBeenCalledWith('c1'); - expect(result).toEqual(messages); - }); - - it('delete delegates to facade.deleteConversation', async () => { - const cap = buildConversationsCapability(mockFacade); - await cap.delete('r1'); - expect(mockFacade.deleteConversation).toHaveBeenCalledWith( - 'r1', - undefined, - ); - }); - - it('deleteContainer delegates to facade.deleteConversationContainer', async () => { - mockFacade.deleteConversationContainer = jest - .fn() - .mockResolvedValue(true); - const cap = buildConversationsCapability(mockFacade); - const result = await cap.deleteContainer!('conv-1'); - expect(mockFacade.deleteConversationContainer).toHaveBeenCalledWith( - 'conv-1', - ); - expect(result).toBe(true); - }); - - it('submitApproval delegates to facade.continueAfterApproval with approval fields', async () => { - const approvalResult = { - content: 'Tool executed successfully', - responseId: 'r1', - toolExecuted: true, - }; - mockFacade.continueAfterApproval.mockResolvedValue(approvalResult); - - const cap = buildConversationsCapability(mockFacade); - const result = await cap.submitApproval({ - responseId: 'r1', - callId: 'call_1', - approved: true, - toolName: 'tool1', - toolArguments: '{}', - }); - - expect(mockFacade.continueAfterApproval).toHaveBeenCalledWith( - 'r1', - 'call_1', - true, - 'tool1', - '{}', - ); - expect(result).toEqual(approvalResult); - }); - }); - - describe('buildRagCapability', () => { - let mockFacade: jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - mockFacade = createMockVectorStoreFacade(); - }); - - it('listDocuments delegates to facade.listDocuments', async () => { - const docs: DocumentInfo[] = [ - { - id: 'f1', - fileName: 'a.md', - format: FileFormat.MARKDOWN, - fileSize: 10, - uploadedAt: '', - status: 'completed', - }, - ]; - mockFacade.listDocuments.mockResolvedValue(docs); - - const cap = buildRagCapability(mockFacade); - const result = await cap.listDocuments('vs_123'); - - expect(mockFacade.listDocuments).toHaveBeenCalledWith('vs_123'); - expect(result).toEqual(docs); - }); - - it('listVectorStores delegates to facade.listVectorStores', async () => { - const stores = [ - { - id: 'vs_1', - name: 'store1', - status: 'active', - fileCount: 5, - createdAt: Date.now(), - }, - ]; - mockFacade.listVectorStores.mockResolvedValue(stores); - - const cap = buildRagCapability(mockFacade); - const result = await cap.listVectorStores(); - - expect(mockFacade.listVectorStores).toHaveBeenCalled(); - expect(result).toEqual(stores); - }); - - it('getDefaultVectorStoreId delegates to facade.getDefaultVectorStoreId', () => { - mockFacade.getDefaultVectorStoreId.mockReturnValue('vs_default'); - const cap = buildRagCapability(mockFacade); - expect(cap.getDefaultVectorStoreId()).toBe('vs_default'); - expect(mockFacade.getDefaultVectorStoreId).toHaveBeenCalled(); - }); - - it('getActiveVectorStoreIds delegates to facade.getActiveVectorStoreIds', async () => { - mockFacade.getActiveVectorStoreIds.mockResolvedValue(['vs_1', 'vs_2']); - const cap = buildRagCapability(mockFacade); - const result = await cap.getActiveVectorStoreIds(); - expect(mockFacade.getActiveVectorStoreIds).toHaveBeenCalled(); - expect(result).toEqual(['vs_1', 'vs_2']); - }); - - it('syncDocuments delegates to facade.syncDocuments', async () => { - const syncResult = { - added: 5, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [], - }; - mockFacade.syncDocuments.mockResolvedValue(syncResult); - - const cap = buildRagCapability(mockFacade); - const result = await cap.syncDocuments(); - - expect(mockFacade.syncDocuments).toHaveBeenCalled(); - expect(result).toEqual(syncResult); - }); - - it('uploadDocument delegates to facade.uploadDocument', async () => { - const content = Buffer.from('hello'); - mockFacade.uploadDocument.mockResolvedValue({ - fileId: 'f1', - fileName: 'doc.txt', - status: 'completed', - }); - - const cap = buildRagCapability(mockFacade); - const result = await cap.uploadDocument!('doc.txt', content, 'vs_123'); - - expect(mockFacade.uploadDocument).toHaveBeenCalledWith( - 'doc.txt', - content, - 'vs_123', - ); - expect(result).toEqual({ - fileId: 'f1', - fileName: 'doc.txt', - status: 'completed', - }); - }); - - it('deleteDocument delegates to facade.deleteDocument', async () => { - mockFacade.deleteDocument.mockResolvedValue({ success: true }); - - const cap = buildRagCapability(mockFacade); - const result = await cap.deleteDocument!('f1', 'vs_123'); - - expect(mockFacade.deleteDocument).toHaveBeenCalledWith('f1', 'vs_123'); - expect(result).toEqual({ success: true }); - }); - - it('searchVectorStore delegates to facade.searchVectorStore', async () => { - const searchResult = { - query: 'test', - chunks: [{ text: 'chunk1', score: 0.9 }], - vectorStoreId: 'vs_123', - totalResults: 1, - }; - mockFacade.searchVectorStore.mockResolvedValue(searchResult); - - const cap = buildRagCapability(mockFacade); - const result = await cap.searchVectorStore!('test', 10, 'vs_123', [ - 'vs_123', - ]); - - expect(mockFacade.searchVectorStore).toHaveBeenCalledWith( - 'test', - 10, - 'vs_123', - ['vs_123'], - ); - expect(result).toEqual(searchResult); - }); - - it('getVectorStoreConfig delegates to facade.getVectorStoreConfig', async () => { - const config = { - vectorStoreName: 'store', - embeddingModel: 'model', - embeddingDimension: 384, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - }; - mockFacade.getVectorStoreConfig.mockResolvedValue(config); - - const cap = buildRagCapability(mockFacade); - const result = await cap.getVectorStoreConfig!(); - - expect(mockFacade.getVectorStoreConfig).toHaveBeenCalled(); - expect(result).toEqual(config); - }); - - it('createVectorStoreWithConfig delegates to facade.createVectorStoreWithConfig', async () => { - const overrides = { embeddingModel: 'custom' }; - mockFacade.createVectorStoreWithConfig.mockResolvedValue({ - vectorStoreId: 'vs_new', - vectorStoreName: 'new', - created: true, - embeddingModel: 'custom', - }); - - const cap = buildRagCapability(mockFacade); - const result = await cap.createVectorStoreWithConfig!(overrides); - - expect(mockFacade.createVectorStoreWithConfig).toHaveBeenCalledWith( - overrides, - ); - expect(result.vectorStoreId).toBe('vs_new'); - }); - - it('getVectorStoreStatus delegates to facade.getVectorStoreStatus', async () => { - const status = { exists: true, vectorStoreId: 'vs_1', ready: true }; - mockFacade.getVectorStoreStatus.mockResolvedValue(status); - - const cap = buildRagCapability(mockFacade); - const result = await cap.getVectorStoreStatus!(); - - expect(mockFacade.getVectorStoreStatus).toHaveBeenCalled(); - expect(result).toEqual(status); - }); - - it('addVectorStoreId delegates to facade.addVectorStoreId', () => { - const cap = buildRagCapability(mockFacade); - cap.addVectorStoreId!('vs_new'); - expect(mockFacade.addVectorStoreId).toHaveBeenCalledWith('vs_new'); - }); - - it('removeVectorStoreId delegates to facade.removeVectorStoreId', () => { - const cap = buildRagCapability(mockFacade); - cap.removeVectorStoreId!('vs_old'); - expect(mockFacade.removeVectorStoreId).toHaveBeenCalledWith('vs_old'); - }); - - it('deleteVectorStore delegates to facade.deleteVectorStore', async () => { - mockFacade.deleteVectorStore.mockResolvedValue({ - success: true, - filesDeleted: 3, - }); - - const cap = buildRagCapability(mockFacade); - const result = await cap.deleteVectorStore!('vs_123'); - - expect(mockFacade.deleteVectorStore).toHaveBeenCalledWith('vs_123'); - expect(result).toEqual({ success: true, filesDeleted: 3 }); - }); - - it('generateAnswer is undefined when no inference accessor provided', () => { - const cap = buildRagCapability(mockFacade); - expect(cap.generateAnswer).toBeUndefined(); - }); - - it('generateAnswer retrieves chunks and generates LLM answer', async () => { - const searchResult = { - query: 'What is RAG?', - chunks: [ - { - text: 'RAG is retrieval-augmented generation.', - score: 0.9, - fileName: 'doc.md', - }, - ], - vectorStoreId: 'vs_1', - totalResults: 1, - }; - mockFacade.searchVectorStore.mockResolvedValue(searchResult); - - const mockRequest = jest.fn().mockResolvedValue({ - output: [ - { - type: 'message', - content: [ - { - type: 'output_text', - text: 'RAG combines retrieval with generation.', - }, - ], - }, - ], - }); - const mockInference = { - getClient: () => ({ request: mockRequest }), - getModel: () => 'test-model', - }; - - const cap = buildRagCapability(mockFacade, mockInference); - const result = await cap.generateAnswer!('What is RAG?', 5, 'vs_1'); - - expect(mockFacade.searchVectorStore).toHaveBeenCalledWith( - 'What is RAG?', - 5, - 'vs_1', - undefined, - ); - expect(mockRequest).toHaveBeenCalledWith( - '/v1/responses', - expect.objectContaining({ method: 'POST' }), - ); - expect(result.query).toBe('What is RAG?'); - expect(result.answer).toBe('RAG combines retrieval with generation.'); - expect(result.model).toBe('test-model'); - expect(result.chunks).toEqual(searchResult.chunks); - expect(result.totalResults).toBe(1); - }); - - it('generateAnswer returns fallback when LLM produces no text', async () => { - mockFacade.searchVectorStore.mockResolvedValue({ - query: 'test', - chunks: [], - vectorStoreId: 'vs_1', - totalResults: 0, - }); - const mockInference = { - getClient: () => ({ - request: jest.fn().mockResolvedValue({ output: [] }), - }), - getModel: () => 'test-model', - }; - - const cap = buildRagCapability(mockFacade, mockInference); - const result = await cap.generateAnswer!('test'); - - expect(result.answer).toBe( - 'The model returned no answer. Try rephrasing the query.', - ); - }); - }); - - describe('buildSafetyCapability', () => { - let mockService: jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - mockService = createMockSafetyService(); - }); - - it('isEnabled delegates to safetyService.isEnabled', () => { - mockService.isEnabled.mockReturnValue(true); - const cap = buildSafetyCapability(mockService); - expect(cap.isEnabled()).toBe(true); - expect(mockService.isEnabled).toHaveBeenCalled(); - - mockService.isEnabled.mockReturnValue(false); - expect(cap.isEnabled()).toBe(false); - }); - - it('getStatus returns enabled and shields from service', async () => { - mockService.isEnabled.mockReturnValue(true); - mockService.getAvailableShields.mockReturnValue([ - { - identifier: 's1', - provider_id: 'p1', - provider_resource_id: 'pr1', - type: 'llama-guard', - }, - { - identifier: 's2', - provider_id: 'p2', - provider_resource_id: 'pr2', - type: 'prompt-guard', - }, - ]); - - const cap = buildSafetyCapability(mockService); - const result = await cap.getStatus(); - - expect(result).toEqual({ - enabled: true, - shields: ['s1', 's2'], - }); - expect(mockService.isEnabled).toHaveBeenCalled(); - expect(mockService.getAvailableShields).toHaveBeenCalled(); - }); - - it('checkInput returns safe: true when no violation', async () => { - mockService.checkInput.mockResolvedValue(undefined); - - const cap = buildSafetyCapability(mockService); - const result = await cap.checkInput('hello'); - - expect(mockService.checkInput).toHaveBeenCalledWith('hello'); - expect(result).toEqual({ safe: true }); - }); - - it('checkInput returns safe: false with violation when service returns violation', async () => { - mockService.checkInput.mockResolvedValue({ - user_message: 'Blocked content', - violation_level: 'error', - }); - - const cap = buildSafetyCapability(mockService); - const result = await cap.checkInput('bad input'); - - expect(mockService.checkInput).toHaveBeenCalledWith('bad input'); - expect(result).toEqual({ - safe: false, - violation: 'Blocked content', - category: 'error', - }); - }); - - it('checkOutput returns safe: true when no violation', async () => { - mockService.checkOutput.mockResolvedValue(undefined); - - const cap = buildSafetyCapability(mockService); - const result = await cap.checkOutput('response'); - - expect(mockService.checkOutput).toHaveBeenCalledWith('response'); - expect(result).toEqual({ safe: true }); - }); - - it('checkOutput returns safe: false with violation when service returns violation', async () => { - mockService.checkOutput.mockResolvedValue({ - user_message: 'Harmful output', - violation_level: 'warn', - }); - - const cap = buildSafetyCapability(mockService); - const result = await cap.checkOutput('bad output'); - - expect(mockService.checkOutput).toHaveBeenCalledWith('bad output'); - expect(result).toEqual({ - safe: false, - violation: 'Harmful output', - category: 'warn', - }); - }); - }); - - describe('buildEvaluationCapability', () => { - let mockService: jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - mockService = createMockEvaluationService(); - }); - - it('isEnabled delegates to evaluationService.isEnabled', () => { - mockService.isEnabled.mockReturnValue(true); - const cap = buildEvaluationCapability(mockService); - expect(cap.isEnabled()).toBe(true); - expect(mockService.isEnabled).toHaveBeenCalled(); - - mockService.isEnabled.mockReturnValue(false); - expect(cap.isEnabled()).toBe(false); - }); - - it('getStatus returns enabled and scoringFunctions from service', async () => { - mockService.isEnabled.mockReturnValue(true); - mockService.getAvailableScoringFunctions.mockReturnValue([ - { - identifier: 'sf1', - provider_id: 'p1', - provider_resource_id: 'pr1', - type: 'llm-as-judge', - }, - { - identifier: 'sf2', - provider_id: 'p2', - provider_resource_id: 'pr2', - type: 'regex', - }, - ]); - - const cap = buildEvaluationCapability(mockService); - const result = await cap.getStatus(); - - expect(result).toEqual({ - enabled: true, - scoringFunctions: ['sf1', 'sf2'], - }); - expect(mockService.isEnabled).toHaveBeenCalled(); - expect(mockService.getAvailableScoringFunctions).toHaveBeenCalled(); - }); - - it('evaluateResponse delegates to evaluationService.scoreResponse', async () => { - const evalResult = { - overallScore: 0.95, - scores: { quality: 0.95 }, - passedThreshold: true, - qualityLevel: 'excellent' as const, - evaluatedAt: new Date().toISOString(), - }; - mockService.scoreResponse.mockResolvedValue(evalResult); - - const cap = buildEvaluationCapability(mockService); - const result = await cap.evaluateResponse('user msg', 'assistant reply', [ - 'ctx1', - ]); - - expect(mockService.scoreResponse).toHaveBeenCalledWith( - 'user msg', - 'assistant reply', - ['ctx1'], - ); - expect(result).toEqual(evalResult); - }); - - it('evaluateResponse returns skipped result when scoreResponse returns null', async () => { - mockService.scoreResponse.mockResolvedValue(null as unknown as undefined); - - const cap = buildEvaluationCapability(mockService); - const result = await cap.evaluateResponse('user', 'assistant'); - - expect(mockService.scoreResponse).toHaveBeenCalledWith( - 'user', - 'assistant', - undefined, - ); - expect(result.skipped).toBe(true); - expect(result.overallScore).toBe(0); - expect(result.qualityLevel).toBe('poor'); - }); - }); - - describe('error propagation', () => { - it('buildConversationsCapability propagates errors from facade', async () => { - const mockFacade = createMockConversationFacade(); - const err = new Error('create failed'); - mockFacade.createConversation.mockRejectedValue(err); - - const cap = buildConversationsCapability(mockFacade); - await expect(cap.create()).rejects.toThrow('create failed'); - }); - - it('buildRagCapability propagates errors from facade', async () => { - const mockFacade = createMockVectorStoreFacade(); - const err = new Error('upload failed'); - mockFacade.uploadDocument.mockRejectedValue(err); - - const cap = buildRagCapability(mockFacade); - await expect( - cap.uploadDocument!('x.txt', Buffer.from('x')), - ).rejects.toThrow('upload failed'); - }); - - it('buildSafetyCapability propagates errors from service', async () => { - const mockService = createMockSafetyService(); - const err = new Error('check failed'); - mockService.checkInput.mockRejectedValue(err); - - const cap = buildSafetyCapability(mockService); - await expect(cap.checkInput('text')).rejects.toThrow('check failed'); - }); - - it('buildEvaluationCapability propagates errors from service', async () => { - const mockService = createMockEvaluationService(); - const err = new Error('score failed'); - mockService.scoreResponse.mockRejectedValue(err); - - const cap = buildEvaluationCapability(mockService); - await expect(cap.evaluateResponse('u', 'a')).rejects.toThrow( - 'score failed', - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/CapabilityBuilders.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/CapabilityBuilders.ts deleted file mode 100644 index 1ab1e268c28..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/CapabilityBuilders.ts +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Standalone factory functions that assemble the four provider capabilities - * (conversations, RAG, safety, evaluation) from their backing services. - * - * Extracted from ResponsesApiProvider to keep the provider class focused on - * lifecycle, chat orchestration, and model testing. - */ - -import type { - ConversationCapability, - SafetyCapability, - EvaluationCapability, - ConversationListResult, - ConversationDetails, - InputItemsResult, - ConversationItemsResult, - ProcessedMessage, - ToolApproval, - ApprovalResult, - SafetyStatus, - SafetyCheckResult, - EvalStatus, - EvaluationResult, -} from '../types'; -import type { ConversationFacade } from './ConversationFacade'; -import type { SafetyService } from './SafetyService'; -import type { EvaluationService } from './EvaluationService'; - -export { buildRagCapability } from './ragCapabilityBuilder'; -export type { RagInferenceAccessor } from './ragCapabilityBuilder'; - -export function buildConversationsCapability( - facade: ConversationFacade, -): ConversationCapability { - return { - create: async () => { - const conversationId = await facade.createConversation(); - return { conversationId }; - }, - - list: async ( - limit?: number, - after?: string, - ): Promise => { - return facade.listConversations(limit, 'desc', after); - }, - - get: async (responseId: string): Promise => { - const details = await facade.getConversation(responseId); - if (!details) { - throw new Error(`Conversation not found: ${responseId}`); - } - return details; - }, - - getInputs: async (responseId: string): Promise => { - return facade.getConversationInputs(responseId); - }, - - getByResponseChain: async ( - responseId: string, - ): Promise => { - const items = await facade.walkResponseChain(responseId); - return { - items: items.map(item => ({ - type: 'message', - role: item.role, - content: item.text, - })), - }; - }, - - getProcessedMessages: async ( - conversationId: string, - ): Promise => { - return facade.getProcessedMessages(conversationId); - }, - - delete: async ( - responseId: string, - conversationId?: string, - ): Promise => { - await facade.deleteConversation(responseId, conversationId); - }, - - deleteContainer: async (conversationId: string): Promise => { - return facade.deleteConversationContainer(conversationId); - }, - - submitApproval: async (approval: ToolApproval): Promise => { - return facade.continueAfterApproval( - approval.responseId, - approval.callId, - approval.approved, - approval.toolName, - approval.toolArguments, - ); - }, - }; -} - -export function buildSafetyCapability( - safetyService: SafetyService, -): SafetyCapability { - return { - isEnabled: (): boolean => { - return safetyService.isEnabled(); - }, - - getStatus: async (): Promise => { - return { - enabled: safetyService.isEnabled(), - shields: safetyService.getAvailableShields().map(s => s.identifier), - }; - }, - - checkInput: async (text: string): Promise => { - const violation = await safetyService.checkInput(text); - if (violation) { - return { - safe: false, - violation: violation.user_message, - category: violation.violation_level, - }; - } - return { safe: true }; - }, - - checkOutput: async (text: string): Promise => { - const violation = await safetyService.checkOutput(text); - if (violation) { - return { - safe: false, - violation: violation.user_message, - category: violation.violation_level, - }; - } - return { safe: true }; - }, - }; -} - -export function buildEvaluationCapability( - evaluationService: EvaluationService, -): EvaluationCapability { - return { - isEnabled: (): boolean => { - return evaluationService.isEnabled(); - }, - - getStatus: async (): Promise => { - return { - enabled: evaluationService.isEnabled(), - scoringFunctions: evaluationService - .getAvailableScoringFunctions() - .map(f => f.identifier), - }; - }, - - evaluateResponse: async ( - userMessage: string, - assistantResponse: string, - context?: string[], - ): Promise => { - const result = await evaluationService.scoreResponse( - userMessage, - assistantResponse, - context, - ); - if (!result) { - return { - overallScore: 0, - scores: {}, - passedThreshold: true, - qualityLevel: 'poor', - evaluatedAt: new Date().toISOString(), - skipped: true, - }; - } - return result; - }, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ChatDepsBuilder.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ChatDepsBuilder.test.ts deleted file mode 100644 index 1494d1e3bc1..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ChatDepsBuilder.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { ChatDepsBuilder } from './ChatDepsBuilder'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { - ChatRequest, - EffectiveConfig, - MCPServerConfig, -} from '../../types'; -import type { ConfigResolutionService } from './ConfigResolutionService'; -import type { ClientManager } from './ClientManager'; -import type { BackendToolExecutor } from './BackendToolExecutor'; - -function mockLogger(): LoggerService { - return { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - child: jest.fn().mockReturnThis(), - } as unknown as LoggerService; -} - -function baseConfig(): EffectiveConfig { - return { - model: 'test-model', - baseUrl: 'https://localhost:8321', - systemPrompt: 'You are helpful.', - enableWebSearch: false, - enableCodeInterpreter: false, - vectorStoreIds: [], - vectorStoreName: 'test', - embeddingModel: 'test-embed', - embeddingDimension: 384, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 800, - chunkOverlapTokens: 400, - skipTlsVerify: false, - zdrMode: false, - verboseStreamLogging: false, - }; -} - -function mockConfigResolution( - config: EffectiveConfig, -): ConfigResolutionService { - return { - resolve: jest.fn().mockResolvedValue(config), - getResolver: jest.fn().mockReturnValue(null), - } as unknown as ConfigResolutionService; -} - -function mockClientManager(): ClientManager { - return { - getExistingClient: jest.fn().mockReturnValue({ request: jest.fn() }), - } as unknown as ClientManager; -} - -function createBuilder(overrides?: { - config?: EffectiveConfig; - mcpServers?: MCPServerConfig[]; - getBackendToolExecutor?: () => any; -}): ChatDepsBuilder { - const config = overrides?.config ?? baseConfig(); - return new ChatDepsBuilder({ - logger: mockLogger(), - configResolution: mockConfigResolution(config), - clientManager: mockClientManager(), - getMcpAuth: () => null, - getConversations: () => null, - getMcpServers: () => overrides?.mcpServers ?? [], - ensureInitialized: () => {}, - getBackendToolExecutor: overrides?.getBackendToolExecutor, - }); -} - -describe('ChatDepsBuilder', () => { - describe('extractUserQuery', () => { - it('returns the last user message content', () => { - const builder = createBuilder(); - const request: ChatRequest = { - messages: [ - { role: 'user', content: 'first question' }, - { role: 'assistant', content: 'response' }, - { role: 'user', content: 'follow-up question' }, - ], - } as ChatRequest; - - expect(builder.extractUserQuery(request)).toBe('follow-up question'); - }); - - it('returns undefined when no user message exists', () => { - const builder = createBuilder(); - const request: ChatRequest = { - messages: [{ role: 'assistant', content: 'solo' }], - } as ChatRequest; - - expect(builder.extractUserQuery(request)).toBeUndefined(); - }); - - it('returns undefined for empty messages', () => { - const builder = createBuilder(); - const request: ChatRequest = { messages: [] } as unknown as ChatRequest; - - expect(builder.extractUserQuery(request)).toBeUndefined(); - }); - }); - - describe('buildChatDeps', () => { - it('returns a ChatDeps snapshot with resolved config', async () => { - const config = baseConfig(); - const builder = createBuilder({ config }); - - const deps = await builder.buildChatDeps(); - - expect(deps.config).toBe(config); - expect(deps.client).toBeDefined(); - expect(deps.mcpServers).toEqual([]); - expect(deps.mcpAuth).toBeNull(); - expect(deps.conversations).toBeNull(); - }); - - it('uses YAML mcpServers when no resolver is set', async () => { - const servers: MCPServerConfig[] = [ - { id: 'mcp-1', url: 'https://mcp-1' } as MCPServerConfig, - ]; - const builder = createBuilder({ mcpServers: servers }); - - const deps = await builder.buildChatDeps(); - - expect(deps.mcpServers).toEqual(servers); - }); - }); - - describe('backendToolExecutor singleton', () => { - it('returns the same backendToolExecutor instance across buildChatDeps calls', async () => { - const mockExecutor = { - invalidateCache: jest.fn(), - ensureToolsDiscovered: jest.fn().mockResolvedValue([]), - }; - const servers: MCPServerConfig[] = [ - { id: 'mcp-1', url: 'https://mcp-1' } as MCPServerConfig, - ]; - const builder = createBuilder({ - mcpServers: servers, - getBackendToolExecutor: () => mockExecutor, - }); - - const deps1 = await builder.buildChatDeps(); - const deps2 = await builder.buildChatDeps(); - - expect(deps1.backendToolExecutor).toBe(mockExecutor); - expect(deps2.backendToolExecutor).toBe(mockExecutor); - expect(deps1.backendToolExecutor).toBe(deps2.backendToolExecutor); - }); - - it('invalidates cache when MCP server list changes', async () => { - const mockExecutor = { - invalidateCache: jest.fn(), - ensureToolsDiscovered: jest.fn().mockResolvedValue([]), - } as unknown as BackendToolExecutor; - let currentServers: MCPServerConfig[] = [ - { id: 'mcp-1', url: 'https://mcp-1' } as MCPServerConfig, - ]; - - const builder = new ChatDepsBuilder({ - logger: mockLogger(), - configResolution: mockConfigResolution(baseConfig()), - clientManager: mockClientManager(), - getMcpAuth: () => null, - getConversations: () => null, - getMcpServers: () => currentServers, - ensureInitialized: () => {}, - getBackendToolExecutor: () => mockExecutor, - }); - - await builder.buildChatDeps(); - expect(mockExecutor.invalidateCache).not.toHaveBeenCalled(); - - currentServers = [ - { id: 'mcp-1', url: 'https://mcp-1' } as MCPServerConfig, - { id: 'mcp-2', url: 'https://mcp-2' } as MCPServerConfig, - ]; - await builder.buildChatDeps(); - expect(mockExecutor.invalidateCache).toHaveBeenCalledTimes(1); - }); - - it('does not invalidate cache when MCP server list stays the same', async () => { - const mockExecutor = { - invalidateCache: jest.fn(), - ensureToolsDiscovered: jest.fn().mockResolvedValue([]), - }; - const servers: MCPServerConfig[] = [ - { id: 'mcp-1', url: 'https://mcp-1' } as MCPServerConfig, - ]; - const builder = createBuilder({ - mcpServers: servers, - getBackendToolExecutor: () => mockExecutor, - }); - - await builder.buildChatDeps(); - await builder.buildChatDeps(); - await builder.buildChatDeps(); - - expect(mockExecutor.invalidateCache).not.toHaveBeenCalled(); - }); - }); - - describe('makeBuildDepsForAgent', () => { - it('caches the result across multiple calls', async () => { - const config = baseConfig(); - const configResolution = mockConfigResolution(config); - const builder = new ChatDepsBuilder({ - logger: mockLogger(), - configResolution, - clientManager: mockClientManager(), - getMcpAuth: () => null, - getConversations: () => null, - getMcpServers: () => [], - ensureInitialized: () => {}, - }); - - const depsBuilder = builder.makeBuildDepsForAgent('test query'); - - const deps1 = await depsBuilder({ key: 'a' } as any); - const deps2 = await depsBuilder({ key: 'b' } as any); - - expect(deps1).toBe(deps2); - expect(configResolution.resolve).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ChatDepsBuilder.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ChatDepsBuilder.ts deleted file mode 100644 index 05a5eb24bf3..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ChatDepsBuilder.ts +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ChatRequest, MCPServerConfig } from '../../types'; -import type { ChatDeps } from './ResponsesApiService'; -import { extractLastUserMessage } from './chatUtils'; -import type { ClientManager } from './ClientManager'; -import type { ConfigResolutionService } from './ConfigResolutionService'; -import type { McpAuthService } from './McpAuthService'; -import type { ConversationService } from './ConversationService'; -import type { BuildDepsForAgent } from '../responses-api/agents/agentGraph'; -import type { BackendToolExecutor } from './BackendToolExecutor'; - -/** - * Builds the ChatDeps snapshot required by ResponsesApiService and AdkOrchestrator. - * - * Extracted from ResponsesApiCoordinator to keep the orchestrator focused on - * lifecycle management and routing. All per-request dependency resolution — - * config merging, MCP proxy updates, tool scoping — lives here. - */ -export class ChatDepsBuilder { - private readonly logger: LoggerService; - private readonly configResolution: ConfigResolutionService; - private readonly clientManager: ClientManager; - - private readonly getMcpAuth: () => McpAuthService | null; - private readonly getConversations: () => ConversationService | null; - private readonly getMcpServers: () => MCPServerConfig[]; - private readonly ensureInitialized: () => void; - private readonly getBackendToolExecutor: () => BackendToolExecutor | null; - private lastServerIds: string | null = null; - - constructor(options: { - logger: LoggerService; - configResolution: ConfigResolutionService; - clientManager: ClientManager; - getMcpAuth: () => McpAuthService | null; - getConversations: () => ConversationService | null; - getMcpServers: () => MCPServerConfig[]; - ensureInitialized: () => void; - getBackendToolExecutor?: () => BackendToolExecutor | null; - }) { - this.logger = options.logger; - this.configResolution = options.configResolution; - this.clientManager = options.clientManager; - this.getMcpAuth = options.getMcpAuth; - this.getConversations = options.getConversations; - this.getMcpServers = options.getMcpServers; - this.ensureInitialized = options.ensureInitialized; - this.getBackendToolExecutor = - options.getBackendToolExecutor ?? (() => null); - } - - /** - * Build the ChatDeps snapshot from current orchestrator state. - * Uses resolveConfig() to get effective YAML + DB merged config. - */ - async buildChatDeps(): Promise { - this.ensureInitialized(); - const config = await this.configResolution.resolve(); - const mcpServers = this.getMcpServers(); - - const effectiveMcpServers = this.configResolution.getResolver() - ? (config.mcpServers ?? mcpServers) - : mcpServers; - - const backendToolExecutor = this.getBackendToolExecutor() ?? undefined; - if (backendToolExecutor) { - const currentIds = effectiveMcpServers - .map(s => s.id) - .sort((a, b) => a.localeCompare(b)) - .join(','); - if (this.lastServerIds !== null && this.lastServerIds !== currentIds) { - backendToolExecutor.invalidateCache(); - this.logger.info( - '[ChatDepsBuilder] MCP server list changed — invalidated BackendToolExecutor cache', - ); - } - this.lastServerIds = currentIds; - } - - return { - client: this.clientManager.getExistingClient(), - config, - mcpServers: effectiveMcpServers, - mcpAuth: this.getMcpAuth(), - conversations: this.getConversations(), - backendToolExecutor, - }; - } - - /** - * Extract the last user message from a chat request for tool scoping. - */ - extractUserQuery(request: ChatRequest): string | undefined { - return extractLastUserMessage(request); - } - - /** - * Create a BuildDepsForAgent callback that resolves per-agent ChatDeps. - * The base deps (config resolution, tool scoping, proxy updates) are - * computed once and cached for the lifetime of the request. Per-agent - * MCP filtering is handled downstream in AdkOrchestrator.buildRunOptions(). - */ - makeBuildDepsForAgent(_userQuery?: string): BuildDepsForAgent { - let cachedDeps: ChatDeps | undefined; - return async () => { - if (!cachedDeps) { - cachedDeps = await this.buildChatDeps(); - } - return cachedDeps; - }; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ClientManager.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ClientManager.test.ts deleted file mode 100644 index 30f14f54b57..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ClientManager.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { LlamaStackConfig } from '../../types'; -import { ClientManager } from './ClientManager'; - -function createMockLogger(): Record { - return { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - child: jest.fn().mockReturnThis(), - }; -} - -function createConfig( - overrides: Partial = {}, -): LlamaStackConfig { - return { - baseUrl: 'https://localhost:8321', - model: 'test-model', - vectorStoreIds: [], - vectorStoreName: 'default', - embeddingModel: 'all-MiniLM-L6-v2', - embeddingDimension: 384, - chunkingStrategy: 'auto', - maxChunkSizeTokens: 800, - chunkOverlapTokens: 400, - ...overrides, - }; -} - -describe('ClientManager', () => { - let mockLogger: Record; - let manager: ClientManager; - - beforeEach(() => { - jest.clearAllMocks(); - mockLogger = createMockLogger(); - manager = new ClientManager(mockLogger as unknown as LoggerService); - }); - - describe('initial state', () => { - it('has no client before first getClient call', () => { - expect(manager.hasClient()).toBe(false); - }); - - it('getExistingClient throws when no client exists', () => { - expect(() => manager.getExistingClient()).toThrow( - 'ClientManager has no client', - ); - }); - }); - - describe('getClient()', () => { - it('creates a client on first call', () => { - const config = createConfig(); - const client = manager.getClient(config); - - expect(client).toBeDefined(); - expect(manager.hasClient()).toBe(true); - }); - - it('returns the same client for identical config', () => { - const config = createConfig(); - const client1 = manager.getClient(config); - const client2 = manager.getClient(config); - - expect(client1).toBe(client2); - }); - - it('returns the same client when non-identity fields change', () => { - const client1 = manager.getClient(createConfig({ model: 'model-A' })); - const client2 = manager.getClient(createConfig({ model: 'model-B' })); - - expect(client1).toBe(client2); - }); - - it('returns the same client when vectorStoreIds change', () => { - const client1 = manager.getClient( - createConfig({ vectorStoreIds: ['vs-1'] }), - ); - const client2 = manager.getClient( - createConfig({ vectorStoreIds: ['vs-2', 'vs-3'] }), - ); - - expect(client1).toBe(client2); - }); - - it('creates a new client when baseUrl changes', () => { - const client1 = manager.getClient( - createConfig({ baseUrl: 'https://host-a:8321' }), - ); - const client2 = manager.getClient( - createConfig({ baseUrl: 'https://host-b:8321' }), - ); - - expect(client1).not.toBe(client2); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('identity changed'), - ); - }); - - it('creates a new client when token changes', () => { - const client1 = manager.getClient(createConfig({ token: 'token-A' })); - const client2 = manager.getClient(createConfig({ token: 'token-B' })); - - expect(client1).not.toBe(client2); - }); - - it('creates a new client when token is added', () => { - const client1 = manager.getClient(createConfig()); - const client2 = manager.getClient(createConfig({ token: 'new-token' })); - - expect(client1).not.toBe(client2); - }); - - it('creates a new client when token is removed', () => { - const client1 = manager.getClient(createConfig({ token: 'old-token' })); - const client2 = manager.getClient(createConfig({ token: undefined })); - - expect(client1).not.toBe(client2); - }); - - it('creates a new client when skipTlsVerify changes', () => { - const client1 = manager.getClient(createConfig({ skipTlsVerify: false })); - const client2 = manager.getClient(createConfig({ skipTlsVerify: true })); - - expect(client1).not.toBe(client2); - }); - - it('does not log identity change on first creation', () => { - manager.getClient(createConfig()); - - expect(mockLogger.info).not.toHaveBeenCalledWith( - expect.stringContaining('identity changed'), - ); - }); - }); - - describe('getExistingClient()', () => { - it('returns the client after getClient has been called', () => { - const config = createConfig(); - const created = manager.getClient(config); - const existing = manager.getExistingClient(); - - expect(existing).toBe(created); - }); - - it('returns the latest client after identity change', () => { - manager.getClient(createConfig({ baseUrl: 'https://host-a:8321' })); - const latest = manager.getClient( - createConfig({ baseUrl: 'https://host-b:8321' }), - ); - const existing = manager.getExistingClient(); - - expect(existing).toBe(latest); - }); - }); - - describe('invalidate()', () => { - it('drops the cached client', () => { - manager.getClient(createConfig()); - expect(manager.hasClient()).toBe(true); - - manager.invalidate(); - expect(manager.hasClient()).toBe(false); - }); - - it('causes getExistingClient to throw', () => { - manager.getClient(createConfig()); - manager.invalidate(); - - expect(() => manager.getExistingClient()).toThrow( - 'ClientManager has no client', - ); - }); - - it('causes next getClient to create a fresh instance', () => { - const config = createConfig(); - const client1 = manager.getClient(config); - manager.invalidate(); - const client2 = manager.getClient(config); - - expect(client1).not.toBe(client2); - }); - }); - - describe('client configuration', () => { - it('created client has the correct baseUrl', () => { - const config = createConfig({ baseUrl: 'https://my-llama:9999' }); - const client = manager.getClient(config); - - expect(client.getConfig().baseUrl).toBe('https://my-llama:9999'); - }); - - it('re-created client uses the new config', () => { - manager.getClient(createConfig({ baseUrl: 'https://old:8321' })); - const newClient = manager.getClient( - createConfig({ baseUrl: 'https://new:8321', token: 'fresh-token' }), - ); - - expect(newClient.getConfig().baseUrl).toBe('https://new:8321'); - expect(newClient.getConfig().token).toBe('fresh-token'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ClientManager.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ClientManager.ts deleted file mode 100644 index 645fdf98847..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ClientManager.ts +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { ResponsesApiClient } from './ResponsesApiClient'; -import type { LlamaStackConfig } from '../../types'; - -/** - * The subset of LlamaStackConfig that determines HTTP client identity. - * A change in any of these fields requires a new ResponsesApiClient. - */ -interface ClientIdentity { - baseUrl: string; - token?: string; - skipTlsVerify?: boolean; -} - -/** - * Manages the lifecycle of ResponsesApiClient instances. - * - * The client is re-created lazily when the connection-relevant config - * fields (baseUrl, token, skipTlsVerify) change. Other config fields - * (model, vectorStoreIds, etc.) do NOT require a new client because - * they are passed per-request in the API body. - * - * Thread-safety: JavaScript is single-threaded, so no locking is needed. - * The manager simply compares the current identity with the last-used - * identity and creates a new client if they differ. - */ -export class ClientManager { - private readonly logger: LoggerService; - private client: ResponsesApiClient | null = null; - private currentIdentity: ClientIdentity | null = null; - - /** Server version string (e.g. "0.5.2"), fetched lazily on first client creation. */ - private _serverVersion: string | undefined; - - constructor(logger: LoggerService) { - this.logger = logger; - } - - /** Server version detected at connect time. `undefined` if not yet probed. */ - get serverVersion(): string | undefined { - return this._serverVersion; - } - - /** - * Get a ResponsesApiClient for the given config. - * - * Returns the cached client if the connection-relevant fields - * haven't changed; otherwise creates a fresh one. - */ - getClient(config: LlamaStackConfig): ResponsesApiClient { - const identity = this.extractIdentity(config); - - if (this.client && this.identityMatches(identity)) { - return this.client; - } - - if (this.client) { - this.logger.info( - `ResponsesApiClient identity changed (baseUrl: ${identity.baseUrl}), creating new client`, - ); - } - - this.client = new ResponsesApiClient(config, this.logger); - this.currentIdentity = identity; - return this.client; - } - - /** - * Returns the cached client without checking identity. - * Throws if no client has been created yet. - * - * Useful for services that need the client synchronously - * (e.g. ConversationClientAccessor.getClient). - */ - getExistingClient(): ResponsesApiClient { - if (!this.client) { - throw new Error( - 'ClientManager has no client. Call getClient(config) first.', - ); - } - return this.client; - } - - /** - * Test whether a client has been created. - */ - hasClient(): boolean { - return this.client !== null; - } - - /** - * Force-drop the cached client. The next getClient() call will - * create a fresh instance regardless of identity. - */ - invalidate(): void { - this.client = null; - this.currentIdentity = null; - } - - /** - * Probe the server version via `/v1/version`. - * Non-blocking: failures are logged and swallowed. - */ - async probeServerVersion(client: ResponsesApiClient): Promise { - try { - const resp = await client.request<{ version?: string }>('/v1/version', { - method: 'GET', - }); - if (resp?.version) { - this._serverVersion = resp.version; - this.logger.info(`Inference server version: ${resp.version}`); - } - } catch { - this.logger.debug('Could not probe server version (non-fatal)'); - } - } - - private extractIdentity(config: LlamaStackConfig): ClientIdentity { - return { - baseUrl: config.baseUrl, - token: config.token, - skipTlsVerify: config.skipTlsVerify, - }; - } - - private identityMatches(identity: ClientIdentity): boolean { - if (!this.currentIdentity) return false; - return ( - this.currentIdentity.baseUrl === identity.baseUrl && - this.currentIdentity.token === identity.token && - this.currentIdentity.skipTlsVerify === identity.skipTlsVerify - ); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigLoader.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigLoader.test.ts deleted file mode 100644 index bfae735661b..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigLoader.test.ts +++ /dev/null @@ -1,413 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ConfigReader, type JsonObject } from '@backstage/config'; -import { ConfigLoader, ConfigValidationError } from './ConfigLoader'; -import { createMockLogger } from '../../test-utils'; - -function createConfigLoader(yamlConfig: JsonObject) { - const config = new ConfigReader(yamlConfig); - const logger = createMockLogger(); - return { loader: new ConfigLoader(config, logger), logger }; -} - -describe('ConfigLoader', () => { - describe('validateRequiredConfig', () => { - it('passes when baseUrl and model are provided', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - baseUrl: 'http://localhost:8321', - model: 'test-model', - }, - }, - }); - expect(() => loader.validateRequiredConfig()).not.toThrow(); - }); - - it('throws ConfigValidationError when llamaStack section is missing', () => { - const { loader } = createConfigLoader({ augment: {} }); - expect(() => loader.validateRequiredConfig()).toThrow( - ConfigValidationError, - ); - }); - - it('throws when baseUrl is missing', () => { - const { loader } = createConfigLoader({ - augment: { llamaStack: { model: 'test-model' } }, - }); - expect(() => loader.validateRequiredConfig()).toThrow( - ConfigValidationError, - ); - }); - - it('throws when model is missing', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { baseUrl: 'http://localhost:8321' }, - }, - }); - expect(() => loader.validateRequiredConfig()).toThrow( - ConfigValidationError, - ); - }); - }); - - describe('loadLlamaStackConfig', () => { - const minimalConfig = { - augment: { - llamaStack: { - baseUrl: 'http://localhost:8321', - model: 'test-model', - }, - }, - }; - - it('loads minimal config with defaults', () => { - const { loader } = createConfigLoader(minimalConfig); - const result = loader.loadLlamaStackConfig(); - - expect(result.baseUrl).toBe('http://localhost:8321'); - expect(result.model).toBe('test-model'); - expect(result.vectorStoreIds).toEqual([]); - expect(result.vectorStoreName).toBe('augment-knowledge-base'); - expect(result.embeddingModel).toBe( - 'sentence-transformers/all-MiniLM-L6-v2', - ); - expect(result.embeddingDimension).toBe(384); - expect(result.chunkingStrategy).toBe('auto'); - expect(result.maxChunkSizeTokens).toBe(512); - expect(result.chunkOverlapTokens).toBe(50); - expect(result.skipTlsVerify).toBe(false); - expect(result.verboseStreamLogging).toBe(false); - expect(result.zdrMode).toBe(false); - expect(result.enableWebSearch).toBe(false); - expect(result.enableCodeInterpreter).toBe(false); - }); - - it('loads vectorStoreIds array', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - vectorStoreIds: ['vs-1', 'vs-2'], - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.vectorStoreIds).toEqual(['vs-1', 'vs-2']); - }); - - it('falls back to single vectorStoreId for backward compat', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - vectorStoreId: 'legacy-vs', - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.vectorStoreIds).toEqual(['legacy-vs']); - }); - - it('loads token when provided', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - token: 'secret-token', - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.token).toBe('secret-token'); - }); - - it('parses toolChoice as string', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - toolChoice: 'required', - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.toolChoice).toBe('required'); - }); - - it('parses toolChoice as function object', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - toolChoice: { type: 'function', name: 'my_func' }, - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.toolChoice).toEqual({ type: 'function', name: 'my_func' }); - }); - - it('parses reasoning config with effort and summary', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - reasoning: { effort: 'high', summary: 'concise' }, - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.reasoning).toEqual({ - effort: 'high', - summary: 'concise', - }); - }); - - it('parses reasoning config as shorthand string', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - reasoning: 'medium', - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.reasoning).toEqual({ effort: 'medium' }); - }); - - it('ignores invalid reasoning string', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - reasoning: 'invalid', - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.reasoning).toBeUndefined(); - }); - - it('loads hybrid search config', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - searchMode: 'hybrid', - bm25Weight: 0.3, - semanticWeight: 0.7, - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.searchMode).toBe('hybrid'); - expect(result.bm25Weight).toBe(0.3); - expect(result.semanticWeight).toBe(0.7); - }); - }); - - describe('loadSecurityConfig', () => { - it('defaults to plugin-only mode', () => { - const { loader } = createConfigLoader({}); - const result = loader.loadSecurityConfig(); - expect(result.mode).toBe('plugin-only'); - }); - - it('loads full mode with mcpOAuth', () => { - const { loader } = createConfigLoader({ - augment: { - security: { - mode: 'full', - mcpOAuth: { - tokenUrl: 'https://auth.example.com/token', - clientId: 'client-1', - clientSecret: 'secret-1', - scopes: ['openid', 'mcp'], - }, - }, - }, - }); - const result = loader.loadSecurityConfig(); - expect(result.mode).toBe('full'); - expect(result.mcpOAuth).toBeDefined(); - expect(result.mcpOAuth!.tokenUrl).toBe('https://auth.example.com/token'); - expect(result.mcpOAuth!.scopes).toEqual(['openid', 'mcp']); - }); - - it('falls back to plugin-only when full mode lacks mcpOAuth', () => { - const { loader, logger } = createConfigLoader({ - augment: { security: { mode: 'full' } }, - }); - const result = loader.loadSecurityConfig(); - expect(result.mode).toBe('plugin-only'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('Falling back'), - ); - }); - - it('loads none mode', () => { - const { loader } = createConfigLoader({ - augment: { security: { mode: 'none' } }, - }); - const result = loader.loadSecurityConfig(); - expect(result.mode).toBe('none'); - }); - }); - - describe('loadDocumentsConfig', () => { - it('returns null when no documents section', () => { - const { loader } = createConfigLoader({}); - expect(loader.loadDocumentsConfig()).toBeNull(); - }); - - it('returns null when sources is empty', () => { - const { loader } = createConfigLoader({ - augment: { documents: { sources: [] } }, - }); - expect(loader.loadDocumentsConfig()).toBeNull(); - }); - - it('loads directory source', () => { - const { loader } = createConfigLoader({ - augment: { - documents: { - sources: [ - { - type: 'directory', - path: '/docs', - patterns: ['**/*.md'], - }, - ], - }, - }, - }); - const result = loader.loadDocumentsConfig(); - expect(result).not.toBeNull(); - expect(result!.sources[0]).toEqual({ - type: 'directory', - path: '/docs', - patterns: ['**/*.md'], - }); - }); - - it('loads url source', () => { - const { loader } = createConfigLoader({ - augment: { - documents: { - sources: [ - { - type: 'url', - urls: ['https://example.com/doc.md'], - }, - ], - }, - }, - }); - const result = loader.loadDocumentsConfig(); - expect(result!.sources[0]).toEqual({ - type: 'url', - urls: ['https://example.com/doc.md'], - headers: undefined, - }); - }); - - it('loads github source', () => { - const { loader } = createConfigLoader({ - augment: { - documents: { - sources: [ - { - type: 'github', - repo: 'org/repo', - branch: 'main', - path: 'docs/', - }, - ], - }, - }, - }); - const result = loader.loadDocumentsConfig(); - expect(result!.sources[0]).toEqual({ - type: 'github', - repo: 'org/repo', - branch: 'main', - path: 'docs/', - patterns: undefined, - token: undefined, - }); - }); - - it('defaults syncMode to append', () => { - const { loader } = createConfigLoader({ - augment: { - documents: { - sources: [{ type: 'directory', path: '/docs' }], - }, - }, - }); - const result = loader.loadDocumentsConfig(); - expect(result!.syncMode).toBe('append'); - }); - }); - - describe('loadSystemPrompt', () => { - it('returns configured prompt', () => { - const { loader } = createConfigLoader({ - augment: { systemPrompt: 'You are a helpful assistant' }, - }); - expect(loader.loadSystemPrompt()).toBe('You are a helpful assistant'); - }); - - it('returns empty string and warns when not configured', () => { - const { loader, logger } = createConfigLoader({}); - expect(loader.loadSystemPrompt()).toBe(''); - expect(logger.warn).toHaveBeenCalled(); - }); - }); - - describe('loadWorkflows', () => { - it('returns empty array when no workflows configured', () => { - const { loader } = createConfigLoader({}); - expect(loader.loadWorkflows()).toEqual([]); - }); - - it('parses workflow with steps', () => { - const { loader } = createConfigLoader({ - augment: { - workflows: [ - { - id: 'wf-1', - name: 'Migration', - description: 'Migrate an app', - steps: [ - { title: 'Step 1', prompt: 'Analyze the app' }, - { title: 'Step 2', prompt: 'Create a plan' }, - ], - }, - ], - }, - }); - const result = loader.loadWorkflows(); - expect(result).toHaveLength(1); - expect(result[0].id).toBe('wf-1'); - expect(result[0].steps).toHaveLength(2); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigLoader.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigLoader.ts deleted file mode 100644 index a78e5e6c5b2..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigLoader.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './config/ConfigLoader'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigResolutionService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigResolutionService.test.ts deleted file mode 100644 index 6600fcbc3f3..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigResolutionService.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { ConfigResolutionService } from './ConfigResolutionService'; -import type { ClientManager } from './ClientManager'; -import type { RuntimeConfigResolver } from '../../services/RuntimeConfigResolver'; -import type { LlamaStackConfig } from '../../types'; - -function createMockClientManager(): jest.Mocked { - return { - getClient: jest.fn().mockReturnValue({}), - getExistingClient: jest.fn().mockReturnValue({}), - } as unknown as jest.Mocked; -} - -function createBasicConfig(): LlamaStackConfig { - return { - baseUrl: 'https://localhost:8321', - model: 'test-model', - vectorStoreIds: ['vs-1'], - vectorStoreName: 'test-store', - embeddingModel: 'test-embed', - embeddingDimension: 384, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - } as LlamaStackConfig; -} - -describe('ConfigResolutionService', () => { - let service: ConfigResolutionService; - let mockClientManager: jest.Mocked; - - beforeEach(() => { - mockClientManager = createMockClientManager(); - service = new ConfigResolutionService(mockClientManager); - }); - - describe('llamaStackConfig management', () => { - it('returns null before config is set', () => { - expect(service.getLlamaStackConfig()).toBeNull(); - }); - - it('stores and retrieves config', () => { - const config = createBasicConfig(); - service.setLlamaStackConfig(config); - expect(service.getLlamaStackConfig()).toBe(config); - }); - }); - - describe('resolver management', () => { - it('returns null before resolver is set', () => { - expect(service.getResolver()).toBeNull(); - }); - - it('stores and retrieves resolver', () => { - const resolver = { - resolve: jest.fn(), - invalidateCache: jest.fn(), - } as unknown as RuntimeConfigResolver; - service.setResolver(resolver); - expect(service.getResolver()).toBe(resolver); - }); - - it('invalidateCache is safe when resolver is null', () => { - expect(() => service.invalidateCache()).not.toThrow(); - }); - - it('invalidateCache delegates to resolver', () => { - const resolver = { - resolve: jest.fn(), - invalidateCache: jest.fn(), - } as unknown as RuntimeConfigResolver; - service.setResolver(resolver); - service.invalidateCache(); - expect(resolver.invalidateCache).toHaveBeenCalled(); - }); - }); - - describe('resolve()', () => { - it('throws when llamaStackConfig is not set', async () => { - await expect(service.resolve()).rejects.toThrow( - 'Llama Stack not configured', - ); - }); - - it('returns YAML fallback when no resolver is set', async () => { - service.setLlamaStackConfig(createBasicConfig()); - service.setSystemPrompt('test prompt'); - - const result = await service.resolve(); - - expect(result.model).toBe('test-model'); - expect(result.baseUrl).toBe('https://localhost:8321'); - expect(result.systemPrompt).toBe('test prompt'); - }); - - it('delegates to resolver when available', async () => { - const resolvedConfig = { - model: 'override-model', - baseUrl: 'https://override:8321', - verboseStreamLogging: true, - skipTlsVerify: false, - token: 'tok', - }; - const resolver = { - resolve: jest.fn().mockResolvedValue(resolvedConfig), - invalidateCache: jest.fn(), - } as unknown as RuntimeConfigResolver; - service.setLlamaStackConfig(createBasicConfig()); - service.setResolver(resolver); - - const result = await service.resolve(); - - expect(result).toEqual(resolvedConfig); - expect(resolver.resolve).toHaveBeenCalled(); - }); - - it('updates lastResolvedModel on resolve', async () => { - service.setLlamaStackConfig(createBasicConfig()); - expect(service.getLastResolvedModel()).toBeNull(); - - await service.resolve(); - - expect(service.getLastResolvedModel()).toBe('test-model'); - }); - - it('updates client via clientManager on resolve', async () => { - const config = createBasicConfig(); - service.setLlamaStackConfig(config); - - await service.resolve(); - - expect(mockClientManager.getClient).toHaveBeenCalledWith( - expect.objectContaining({ - baseUrl: 'https://localhost:8321', - model: 'test-model', - }), - ); - }); - }); - - describe('buildYamlFallback()', () => { - it('throws when llamaStackConfig is not set', () => { - expect(() => service.buildYamlFallback()).toThrow( - 'Llama Stack not configured', - ); - }); - - it('builds config from YAML values', () => { - service.setLlamaStackConfig(createBasicConfig()); - service.setSystemPrompt('fallback prompt'); - - const result = service.buildYamlFallback(); - - expect(result.model).toBe('test-model'); - expect(result.systemPrompt).toBe('fallback prompt'); - expect(result.vectorStoreIds).toEqual(['vs-1']); - expect(result.skipTlsVerify).toBe(false); - expect(result.verboseStreamLogging).toBe(false); - }); - - it('copies vectorStoreIds to prevent mutation', () => { - const config = createBasicConfig(); - service.setLlamaStackConfig(config); - - const result = service.buildYamlFallback(); - result.vectorStoreIds.push('vs-new'); - - expect(config.vectorStoreIds).toEqual(['vs-1']); - }); - }); - - describe('isVerboseStreamLoggingEnabled()', () => { - it('returns false by default', () => { - expect(service.isVerboseStreamLoggingEnabled()).toBe(false); - }); - - it('returns resolved value when set', () => { - service.setLastResolvedVerboseLogging(true); - expect(service.isVerboseStreamLoggingEnabled()).toBe(true); - }); - - it('falls back to llamaStackConfig when resolved is null', () => { - const config: LlamaStackConfig = { - ...createBasicConfig(), - verboseStreamLogging: true, - }; - service.setLlamaStackConfig(config); - - expect(service.isVerboseStreamLoggingEnabled()).toBe(true); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigResolutionService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigResolutionService.ts deleted file mode 100644 index c7c2f89bd46..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConfigResolutionService.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './config/ConfigResolutionService'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationFacade.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationFacade.test.ts deleted file mode 100644 index 589c2dc7b7f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationFacade.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { ConversationFacade } from './ConversationFacade'; -import type { - ConversationFacadeContext, - ConversationFacadeDeps, -} from './ConversationFacade'; -import type { ConversationService } from './ConversationService'; -import type { - ConversationListResult, - ConversationDetails, - InputItemsResult, - ConversationItemsResult, - ProcessedMessage, - ApprovalResult, -} from './conversationTypes'; - -function createMockContext( - overrides?: Partial, -): ConversationFacadeContext { - return { - ensureInitialized: jest.fn(), - ...overrides, - }; -} - -function createMockConversations(): jest.Mocked { - return { - listConversations: jest - .fn() - .mockResolvedValue({ conversations: [], hasMore: false }), - getConversation: jest.fn().mockResolvedValue(null), - getConversationInputs: jest - .fn() - .mockResolvedValue({ items: [], hasMore: false }), - deleteConversation: jest.fn().mockResolvedValue(true), - createConversation: jest.fn().mockResolvedValue('conv_123'), - getConversationItems: jest.fn().mockResolvedValue({ items: [] }), - getProcessedMessages: jest.fn().mockResolvedValue([]), - walkResponseChain: jest.fn().mockResolvedValue([]), - continueAfterApproval: jest.fn().mockResolvedValue({} as ApprovalResult), - } as unknown as jest.Mocked; -} - -describe('ConversationFacade', () => { - let mockContext: jest.Mocked; - let mockConversations: jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - mockContext = createMockContext() as jest.Mocked; - mockConversations = createMockConversations(); - }); - - function createFacade( - deps?: Partial, - ): ConversationFacade { - return new ConversationFacade({ - conversations: mockConversations, - context: mockContext, - ...deps, - }); - } - - describe('constructor and setConversations', () => { - it('creates instance with dependencies', () => { - const facade = createFacade(); - expect(facade).toBeDefined(); - }); - - it('setConversations updates conversation service reference', () => { - const facade = createFacade({ conversations: null }); - facade.setConversations(mockConversations); - expect(mockConversations).toBeDefined(); - }); - }); - - describe('listConversations', () => { - it('delegates to conversations service', async () => { - const listResult: ConversationListResult = { - conversations: [ - { - responseId: 'r1', - conversationId: 'c1', - createdAt: new Date(0), - preview: 'Chat', - model: 'test-model', - status: 'completed' as const, - }, - ], - hasMore: true, - }; - mockConversations.listConversations.mockResolvedValue(listResult); - - const facade = createFacade(); - const result = await facade.listConversations(10, 'desc', 'after_1'); - - expect(mockContext.ensureInitialized).toHaveBeenCalled(); - expect(mockConversations.listConversations).toHaveBeenCalledWith( - 10, - 'desc', - 'after_1', - ); - expect(result).toEqual(listResult); - }); - - it('returns empty result when conversations is null', async () => { - const facade = createFacade({ conversations: null }); - const result = await facade.listConversations(); - expect(result).toEqual({ conversations: [], hasMore: false }); - expect(mockConversations.listConversations).not.toHaveBeenCalled(); - }); - }); - - describe('getConversation', () => { - it('delegates to conversations service', async () => { - const details: ConversationDetails = { - id: 'r1', - model: 'test-model', - status: 'completed', - createdAt: new Date(0), - input: [], - output: [], - }; - mockConversations.getConversation.mockResolvedValue(details); - - const facade = createFacade(); - const result = await facade.getConversation('r1'); - - expect(mockContext.ensureInitialized).toHaveBeenCalled(); - expect(mockConversations.getConversation).toHaveBeenCalledWith('r1'); - expect(result).toEqual(details); - }); - - it('returns null when conversations is null', async () => { - const facade = createFacade({ conversations: null }); - const result = await facade.getConversation('r1'); - expect(result).toBeNull(); - }); - }); - - describe('getConversationInputs', () => { - it('delegates to conversations service', async () => { - const inputs: InputItemsResult = { - items: [{ type: 'message', role: 'user', content: 'Hi' }], - hasMore: false, - }; - mockConversations.getConversationInputs.mockResolvedValue(inputs); - - const facade = createFacade(); - const result = await facade.getConversationInputs('r1'); - - expect(mockConversations.getConversationInputs).toHaveBeenCalledWith( - 'r1', - ); - expect(result).toEqual(inputs); - }); - - it('returns empty when conversations is null', async () => { - const facade = createFacade({ conversations: null }); - const result = await facade.getConversationInputs('r1'); - expect(result).toEqual({ items: [], hasMore: false }); - }); - }); - - describe('deleteConversation', () => { - it('delegates to conversations service', async () => { - mockConversations.deleteConversation.mockResolvedValue(true); - - const facade = createFacade(); - const result = await facade.deleteConversation('r1', 'c1'); - - expect(mockConversations.deleteConversation).toHaveBeenCalledWith( - 'r1', - 'c1', - ); - expect(result).toBe(true); - }); - - it('returns false when conversations is null', async () => { - const facade = createFacade({ conversations: null }); - const result = await facade.deleteConversation('r1'); - expect(result).toBe(false); - }); - }); - - describe('deleteConversationContainer', () => { - it('delegates to conversations service', async () => { - mockConversations.deleteConversationContainer = jest - .fn() - .mockResolvedValue(true); - - const facade = createFacade(); - const result = await facade.deleteConversationContainer('conv-1'); - - expect(mockContext.ensureInitialized).toHaveBeenCalled(); - expect( - mockConversations.deleteConversationContainer, - ).toHaveBeenCalledWith('conv-1'); - expect(result).toBe(true); - }); - - it('returns false when conversations is null', async () => { - const facade = createFacade({ conversations: null }); - const result = await facade.deleteConversationContainer('conv-1'); - expect(result).toBe(false); - }); - }); - - describe('createConversation', () => { - it('delegates to conversations service and returns conversation ID', async () => { - mockConversations.createConversation.mockResolvedValue('conv_new'); - - const facade = createFacade(); - const result = await facade.createConversation(); - - expect(mockContext.ensureInitialized).toHaveBeenCalled(); - expect(mockConversations.createConversation).toHaveBeenCalled(); - expect(result).toBe('conv_new'); - }); - - it('throws when conversations is null', async () => { - const facade = createFacade({ conversations: null }); - await expect(facade.createConversation()).rejects.toThrow( - 'Conversation service not initialized', - ); - }); - }); - - describe('getConversationItems', () => { - it('delegates to conversations service', async () => { - const items: ConversationItemsResult = { - items: [{ type: 'message', role: 'user', content: 'Hello' }], - }; - mockConversations.getConversationItems.mockResolvedValue(items); - - const facade = createFacade(); - const result = await facade.getConversationItems('c1'); - - expect(mockConversations.getConversationItems).toHaveBeenCalledWith('c1'); - expect(result).toEqual(items); - }); - - it('returns empty items when conversations is null', async () => { - const facade = createFacade({ conversations: null }); - const result = await facade.getConversationItems('c1'); - expect(result).toEqual({ items: [] }); - }); - }); - - describe('getProcessedMessages', () => { - it('delegates to conversations service', async () => { - const messages: ProcessedMessage[] = [ - { role: 'user', text: 'Hi' }, - { role: 'assistant', text: 'Hello' }, - ]; - mockConversations.getProcessedMessages.mockResolvedValue(messages); - - const facade = createFacade(); - const result = await facade.getProcessedMessages('c1'); - - expect(mockConversations.getProcessedMessages).toHaveBeenCalledWith('c1'); - expect(result).toEqual(messages); - }); - - it('returns empty array when conversations is null', async () => { - const facade = createFacade({ conversations: null }); - const result = await facade.getProcessedMessages('c1'); - expect(result).toEqual([]); - }); - }); - - describe('walkResponseChain', () => { - it('delegates to conversations service', async () => { - const chain = [ - { role: 'user' as const, text: 'Hi' }, - { role: 'assistant' as const, text: 'Hello' }, - ]; - mockConversations.walkResponseChain.mockResolvedValue(chain); - - const facade = createFacade(); - const result = await facade.walkResponseChain('r1'); - - expect(mockConversations.walkResponseChain).toHaveBeenCalledWith('r1'); - expect(result).toEqual(chain); - }); - - it('returns empty array when conversations is null', async () => { - const facade = createFacade({ conversations: null }); - const result = await facade.walkResponseChain('r1'); - expect(result).toEqual([]); - }); - }); - - describe('continueAfterApproval', () => { - it('delegates to conversations service', async () => { - const approvalResult: ApprovalResult = { - content: 'ok', - responseId: 'r1', - toolExecuted: true, - }; - mockConversations.continueAfterApproval.mockResolvedValue(approvalResult); - - const facade = createFacade(); - const result = await facade.continueAfterApproval( - 'r1', - 'approval_1', - true, - 'tool_name', - '{"arg":1}', - 'c1', - ); - - expect(mockConversations.continueAfterApproval).toHaveBeenCalledWith( - 'r1', - 'approval_1', - true, - 'tool_name', - '{"arg":1}', - 'c1', - ); - expect(result).toEqual(approvalResult); - }); - - it('throws when conversations is null', async () => { - const facade = createFacade({ conversations: null }); - await expect( - facade.continueAfterApproval('r1', 'approval_1', true), - ).rejects.toThrow('Conversation service not initialized'); - }); - - it('routes to backend handler when pending approval exists and preserves store until handler completes', async () => { - const { BackendApprovalStore } = jest.requireActual( - './BackendApprovalStore', - ) as { - BackendApprovalStore: new () => import('./BackendApprovalStore').BackendApprovalStore; - }; - const store = new BackendApprovalStore(); - store.store({ - responseId: 'r1', - callId: 'call-1', - functionName: 'ocp-mcp__namespaces_list', - argumentsJson: '{"ns":"default"}', - serverId: 'ocp-mcp', - serverUrl: 'https://example.com/mcp', - originalToolName: 'namespaces_list', - createdAt: Date.now(), - }); - - const handlerResult: ApprovalResult = { - content: 'Tool executed', - responseId: 'r2', - toolExecuted: true, - }; - const handler = jest.fn().mockImplementation(async approval => { - const stillInStore = store.get(approval.responseId, approval.callId); - expect(stillInStore).toBeDefined(); - expect(stillInStore!.functionName).toBe('ocp-mcp__namespaces_list'); - return handlerResult; - }); - - const facade = createFacade(); - facade.setBackendApprovalHandler(store, handler); - - const result = await facade.continueAfterApproval( - 'r1', - 'call-1', - true, - 'namespaces_list', - '{}', - ); - - expect(handler).toHaveBeenCalledWith({ - responseId: 'r1', - callId: 'call-1', - approved: true, - toolName: 'namespaces_list', - toolArguments: '{}', - }); - expect(result).toEqual(handlerResult); - expect(store.get('r1', 'call-1')).toBeUndefined(); - }); - - it('keeps pending approval when handler throws to allow retries', async () => { - const { BackendApprovalStore } = jest.requireActual( - './BackendApprovalStore', - ) as { - BackendApprovalStore: new () => import('./BackendApprovalStore').BackendApprovalStore; - }; - const store = new BackendApprovalStore(); - store.store({ - responseId: 'r1', - callId: 'call-1', - functionName: 'srv__tool', - argumentsJson: '{}', - serverId: 'srv', - serverUrl: 'https://example.com', - originalToolName: 'tool', - createdAt: Date.now(), - }); - - const handler = jest.fn().mockRejectedValue(new Error('LlamaStack down')); - const facade = createFacade(); - facade.setBackendApprovalHandler(store, handler); - - await expect( - facade.continueAfterApproval('r1', 'call-1', true), - ).rejects.toThrow('LlamaStack down'); - - expect(store.get('r1', 'call-1')).toBeDefined(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationFacade.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationFacade.ts deleted file mode 100644 index 6517f885439..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationFacade.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export * from '../responses-api/conversations/ConversationFacade'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationHelpers.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationHelpers.test.ts deleted file mode 100644 index 0220e9f98b5..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationHelpers.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - mapRawItemsToConversationItems, - mapRawInputItemsToNormalized, - toConversationSummary, - createOutputSummaryForLogging, -} from './ConversationHelpers'; - -describe('ConversationHelpers', () => { - describe('mapRawItemsToConversationItems', () => { - it('maps basic message items', () => { - const raw = [ - { - type: 'message', - id: 'msg-1', - role: 'user', - content: 'Hello', - }, - ]; - const result = mapRawItemsToConversationItems(raw); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: 'message', - id: 'msg-1', - role: 'user', - content: 'Hello', - }); - }); - - it('maps mcp_call with tool fields', () => { - const raw = [ - { - type: 'mcp_call', - id: 'call-1', - name: 'projects_list', - server_label: 'ocp-mcp', - arguments: '{"org":"acme"}', - output: '{"projects":[]}', - }, - ]; - const result = mapRawItemsToConversationItems(raw); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: 'mcp_call', - name: 'projects_list', - server_label: 'ocp-mcp', - arguments: '{"org":"acme"}', - output: '{"projects":[]}', - }); - }); - - it('maps file_search_call with results', () => { - const raw = [ - { - type: 'file_search_call', - call_id: 'fs-1', - queries: ['query1'], - results: [{ filename: 'doc.pdf', text: 'snippet', score: 0.9 }], - }, - ]; - const result = mapRawItemsToConversationItems(raw); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('file_search_call'); - expect(result[0].queries).toEqual(['query1']); - expect(result[0].results).toHaveLength(1); - expect(result[0].results![0]).toMatchObject({ - filename: 'doc.pdf', - text: 'snippet', - score: 0.9, - }); - }); - - it('maps created_at timestamp', () => { - const raw = [ - { - type: 'message', - role: 'assistant', - content: 'Hi', - created_at: 1700000000, - }, - ]; - const result = mapRawItemsToConversationItems(raw); - expect(result[0].created_at).toBe(1700000000); - }); - - it('returns empty array for empty input', () => { - const result = mapRawItemsToConversationItems([]); - expect(result).toEqual([]); - }); - }); - - describe('mapRawInputItemsToNormalized', () => { - it('normalizes content for each item', () => { - const raw = [ - { - type: 'message', - id: 'm1', - role: 'user', - content: 'Hello', - status: 'completed', - }, - ]; - const result = mapRawInputItemsToNormalized(raw); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: 'message', - id: 'm1', - role: 'user', - content: 'Hello', - status: 'completed', - }); - }); - - it('handles array content', () => { - const raw = [ - { - type: 'message', - content: [{ type: 'input_text', text: 'Hi' }], - }, - ]; - const result = mapRawInputItemsToNormalized(raw); - expect(result[0].content).toEqual([{ type: 'input_text', text: 'Hi' }]); - }); - - it('returns empty array for empty input', () => { - const result = mapRawInputItemsToNormalized([]); - expect(result).toEqual([]); - }); - }); - - describe('toConversationSummary', () => { - it('builds summary from valid raw item', () => { - const r = { - id: 'resp-1', - model: 'model', - status: 'completed', - created_at: 1700000000, - input: [], - previous_response_id: undefined, - conversation: undefined, - }; - const summary = toConversationSummary(r, 'Preview text', 'conv-123'); - expect(summary).not.toBeNull(); - expect(summary!.responseId).toBe('resp-1'); - expect(summary!.preview).toBe('Preview text'); - expect(summary!.createdAt).toEqual(new Date(1700000000 * 1000)); - expect(summary!.model).toBe('model'); - expect(summary!.status).toBe('completed'); - expect(summary!.conversationId).toBe('conv-123'); - }); - - it('uses r.conversation over passed conversationId', () => { - const r = { - id: 'resp-1', - model: 'm', - status: 'completed', - created_at: 1700000000, - conversation: 'from-api', - }; - const summary = toConversationSummary(r, 'P', 'from-registry'); - expect(summary!.conversationId).toBe('from-api'); - }); - - it('returns null when id is missing', () => { - const r = { - model: 'm', - status: 'completed', - created_at: 1700000000, - }; - expect(toConversationSummary(r, 'P', undefined)).toBeNull(); - }); - - it('returns null when created_at is missing', () => { - const r = { - id: 'resp-1', - model: 'm', - status: 'completed', - }; - expect(toConversationSummary(r, 'P', undefined)).toBeNull(); - }); - - it('returns null when model is missing', () => { - const r = { - id: 'resp-1', - status: 'completed', - created_at: 1700000000, - }; - expect(toConversationSummary(r, 'P', undefined)).toBeNull(); - }); - }); - - describe('createOutputSummaryForLogging', () => { - it('creates compact summary for each output item', () => { - const output = [ - { type: 'mcp_call', name: 'tool1', output: 'result' }, - { type: 'message', content: [{ type: 'text', text: 'Hi' }] }, - ]; - const result = createOutputSummaryForLogging(output); - expect(result).toHaveLength(2); - expect(result[0]).toMatchObject({ - type: 'mcp_call', - name: 'tool1', - hasOutput: true, - }); - expect(result[1]).toMatchObject({ - type: 'message', - contentParts: 1, - }); - }); - - it('includes error when present', () => { - const output = [{ type: 'mcp_call', name: 'tool1', error: 'failed' }]; - const result = createOutputSummaryForLogging(output); - expect(result[0].error).toBe('failed'); - }); - - it('returns empty array for empty output', () => { - const result = createOutputSummaryForLogging([]); - expect(result).toEqual([]); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationHelpers.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationHelpers.ts deleted file mode 100644 index 36c6dbf14c2..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationHelpers.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export * from '../responses-api/conversations/ConversationHelpers'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationRegistry.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationRegistry.ts deleted file mode 100644 index 480ce826d78..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationRegistry.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/conversations/ConversationRegistry' instead */ -export * from '../responses-api/conversations/ConversationRegistry'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationService.test.ts deleted file mode 100644 index db0bb1ace42..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationService.test.ts +++ /dev/null @@ -1,597 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { ConversationService } from './ConversationService'; -import type { ConversationClientAccessor } from './conversationTypes'; -import { McpAuthService } from './McpAuthService'; -import { ResponsesApiClient, ResponsesApiError } from './ResponsesApiClient'; -import { createMockLogger } from '../../test-utils/mocks'; - -function createMockClient(): ResponsesApiClient & { request: jest.Mock } { - const request = jest.fn(); - return { request } as unknown as ResponsesApiClient & { request: jest.Mock }; -} - -function createMockMcpAuth(approvalOverride?: string): McpAuthService { - return { - getServerHeaders: jest.fn().mockResolvedValue({}), - getApiApprovalConfig: jest - .fn() - .mockReturnValue(approvalOverride ?? 'never'), - } as unknown as McpAuthService; -} - -function createAccessor( - client: ResponsesApiClient, - model = 'test-model', -): ConversationClientAccessor { - return { getClient: () => client, getModel: () => model }; -} - -describe('ConversationService', () => { - let mockLogger: ReturnType; - - function createService(options?: { - client?: ResponsesApiClient; - accessor?: ConversationClientAccessor; - mcpServers?: Array<{ - id: string; - name: string; - type: string; - url: string; - requireApproval?: string; - allowedTools?: string[]; - }>; - mcpAuth?: McpAuthService; - }): ConversationService { - const accessor = - options?.accessor ?? - createAccessor(options?.client ?? createMockClient()); - return new ConversationService( - accessor, - options?.mcpAuth ?? createMockMcpAuth(), - (options?.mcpServers ?? []) as import('../../types').MCPServerConfig[], - mockLogger, - ); - } - - beforeEach(() => { - jest.clearAllMocks(); - mockLogger = createMockLogger(); - }); - - describe('constructor', () => { - it('creates instance with required dependencies', () => { - const service = createService(); - expect(service).toBeDefined(); - }); - }); - - describe('createConversation', () => { - it('calls Llama Stack API to create a conversation and returns its ID', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ id: 'conv_123' }); - - const service = createService({ client: mockClient }); - - const convId = await service.createConversation(); - expect(convId).toBe('conv_123'); - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/conversations', - expect.objectContaining({ method: 'POST' }), - ); - }); - - it('propagates errors from the API', async () => { - const mockClient = createMockClient(); - mockClient.request.mockRejectedValue(new Error('Connection refused')); - - const service = createService({ client: mockClient }); - - await expect(service.createConversation()).rejects.toThrow( - 'Connection refused', - ); - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Failed to create conversation'), - ); - }); - }); - - describe('markFirstStoredTurn', () => { - it('returns true for a new conversation ID', () => { - const service = createService(); - expect(service.markFirstStoredTurn('new-conv')).toBe(true); - }); - - it('returns false after conversation is marked as stored', () => { - const service = createService(); - expect(service.markFirstStoredTurn('my-conv')).toBe(true); - expect(service.markFirstStoredTurn('my-conv')).toBe(false); - }); - - it('tracks multiple conversation IDs independently', () => { - const service = createService(); - expect(service.markFirstStoredTurn('conv-a')).toBe(true); - expect(service.markFirstStoredTurn('conv-b')).toBe(true); - expect(service.markFirstStoredTurn('conv-a')).toBe(false); - expect(service.markFirstStoredTurn('conv-b')).toBe(false); - }); - }); - - describe('registerResponse', () => { - it('caches the mapping in memory and logs it', async () => { - const service = createService(); - await service.registerResponse('conv_1', 'resp_1'); - expect(mockLogger.debug).toHaveBeenCalledWith( - expect.stringContaining('resp_1'), - ); - }); - }); - - describe('getConversationForResponse', () => { - it('returns undefined for an unknown response ID', async () => { - const service = createService(); - const result = await service.getConversationForResponse('unknown'); - expect(result).toBeUndefined(); - }); - - it('returns cached conversation ID after registerResponse', async () => { - const service = createService(); - await service.registerResponse('conv_1', 'resp_1'); - const result = await service.getConversationForResponse('resp_1'); - expect(result).toBe('conv_1'); - }); - }); - - describe('deleteConversation', () => { - it('calls Llama Stack API to delete a response and returns true', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({}); - - const service = createService({ client: mockClient }); - - const result = await service.deleteConversation('resp_delete'); - expect(result).toBe(true); - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/responses/resp_delete', - expect.objectContaining({ method: 'DELETE' }), - ); - }); - - it('also deletes the conversation container when conversationId is provided', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({}); - - const service = createService({ client: mockClient }); - - const result = await service.deleteConversation('resp_1', 'conv_1'); - expect(result).toBe(true); - expect(mockClient.request).toHaveBeenCalledTimes(2); - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/responses/resp_1', - expect.objectContaining({ method: 'DELETE' }), - ); - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/conversations/conv_1', - expect.objectContaining({ method: 'DELETE' }), - ); - }); - - it('returns false when response deletion fails', async () => { - const mockClient = createMockClient(); - mockClient.request.mockRejectedValue(new Error('Not found')); - - const service = createService({ client: mockClient }); - - const result = await service.deleteConversation('resp_bad'); - expect(result).toBe(false); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to delete response resp_bad'), - ); - }); - - it('returns true even if conversation container deletion fails (non-fatal)', async () => { - const mockClient = createMockClient(); - mockClient.request - .mockResolvedValueOnce({}) - .mockRejectedValueOnce(new Error('Container not found')); - - const service = createService({ client: mockClient }); - - const result = await service.deleteConversation('resp_ok', 'conv_gone'); - expect(result).toBe(true); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('non-fatal'), - ); - }); - }); - - describe('deleteConversationContainer', () => { - it('deletes the conversation container and returns true', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({}); - - const service = createService({ client: mockClient }); - const result = await service.deleteConversationContainer('conv_123'); - - expect(result).toBe(true); - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/conversations/conv_123', - expect.objectContaining({ method: 'DELETE' }), - ); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Deleted conversation container conv_123'), - ); - }); - - it('returns false when the container deletion fails', async () => { - const mockClient = createMockClient(); - mockClient.request.mockRejectedValue(new Error('Not found')); - - const service = createService({ client: mockClient }); - const result = await service.deleteConversationContainer('conv_bad'); - - expect(result).toBe(false); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'Failed to delete conversation container conv_bad', - ), - ); - }); - }); - - describe('dynamic client accessor', () => { - it('picks up a new client when the accessor returns a different instance', async () => { - const client1 = createMockClient(); - const client2 = createMockClient(); - client1.request.mockResolvedValue({ id: 'conv-from-client1' }); - client2.request.mockResolvedValue({ id: 'conv-from-client2' }); - - let activeClient: ResponsesApiClient & { request: jest.Mock } = client1; - const accessor: ConversationClientAccessor = { - getClient: () => activeClient, - getModel: () => 'test-model', - }; - - const service = createService({ accessor }); - - const id1 = await service.createConversation(); - expect(id1).toBe('conv-from-client1'); - expect(client1.request).toHaveBeenCalledTimes(1); - - activeClient = client2; - const id2 = await service.createConversation(); - expect(id2).toBe('conv-from-client2'); - expect(client2.request).toHaveBeenCalledTimes(1); - }); - - it('always uses previous_response_id even when registry has conversationId', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-approval-out', - output: [ - { - type: 'mcp_call', - name: 'myTool', - output: '{"ok":true}', - }, - ], - }); - - const service = createService({ client: mockClient }); - - await service.registerResponse('conv-abc', 'resp-original'); - - await service.continueAfterApproval( - 'resp-original', - 'appr-1', - true, - 'myTool', - '{"key":"val"}', - ); - - const body = JSON.parse(mockClient.request.mock.calls[0][1].body); - expect(body.previous_response_id).toBe('resp-original'); - expect(body.conversation).toBeUndefined(); - }); - - it('registers approval response ID in registry for future lookups', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-approval-new', - output: [ - { - type: 'mcp_call', - name: 'myTool', - output: '{"ok":true}', - }, - ], - }); - - const service = createService({ client: mockClient }); - - await service.registerResponse('conv-abc', 'resp-original'); - - await service.continueAfterApproval( - 'resp-original', - 'appr-1', - true, - 'myTool', - '{"key":"val"}', - ); - - const convId = - await service.getConversationForResponse('resp-approval-new'); - expect(convId).toBe('conv-abc'); - }); - - it('uses previous_response_id for rejection even when registry has mapping', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-rejection-out', - output: [], - }); - - const service = createService({ client: mockClient }); - - await service.registerResponse('conv-abc', 'resp-to-reject'); - - await service.continueAfterApproval('resp-to-reject', 'appr-1', false); - - const body = JSON.parse(mockClient.request.mock.calls[0][1].body); - expect(body.previous_response_id).toBe('resp-to-reject'); - expect(body.conversation).toBeUndefined(); - expect(body.input[0].approve).toBe(false); - }); - - it('falls back to previous_response_id when no registry entry exists', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-approval-out', - output: [ - { - type: 'message', - content: [{ type: 'output_text', text: 'Done' }], - }, - ], - }); - - const service = createService({ client: mockClient }); - - await service.continueAfterApproval( - 'resp-orphan', - 'appr-1', - true, - 'myTool', - '{"key":"val"}', - ); - - const body = JSON.parse(mockClient.request.mock.calls[0][1].body); - expect(body.previous_response_id).toBe('resp-orphan'); - expect(body.conversation).toBeUndefined(); - }); - - it('includes require_approval in approval continuation tools payload', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-approval-out', - output: [{ type: 'mcp_call', name: 'myTool', output: '{"ok":true}' }], - }); - - const mcpAuth = createMockMcpAuth('always'); - const service = createService({ - client: mockClient, - mcpServers: [ - { - id: 'srv-1', - name: 'Server 1', - type: 'streamable-http', - url: 'http://srv1.example.com', - requireApproval: 'always', - }, - ], - mcpAuth, - }); - - await service.continueAfterApproval( - 'resp-1', - 'appr-1', - true, - 'myTool', - '{}', - ); - - const body = JSON.parse(mockClient.request.mock.calls[0][1].body); - expect(body.tools).toHaveLength(1); - expect(body.tools[0]).toMatchObject({ - type: 'mcp', - server_url: 'http://srv1.example.com', - server_label: 'srv-1', - require_approval: 'never', - }); - expect(mcpAuth.getApiApprovalConfig).toHaveBeenCalledWith('always'); - }); - - it('includes allowed_tools in approval continuation tools payload', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-approval-out', - output: [ - { type: 'mcp_call', name: 'allowed_tool', output: '{"ok":true}' }, - ], - }); - - const service = createService({ - client: mockClient, - mcpServers: [ - { - id: 'srv-1', - name: 'Server 1', - type: 'streamable-http', - url: 'http://srv1.example.com', - allowedTools: ['tool_a', 'tool_b'], - }, - ], - }); - - await service.continueAfterApproval( - 'resp-1', - 'appr-1', - true, - 'allowed_tool', - '{}', - ); - - const body = JSON.parse(mockClient.request.mock.calls[0][1].body); - expect(body.tools[0].allowed_tools).toEqual(['tool_a', 'tool_b']); - }); - - it('picks up a new model when the accessor returns a different value', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-approval', - output: [ - { - type: 'message', - content: [{ type: 'output_text', text: 'Done' }], - }, - ], - }); - - let currentModel = 'model-A'; - const accessor: ConversationClientAccessor = { - getClient: () => mockClient, - getModel: () => currentModel, - }; - - const service = createService({ accessor }); - - await service.continueAfterApproval( - 'resp-1', - 'appr-1', - true, - 'myTool', - '{"key":"val"}', - ); - let body = JSON.parse(mockClient.request.mock.calls[0][1].body); - expect(body.model).toBe('model-A'); - - currentModel = 'model-B'; - await service.continueAfterApproval( - 'resp-2', - 'appr-2', - true, - 'myTool', - '{"key":"val2"}', - ); - body = JSON.parse(mockClient.request.mock.calls[1][1].body); - expect(body.model).toBe('model-B'); - }); - }); - - describe('getConversationItems', () => { - it('handles { data: [...] } response format', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - data: [ - { type: 'message', role: 'user', content: 'Hello' }, - { type: 'message', role: 'assistant', content: 'Hi there' }, - ], - }); - - const service = createService({ client: mockClient }); - const result = await service.getConversationItems('conv-1'); - - expect(result.items).toHaveLength(2); - expect(result.items[0].type).toBe('message'); - }); - - it('handles bare array response format', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue([ - { type: 'message', role: 'user', content: 'Hello' }, - { type: 'message', role: 'assistant', content: 'Hi there' }, - ]); - - const service = createService({ client: mockClient }); - const result = await service.getConversationItems('conv-1'); - - expect(result.items).toHaveLength(2); - expect(mockLogger.debug).toHaveBeenCalledWith( - expect.stringContaining('bare array'), - ); - }); - - it('handles { items: [...] } response format', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - items: [{ type: 'message', role: 'user', content: 'Hello' }], - }); - - const service = createService({ client: mockClient }); - const result = await service.getConversationItems('conv-1'); - - expect(result.items).toHaveLength(1); - expect(mockLogger.debug).toHaveBeenCalledWith( - expect.stringContaining('"items" key'), - ); - }); - - it('returns empty array and warns for unexpected response shape', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue('not-an-object'); - - const service = createService({ client: mockClient }); - const result = await service.getConversationItems('conv-1'); - - expect(result.items).toHaveLength(0); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Unexpected conversation items response shape'), - ); - }); - - it('returns empty items on 404 instead of throwing', async () => { - const mockClient = createMockClient(); - mockClient.request.mockRejectedValue( - new ResponsesApiError( - 404, - 'Not Found', - '{"detail":"Conversation not found"}', - ), - ); - - const service = createService({ client: mockClient }); - const result = await service.getConversationItems('conv-deleted'); - - expect(result.items).toHaveLength(0); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('not found (404)'), - ); - }); - - it('rethrows non-404 errors', async () => { - const mockClient = createMockClient(); - mockClient.request.mockRejectedValue( - new ResponsesApiError(500, 'Internal Server Error', '{}'), - ); - - const service = createService({ client: mockClient }); - - await expect(service.getConversationItems('conv-1')).rejects.toThrow( - 'Responses API error', - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationService.ts deleted file mode 100644 index 57f6de9ad22..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ConversationService.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export * from '../responses-api/conversations/ConversationService'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/CoordinatorChatOps.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/CoordinatorChatOps.ts deleted file mode 100644 index d107e70a12e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/CoordinatorChatOps.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ChatRequest, ChatResponse } from '../../types'; -import { WorkflowHydrator } from './workflow/WorkflowHydrator'; -import { LlamaStackProvider } from './openai-agents-adapters/LlamaStackProvider'; -import { toChatResponse } from './openai-agents-adapters/responseMapper'; -import { mapRunStreamEventToFrontend } from './openai-agents-adapters/streamMapper'; -import { requireLastUserMessage } from '../responses-api/chat/chatUtils'; -import { migrateAgentConfigsToWorkflow } from '../../services/WorkflowMigration'; -import type { ResponsesApiService } from './ResponsesApiService'; -import type { ChatDepsBuilder } from './ChatDepsBuilder'; -import type { AgentGraphManager } from './AgentGraphManager'; -import type { OpenAIAgentsOrchestrator } from './openai-agents-adapters/OpenAIAgentsOrchestrator'; - -export interface ChatOpsContext { - logger: LoggerService; - chatService: ResponsesApiService; - chatDepsBuilder: ChatDepsBuilder; - getOrchestrator: () => OpenAIAgentsOrchestrator; - requireAgentGraphManager: () => AgentGraphManager; - ensureInitialized: () => void; -} - -export async function coordinatorChat( - ctx: ChatOpsContext, - request: ChatRequest, -): Promise { - ctx.ensureInitialized(); - - const snapshot = await ctx.requireAgentGraphManager().getSnapshot(); - const deps = await ctx.chatDepsBuilder.buildChatDeps(); - const orchestrator = ctx.getOrchestrator(); - const backendTools = await orchestrator.discoverBackendTools(deps); - - const agentConfigs: Record = {}; - for (const [key, resolved] of snapshot.agents) { - agentConfigs[key] = resolved.config; - } - - const workflowDef = migrateAgentConfigsToWorkflow( - agentConfigs, - snapshot.defaultAgentKey, - ); - workflowDef.settings.maxTurns = snapshot.maxTurns; - - const provider = new LlamaStackProvider( - ctx.chatService, - deps.client, - () => deps.config, - ); - - const hydrator = new WorkflowHydrator(ctx.logger); - const { runner, entryAgent, maxTurns } = hydrator.hydrate( - workflowDef, - provider, - backendTools, - ); - - const userInput = requireLastUserMessage(request, '[Chat] '); - const result = await runner.run(entryAgent, userInput, { maxTurns }); - - return toChatResponse(result); -} - -export async function coordinatorChatStream( - ctx: ChatOpsContext, - request: ChatRequest, - onEvent: (event: string) => void, - signal?: AbortSignal, -): Promise { - ctx.ensureInitialized(); - - const snapshot = await ctx.requireAgentGraphManager().getSnapshot(); - const deps = await ctx.chatDepsBuilder.buildChatDeps(); - const orchestrator = ctx.getOrchestrator(); - const backendTools = await orchestrator.discoverBackendTools(deps); - - const agentConfigs: Record = {}; - for (const [key, resolved] of snapshot.agents) { - agentConfigs[key] = resolved.config; - } - - const workflowDef = migrateAgentConfigsToWorkflow( - agentConfigs, - snapshot.defaultAgentKey, - ); - workflowDef.settings.maxTurns = snapshot.maxTurns; - - const provider = new LlamaStackProvider( - ctx.chatService, - deps.client, - () => deps.config, - ); - - const hydrator = new WorkflowHydrator(ctx.logger); - const { runner, entryAgent, maxTurns } = hydrator.hydrate( - workflowDef, - provider, - backendTools, - ); - - const userInput = requireLastUserMessage(request, '[ChatStream] '); - - try { - const streamed = await runner.run(entryAgent, userInput, { - stream: true, - maxTurns, - signal, - }); - - for await (const event of streamed) { - for (const fe of mapRunStreamEventToFrontend(event)) { - onEvent(fe); - } - } - - onEvent( - JSON.stringify({ - type: 'stream.completed', - agentName: streamed.lastAgent?.name, - }), - ); - } catch (error) { - if (signal?.aborted) return; - const message = error instanceof Error ? error.message : String(error); - ctx.logger.error('[ChatStream] Stream error', { error: message }); - onEvent( - JSON.stringify({ - type: 'stream.error', - error: message, - code: 'execution_error', - }), - ); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/CoordinatorLifecycleOps.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/CoordinatorLifecycleOps.ts deleted file mode 100644 index 0c3f2794f0a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/CoordinatorLifecycleOps.ts +++ /dev/null @@ -1,170 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { - AugmentStatus, - MCPServerConfig, - SecurityConfig, -} from '../../types'; -import { aggregateStatus } from './StatusAggregator'; -import type { ClientManager } from './ClientManager'; -import type { ConfigResolutionService } from './ConfigResolutionService'; -import type { ConfigLoader } from './ConfigLoader'; -import type { McpAuthService } from './McpAuthService'; -import type { StatusService } from './StatusService'; -import type { AgentGraphManager } from './AgentGraphManager'; -import type { OpenAIAgentsOrchestrator } from './openai-agents-adapters/OpenAIAgentsOrchestrator'; -import type { ChatDepsBuilder } from './ChatDepsBuilder'; -import type { BackendToolExecutor } from './BackendToolExecutor'; -import type { BackendApprovalStore } from './BackendApprovalStore'; -import type { ConversationFacade } from './ConversationFacade'; -import type { ResponsesApiService } from './ResponsesApiService'; -import type { ConversationService } from './ConversationService'; -import { BackendApprovalHandler } from './BackendApprovalHandler'; -import { toErrorMessage } from '../../services/utils'; - -export interface StatusContext { - configResolution: ConfigResolutionService; - clientManager: ClientManager; - configLoader: ConfigLoader; - mcpAuth: McpAuthService | null; - mcpServers: MCPServerConfig[]; - securityConfig: SecurityConfig; - vectorStoreReady: boolean; - statusService: StatusService; - agentGraphManager: AgentGraphManager | null; - logger: LoggerService; - ensureInitialized: () => void; -} - -export async function getCoordinatorStatus( - ctx: StatusContext, -): Promise { - const llamaStackConfig = ctx.configResolution.getLlamaStackConfig(); - if (!llamaStackConfig) { - return aggregateStatus({ - llamaStackConfig: null, - clientManager: ctx.clientManager, - mcpAuth: ctx.mcpAuth, - mcpServers: ctx.mcpServers, - yamlMcpServers: ctx.mcpServers, - securityConfig: ctx.securityConfig, - vectorStoreReady: ctx.vectorStoreReady, - statusService: ctx.statusService, - logger: ctx.logger, - }); - } - - let resolved; - try { - ctx.ensureInitialized(); - resolved = await ctx.configResolution.resolve(); - } catch (error) { - ctx.logger.debug( - 'Config resolution failed, using YAML fallback config', - error instanceof Error ? error : undefined, - ); - resolved = ctx.configResolution.buildYamlFallback(); - } - - return aggregateStatus({ - llamaStackConfig, - resolved, - clientManager: ctx.clientManager, - mcpAuth: ctx.mcpAuth, - mcpServers: resolved.mcpServers ?? ctx.mcpServers, - yamlMcpServers: ctx.mcpServers, - securityConfig: ctx.securityConfig, - vectorStoreReady: ctx.vectorStoreReady, - statusService: ctx.statusService, - logger: ctx.logger, - toolExecutionMode: ctx.configLoader.loadToolExecutionMode(), - agentGraphError: ctx.agentGraphManager?.getLastResolutionError(), - }); -} - -export interface PostInitContext { - configResolution: ConfigResolutionService; - mcpServers: MCPServerConfig[]; - orchestrator: OpenAIAgentsOrchestrator | null; - chatDepsBuilder: ChatDepsBuilder; - logger: LoggerService; - getStatus: () => Promise; -} - -export async function postInitialize(ctx: PostInitContext): Promise { - const warmupTasks: Array<{ name: string; task: Promise }> = []; - - warmupTasks.push({ name: 'config', task: ctx.configResolution.resolve() }); - - if (ctx.mcpServers.length > 0) { - warmupTasks.push({ name: 'mcp-status-warmup', task: ctx.getStatus() }); - } - - if (ctx.orchestrator && ctx.mcpServers.length > 0) { - warmupTasks.push({ - name: 'tool-discovery-warmup', - task: ctx.chatDepsBuilder - .buildChatDeps() - .then(deps => ctx.orchestrator!.warmUpToolCache(deps)), - }); - } - - const results = await Promise.allSettled(warmupTasks.map(w => w.task)); - - for (let i = 0; i < results.length; i++) { - const result = results[i]; - if (result.status === 'rejected') { - ctx.logger.warn( - `Warmup task "${warmupTasks[i].name}" failed (non-fatal): ${toErrorMessage(result.reason)}`, - ); - } - } - - ctx.logger.info('Post-initialization warmup complete'); -} - -export interface ApprovalHandlerContext { - conversationFacade: ConversationFacade; - backendApprovalStore: BackendApprovalStore; - backendToolExecutor: BackendToolExecutor; - chatService: ResponsesApiService; - clientManager: ClientManager; - configResolution: ConfigResolutionService; - getConversations: () => ConversationService | null; - getAgentGraphManager?: () => AgentGraphManager | null; - getMcpServers: () => MCPServerConfig[]; - logger: LoggerService; -} - -export function initializeBackendApprovalHandler( - ctx: ApprovalHandlerContext, -): void { - const handler = new BackendApprovalHandler({ - conversationFacade: ctx.conversationFacade, - backendApprovalStore: ctx.backendApprovalStore, - backendToolExecutor: ctx.backendToolExecutor, - chatService: ctx.chatService, - clientManager: ctx.clientManager, - configResolution: ctx.configResolution, - getConversations: ctx.getConversations, - getAgentGraphManager: ctx.getAgentGraphManager, - getMcpServers: ctx.getMcpServers, - logger: ctx.logger, - }); - handler.initialize(); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentLister.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentLister.test.ts deleted file mode 100644 index 20a1189ba0e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentLister.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { fetchAllVectorStoreFiles, listDocuments } from './DocumentLister'; -import type { DocumentListerDeps } from './DocumentLister'; -import type { ResponsesApiClient } from './ResponsesApiClient'; -import type { LlamaStackConfig } from '../../types'; -import { createMockLogger } from '../../test-utils/mocks'; - -function createMockClient(): jest.Mocked> { - return { - request: jest.fn(), - }; -} - -function createDeps( - overrides?: Partial, -): DocumentListerDeps & { client: jest.Mocked } { - const client = createMockClient(); - const config: LlamaStackConfig = { - baseUrl: 'https://llama.example.com', - vectorStoreIds: ['vs_123'], - vectorStoreName: 'test-store', - embeddingModel: 'test-emb', - embeddingDimension: 384, - model: 'test-model', - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - }; - return { - client: client as unknown as jest.Mocked, - config, - logger: createMockLogger(), - ...overrides, - } as unknown as DocumentListerDeps & { - client: jest.Mocked; - }; -} - -describe('DocumentLister', () => { - describe('fetchAllVectorStoreFiles', () => { - it('fetches files when response is object with data array', async () => { - const deps = createDeps(); - deps.client.request.mockResolvedValue({ - data: [ - { - id: 'file_1', - object: 'vector_store.file', - status: 'completed', - created_at: 1234567890, - usage_bytes: 100, - vector_store_id: 'vs_123', - }, - ], - has_more: false, - }); - - const result = await fetchAllVectorStoreFiles(deps); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe('file_1'); - expect(result[0].status).toBe('completed'); - expect(deps.client.request).toHaveBeenCalledWith( - '/v1/vector_stores/vs_123/files?limit=100', - { method: 'GET' }, - ); - }); - - it('uses custom vectorStoreId when provided', async () => { - const deps = createDeps(); - deps.client.request.mockResolvedValue({ data: [], has_more: false }); - - await fetchAllVectorStoreFiles(deps, 'vs_custom'); - - expect(deps.client.request).toHaveBeenCalledWith( - '/v1/vector_stores/vs_custom/files?limit=100', - { method: 'GET' }, - ); - }); - - it('throws InputError when no vector store configured', async () => { - const deps = createDeps({ - config: { - baseUrl: 'https://x.com', - vectorStoreIds: [], - vectorStoreName: 'x', - embeddingModel: 'emb', - embeddingDimension: 384, - model: 'm', - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - } as LlamaStackConfig, - }); - - await expect(fetchAllVectorStoreFiles(deps)).rejects.toThrow( - 'No vector store configured', - ); - }); - - it('paginates when has_more is true', async () => { - const deps = createDeps(); - deps.client.request - .mockResolvedValueOnce({ - data: [ - { - id: 'file_1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_123', - }, - ], - has_more: true, - }) - .mockResolvedValueOnce({ - data: [ - { - id: 'file_2', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_123', - }, - ], - has_more: false, - }); - - const result = await fetchAllVectorStoreFiles(deps); - - expect(result).toHaveLength(2); - expect(result[0].id).toBe('file_1'); - expect(result[1].id).toBe('file_2'); - - expect(deps.client.request).toHaveBeenCalledTimes(2); - expect(deps.client.request).toHaveBeenNthCalledWith( - 1, - '/v1/vector_stores/vs_123/files?limit=100', - { method: 'GET' }, - ); - expect(deps.client.request).toHaveBeenNthCalledWith( - 2, - '/v1/vector_stores/vs_123/files?limit=100&after=file_1', - { method: 'GET' }, - ); - }); - - it('handles array response format', async () => { - const deps = createDeps(); - deps.client.request.mockResolvedValue([ - { - id: 'f1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_123', - }, - ]); - - const result = await fetchAllVectorStoreFiles(deps); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe('f1'); - }); - }); - - describe('listDocuments', () => { - it('returns documents from vector store files endpoint', async () => { - const deps = createDeps(); - deps.client.request - .mockResolvedValueOnce({ - data: [ - { - id: 'vsfile_1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 50, - vector_store_id: 'vs_123', - }, - ], - has_more: false, - }) - .mockResolvedValueOnce({ - id: 'vsfile_1', - object: 'file', - bytes: 50, - created_at: 0, - filename: 'doc.md', - purpose: 'assistants', - }); - - const result = await listDocuments(deps); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - id: 'vsfile_1', - fileName: 'doc.md', - fileSize: 50, - status: 'completed', - }); - expect(result[0].format).toBeDefined(); - expect(result[0].uploadedAt).toBeDefined(); - - expect(deps.client.request).toHaveBeenCalledWith('/v1/files/vsfile_1', { - method: 'GET', - }); - }); - - it('logs and uses basic info when file details request fails', async () => { - const deps = createDeps(); - deps.client.request - .mockResolvedValueOnce({ - data: [ - { - id: 'vsfile_1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 50, - vector_store_id: 'vs_123', - }, - ], - has_more: false, - }) - .mockRejectedValueOnce(new Error('File not found')); - - const result = await listDocuments(deps); - - expect(deps.logger.debug).toHaveBeenCalledWith( - 'Could not get details for file vsfile_1, using basic info', - ); - expect(result).toBeDefined(); - expect(Array.isArray(result)).toBe(true); - }); - - it('falls back to Files API when vector store endpoint fails', async () => { - const deps = createDeps(); - deps.client.request - .mockRejectedValueOnce(new Error('Vector store unavailable')) - .mockResolvedValueOnce({ - data: [ - { - id: 'file_1', - object: 'file', - bytes: 100, - created_at: 1234567890, - filename: 'fallback.txt', - purpose: 'assistants', - }, - ], - }); - - const result = await listDocuments(deps); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe('file_1'); - expect(result[0].fileName).toBe('fallback.txt'); - expect(result[0].status).toBe('completed'); - expect(deps.logger.debug).toHaveBeenCalledWith( - expect.stringContaining('Vector store files endpoint not available'), - ); - }); - - it('filters Files API fallback to purpose=assistants only', async () => { - const deps = createDeps(); - deps.client.request - .mockRejectedValueOnce(new Error('VS unavailable')) - .mockResolvedValueOnce({ - data: [ - { - id: 'f1', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'assistants.txt', - purpose: 'assistants', - }, - { - id: 'f2', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'other.txt', - purpose: 'fine-tune', - }, - ], - }); - - const result = await listDocuments(deps); - - expect(result).toHaveLength(1); - expect(result[0].fileName).toBe('assistants.txt'); - }); - - it('throws when both endpoints fail', async () => { - const deps = createDeps(); - deps.client.request - .mockRejectedValueOnce(new Error('Vector store failed')) - .mockRejectedValueOnce(new Error('Files API failed')); - - await expect(listDocuments(deps)).rejects.toThrow( - 'Could not load documents: Vector store failed', - ); - expect(deps.logger.warn).toHaveBeenCalledWith( - expect.stringContaining('Could not list documents'), - ); - }); - - it('deduplicates files in Files API fallback', async () => { - const deps = createDeps(); - deps.client.request - .mockRejectedValueOnce(new Error('VS failed')) - .mockResolvedValueOnce({ - data: [ - { - id: 'dup', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'a.txt', - purpose: 'assistants', - }, - { - id: 'dup', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'a.txt', - purpose: 'assistants', - }, - ], - }); - - const result = await listDocuments(deps); - - expect(result).toHaveLength(1); - }); - - it('processes files in batches of 10 for detail fetching', async () => { - const deps = createDeps(); - const vsFiles = Array.from({ length: 15 }, (_, i) => ({ - id: `vsfile_${i}`, - object: 'vector_store.file', - status: 'completed' as const, - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_123', - })); - deps.client.request.mockResolvedValueOnce({ - data: vsFiles, - has_more: false, - }); - - for (let i = 0; i < 15; i++) { - deps.client.request.mockResolvedValueOnce({ - id: `vsfile_${i}`, - object: 'file', - bytes: 10, - created_at: 0, - filename: `f${i}.txt`, - purpose: 'assistants', - }); - } - - const result = await listDocuments(deps); - - expect(result).toHaveLength(15); - expect(deps.client.request).toHaveBeenCalledTimes(16); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentLister.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentLister.ts deleted file mode 100644 index 73a9a3bc623..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentLister.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/documents/DocumentLister' instead */ -export * from '../responses-api/documents/DocumentLister'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentSyncService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentSyncService.test.ts deleted file mode 100644 index dd0d92d85c7..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentSyncService.test.ts +++ /dev/null @@ -1,661 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import os from 'os'; -import path from 'path'; -import type { DatabaseService } from '@backstage/backend-plugin-api'; -import { FileFormat } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { DocumentInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { VectorStoreService } from './VectorStoreService'; -import type { DocumentIngestionService } from '../../services/DocumentIngestionService'; -import type { DocumentsConfig, FetchedDocument } from '../../types'; -import { createMockLogger } from '../../test-utils'; -import { DocumentSyncService } from './DocumentSyncService'; - -const HASH_TABLE = 'augment_content_hashes'; - -function createMockVectorStore(): jest.Mocked { - return { - listDocuments: jest.fn().mockResolvedValue([]), - uploadDocuments: jest.fn().mockResolvedValue({ uploaded: [], failed: [] }), - deleteDocument: jest.fn().mockResolvedValue(undefined), - } as unknown as jest.Mocked; -} - -function createMockIngestion(): jest.Mocked< - Pick -> { - return { - fetchFromSources: jest.fn().mockResolvedValue([]), - } as unknown as jest.Mocked< - Pick - >; -} - -function createMockDatabase( - rows: Array<{ - file_name: string; - content_hash: string; - source_id: string | null; - }> = [], -): { - getClient: jest.Mock; - tableChain: { - select: jest.Mock; - insert: jest.Mock; - onConflict: jest.Mock; - merge: jest.Mock; - where: jest.Mock; - delete: jest.Mock; - }; - schema: { hasTable: jest.Mock; createTable: jest.Mock }; -} { - const tableChain = { - select: jest.fn().mockResolvedValue(rows), - insert: jest.fn().mockReturnValue({ - onConflict: jest.fn().mockReturnValue({ - merge: jest.fn().mockResolvedValue(undefined), - }), - }), - onConflict: jest.fn().mockReturnThis(), - merge: jest.fn().mockResolvedValue(undefined), - where: jest.fn().mockReturnValue({ - delete: jest.fn().mockResolvedValue(undefined), - }), - delete: jest.fn().mockResolvedValue(undefined), - }; - - const schema = { - hasTable: jest.fn().mockResolvedValue(false), - createTable: jest - .fn() - .mockImplementation( - (_name: string, callback: (table: unknown) => void) => { - const table = { - string: jest.fn().mockReturnThis(), - primary: jest.fn().mockReturnThis(), - notNullable: jest.fn().mockReturnThis(), - nullable: jest.fn().mockReturnThis(), - timestamp: jest.fn().mockReturnThis(), - defaultTo: jest.fn().mockReturnThis(), - }; - callback(table); - return Promise.resolve(); - }, - ), - }; - const fn = { now: jest.fn().mockReturnValue('now()') }; - - const callableDb = jest.fn((_table: string) => tableChain) as jest.Mock & { - schema: typeof schema; - fn: typeof fn; - }; - callableDb.schema = schema; - callableDb.fn = fn; - - const getClient = jest.fn().mockResolvedValue(callableDb); - - return { - getClient, - tableChain, - schema, - }; -} - -function doc( - fileName: string, - contentHash?: string, - sourceId = `source://${fileName}`, -): FetchedDocument { - return { - fileName, - content: `content of ${fileName}`, - sourceId, - sourceType: 'directory', - contentHash: contentHash ?? `hash-${fileName}`, - }; -} - -function existingDoc(fileName: string, id: string): DocumentInfo { - return { - id, - fileName, - format: FileFormat.TEXT, - fileSize: 0, - uploadedAt: new Date().toISOString(), - status: 'completed', - }; -} - -function createDocumentsConfig( - overrides: Partial = {}, -): DocumentsConfig { - return { - syncMode: 'append', - sources: [ - { type: 'directory', path: path.join(os.tmpdir(), 'augment-test-docs') }, - ], - ...overrides, - }; -} - -describe('DocumentSyncService', () => { - let mockVectorStore: jest.Mocked; - let mockIngestion: jest.Mocked< - Pick - >; - let mockLogger: ReturnType; - - beforeEach(() => { - jest.clearAllMocks(); - mockVectorStore = createMockVectorStore(); - mockIngestion = createMockIngestion(); - mockLogger = createMockLogger(); - }); - - describe('constructor', () => { - it('sets up dependencies correctly', () => { - const config = createDocumentsConfig(); - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - ); - - expect(service.getDocumentsConfig()).toEqual(config); - expect(service.getSyncSchedule()).toBeUndefined(); - }); - }); - - describe('initialize', () => { - it('calls debug when no database provided', async () => { - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - null, - mockLogger, - ); - - await service.initialize(); - - expect(mockLogger.debug).toHaveBeenCalledWith( - 'No database provided — content hashes will not persist across restarts', - ); - }); - - it('creates DB table when needed', async () => { - const { getClient, schema } = createMockDatabase(); - schema.hasTable.mockResolvedValue(false); - - const database = { getClient } as unknown as DatabaseService; - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - null, - mockLogger, - database, - ); - - await service.initialize(); - - expect(getClient).toHaveBeenCalled(); - expect(schema.hasTable).toHaveBeenCalledWith(HASH_TABLE); - expect(schema.createTable).toHaveBeenCalledWith( - HASH_TABLE, - expect.any(Function), - ); - expect(mockLogger.info).toHaveBeenCalledWith( - `Created ${HASH_TABLE} table`, - ); - }); - - it('handles concurrent table creation - another instance created it', async () => { - const { getClient, schema } = createMockDatabase(); - schema.hasTable.mockResolvedValue(false); - schema.createTable.mockRejectedValueOnce(new Error('already exists')); - schema.hasTable.mockResolvedValueOnce(false).mockResolvedValueOnce(true); - - const database = { getClient } as unknown as DatabaseService; - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - null, - mockLogger, - database, - ); - - await service.initialize(); - - expect(mockLogger.info).toHaveBeenCalledWith( - `${HASH_TABLE} table was created by another instance`, - ); - }); - - it('falls back to in-memory when DB fails', async () => { - const { getClient, schema } = createMockDatabase(); - schema.hasTable.mockRejectedValue(new Error('DB connection failed')); - - const database = { getClient } as unknown as DatabaseService; - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - null, - mockLogger, - database, - ); - - await service.initialize(); - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to initialize hash cache table'), - ); - }); - }); - - describe('setDocumentsConfig and getDocumentsConfig', () => { - it('set and get documents config', () => { - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - null, - mockLogger, - ); - - expect(service.getDocumentsConfig()).toBeNull(); - - const config = createDocumentsConfig({ syncSchedule: '0 * * * *' }); - service.setDocumentsConfig(config); - - expect(service.getDocumentsConfig()).toEqual(config); - expect(service.getSyncSchedule()).toBe('0 * * * *'); - - service.setDocumentsConfig(null); - expect(service.getDocumentsConfig()).toBeNull(); - }); - }); - - describe('isSyncInProgress and getSyncSchedule', () => { - it('isSyncInProgress returns false when no sync', () => { - const config = createDocumentsConfig(); - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - ); - - expect(service.isSyncInProgress()).toBe(false); - }); - - it('getSyncSchedule returns config.syncSchedule', () => { - const config = createDocumentsConfig({ syncSchedule: '*/5 * * * *' }); - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - ); - - expect(service.getSyncSchedule()).toBe('*/5 * * * *'); - }); - }); - - describe('sync mutex', () => { - it('returns early when sync already in progress', async () => { - const config = createDocumentsConfig(); - let resolveFetch: (docs: FetchedDocument[]) => void; - const fetchPromise = new Promise(resolve => { - resolveFetch = resolve; - }); - mockIngestion.fetchFromSources.mockReturnValue(fetchPromise); - mockVectorStore.uploadDocuments.mockResolvedValue({ - uploaded: [{ id: 'id-a', fileName: 'a.md', status: 'completed' }], - failed: [], - }); - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - ); - - const sync1Promise = service.sync(); - const sync2Promise = service.sync(); - - resolveFetch!([doc('a.md')]); - const [result1, result2] = await Promise.all([ - sync1Promise, - sync2Promise, - ]); - - expect(mockLogger.info).toHaveBeenCalledWith( - 'Document sync already in progress, skipping', - ); - const fullResult = result1.added > 0 ? result1 : result2; - const earlyResult = result1.added > 0 ? result2 : result1; - expect(fullResult.added).toBe(1); - expect(earlyResult).toEqual({ - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [], - }); - }); - }); - - describe('sync skips when no sources', () => { - it('returns empty result when documentsConfig is null', async () => { - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - null, - mockLogger, - ); - - const result = await service.sync(); - - expect(result).toEqual({ - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [], - }); - expect(mockIngestion.fetchFromSources).not.toHaveBeenCalled(); - }); - - it('returns empty result when sources array is empty', async () => { - const config = createDocumentsConfig({ sources: [] }); - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - ); - - const result = await service.sync(); - - expect(result).toEqual({ - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [], - }); - expect(mockIngestion.fetchFromSources).not.toHaveBeenCalled(); - }); - }); - - describe("sync in 'append' mode", () => { - it('adds new docs, skips existing', async () => { - const config = createDocumentsConfig({ syncMode: 'append' }); - mockIngestion.fetchFromSources.mockResolvedValue([ - doc('new.md', 'hash-new'), - doc('existing.md', 'hash-existing'), - ]); - mockVectorStore.listDocuments.mockResolvedValue([ - existingDoc('existing.md', 'id-existing'), - ]); - mockVectorStore.uploadDocuments.mockResolvedValue({ - uploaded: [{ id: 'id-new', fileName: 'new.md', status: 'completed' }], - failed: [], - }); - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - ); - - const result = await service.sync(); - - expect(result.added).toBe(1); - expect(result.updated).toBe(0); - expect(result.unchanged).toBe(1); - expect(mockVectorStore.uploadDocuments).toHaveBeenCalledTimes(1); - expect(mockVectorStore.uploadDocuments).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - fileName: 'new.md', - content: 'content of new.md', - }), - ]), - ); - }); - }); - - describe("sync in 'full' mode", () => { - it('adds new, updates changed, removes deleted', async () => { - const config = createDocumentsConfig({ syncMode: 'full' }); - mockIngestion.fetchFromSources.mockResolvedValue([ - doc('new.md', 'hash-new'), - doc('changed.md', 'hash-changed'), - ]); - mockVectorStore.listDocuments.mockResolvedValue([ - existingDoc('changed.md', 'id-changed'), - existingDoc('deleted.md', 'id-deleted'), - ]); - mockVectorStore.uploadDocuments - .mockResolvedValueOnce({ - uploaded: [{ id: 'id-new', fileName: 'new.md', status: 'completed' }], - failed: [], - }) - .mockResolvedValueOnce({ - uploaded: [ - { id: 'id-changed-2', fileName: 'changed.md', status: 'completed' }, - ], - failed: [], - }); - - const rows: Array<{ - file_name: string; - content_hash: string; - source_id: string | null; - }> = [ - { - file_name: 'changed.md', - content_hash: 'hash-old', - source_id: 'source://changed.md', - }, - ]; - const { getClient, schema } = createMockDatabase(rows); - schema.hasTable.mockResolvedValue(true); - - const database = { getClient } as unknown as DatabaseService; - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - database, - ); - - await service.initialize(); - const result = await service.sync(); - - expect(result.added).toBe(1); - expect(result.updated).toBe(1); - expect(result.removed).toBe(1); - expect(mockVectorStore.deleteDocument).toHaveBeenCalledWith('id-changed'); - expect(mockVectorStore.deleteDocument).toHaveBeenCalledWith('id-deleted'); - }); - }); - - describe('content hash cache', () => { - it('loads from DB when available', async () => { - const config = createDocumentsConfig({ syncMode: 'append' }); - const rows = [ - { - file_name: 'cached.md', - content_hash: 'cached-hash', - source_id: 'source://cached.md', - }, - ]; - - const { getClient, schema } = createMockDatabase(rows); - schema.hasTable.mockResolvedValue(true); - - const database = { getClient } as unknown as DatabaseService; - - mockIngestion.fetchFromSources.mockResolvedValue([ - doc('cached.md', 'cached-hash'), - ]); - mockVectorStore.listDocuments.mockResolvedValue([ - existingDoc('cached.md', 'id-cached'), - ]); - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - database, - ); - - await service.initialize(); - const result = await service.sync(); - - expect(result.unchanged).toBe(1); - expect(mockVectorStore.uploadDocuments).not.toHaveBeenCalled(); - }); - - it('persists new hashes when adding documents', async () => { - const config = createDocumentsConfig({ syncMode: 'append' }); - mockIngestion.fetchFromSources.mockResolvedValue([ - doc('new.md', 'hash-new'), - ]); - mockVectorStore.listDocuments.mockResolvedValue([]); - mockVectorStore.uploadDocuments.mockResolvedValue({ - uploaded: [{ id: 'id-new', fileName: 'new.md', status: 'completed' }], - failed: [], - }); - - const { getClient, tableChain, schema } = createMockDatabase([]); - schema.hasTable.mockResolvedValue(true); - const database = { getClient } as unknown as DatabaseService; - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - database, - ); - - await service.initialize(); - await service.sync(); - - expect(tableChain.insert).toHaveBeenCalledWith( - expect.objectContaining({ - file_name: 'new.md', - content_hash: 'hash-new', - }), - ); - }); - - it('evicts cache when at capacity', async () => { - const config = createDocumentsConfig({ syncMode: 'append' }); - mockIngestion.fetchFromSources.mockResolvedValue([ - doc('a.md', 'hash-a'), - doc('b.md', 'hash-b'), - doc('c.md', 'hash-c'), - ]); - mockVectorStore.listDocuments.mockResolvedValue([]); - mockVectorStore.uploadDocuments.mockResolvedValue({ - uploaded: [ - { id: 'id-a', fileName: 'a.md', status: 'completed' }, - { id: 'id-b', fileName: 'b.md', status: 'completed' }, - { id: 'id-c', fileName: 'c.md', status: 'completed' }, - ], - failed: [], - }); - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - ); - - const result = await service.sync(); - - expect(result.added).toBe(3); - expect(result.failed).toBe(0); - }); - }); - - describe('change detection via content hashing', () => { - it('detects changed documents and re-uploads', async () => { - const config = createDocumentsConfig({ syncMode: 'append' }); - mockIngestion.fetchFromSources.mockResolvedValue([ - doc('changed.md', 'hash-new'), - ]); - mockVectorStore.listDocuments.mockResolvedValue([ - existingDoc('changed.md', 'id-changed'), - ]); - mockVectorStore.uploadDocuments.mockResolvedValue({ - uploaded: [ - { id: 'id-changed-2', fileName: 'changed.md', status: 'completed' }, - ], - failed: [], - }); - - const rows = [ - { - file_name: 'changed.md', - content_hash: 'hash-old', - source_id: 'source://changed.md', - }, - ]; - const { getClient, schema } = createMockDatabase(rows); - schema.hasTable.mockResolvedValue(true); - const database = { getClient } as unknown as DatabaseService; - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - database, - ); - - await service.initialize(); - const result = await service.sync(); - - expect(result.updated).toBe(1); - expect(mockVectorStore.deleteDocument).toHaveBeenCalledWith('id-changed'); - expect(mockVectorStore.uploadDocuments).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - fileName: 'changed.md', - content: 'content of changed.md', - }), - ]), - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentSyncService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentSyncService.ts deleted file mode 100644 index 9941729cc53..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentSyncService.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/documents/DocumentSyncService' instead */ -export * from '../responses-api/documents/DocumentSyncService'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentUploader.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentUploader.test.ts deleted file mode 100644 index d6eda9a468b..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentUploader.test.ts +++ /dev/null @@ -1,341 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { uploadDocuments } from './DocumentUploader'; -import type { DocumentUploaderDeps, UploadFile } from './DocumentUploader'; -import type { ResponsesApiClient } from './ResponsesApiClient'; -import type { LlamaStackConfig } from '../../types'; -import { createMockLogger } from '../../test-utils/mocks'; - -function createMockClient(): jest.Mocked> { - return { - request: jest.fn(), - }; -} - -function createDeps( - overrides?: Partial, -): DocumentUploaderDeps & { client: jest.Mocked } { - const client = createMockClient(); - const config: LlamaStackConfig = { - baseUrl: 'https://llama.example.com', - vectorStoreIds: ['vs_123'], - vectorStoreName: 'test-store', - embeddingModel: 'test-emb', - embeddingDimension: 384, - model: 'test-model', - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - }; - return { - client: client as unknown as jest.Mocked, - config, - logger: createMockLogger(), - ...overrides, - } as unknown as DocumentUploaderDeps & { - client: jest.Mocked; - }; -} - -describe('DocumentUploader', () => { - describe('uploadDocuments', () => { - it('uploads a single document successfully', async () => { - const deps = createDeps(); - deps.client.request - .mockResolvedValueOnce({ - id: 'file_abc', - object: 'file', - bytes: 100, - created_at: 1234567890, - filename: 'doc.md', - purpose: 'assistants', - }) - .mockResolvedValueOnce({ - id: 'vsfile_xyz', - object: 'vector_store.file', - status: 'completed', - created_at: 1234567890, - usage_bytes: 100, - vector_store_id: 'vs_123', - }); - - const files: UploadFile[] = [{ fileName: 'doc.md', content: '# Hello' }]; - const result = await uploadDocuments(files, deps); - - expect(result.uploaded).toHaveLength(1); - expect(result.uploaded[0]).toEqual({ - id: 'file_abc', - fileName: 'doc.md', - status: 'completed', - }); - expect(result.failed).toHaveLength(0); - - expect(deps.client.request).toHaveBeenCalledTimes(2); - expect(deps.client.request).toHaveBeenNthCalledWith( - 1, - '/v1/files', - expect.objectContaining({ method: 'POST' }), - ); - expect(deps.client.request).toHaveBeenNthCalledWith( - 2, - '/v1/vector_stores/vs_123/files', - expect.objectContaining({ - method: 'POST', - body: expect.objectContaining({ - file_id: 'file_abc', - chunking_strategy: { type: 'auto' }, - }), - }), - ); - }); - - it('uses custom vectorStoreId when provided', async () => { - const deps = createDeps(); - deps.client.request - .mockResolvedValueOnce({ - id: 'file_1', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'a.txt', - purpose: 'assistants', - }) - .mockResolvedValueOnce({ - id: 'vsf_1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_custom', - }); - - const result = await uploadDocuments( - [{ fileName: 'a.txt', content: 'x' }], - deps, - 'vs_custom', - ); - - expect(result.uploaded).toHaveLength(1); - expect(deps.client.request).toHaveBeenNthCalledWith( - 2, - '/v1/vector_stores/vs_custom/files', - expect.any(Object), - ); - }); - - it('includes attributes in attach request when provided', async () => { - const deps = createDeps(); - deps.client.request - .mockResolvedValueOnce({ - id: 'file_1', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'doc.md', - purpose: 'assistants', - }) - .mockResolvedValueOnce({ - id: 'vsf_1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_123', - }); - - const files: UploadFile[] = [ - { - fileName: 'doc.md', - content: 'content', - attributes: { title: 'My Doc', source_url: 'https://example.com' }, - }, - ]; - await uploadDocuments(files, deps); - - expect(deps.client.request).toHaveBeenNthCalledWith( - 2, - '/v1/vector_stores/vs_123/files', - expect.objectContaining({ - body: expect.objectContaining({ - file_id: 'file_1', - attributes: { title: 'My Doc', source_url: 'https://example.com' }, - }), - }), - ); - }); - - it('uses static chunking when config specifies it', async () => { - const deps = createDeps({ - config: { - baseUrl: 'https://x.com', - vectorStoreIds: ['vs_1'], - vectorStoreName: 'x', - embeddingModel: 'emb', - embeddingDimension: 384, - model: 'm', - chunkingStrategy: 'static', - maxChunkSizeTokens: 256, - chunkOverlapTokens: 25, - } as LlamaStackConfig, - }); - deps.client.request - .mockResolvedValueOnce({ - id: 'f1', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'a.txt', - purpose: 'assistants', - }) - .mockResolvedValueOnce({ - id: 'vsf1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_1', - }); - - await uploadDocuments([{ fileName: 'a.txt', content: 'x' }], deps); - - expect(deps.client.request).toHaveBeenNthCalledWith( - 2, - expect.any(String), - expect.objectContaining({ - body: expect.objectContaining({ - chunking_strategy: { - type: 'static', - static: { - max_chunk_size_tokens: 256, - chunk_overlap_tokens: 25, - }, - }, - }), - }), - ); - }); - - it('batches multiple files and reports individual failures', async () => { - const deps = createDeps(); - deps.client.request - .mockResolvedValueOnce({ - id: 'file_1', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'ok.txt', - purpose: 'assistants', - }) - .mockResolvedValueOnce({ - id: 'vsf_1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_123', - }) - .mockRejectedValueOnce(new Error('Files API error')) - .mockResolvedValueOnce({ - id: 'file_3', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'third.txt', - purpose: 'assistants', - }) - .mockResolvedValueOnce({ - id: 'vsf_3', - object: 'vector_store.file', - status: 'failed', - created_at: 0, - usage_bytes: 0, - vector_store_id: 'vs_123', - last_error: { code: 'err', message: 'Attachment failed' }, - }); - - const files: UploadFile[] = [ - { fileName: 'ok.txt', content: 'a' }, - { fileName: 'fail.txt', content: 'b' }, - { fileName: 'third.txt', content: 'c' }, - ]; - const result = await uploadDocuments(files, deps); - - expect(result.uploaded).toHaveLength(1); - expect(result.uploaded[0].fileName).toBe('ok.txt'); - - expect(result.failed).toHaveLength(2); - const failedFileNames = result.failed - .map(f => f.fileName) - .sort((a, b) => a!.localeCompare(b!)); - expect(failedFileNames).toEqual(['fail.txt', 'third.txt']); - const errors = result.failed.map(f => f.error); - expect(errors.some(e => e.includes('Files API error'))).toBe(true); - expect(errors.some(e => e.includes('Attachment failed'))).toBe(true); - }); - - it('throws InputError when no vector store configured', async () => { - const deps = createDeps({ - config: { - baseUrl: 'https://x.com', - vectorStoreIds: [], - vectorStoreName: 'x', - embeddingModel: 'emb', - embeddingDimension: 384, - model: 'm', - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - } as LlamaStackConfig, - }); - - await expect( - uploadDocuments([{ fileName: 'a.txt', content: 'x' }], deps), - ).rejects.toThrow('No vector store configured'); - }); - - it('processes files in batches of 5', async () => { - const deps = createDeps(); - for (let i = 0; i < 7; i++) { - deps.client.request - .mockResolvedValueOnce({ - id: `file_${i}`, - object: 'file', - bytes: 10, - created_at: 0, - filename: `f${i}.txt`, - purpose: 'assistants', - }) - .mockResolvedValueOnce({ - id: `vsf_${i}`, - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_123', - }); - } - - const files: UploadFile[] = Array.from({ length: 7 }, (_, i) => ({ - fileName: `f${i}.txt`, - content: `content${i}`, - })); - const result = await uploadDocuments(files, deps); - - expect(result.uploaded).toHaveLength(7); - expect(deps.client.request).toHaveBeenCalledTimes(14); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentUploader.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentUploader.ts deleted file mode 100644 index ddf2401b540..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/DocumentUploader.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/documents/DocumentUploader' instead */ -export * from '../responses-api/documents/DocumentUploader'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/EvaluationService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/EvaluationService.test.ts deleted file mode 100644 index 1d31c745699..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/EvaluationService.test.ts +++ /dev/null @@ -1,536 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { RootConfigService } from '@backstage/backend-plugin-api'; -import { EvaluationService } from './EvaluationService'; -import type { ResponsesApiClient } from './ResponsesApiClient'; -import { createMockLogger } from '../../test-utils/mocks'; - -function createMockClient(): jest.Mocked { - return { - request: jest.fn(), - requestWithRetry: jest.fn(), - streamRequest: jest.fn(), - } as unknown as jest.Mocked; -} - -function makeConfig(overrides: Record = {}) { - const evalValues: Record = { - enabled: overrides.enabled ?? false, - scoringFunctions: overrides.scoringFunctions, - minScoreThreshold: overrides.minScoreThreshold, - onError: overrides.onError, - }; - - const evalConfig = { - getOptionalBoolean: jest.fn((key: string) => - key === 'enabled' ? evalValues.enabled : undefined, - ), - getOptionalStringArray: jest.fn((key: string) => evalValues[key]), - getOptionalNumber: jest.fn((key: string) => evalValues[key]), - getOptionalString: jest.fn((key: string) => evalValues[key]), - }; - - const llamaStackConfig = { - getString: jest.fn((key: string) => { - if (key === 'baseUrl') return 'http://localhost:8321'; - return ''; - }), - getOptionalString: jest.fn(() => undefined), - getOptionalBoolean: jest.fn(() => false), - }; - - return { - getOptionalConfig: jest.fn((key: string) => { - if (key === 'augment.llamaStack') return llamaStackConfig; - if (key === 'augment.evaluation') return evalConfig; - return undefined; - }), - } as unknown as RootConfigService; -} - -describe('EvaluationService', () => { - let mockLogger: ReturnType; - let mockClient: jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - mockLogger = createMockLogger(); - mockClient = createMockClient(); - }); - - // --------------------------------------------------------------------------- - // Basic state - // --------------------------------------------------------------------------- - - describe('isEnabled', () => { - it('returns false before initialization', () => { - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({}), - }); - expect(service.isEnabled()).toBe(false); - }); - }); - - describe('getAvailableScoringFunctions', () => { - it('returns empty array before initialization', () => { - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({}), - }); - expect(service.getAvailableScoringFunctions()).toEqual([]); - }); - }); - - // --------------------------------------------------------------------------- - // Initialization - // --------------------------------------------------------------------------- - - describe('initialize', () => { - it('handles missing llamaStack config gracefully', async () => { - const service = new EvaluationService({ - logger: mockLogger, - config: { - getOptionalConfig: jest.fn(() => undefined), - } as unknown as RootConfigService, - }); - - await service.initialize(); - expect(service.isEnabled()).toBe(false); - }); - - it('is idempotent', async () => { - const config = makeConfig({ enabled: false }); - const service = new EvaluationService({ - logger: mockLogger, - config, - }); - - await service.initialize(); - await service.initialize(); - expect(config.getOptionalConfig).toHaveBeenCalledTimes(2); - }); - - it('warns when enabled but no client accessor provided', async () => { - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - }); - - await service.initialize(); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('ResponsesApiClient accessor'), - ); - }); - - it('loads scoring functions on init when enabled', async () => { - const fns = [ - { - identifier: 'basic::subset_of', - provider_id: 'basic', - provider_resource_id: '', - type: 'scoring', - description: 'Subset match', - }, - ]; - mockClient.request.mockResolvedValueOnce(fns); - - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - - await service.initialize(); - expect(service.isEnabled()).toBe(true); - expect(service.getAvailableScoringFunctions()).toEqual(fns); - expect(mockClient.request).toHaveBeenCalledWith('/v1/scoring-functions', { - method: 'GET', - }); - }); - - it('handles {data: [...]} response format', async () => { - const fns = [ - { - identifier: 'fn1', - provider_id: 'p', - provider_resource_id: '', - type: 's', - }, - ]; - mockClient.request.mockResolvedValueOnce({ data: fns }); - - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - - await service.initialize(); - expect(service.getAvailableScoringFunctions()).toEqual(fns); - }); - }); - - // --------------------------------------------------------------------------- - // scoreResponse - field names and scoring - // --------------------------------------------------------------------------- - - describe('scoreResponse', () => { - async function createEnabledService( - configOverrides: Record = {}, - ) { - const fns = [ - { - identifier: 'basic::subset_of', - provider_id: 'basic', - provider_resource_id: '', - type: 'scoring', - }, - ]; - mockClient.request.mockResolvedValueOnce(fns); - - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true, ...configOverrides }), - getClient: () => mockClient, - }); - - await service.initialize(); - return service; - } - - it('returns undefined when evaluation is disabled', async () => { - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: false }), - }); - const result = await service.scoreResponse('q', 'a'); - expect(result).toBeUndefined(); - }); - - it('sends correct Llama Stack field names (input_query, generated_answer)', async () => { - const service = await createEnabledService(); - - mockClient.request.mockResolvedValueOnce({ - results: { - 'basic::subset_of': { - score_rows: [{ score: 1.0 }], - }, - }, - }); - - await service.scoreResponse('What is 2+2?', '4'); - - const scoreCall = mockClient.request.mock.calls.find( - (c: unknown[]) => c[0] === '/v1/scoring/score', - ); - expect(scoreCall).toBeDefined(); - - const body = (scoreCall![1] as { body: Record }).body; - expect((body.input_rows as Record[])[0]).toEqual({ - input_query: 'What is 2+2?', - generated_answer: '4', - }); - expect( - (body.input_rows as Record[])[0], - ).not.toHaveProperty('input'); - expect( - (body.input_rows as Record[])[0], - ).not.toHaveProperty('generated_output'); - }); - - it('includes expected_answer when provided', async () => { - const service = await createEnabledService(); - - mockClient.request.mockResolvedValueOnce({ - results: { 'basic::subset_of': { score_rows: [{ score: 1.0 }] } }, - }); - - await service.scoreResponse('What is 2+2?', '4', undefined, '4'); - - const scoreCall = mockClient.request.mock.calls.find( - (c: unknown[]) => c[0] === '/v1/scoring/score', - ); - const body = ( - scoreCall![1] as { - body: { input_rows: Array<{ expected_answer?: string }> }; - } - ).body; - expect(body.input_rows[0].expected_answer).toBe('4'); - }); - - it('includes context when provided', async () => { - const service = await createEnabledService(); - - mockClient.request.mockResolvedValueOnce({ - results: { 'basic::subset_of': { score_rows: [{ score: 0.8 }] } }, - }); - - await service.scoreResponse('q', 'a', ['doc1', 'doc2']); - - const scoreCall = mockClient.request.mock.calls.find( - (c: unknown[]) => c[0] === '/v1/scoring/score', - ); - const body = ( - scoreCall![1] as { body: { input_rows: Array<{ context?: string }> } } - ).body; - expect(body.input_rows[0].context).toBe('doc1\ndoc2'); - }); - - it('parses direct score format (basic::subset_of style)', async () => { - const service = await createEnabledService(); - - mockClient.request.mockResolvedValueOnce({ - results: { - 'basic::subset_of': { - score_rows: [{ score: 1.0 }], - aggregated_results: { - accuracy: { accuracy: 1.0, num_correct: 1, num_total: 1 }, - }, - }, - }, - }); - - const result = await service.scoreResponse('What is 2+2?', '4'); - - expect(result).toBeDefined(); - expect(result!.overallScore).toBe(1.0); - expect(result!.scores['basic::subset_of']).toBe(1.0); - expect(result!.qualityLevel).toBe('excellent'); - expect(result!.passedThreshold).toBe(true); - }); - - it('parses nested scores format', async () => { - const service = await createEnabledService(); - - mockClient.request.mockResolvedValueOnce({ - results: { - 'basic::subset_of': { - score_rows: [ - { - scores: { - accuracy: { score_type: 'float', value: 0.8 }, - }, - }, - ], - }, - }, - }); - - const result = await service.scoreResponse('q', 'a'); - - expect(result!.overallScore).toBe(0.8); - expect(result!.scores['basic::subset_of:accuracy']).toBe(0.8); - }); - - it('computes average score across multiple functions', async () => { - const fns = [ - { - identifier: 'fn1', - provider_id: 'p', - provider_resource_id: '', - type: 's', - }, - { - identifier: 'fn2', - provider_id: 'p', - provider_resource_id: '', - type: 's', - }, - ]; - mockClient.request.mockResolvedValueOnce(fns); - - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - await service.initialize(); - - mockClient.request.mockResolvedValueOnce({ - results: { - fn1: { score_rows: [{ score: 1.0 }] }, - fn2: { score_rows: [{ score: 0.5 }] }, - }, - }); - - const result = await service.scoreResponse('q', 'a'); - - expect(result!.overallScore).toBe(0.75); - expect(result!.qualityLevel).toBe('good'); - }); - - it('uses configured scoring functions instead of all available', async () => { - const service = await createEnabledService({ - scoringFunctions: ['specific::fn'], - }); - - mockClient.request.mockResolvedValueOnce({ - results: { 'specific::fn': { score_rows: [{ score: 0.9 }] } }, - }); - - await service.scoreResponse('q', 'a'); - - const scoreCall = mockClient.request.mock.calls.find( - (c: unknown[]) => c[0] === '/v1/scoring/score', - ); - const body = ( - scoreCall![1] as { - body: { scoring_functions: Record }; - } - ).body; - expect(Object.keys(body.scoring_functions)).toEqual(['specific::fn']); - }); - - it('uses null (not {}) as scoring function config values', async () => { - const service = await createEnabledService(); - - mockClient.request.mockResolvedValueOnce({ - results: { 'basic::subset_of': { score_rows: [{ score: 1.0 }] } }, - }); - - await service.scoreResponse('q', 'a'); - - const scoreCall = mockClient.request.mock.calls.find( - (c: unknown[]) => c[0] === '/v1/scoring/score', - ); - const body = ( - scoreCall![1] as { - body: { scoring_functions: Record }; - } - ).body; - expect(body.scoring_functions['basic::subset_of']).toBeNull(); - }); - - it('respects minScoreThreshold', async () => { - const service = await createEnabledService({ minScoreThreshold: 0.9 }); - - mockClient.request.mockResolvedValueOnce({ - results: { 'basic::subset_of': { score_rows: [{ score: 0.8 }] } }, - }); - - const result = await service.scoreResponse('q', 'a'); - - expect(result!.passedThreshold).toBe(false); - expect(result!.overallScore).toBe(0.8); - }); - }); - - // --------------------------------------------------------------------------- - // onError behavior - // --------------------------------------------------------------------------- - - describe('onError behavior', () => { - it('returns undefined on API failure when onError is "skip" (default)', async () => { - const fns = [ - { - identifier: 'fn1', - provider_id: 'p', - provider_resource_id: '', - type: 's', - }, - ]; - mockClient.request - .mockResolvedValueOnce(fns) - .mockRejectedValueOnce(new Error('Scoring API down')); - - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - await service.initialize(); - - const result = await service.scoreResponse('q', 'a'); - expect(result).toBeUndefined(); - }); - - it('returns error result on API failure when onError is "fail"', async () => { - const fns = [ - { - identifier: 'fn1', - provider_id: 'p', - provider_resource_id: '', - type: 's', - }, - ]; - mockClient.request - .mockResolvedValueOnce(fns) - .mockRejectedValueOnce(new Error('Scoring API down')); - - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true, onError: 'fail' }), - getClient: () => mockClient, - }); - await service.initialize(); - - const result = await service.scoreResponse('q', 'a'); - expect(result).toBeDefined(); - expect(result!.overallScore).toBe(0); - expect(result!.passedThreshold).toBe(false); - expect(result!.error).toContain('Scoring API down'); - }); - }); - - // --------------------------------------------------------------------------- - // Quality levels - // --------------------------------------------------------------------------- - - describe('quality levels', () => { - async function scoreWith(scoreValue: number) { - const fns = [ - { - identifier: 'fn1', - provider_id: 'p', - provider_resource_id: '', - type: 's', - }, - ]; - mockClient.request.mockResolvedValueOnce(fns).mockResolvedValueOnce({ - results: { fn1: { score_rows: [{ score: scoreValue }] } }, - }); - - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - await service.initialize(); - return service.scoreResponse('q', 'a'); - } - - it('maps 0.95 to excellent', async () => { - const result = await scoreWith(0.95); - expect(result!.qualityLevel).toBe('excellent'); - }); - - it('maps 0.75 to good', async () => { - const result = await scoreWith(0.75); - expect(result!.qualityLevel).toBe('good'); - }); - - it('maps 0.55 to fair', async () => { - const result = await scoreWith(0.55); - expect(result!.qualityLevel).toBe('fair'); - }); - - it('maps 0.3 to poor', async () => { - const result = await scoreWith(0.3); - expect(result!.qualityLevel).toBe('poor'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/EvaluationService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/EvaluationService.ts deleted file mode 100644 index 8d9ba794a7d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/EvaluationService.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './safety/EvaluationService'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/InputItemsNormalizer.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/InputItemsNormalizer.test.ts deleted file mode 100644 index 731ded5e61e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/InputItemsNormalizer.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { normalizeContent } from './InputItemsNormalizer'; - -describe('normalizeContent', () => { - describe('string input', () => { - it('returns string as-is', () => { - expect(normalizeContent('hello')).toBe('hello'); - expect(normalizeContent('')).toBe(''); - }); - }); - - describe('array input', () => { - it('normalizes array of objects with type and text', () => { - const input = [ - { type: 'input_text', text: 'hello' }, - { type: 'output_text', text: 'world' }, - ]; - expect(normalizeContent(input)).toEqual([ - { type: 'input_text', text: 'hello' }, - { type: 'output_text', text: 'world' }, - ]); - }); - - it('uses default type "text" when type is missing', () => { - const input = [{ text: 'hello' }]; - expect(normalizeContent(input)).toEqual([ - { type: 'text', text: 'hello' }, - ]); - }); - - it('omits text when not a string', () => { - const input = [{ type: 'input_text', text: 123 }]; - expect(normalizeContent(input)).toEqual([ - { type: 'input_text', text: undefined }, - ]); - }); - - it('converts non-object array elements to { type: "text", text: String(c) }', () => { - const input = ['hello', 42, null]; - expect(normalizeContent(input)).toEqual([ - { type: 'text', text: 'hello' }, - { type: 'text', text: '42' }, - { type: 'text', text: 'null' }, - ]); - }); - - it('handles empty array', () => { - expect(normalizeContent([])).toEqual([]); - }); - - it('handles mixed object and primitive elements', () => { - const input = [{ type: 'input_text', text: 'user said' }, 'plain string']; - expect(normalizeContent(input)).toEqual([ - { type: 'input_text', text: 'user said' }, - { type: 'text', text: 'plain string' }, - ]); - }); - - it('converts null/undefined in object to default type', () => { - const input = [{ type: null, text: 'ok' }]; - expect(normalizeContent(input)).toEqual([{ type: 'text', text: 'ok' }]); - }); - }); - - describe('edge cases', () => { - it('returns undefined for null', () => { - expect(normalizeContent(null)).toBeUndefined(); - }); - - it('returns undefined for undefined', () => { - expect(normalizeContent(undefined)).toBeUndefined(); - }); - - it('returns undefined for number', () => { - expect(normalizeContent(42)).toBeUndefined(); - }); - - it('returns undefined for boolean', () => { - expect(normalizeContent(true)).toBeUndefined(); - }); - - it('returns undefined for plain object (not array)', () => { - expect(normalizeContent({ type: 'text', text: 'hi' })).toBeUndefined(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/InputItemsNormalizer.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/InputItemsNormalizer.ts deleted file mode 100644 index db63e5fb693..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/InputItemsNormalizer.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export * from '../responses-api/conversations/InputItemsNormalizer'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackApiError.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackApiError.test.ts deleted file mode 100644 index 9fbfdf1a318..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackApiError.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ResponsesApiError } from './ResponsesApiClient'; - -describe('ResponsesApiError', () => { - it('extracts detail from JSON body', () => { - const err = new ResponsesApiError( - 400, - 'Bad Request', - JSON.stringify({ detail: 'unsupported tool type: function' }), - ); - expect(err.statusCode).toBe(400); - expect(err.detail).toBe('unsupported tool type: function'); - expect(err.message).toContain('unsupported tool type: function'); - }); - - it('falls back to raw body when not JSON', () => { - const err = new ResponsesApiError(500, 'Server Error', 'raw text error'); - expect(err.detail).toBe('raw text error'); - }); - - it('truncates long raw body', () => { - const longBody = 'x'.repeat(500); - const err = new ResponsesApiError(500, 'Error', longBody); - expect(err.detail.length).toBeLessThanOrEqual(303); - expect(err.detail).toContain('...'); - }); - - it('isValidationError returns true for 400 and 422', () => { - expect(new ResponsesApiError(400, '', '').isValidationError()).toBe(true); - expect(new ResponsesApiError(422, '', '').isValidationError()).toBe(true); - expect(new ResponsesApiError(500, '', '').isValidationError()).toBe(false); - }); - - it('mentionsToolType detects tool-related errors', () => { - const err = new ResponsesApiError( - 400, - '', - JSON.stringify({ detail: 'unsupported tool type: function' }), - ); - expect(err.mentionsToolType()).toBe(true); - }); - - it('mentionsToolType returns false for unrelated errors', () => { - const err = new ResponsesApiError( - 400, - '', - JSON.stringify({ detail: 'invalid model name' }), - ); - expect(err.mentionsToolType()).toBe(false); - }); - - it('mentionsStrictField detects strict-related errors with field context', () => { - const err = new ResponsesApiError( - 422, - '', - JSON.stringify({ detail: 'unexpected field strict in schema' }), - ); - expect(err.mentionsStrictField()).toBe(true); - }); - - it('has correct name property', () => { - const err = new ResponsesApiError(400, '', ''); - expect(err.name).toBe('ResponsesApiError'); - expect(err instanceof Error).toBe(true); - }); - - it('message uses Responses API prefix', () => { - const err = new ResponsesApiError(400, 'Bad Request', '{}'); - expect(err.message).toContain('Responses API error'); - }); - - it('instanceof works correctly after Object.setPrototypeOf', () => { - const err = new ResponsesApiError(422, 'Unprocessable Entity', '{}'); - expect(err instanceof ResponsesApiError).toBe(true); - expect(err instanceof Error).toBe(true); - expect(err.isValidationError()).toBe(true); - }); - - it('extracts error from different JSON field names', () => { - expect( - new ResponsesApiError(400, '', JSON.stringify({ error: 'err1' })).detail, - ).toBe('err1'); - expect( - new ResponsesApiError(400, '', JSON.stringify({ message: 'msg1' })) - .detail, - ).toBe('msg1'); - }); - - it('extracts detail from Pydantic array-style validation errors', () => { - const pydanticBody = JSON.stringify({ - detail: [ - { - msg: 'extra inputs are not permitted', - loc: ['body', 'tools', 0, 'strict'], - }, - { msg: 'value is not valid', loc: ['body', 'tools', 0, 'type'] }, - ], - }); - const err = new ResponsesApiError( - 422, - 'Unprocessable Entity', - pydanticBody, - ); - expect(err.detail).toContain('extra inputs are not permitted'); - expect(err.detail).toContain('strict'); - expect(err.detail).toContain('value is not valid'); - }); - - it('mentionsStrictField avoids false positives on unrelated use', () => { - const err = new ResponsesApiError( - 400, - '', - JSON.stringify({ detail: 'strict mode is enabled for safety' }), - ); - expect(err.mentionsStrictField()).toBe(false); - }); - - it('mentionsStrictField detects Pydantic-style strict field errors', () => { - const err = new ResponsesApiError( - 422, - '', - JSON.stringify({ - detail: 'extra inputs are not permitted: strict field', - }), - ); - expect(err.mentionsStrictField()).toBe(true); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackOrchestrator.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackOrchestrator.test.ts deleted file mode 100644 index ce55efa454d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackOrchestrator.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - LoggerService, - RootConfigService, -} from '@backstage/backend-plugin-api'; -import { LlamaStackOrchestrator } from './LlamaStackOrchestrator'; -import { createMockLogger } from '../../test-utils/mocks'; - -jest.mock('./ConfigLoader', () => { - const mockInstance = { - validateRequiredConfig: jest.fn(), - loadSecurityConfig: jest.fn().mockReturnValue({ mode: 'plugin-only' }), - loadSystemPrompt: jest.fn().mockReturnValue(''), - loadPostToolInstructions: jest.fn().mockReturnValue(undefined), - }; - return { - ConfigLoader: jest.fn().mockImplementation(() => mockInstance), - }; -}); - -function createMockConfig(): RootConfigService { - return { - getOptionalConfig: jest.fn().mockReturnValue(undefined), - getConfig: jest.fn().mockReturnValue({ - getOptionalString: jest.fn().mockReturnValue(undefined), - getOptional: jest.fn().mockReturnValue(undefined), - getOptionalConfig: jest.fn().mockReturnValue(undefined), - getOptionalConfigArray: jest.fn().mockReturnValue(undefined), - getOptionalStringArray: jest.fn().mockReturnValue(undefined), - }), - getOptionalString: jest.fn().mockReturnValue(undefined), - getOptional: jest.fn().mockReturnValue(undefined), - getOptionalConfigArray: jest.fn().mockReturnValue(undefined), - getOptionalStringArray: jest.fn().mockReturnValue(undefined), - getString: jest.fn().mockReturnValue(''), - has: jest.fn().mockReturnValue(false), - keys: jest.fn().mockReturnValue([]), - } as unknown as RootConfigService; -} - -describe('LlamaStackOrchestrator', () => { - let mockLogger: ReturnType; - let mockConfig: RootConfigService; - - beforeEach(() => { - jest.clearAllMocks(); - mockLogger = createMockLogger(); - mockConfig = createMockConfig(); - }); - - describe('constructor', () => { - it('creates an instance without errors', () => { - const orchestrator = new LlamaStackOrchestrator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - expect(orchestrator).toBeDefined(); - }); - }); - - describe('pre-initialization state', () => { - it('getSecurityConfig returns default plugin-only mode', () => { - const orchestrator = new LlamaStackOrchestrator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - expect(orchestrator.getSecurityConfig()).toEqual({ mode: 'plugin-only' }); - }); - - it('isVerboseStreamLoggingEnabled returns false', () => { - const orchestrator = new LlamaStackOrchestrator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - expect(orchestrator.isVerboseStreamLoggingEnabled()).toBe(false); - }); - - it('getDefaultVectorStoreId returns undefined via facade', () => { - const orchestrator = new LlamaStackOrchestrator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - expect( - orchestrator.getVectorStoreFacade().getDefaultVectorStoreId(), - ).toBeUndefined(); - }); - - it('getSyncSchedule returns undefined via facade', () => { - const orchestrator = new LlamaStackOrchestrator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - expect( - orchestrator.getVectorStoreFacade().getSyncSchedule(), - ).toBeUndefined(); - }); - - it('conversations getter returns null', () => { - const orchestrator = new LlamaStackOrchestrator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - expect( - (orchestrator as unknown as { conversations: unknown }).conversations, - ).toBeNull(); - }); - }); - - describe('ensureInitialized guard (via facades)', () => { - it('listConversations throws when not initialized', async () => { - const orchestrator = new LlamaStackOrchestrator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - await expect( - orchestrator.getConversationFacade().listConversations(), - ).rejects.toThrow('not initialized'); - }); - - it('getConversation throws when not initialized', async () => { - const orchestrator = new LlamaStackOrchestrator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - await expect( - orchestrator.getConversationFacade().getConversation('resp_1'), - ).rejects.toThrow('not initialized'); - }); - - it('syncDocuments throws when not initialized', async () => { - const orchestrator = new LlamaStackOrchestrator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - await expect( - orchestrator.getVectorStoreFacade().syncDocuments(), - ).rejects.toThrow('not initialized'); - }); - }); - - describe('getStatus', () => { - it('returns not-configured status when Llama Stack is not configured', async () => { - const orchestrator = new LlamaStackOrchestrator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - - const status = await orchestrator.getStatus(); - expect(status.ready).toBe(false); - expect(status.provider.connected).toBe(false); - expect(status.provider.model).toBe('not configured'); - expect(status.configurationErrors).toEqual( - expect.arrayContaining([expect.stringContaining('not configured')]), - ); - expect(status.securityMode).toBe('plugin-only'); - expect(status.timestamp).toBeDefined(); - }); - }); - - describe('initialize idempotency', () => { - it('skips re-initialization if already initialized', async () => { - const { ConfigLoader } = require('./ConfigLoader'); - ConfigLoader.mockImplementation(() => ({ - validateRequiredConfig: jest.fn().mockImplementation(() => { - throw new Error('Config error on first call'); - }), - loadSecurityConfig: jest.fn(), - loadSystemPrompt: jest.fn().mockReturnValue('test prompt'), - loadPostToolInstructions: jest.fn().mockReturnValue(undefined), - })); - - const orchestrator = new LlamaStackOrchestrator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - - // First call fails because validateRequiredConfig throws - await expect(orchestrator.initialize()).rejects.toThrow( - 'Config error on first call', - ); - - // The orchestrator is not marked as initialized, so calling again - // should attempt initialization again (not silently return) - await expect(orchestrator.initialize()).rejects.toThrow( - 'Config error on first call', - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackOrchestrator.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackOrchestrator.ts deleted file mode 100644 index 6ae6a3d16e4..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackOrchestrator.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * @deprecated Use {@link ResponsesApiCoordinator} from './ResponsesApiCoordinator' instead. - */ -export { ResponsesApiCoordinator as LlamaStackOrchestrator } from './ResponsesApiCoordinator'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackProvider.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackProvider.test.ts deleted file mode 100644 index 11df2256763..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackProvider.test.ts +++ /dev/null @@ -1,887 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createMockLogger } from '../../test-utils'; -import type { NormalizedStreamEvent } from '../types'; - -const mockVectorStoreFacade = { - listDocuments: jest.fn().mockResolvedValue([]), - listVectorStores: jest.fn().mockResolvedValue([]), - getDefaultVectorStoreId: jest.fn().mockReturnValue('vs-1'), - getActiveVectorStoreIds: jest.fn().mockResolvedValue(['vs-1']), - syncDocuments: jest.fn().mockResolvedValue({ - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [] as string[], - }), - uploadDocument: jest.fn().mockResolvedValue({ - fileId: 'f1', - fileName: 'f1.txt', - status: 'completed', - }), - deleteDocument: jest.fn().mockResolvedValue({ success: true }), - searchVectorStore: jest.fn().mockResolvedValue({ - query: '', - chunks: [], - vectorStoreId: 'vs-1', - totalResults: 0, - }), - getVectorStoreConfig: jest.fn().mockResolvedValue(null), - createVectorStoreWithConfig: jest.fn().mockResolvedValue({ - vectorStoreId: 'vs-1', - vectorStoreName: 'store', - created: true, - embeddingModel: 'model', - embeddingDimension: 384, - }), - getVectorStoreStatus: jest.fn().mockResolvedValue({ - exists: true, - vectorStoreId: 'vs-1', - ready: true, - }), - addVectorStoreId: jest.fn(), - removeVectorStoreId: jest.fn(), - deleteVectorStore: jest.fn().mockResolvedValue(undefined), - getSyncSchedule: jest.fn().mockReturnValue(undefined), -}; - -const mockConversationFacade = { - createConversation: jest.fn().mockResolvedValue('conv-1'), - listConversations: jest - .fn() - .mockResolvedValue({ conversations: [], nextCursor: undefined }), - getConversation: jest.fn().mockResolvedValue(null), - getConversationInputs: jest.fn().mockResolvedValue({ items: [] }), - walkResponseChain: jest.fn().mockResolvedValue([]), - getProcessedMessages: jest.fn().mockResolvedValue([]), - deleteConversation: jest.fn().mockResolvedValue(undefined), - continueAfterApproval: jest - .fn() - .mockResolvedValue({ responseId: 'r1', output: [] }), -}; - -const mockOrchestrator = { - initialize: jest.fn().mockResolvedValue(undefined), - postInitialize: jest.fn().mockResolvedValue(undefined), - invalidateRuntimeConfig: jest.fn(), - getResolver: jest.fn().mockReturnValue(null), - getClientManager: jest.fn().mockReturnValue({ - getExistingClient: jest.fn().mockReturnValue({ - request: jest.fn().mockResolvedValue({ data: [], output: [] }), - }), - }), - getVectorStoreFacade: jest.fn().mockReturnValue(mockVectorStoreFacade), - getConversationFacade: jest.fn().mockReturnValue(mockConversationFacade), - getStatus: jest.fn().mockResolvedValue({ ready: true }), - chat: jest.fn().mockResolvedValue({ responseId: 'r1', output: [] }), - chatStream: jest.fn().mockResolvedValue(undefined), - handleMcpProxyRequest: jest.fn(), -}; - -const mockSafetyService = { - initialize: jest.fn().mockResolvedValue(undefined), - isEnabled: jest.fn().mockReturnValue(true), - getAvailableShields: jest.fn().mockReturnValue([{ identifier: 'shield1' }]), - checkInput: jest.fn().mockResolvedValue(null), - checkOutput: jest.fn().mockResolvedValue(null), - applyDynamicOverrides: jest.fn(), -}; - -const mockEvaluationService = { - initialize: jest.fn().mockResolvedValue(undefined), - isEnabled: jest.fn().mockReturnValue(true), - getAvailableScoringFunctions: jest - .fn() - .mockReturnValue([{ identifier: 'score1' }]), - scoreResponse: jest.fn().mockResolvedValue(null), - applyDynamicOverrides: jest.fn(), -}; - -jest.mock('./ResponsesApiCoordinator', () => ({ - ResponsesApiCoordinator: jest.fn().mockImplementation(() => mockOrchestrator), -})); - -jest.mock('./SafetyService', () => ({ - SafetyService: jest.fn().mockImplementation(() => mockSafetyService), -})); - -jest.mock('./EvaluationService', () => ({ - EvaluationService: jest.fn().mockImplementation(() => mockEvaluationService), -})); - -import { LlamaStackProvider } from './LlamaStackProvider'; - -function createMockConfig() { - return { - getOptionalString: jest.fn().mockReturnValue(undefined), - getOptionalConfig: jest.fn().mockReturnValue(undefined), - has: jest.fn().mockReturnValue(false), - } as unknown as import('@backstage/backend-plugin-api').RootConfigService; -} - -function createProvider( - overrides: { - logger?: ReturnType; - config?: unknown; - database?: unknown; - adminConfig?: unknown; - } = {}, -) { - const logger = overrides.logger ?? createMockLogger(); - return new LlamaStackProvider({ - logger, - config: (overrides.config ?? - createMockConfig()) as import('@backstage/backend-plugin-api').RootConfigService, - database: overrides.database as - | import('@backstage/backend-plugin-api').DatabaseService - | undefined, - adminConfig: overrides.adminConfig as - | import('../../services/AdminConfigService').AdminConfigService - | undefined, - }); -} - -beforeEach(() => { - jest.clearAllMocks(); - mockOrchestrator.getResolver.mockReturnValue(null); - mockOrchestrator.getClientManager.mockReturnValue({ - getExistingClient: jest.fn().mockReturnValue({ - request: jest.fn().mockResolvedValue({ data: [], output: [] }), - }), - }); -}); - -describe('LlamaStackProvider', () => { - describe('constructor', () => { - it('creates orchestrator, safety, and evaluation services', () => { - const { ResponsesApiCoordinator } = require('./ResponsesApiCoordinator'); - const { SafetyService } = require('./SafetyService'); - const { EvaluationService } = require('./EvaluationService'); - - const logger = createMockLogger(); - createProvider({ logger }); - - expect(ResponsesApiCoordinator).toHaveBeenCalledWith( - expect.objectContaining({ logger }), - ); - expect(SafetyService).toHaveBeenCalledWith( - expect.objectContaining({ - logger, - getClient: expect.any(Function), - }), - ); - expect(EvaluationService).toHaveBeenCalledWith( - expect.objectContaining({ - logger, - getClient: expect.any(Function), - }), - ); - }); - }); - - describe('initialize', () => { - it('calls initialize on all 3 services', async () => { - const provider = createProvider(); - await provider.initialize(); - - expect(mockOrchestrator.initialize).toHaveBeenCalled(); - expect(mockSafetyService.initialize).toHaveBeenCalled(); - expect(mockEvaluationService.initialize).toHaveBeenCalled(); - }); - }); - - describe('postInitialize', () => { - it('delegates to orchestrator', async () => { - const provider = createProvider(); - await provider.postInitialize(); - - expect(mockOrchestrator.postInitialize).toHaveBeenCalled(); - }); - }); - - describe('invalidateRuntimeConfig', () => { - it('delegates to orchestrator', () => { - const provider = createProvider(); - provider.invalidateRuntimeConfig(); - - expect(mockOrchestrator.invalidateRuntimeConfig).toHaveBeenCalled(); - }); - }); - - describe('getEffectiveConfig', () => { - it('returns empty object when resolver is null', async () => { - mockOrchestrator.getResolver.mockReturnValue(null); - const provider = createProvider(); - - const config = await provider.getEffectiveConfig(); - - expect(config).toEqual({}); - }); - - it('returns config when resolver is set', async () => { - const resolvedConfig = { model: 'test-model', baseUrl: 'https://test' }; - mockOrchestrator.getResolver.mockReturnValue({ - resolve: jest.fn().mockResolvedValue(resolvedConfig), - }); - const provider = createProvider(); - - const config = await provider.getEffectiveConfig(); - - expect(config).toEqual(resolvedConfig); - }); - }); - - describe('getStatus', () => { - it('delegates to orchestrator', async () => { - const status = { ready: true, provider: { id: 'llamastack' } }; - mockOrchestrator.getStatus.mockResolvedValue(status); - const provider = createProvider(); - - const result = await provider.getStatus(); - - expect(mockOrchestrator.getStatus).toHaveBeenCalled(); - expect(result).toEqual(status); - }); - }); - - describe('chat', () => { - it('delegates to orchestrator', async () => { - const request = { messages: [], model: 'test' }; - const response = { responseId: 'r1', output: [] }; - mockOrchestrator.chat.mockResolvedValue(response); - const provider = createProvider(); - - const result = await provider.chat(request); - - expect(mockOrchestrator.chat).toHaveBeenCalledWith(request); - expect(result).toEqual(response); - }); - }); - - describe('chatStream', () => { - it('delegates to orchestrator', async () => { - const request = { messages: [], model: 'test' }; - const onEvent = jest.fn(); - const provider = createProvider(); - - await provider.chatStream(request, onEvent); - - expect(mockOrchestrator.chatStream).toHaveBeenCalledWith( - request, - expect.any(Function), - undefined, - ); - }); - - it('normalizes events and forwards to onEvent', async () => { - const request = { messages: [], model: 'test' }; - const onEvent = jest.fn(); - const provider = createProvider(); - - mockOrchestrator.chatStream.mockImplementation( - async ( - _req: unknown, - rawCallback: (raw: string) => void, - _signal?: AbortSignal, - ) => { - rawCallback( - JSON.stringify({ - type: 'response.output_text.delta', - delta: 'hello', - }), - ); - rawCallback( - JSON.stringify({ type: 'response.created', response_id: 'resp-1' }), - ); - }, - ); - - await provider.chatStream(request, onEvent); - - expect(onEvent).toHaveBeenCalled(); - const textDeltaCall = onEvent.mock.calls.find( - (c: [NormalizedStreamEvent]) => c[0].type === 'stream.text.delta', - ); - expect(textDeltaCall).toBeDefined(); - expect(textDeltaCall[0]).toEqual({ - type: 'stream.text.delta', - delta: 'hello', - }); - }); - - it('handles HITL approval events and backfills responseId', async () => { - const request = { messages: [], model: 'test' }; - const onEvent = jest.fn(); - const logger = createMockLogger(); - const provider = createProvider({ logger }); - - mockOrchestrator.chatStream.mockImplementation( - async ( - _req: unknown, - rawCallback: (raw: string) => void, - _signal?: AbortSignal, - ) => { - rawCallback( - JSON.stringify({ - type: 'response.created', - response: { id: 'resp-123' }, - }), - ); - rawCallback( - JSON.stringify({ - type: 'response.output_item.added', - item: { - type: 'mcp_approval_request', - id: 'call-1', - name: 'tool1', - server_label: 'Server1', - }, - }), - ); - }, - ); - - await provider.chatStream(request, onEvent); - - const approvalEvent = onEvent.mock.calls.find( - (c: [NormalizedStreamEvent]) => - c[0].type === 'stream.tool.approval' && 'responseId' in c[0], - ); - expect(approvalEvent).toBeDefined(); - expect(approvalEvent[0]).toMatchObject({ - type: 'stream.tool.approval', - callId: 'call-1', - name: 'tool1', - serverLabel: 'Server1', - responseId: 'resp-123', - }); - expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining('[HITL] Emitting stream.tool.approval'), - ); - }); - }); - - describe('conversations capability', () => { - it('create delegates to conversation facade', async () => { - mockConversationFacade.createConversation.mockResolvedValue('conv-1'); - const provider = createProvider(); - - const result = await provider.conversations.create(); - - expect(mockConversationFacade.createConversation).toHaveBeenCalled(); - expect(result).toEqual({ conversationId: 'conv-1' }); - }); - - it('list delegates to conversation facade', async () => { - const listResult = { conversations: [], nextCursor: 'c1' }; - mockConversationFacade.listConversations.mockResolvedValue(listResult); - const provider = createProvider(); - - const result = await provider.conversations.list(10, 'after-1'); - - expect(mockConversationFacade.listConversations).toHaveBeenCalledWith( - 10, - 'desc', - 'after-1', - ); - expect(result).toEqual(listResult); - }); - - it('get throws on not found', async () => { - mockConversationFacade.getConversation.mockResolvedValue(null); - const provider = createProvider(); - - await expect(provider.conversations.get('nonexistent')).rejects.toThrow( - 'Conversation not found: nonexistent', - ); - }); - - it('get returns details when found', async () => { - const details = { - responseId: 'r1', - conversationId: 'c1', - model: 'test', - createdAt: '2025-01-01', - }; - mockConversationFacade.getConversation.mockResolvedValue(details); - const provider = createProvider(); - - const result = await provider.conversations.get('r1'); - - expect(result).toEqual(details); - }); - - it('getInputs delegates to conversation facade', async () => { - const inputs = { items: [] }; - mockConversationFacade.getConversationInputs.mockResolvedValue(inputs); - const provider = createProvider(); - - const result = await provider.conversations.getInputs('r1'); - - expect(mockConversationFacade.getConversationInputs).toHaveBeenCalledWith( - 'r1', - ); - expect(result).toEqual(inputs); - }); - - it('getByResponseChain delegates to conversation facade', async () => { - const chainItems = [{ role: 'user', text: 'hi' }]; - mockConversationFacade.walkResponseChain.mockResolvedValue(chainItems); - const provider = createProvider(); - - // eslint-disable-next-line testing-library/no-await-sync-queries - const result = await provider.conversations.getByResponseChain('r1'); - - expect(mockConversationFacade.walkResponseChain).toHaveBeenCalledWith( - 'r1', - ); - expect(result.items).toEqual([ - { type: 'message', role: 'user', content: 'hi' }, - ]); - }); - - it('getProcessedMessages delegates to conversation facade', async () => { - const messages = [{ role: 'user', content: 'hi' }]; - mockConversationFacade.getProcessedMessages.mockResolvedValue(messages); - const provider = createProvider(); - - const result = await provider.conversations.getProcessedMessages('c1'); - - expect(mockConversationFacade.getProcessedMessages).toHaveBeenCalledWith( - 'c1', - ); - expect(result).toEqual(messages); - }); - - it('delete delegates to conversation facade', async () => { - const provider = createProvider(); - - await provider.conversations.delete('r1'); - - expect(mockConversationFacade.deleteConversation).toHaveBeenCalledWith( - 'r1', - undefined, - ); - }); - - it('submitApproval delegates to conversation facade', async () => { - const approval = { - responseId: 'r1', - callId: 'call-1', - approved: true, - toolName: 'tool1', - toolArguments: '{}', - }; - mockConversationFacade.continueAfterApproval.mockResolvedValue({ - responseId: 'r1', - output: [], - }); - const provider = createProvider(); - - await provider.conversations.submitApproval(approval); - - expect(mockConversationFacade.continueAfterApproval).toHaveBeenCalledWith( - 'r1', - 'call-1', - true, - 'tool1', - '{}', - ); - }); - }); - - describe('rag capability', () => { - it('listDocuments delegates to vector store facade', async () => { - const docs = [{ id: 'd1', fileName: 'f1' }]; - mockVectorStoreFacade.listDocuments.mockResolvedValue(docs); - const provider = createProvider(); - - const result = await provider.rag.listDocuments('vs-1'); - - expect(mockVectorStoreFacade.listDocuments).toHaveBeenCalledWith('vs-1'); - expect(result).toEqual(docs); - }); - - it('listVectorStores delegates to vector store facade', async () => { - const stores = [{ id: 'vs-1', name: 'store' }]; - mockVectorStoreFacade.listVectorStores.mockResolvedValue(stores); - const provider = createProvider(); - - const result = await provider.rag.listVectorStores(); - - expect(mockVectorStoreFacade.listVectorStores).toHaveBeenCalled(); - expect(result).toEqual(stores); - }); - - it('getDefaultVectorStoreId delegates to vector store facade', () => { - mockVectorStoreFacade.getDefaultVectorStoreId.mockReturnValue( - 'vs-default', - ); - const provider = createProvider(); - - const result = provider.rag.getDefaultVectorStoreId(); - - expect(result).toBe('vs-default'); - }); - - it('syncDocuments delegates to vector store facade', async () => { - const syncResult = { - added: 2, - updated: 1, - removed: 0, - failed: 0, - unchanged: 5, - errors: [] as string[], - }; - mockVectorStoreFacade.syncDocuments.mockResolvedValue(syncResult); - const provider = createProvider(); - - const result = await provider.rag.syncDocuments(); - - expect(mockVectorStoreFacade.syncDocuments).toHaveBeenCalled(); - expect(result).toEqual(syncResult); - }); - - it('uploadDocument delegates to vector store facade', async () => { - const provider = createProvider(); - - const result = await provider.rag.uploadDocument!( - 'file.txt', - Buffer.from('content'), - 'vs-1', - ); - - expect(mockVectorStoreFacade.uploadDocument).toHaveBeenCalledWith( - 'file.txt', - expect.any(Buffer), - 'vs-1', - ); - expect(result).toEqual({ - fileId: 'f1', - fileName: 'f1.txt', - status: 'completed', - }); - }); - - it('deleteDocument delegates to vector store facade', async () => { - const provider = createProvider(); - - const result = await provider.rag.deleteDocument!('f1', 'vs-1'); - - expect(mockVectorStoreFacade.deleteDocument).toHaveBeenCalledWith( - 'f1', - 'vs-1', - ); - expect(result).toEqual({ success: true }); - }); - - it('searchVectorStore delegates to vector store facade', async () => { - const searchResult = { - query: 'test', - chunks: [{ text: 'chunk1' }], - vectorStoreId: 'vs-1', - totalResults: 1, - }; - mockVectorStoreFacade.searchVectorStore.mockResolvedValue(searchResult); - const provider = createProvider(); - - const result = await provider.rag.searchVectorStore!('test', 10, 'vs-1', [ - 'vs-1', - ]); - - expect(mockVectorStoreFacade.searchVectorStore).toHaveBeenCalledWith( - 'test', - 10, - 'vs-1', - ['vs-1'], - ); - expect(result).toEqual(searchResult); - }); - }); - - describe('safety capability', () => { - it('isEnabled returns safety service state', () => { - mockSafetyService.isEnabled.mockReturnValue(true); - const provider = createProvider(); - - expect(provider.safety.isEnabled()).toBe(true); - - mockSafetyService.isEnabled.mockReturnValue(false); - expect(provider.safety.isEnabled()).toBe(false); - }); - - it('getStatus returns enabled and shields', async () => { - mockSafetyService.isEnabled.mockReturnValue(true); - mockSafetyService.getAvailableShields.mockReturnValue([ - { identifier: 's1' }, - { identifier: 's2' }, - ]); - const provider = createProvider(); - - const result = await provider.safety.getStatus(); - - expect(result).toEqual({ - enabled: true, - shields: ['s1', 's2'], - }); - }); - - it('checkInput returns safe when no violation', async () => { - mockSafetyService.checkInput.mockResolvedValue(null); - const provider = createProvider(); - - const result = await provider.safety.checkInput('safe text'); - - expect(result).toEqual({ safe: true }); - }); - - it('checkInput returns unsafe when violation', async () => { - mockSafetyService.checkInput.mockResolvedValue({ - user_message: 'Blocked content', - violation_level: 'high', - }); - const provider = createProvider(); - - const result = await provider.safety.checkInput('bad text'); - - expect(result).toEqual({ - safe: false, - violation: 'Blocked content', - category: 'high', - }); - }); - - it('checkOutput returns safe when no violation', async () => { - mockSafetyService.checkOutput.mockResolvedValue(null); - const provider = createProvider(); - - const result = await provider.safety.checkOutput('safe output'); - - expect(result).toEqual({ safe: true }); - }); - - it('checkOutput returns unsafe when violation', async () => { - mockSafetyService.checkOutput.mockResolvedValue({ - user_message: 'Harmful output', - violation_level: 'medium', - }); - const provider = createProvider(); - - const result = await provider.safety.checkOutput('bad output'); - - expect(result).toEqual({ - safe: false, - violation: 'Harmful output', - category: 'medium', - }); - }); - }); - - describe('evaluation capability', () => { - it('isEnabled returns evaluation service state', () => { - mockEvaluationService.isEnabled.mockReturnValue(true); - const provider = createProvider(); - - expect(provider.evaluation.isEnabled()).toBe(true); - - mockEvaluationService.isEnabled.mockReturnValue(false); - expect(provider.evaluation.isEnabled()).toBe(false); - }); - - it('getStatus returns enabled and scoring functions', async () => { - mockEvaluationService.isEnabled.mockReturnValue(true); - mockEvaluationService.getAvailableScoringFunctions.mockReturnValue([ - { identifier: 'f1' }, - ]); - const provider = createProvider(); - - const result = await provider.evaluation.getStatus(); - - expect(result).toEqual({ - enabled: true, - scoringFunctions: ['f1'], - }); - }); - - it('evaluateResponse returns skipped result when no result', async () => { - mockEvaluationService.scoreResponse.mockResolvedValue(null); - const provider = createProvider(); - - const result = await provider.evaluation.evaluateResponse( - 'user msg', - 'assistant msg', - ); - - expect(result.skipped).toBe(true); - expect(result.overallScore).toBe(0); - expect(result.passedThreshold).toBe(true); - }); - - it('evaluateResponse returns result when scoreResponse returns data', async () => { - const evalResult = { - overallScore: 0.9, - scores: { relevance: 0.95 }, - passedThreshold: true, - qualityLevel: 'good' as const, - evaluatedAt: '2025-01-01T00:00:00Z', - }; - mockEvaluationService.scoreResponse.mockResolvedValue(evalResult); - const provider = createProvider(); - - const result = await provider.evaluation.evaluateResponse( - 'user msg', - 'assistant msg', - ['ctx1'], - ); - - expect(mockEvaluationService.scoreResponse).toHaveBeenCalledWith( - 'user msg', - 'assistant msg', - ['ctx1'], - ); - expect(result).toEqual(evalResult); - }); - }); - - describe('testModel', () => { - it('returns connection failure when listAvailableModels throws', async () => { - const mockRequest = jest - .fn() - .mockRejectedValue(new Error('Connection refused')); - mockOrchestrator.getClientManager.mockReturnValue({ - getExistingClient: jest.fn().mockReturnValue({ request: mockRequest }), - }); - const provider = createProvider(); - - const result = await provider.testModel(); - - expect(result).toEqual({ - connected: false, - modelFound: false, - canGenerate: false, - error: 'Connection refused', - }); - }); - - it('returns model not found when target model not in list', async () => { - mockOrchestrator.getResolver.mockReturnValue({ - resolve: jest.fn().mockResolvedValue({ model: 'missing-model' }), - }); - mockOrchestrator.getClientManager.mockReturnValue({ - getExistingClient: jest.fn().mockReturnValue({ - request: jest - .fn() - .mockResolvedValueOnce({ data: [{ id: 'other-model' }] }) - .mockResolvedValueOnce({ output: [], usage: { output_tokens: 1 } }), - }), - }); - const provider = createProvider(); - - const result = await provider.testModel(); - - expect(result).toEqual({ - connected: true, - modelFound: false, - canGenerate: false, - error: 'Model "missing-model" not found on server', - }); - }); - - it('returns no model configured when resolver has no model', async () => { - mockOrchestrator.getResolver.mockReturnValue({ - resolve: jest.fn().mockResolvedValue({}), - }); - mockOrchestrator.getClientManager.mockReturnValue({ - getExistingClient: jest.fn().mockReturnValue({ - request: jest.fn().mockResolvedValue({ data: [{ id: 'model-1' }] }), - }), - }); - const provider = createProvider(); - - const result = await provider.testModel(); - - expect(result).toEqual({ - connected: true, - modelFound: false, - canGenerate: false, - error: 'No model configured', - }); - }); - - it('returns successful when model found and inference works', async () => { - mockOrchestrator.getResolver.mockReturnValue({ - resolve: jest.fn().mockResolvedValue({ model: 'model-1' }), - }); - mockOrchestrator.getClientManager.mockReturnValue({ - getExistingClient: jest.fn().mockReturnValue({ - request: jest - .fn() - .mockResolvedValueOnce({ data: [{ id: 'model-1' }] }) - .mockResolvedValueOnce({ - output: [ - { - type: 'message', - content: [{ type: 'output_text', text: 'hi' }], - }, - ], - usage: { output_tokens: 5 }, - }), - }), - }); - const provider = createProvider(); - - const result = await provider.testModel(); - - expect(result).toEqual({ - connected: true, - modelFound: true, - canGenerate: true, - }); - }); - - it('uses modelOverride when provided', async () => { - mockOrchestrator.getResolver.mockReturnValue(null); - mockOrchestrator.getClientManager.mockReturnValue({ - getExistingClient: jest.fn().mockReturnValue({ - request: jest - .fn() - .mockResolvedValueOnce({ data: [{ id: 'override-model' }] }) - .mockResolvedValueOnce({ - output: [ - { - type: 'message', - content: [{ type: 'output_text', text: 'ok' }], - }, - ], - usage: { output_tokens: 2 }, - }), - }), - }); - const provider = createProvider(); - - const result = await provider.testModel('override-model'); - - expect(result).toEqual({ - connected: true, - modelFound: true, - canGenerate: true, - }); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackProvider.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackProvider.ts deleted file mode 100644 index e73c3412c12..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/LlamaStackProvider.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * @deprecated Use {@link ResponsesApiProvider} from './ResponsesApiProvider' instead. - */ -export { ResponsesApiProvider as LlamaStackProvider } from './ResponsesApiProvider'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpAuthService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpAuthService.test.ts deleted file mode 100644 index ed8d9cca271..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpAuthService.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { MCPAuthConfig } from '../../types'; -import { McpAuthService } from './McpAuthService'; -import { createMockLogger } from '../../test-utils/mocks'; - -describe('McpAuthService', () => { - const mockLogger = createMockLogger(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('constructor', () => { - it('creates an instance with no MCP auth configs', () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - expect(service).toBeDefined(); - }); - - it('creates an instance with security mode full', () => { - const service = new McpAuthService( - { mode: 'full' }, - new Map([ - [ - 'mcp-1', - { - type: 'oauth', - tokenUrl: 'https://keycloak/token', - clientId: 'c', - clientSecret: 's', - }, - ], - ]), - mockLogger as unknown as LoggerService, - false, - ); - expect(service).toBeDefined(); - }); - }); - - describe('getServerHeaders', () => { - it('returns empty headers when mode is none', async () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - const headers = await service.getServerHeaders({ - id: 'test', - name: 'Test', - url: 'https://localhost:3000', - type: 'streamable-http', - }); - expect(headers).toEqual({}); - }); - - it('returns empty headers when no auth config matches the server', async () => { - const service = new McpAuthService( - { mode: 'full' }, - new Map([ - [ - 'other-server', - { - type: 'oauth', - tokenUrl: 'https://keycloak/token', - clientId: 'c', - clientSecret: 's', - }, - ], - ]), - mockLogger as unknown as LoggerService, - false, - ); - const headers = await service.getServerHeaders({ - id: 'test', - name: 'Test', - url: 'https://localhost:3000', - type: 'streamable-http', - }); - expect(headers).toEqual({}); - }); - }); - - describe('getServerHeaders - null token warnings', () => { - let originalFetch: typeof global.fetch; - - beforeEach(() => { - originalFetch = global.fetch; - }); - - afterEach(() => { - global.fetch = originalFetch; - }); - - it('logs warning when authRef OAuth token returns null', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: false, - status: 401, - text: () => Promise.resolve('Unauthorized'), - json: () => Promise.resolve({}), - }); - - const service = new McpAuthService( - { mode: 'none' }, - new Map([ - [ - 'my-auth', - { - type: 'oauth', - tokenUrl: 'https://keycloak/token', - clientId: 'c', - clientSecret: 's', - }, - ], - ]), - mockLogger as unknown as LoggerService, - false, - ); - - const headers = await service.getServerHeaders({ - id: 'srv-1', - name: 'Server 1', - url: 'https://mcp.example.com', - type: 'streamable-http', - authRef: 'my-auth', - }); - - expect(headers.Authorization).toBeUndefined(); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'OAuth token via authRef "my-auth" returned null', - ), - ); - }); - - it('logs warning when inline OAuth token returns null', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: false, - status: 500, - text: () => Promise.resolve('Server Error'), - json: () => Promise.resolve({}), - }); - - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - - const headers = await service.getServerHeaders({ - id: 'srv-2', - name: 'Server 2', - url: 'https://mcp.example.com', - type: 'streamable-http', - oauth: { - tokenUrl: 'https://keycloak/token', - clientId: 'c', - clientSecret: 's', - }, - }); - - expect(headers.Authorization).toBeUndefined(); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('inline OAuth token returned null'), - ); - }); - - it('sets Authorization header when OAuth token succeeds', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - text: () => - Promise.resolve( - JSON.stringify({ access_token: 'test-token', expires_in: 300 }), - ), - json: () => - Promise.resolve({ access_token: 'test-token', expires_in: 300 }), - }); - - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - - const headers = await service.getServerHeaders({ - id: 'srv-3', - name: 'Server 3', - url: 'https://mcp.example.com', - type: 'streamable-http', - oauth: { - tokenUrl: 'https://keycloak/token', - clientId: 'c', - clientSecret: 's', - }, - }); - - expect(headers.Authorization).toBe('Bearer test-token'); - expect(mockLogger.warn).not.toHaveBeenCalledWith( - expect.stringContaining('returned null'), - ); - }); - - it('caches OAuth token on subsequent calls', async () => { - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - text: () => - Promise.resolve( - JSON.stringify({ access_token: 'cached-token', expires_in: 3600 }), - ), - json: () => - Promise.resolve({ access_token: 'cached-token', expires_in: 3600 }), - }); - global.fetch = mockFetch; - - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - - const server = { - id: 'srv-cache', - name: 'Cache Server', - url: 'https://mcp.example.com', - type: 'streamable-http' as const, - oauth: { - tokenUrl: 'https://keycloak/token', - clientId: 'c', - clientSecret: 's', - }, - }; - - await service.getServerHeaders(server); - await service.getServerHeaders(server); - - // Only one actual fetch should occur (second should hit cache) - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it('deduplicates concurrent OAuth token requests', async () => { - let resolveToken: (v: unknown) => void; - const tokenPromise = new Promise(resolve => { - resolveToken = resolve; - }); - - const mockFetch = jest.fn().mockImplementation(() => tokenPromise); - global.fetch = mockFetch; - - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - - const server = { - id: 'srv-dedup', - name: 'Dedup Server', - url: 'https://mcp.example.com', - type: 'streamable-http' as const, - oauth: { - tokenUrl: 'https://keycloak/token', - clientId: 'c', - clientSecret: 's', - }, - }; - - // Fire two concurrent requests - const p1 = service.getServerHeaders(server); - const p2 = service.getServerHeaders(server); - - // Resolve the shared promise - resolveToken!({ - ok: true, - status: 200, - text: () => - Promise.resolve( - JSON.stringify({ access_token: 'dedup-token', expires_in: 300 }), - ), - json: () => - Promise.resolve({ access_token: 'dedup-token', expires_in: 300 }), - }); - - const [h1, h2] = await Promise.all([p1, p2]); - - expect(h1.Authorization).toBe('Bearer dedup-token'); - expect(h2.Authorization).toBe('Bearer dedup-token'); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it('logs warning for unknown authRef', async () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - - await service.getServerHeaders({ - id: 'srv-missing', - name: 'Missing Auth', - url: 'https://mcp.example.com', - type: 'streamable-http', - authRef: 'nonexistent', - }); - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('references unknown auth config: nonexistent'), - ); - }); - - it('preserves static headers from server config', async () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - - const headers = await service.getServerHeaders({ - id: 'srv-static', - name: 'Static Headers', - url: 'https://mcp.example.com', - type: 'streamable-http', - headers: { 'X-Custom': 'value' }, - }); - - expect(headers['X-Custom']).toBe('value'); - }); - }); - - describe('getApiApprovalConfig', () => { - it('returns "never" when no config is provided', () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - expect(service.getApiApprovalConfig(undefined)).toBe('never'); - }); - - it('returns "always" when configured as string', () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - expect(service.getApiApprovalConfig('always')).toBe('always'); - }); - - it('returns "never" when configured as string', () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - expect(service.getApiApprovalConfig('never')).toBe('never'); - }); - - it('falls back to "never" for object format (Llama Stack limitation) and logs warning', () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - const result = service.getApiApprovalConfig({ - always: ['tool1'], - never: ['tool2'], - }); - expect(result).toBe('never'); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('does not support per-tool require_approval'), - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpAuthService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpAuthService.ts deleted file mode 100644 index 9b5d9504866..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpAuthService.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './auth/McpAuthService'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpConfigLoader.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpConfigLoader.ts deleted file mode 100644 index f504a44ea9c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpConfigLoader.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './config/McpConfigLoader'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpToolBuilder.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpToolBuilder.ts deleted file mode 100644 index d8490a434ac..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/McpToolBuilder.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/tools/McpToolBuilder' instead */ -export * from '../responses-api/tools/McpToolBuilder'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/MessageProcessor.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/MessageProcessor.test.ts deleted file mode 100644 index 3289e19a862..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/MessageProcessor.test.ts +++ /dev/null @@ -1,619 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - extractContentFromItem, - extractUserInputFromRaw, - getInputText, - processConversationItems, -} from './MessageProcessor'; -import { createMockLogger } from '../../test-utils'; -import type { ConversationItem } from './conversationTypes'; - -describe('MessageProcessor', () => { - const logger = createMockLogger(); - - describe('extractContentFromItem', () => { - it('returns empty string for undefined', () => { - expect(extractContentFromItem(undefined)).toBe(''); - }); - - it('returns string content as-is', () => { - expect(extractContentFromItem('Hello world')).toBe('Hello world'); - }); - - it('extracts and joins text from input_text, output_text, and text parts', () => { - const content = [ - { type: 'input_text', text: 'Part1' }, - { type: 'output_text', text: 'Part2' }, - { type: 'text', text: 'Part3' }, - { type: 'other', text: 'Ignored' }, - ]; - expect(extractContentFromItem(content)).toBe('Part1Part2Part3'); - }); - - it('skips parts without text or with non-string text', () => { - const content = [ - { type: 'input_text', text: 'Valid' }, - { type: 'text' }, - { type: 'output_text', text: 123 as unknown as string }, - ]; - expect(extractContentFromItem(content)).toBe('Valid'); - }); - - it('returns empty string for empty array', () => { - expect(extractContentFromItem([])).toBe(''); - }); - - it('returns empty string for non-matching array', () => { - const content = [{ type: 'image', url: 'x' }, { type: 'other' }]; - expect( - extractContentFromItem( - content as Array<{ type: string; text?: string }>, - ), - ).toBe(''); - }); - - it('excludes input_text when excludeInputText is true', () => { - const content = [ - { type: 'input_text', text: 'System prompt here' }, - { type: 'output_text', text: 'Model response' }, - ]; - expect(extractContentFromItem(content, true)).toBe('Model response'); - }); - - it('includes input_text when excludeInputText is false (default)', () => { - const content = [ - { type: 'input_text', text: 'User query' }, - { type: 'output_text', text: 'Reply' }, - ]; - expect(extractContentFromItem(content, false)).toBe('User queryReply'); - expect(extractContentFromItem(content)).toBe('User queryReply'); - }); - - it('returns only output_text and text when excludeInputText is true', () => { - const content = [ - { type: 'input_text', text: 'Instructions leaked' }, - { type: 'output_text', text: 'Here are the results' }, - { type: 'text', text: ' with details' }, - ]; - expect(extractContentFromItem(content, true)).toBe( - 'Here are the results with details', - ); - }); - }); - - describe('extractUserInputFromRaw', () => { - it('returns string input as-is', () => { - expect(extractUserInputFromRaw('User query')).toBe('User query'); - }); - - it('extracts from message with role user and string content', () => { - const input = [{ type: 'message', role: 'user', content: 'Hello' }]; - expect(extractUserInputFromRaw(input)).toBe('Hello'); - }); - - it('extracts from message with role user and array content', () => { - const input = [ - { - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: 'From array' }], - }, - ]; - expect(extractUserInputFromRaw(input)).toBe('From array'); - }); - - it('extracts from input_text item', () => { - const input = [{ type: 'input_text', text: 'Direct input' }]; - expect(extractUserInputFromRaw(input)).toBe('Direct input'); - }); - - it('returns empty string for empty array', () => { - expect(extractUserInputFromRaw([])).toBe(''); - }); - - it('returns empty string when no user message or input_text', () => { - const input = [ - { type: 'message', role: 'assistant', content: 'Hi' }, - { type: 'other' }, - ]; - expect(extractUserInputFromRaw(input)).toBe(''); - }); - - it('skips null/undefined items in array', () => { - const input = [null, { type: 'input_text', text: 'Valid' }]; - expect(extractUserInputFromRaw(input as unknown[])).toBe('Valid'); - }); - - it('prefers user message over bare input_text (prevents instruction leak)', () => { - const input = [ - { type: 'input_text', text: 'You are an AI assistant. Be helpful.' }, - { type: 'message', role: 'user', content: 'List namespaces' }, - ]; - expect(extractUserInputFromRaw(input)).toBe('List namespaces'); - }); - - it('falls back to input_text only when no user message exists', () => { - const input = [{ type: 'input_text', text: 'Direct user query' }]; - expect(extractUserInputFromRaw(input)).toBe('Direct user query'); - }); - }); - - describe('getInputText', () => { - it('returns string truncated to 80 chars with ellipsis', () => { - const long = 'a'.repeat(100); - expect(getInputText(long)).toBe(`${'a'.repeat(80)}...`); - }); - - it('returns short string without ellipsis', () => { - expect(getInputText('Short')).toBe('Short'); - }); - - it('returns empty string for non-array when input is not string', () => { - expect(getInputText(null as unknown as string)).toBe(''); - }); - - it('extracts from first message in array and truncates', () => { - const input = [ - { - type: 'message', - role: 'user', - content: 'x'.repeat(100), - }, - ]; - expect(getInputText(input)).toBe(`${'x'.repeat(80)}...`); - }); - - it('returns empty string for array without message type', () => { - const input = [{ type: 'other', content: 'x' }]; - expect(getInputText(input)).toBe(''); - }); - - it('prefers user message over developer/system message for preview', () => { - const input = [ - { - type: 'message', - role: 'developer', - content: 'You are an AI assistant', - }, - { type: 'message', role: 'user', content: 'List pods' }, - ]; - expect(getInputText(input)).toBe('List pods'); - }); - - it('skips developer and system messages entirely', () => { - const input = [ - { type: 'message', role: 'system', content: 'System prompt text' }, - ]; - expect(getInputText(input)).toBe(''); - }); - }); - - describe('processConversationItems', () => { - it('processes user and assistant messages', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'Hello' }, - { type: 'message', role: 'assistant', content: 'Hi there' }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ role: 'user', text: 'Hello' }); - expect(result[1]).toEqual({ role: 'assistant', text: 'Hi there' }); - }); - - it('skips mcp_list_tools items', () => { - const items: ConversationItem[] = [ - { type: 'mcp_list_tools' }, - { type: 'message', role: 'user', content: 'Hi' }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(1); - expect(result[0].text).toBe('Hi'); - }); - - it('groups tool calls with next assistant message', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'Use tool' }, - { - type: 'mcp_call', - id: 'tc1', - name: 'my_tool', - server_label: 'server1', - }, - { type: 'message', role: 'assistant', content: 'Done' }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(2); - expect(result[1].toolCalls).toHaveLength(1); - expect(result[1].toolCalls![0].name).toBe('my_tool'); - }); - - it('drops orphaned tool calls before user message', () => { - const items: ConversationItem[] = [ - { type: 'mcp_call', id: 'tc1', name: 'tool' }, - { type: 'message', role: 'user', content: 'Hi' }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(1); - expect(logger.debug).toHaveBeenCalledWith( - expect.stringContaining('orphaned tool call'), - ); - }); - - it('processes file_search_call and attaches RAG sources', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'Search' }, - { - type: 'file_search_call', - id: 'fs1', - queries: ['q1'], - results: [ - { filename: 'doc.txt', file_id: 'f1', text: 'chunk', score: 0.9 }, - ], - }, - { type: 'message', role: 'assistant', content: 'Found it' }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(2); - expect(result[1].ragSources).toHaveLength(1); - expect(result[1].ragSources![0].filename).toBe('doc.txt'); - expect(result[1].ragSources![0].fileId).toBe('f1'); - }); - - it('adds createdAt from created_at when present', () => { - const items: ConversationItem[] = [ - { - type: 'message', - role: 'user', - content: 'Hi', - created_at: 1709107200, // Unix seconds - }, - ]; - const result = processConversationItems(items, logger); - expect(result[0].createdAt).toBe( - new Date(1709107200 * 1000).toISOString(), - ); - }); - - it('skips messages with empty text', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: ' ' }, - { type: 'message', role: 'assistant', content: 'Reply' }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(1); - expect(result[0].text).toBe('Reply'); - }); - - it('excludes system prompt (input_text) from assistant messages', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'List namespaces' }, - { - type: 'message', - role: 'assistant', - content: [ - { - type: 'input_text', - text: 'You are an intelligent AI assistant for the Application Platform.', - }, - { - type: 'output_text', - text: 'Here are the namespaces in the cluster.', - }, - ], - }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(2); - expect(result[1].text).toBe('Here are the namespaces in the cluster.'); - expect(result[1].text).not.toContain('intelligent AI assistant'); - }); - - it('preserves input_text in user messages', () => { - const items: ConversationItem[] = [ - { - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: 'User query here' }], - }, - { type: 'message', role: 'assistant', content: 'Response' }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(2); - expect(result[0].text).toBe('User query here'); - }); - - it('extracts reasoning from summary_text and attaches to next assistant message', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'Think about this' }, - { - type: 'reasoning', - id: 'rs_1', - summary: [ - { type: 'summary_text', text: 'Let me analyze the request.' }, - { type: 'summary_text', text: 'The user wants reasoning.' }, - ], - }, - { type: 'message', role: 'assistant', content: 'Here is my answer.' }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(2); - expect(result[1].reasoning).toBe( - 'Let me analyze the request.\n\nThe user wants reasoning.', - ); - }); - - it('extracts reasoning from content when summary is absent', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'Think' }, - { - type: 'reasoning', - id: 'rs_2', - content: [{ type: 'text', text: 'Reasoning via content field.' }], - }, - { type: 'message', role: 'assistant', content: 'Done' }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(2); - expect(result[1].reasoning).toBe('Reasoning via content field.'); - }); - - it('accumulates multiple reasoning items into one string', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'Deep thought' }, - { - type: 'reasoning', - summary: [{ type: 'summary_text', text: 'Step 1' }], - }, - { - type: 'reasoning', - summary: [{ type: 'summary_text', text: 'Step 2' }], - }, - { type: 'message', role: 'assistant', content: 'Result' }, - ]; - const result = processConversationItems(items, logger); - expect(result[1].reasoning).toBe('Step 1\n\nStep 2'); - }); - - it('does not attach reasoning to user messages', () => { - const items: ConversationItem[] = [ - { - type: 'reasoning', - summary: [{ type: 'summary_text', text: 'Orphaned reasoning' }], - }, - { type: 'message', role: 'user', content: 'Hello' }, - { type: 'message', role: 'assistant', content: 'Hi' }, - ]; - const result = processConversationItems(items, logger); - expect(result[0].reasoning).toBeUndefined(); - expect(result[1].reasoning).toBeUndefined(); - }); - - it('infers agent name from transfer_to_* handoff function_call', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'Route me' }, - { - type: 'function_call', - name: 'transfer_to_billing_agent', - call_id: 'call_1', - arguments: '{}', - }, - { - type: 'function_call_output', - call_id: 'call_1', - output: JSON.stringify({ assistant: 'Billing Agent' }), - }, - { - type: 'message', - role: 'assistant', - content: 'I can help with billing.', - }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(2); - expect(result[1].agentName).toBe('Billing Agent'); - expect(result[1].toolCalls).toBeUndefined(); - }); - - it('uses parsed function name when function_call_output lacks assistant field', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'Route me' }, - { - type: 'function_call', - name: 'transfer_to_support_team', - call_id: 'call_2', - arguments: '{}', - }, - { - type: 'function_call_output', - call_id: 'call_2', - output: 'Some plain text', - }, - { - type: 'message', - role: 'assistant', - content: 'Support here.', - }, - ]; - const result = processConversationItems(items, logger); - expect(result[1].agentName).toBe('support team'); - }); - - it('does not filter non-handoff function_calls from toolCalls', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'Use a tool' }, - { - type: 'function_call', - name: 'get_weather', - call_id: 'call_3', - arguments: '{"city":"NYC"}', - }, - { - type: 'message', - role: 'assistant', - content: 'The weather is sunny.', - }, - ]; - const result = processConversationItems(items, logger); - expect(result[1].toolCalls).toHaveLength(1); - expect(result[1].toolCalls![0].name).toBe('get_weather'); - expect(result[1].agentName).toBeUndefined(); - }); - - it('handles reasoning + handoff together on one assistant message', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'Complex request' }, - { - type: 'reasoning', - summary: [{ type: 'summary_text', text: 'Deciding which agent.' }], - }, - { - type: 'function_call', - name: 'transfer_to_expert', - call_id: 'call_4', - arguments: '{}', - }, - { - type: 'function_call_output', - call_id: 'call_4', - output: JSON.stringify({ assistant: 'Expert Agent' }), - }, - { - type: 'message', - role: 'assistant', - content: 'Expert here to help.', - }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(2); - expect(result[1].reasoning).toBe('Deciding which agent.'); - expect(result[1].agentName).toBe('Expert Agent'); - expect(result[1].toolCalls).toBeUndefined(); - }); - - it('resets pending state when a user message appears', () => { - const items: ConversationItem[] = [ - { - type: 'reasoning', - summary: [{ type: 'summary_text', text: 'Leftover reasoning' }], - }, - { - type: 'function_call', - name: 'transfer_to_old_agent', - call_id: 'call_5', - arguments: '{}', - }, - { type: 'message', role: 'user', content: 'New question' }, - { type: 'message', role: 'assistant', content: 'Fresh answer' }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(2); - expect(result[1].reasoning).toBeUndefined(); - expect(result[1].agentName).toBeUndefined(); - }); - - it('handles multiple handoffs in sequence', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'Route me' }, - { - type: 'function_call', - name: 'transfer_to_router', - call_id: 'c1', - arguments: '{}', - }, - { - type: 'function_call_output', - call_id: 'c1', - output: JSON.stringify({ assistant: 'Router' }), - }, - { - type: 'message', - role: 'assistant', - content: 'Let me redirect you.', - }, - { - type: 'function_call', - name: 'transfer_to_specialist', - call_id: 'c2', - arguments: '{}', - }, - { - type: 'function_call_output', - call_id: 'c2', - output: JSON.stringify({ assistant: 'Specialist' }), - }, - { - type: 'message', - role: 'assistant', - content: 'I am the specialist.', - }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(3); - expect(result[1].agentName).toBe('Router'); - expect(result[2].agentName).toBe('Specialist'); - }); - - it('ignores reasoning items with empty summary', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'Hi' }, - { type: 'reasoning', summary: [] }, - { type: 'message', role: 'assistant', content: 'Hello' }, - ]; - const result = processConversationItems(items, logger); - expect(result[1].reasoning).toBeUndefined(); - }); - - it('logs debug when messages are skipped due to empty text', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'Hello' }, - { type: 'message', role: 'assistant', content: ' ' }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(1); - expect(logger.debug).toHaveBeenCalledWith( - expect.stringContaining('Skipping assistant message with empty text'), - ); - }); - - it('warns when all items are filtered out', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: '' }, - { type: 'message', role: 'assistant', content: '' }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(0); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('produced 0 messages'), - ); - }); - - it('skips assistant messages where all content is input_text', () => { - const items: ConversationItem[] = [ - { type: 'message', role: 'user', content: 'Hello' }, - { - type: 'message', - role: 'assistant', - content: [{ type: 'input_text', text: 'system prompt instructions' }], - }, - ]; - const result = processConversationItems(items, logger); - expect(result).toHaveLength(1); - expect(result[0].role).toBe('user'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/MessageProcessor.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/MessageProcessor.ts deleted file mode 100644 index c8f7b83ee1f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/MessageProcessor.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export * from '../responses-api/conversations/MessageProcessor'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ModerationService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ModerationService.ts deleted file mode 100644 index f36f821382f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ModerationService.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ResponsesApiClient } from './ResponsesApiClient'; - -/** - * Category classification from the Moderations API. - */ -export interface ModerationCategory { - name: string; - flagged: boolean; - score: number; -} - -/** - * Result from a single moderation input. - */ -export interface ModerationResult { - flagged: boolean; - categories: Record; - category_scores: Record; -} - -/** - * Full response from `POST /v1/moderations`. - */ -export interface ModerationResponse { - id: string; - model: string; - results: ModerationResult[]; -} - -/** - * Service wrapping the Llama Stack `POST /v1/moderations` endpoint. - * - * Provides fine-grained content classification that supplements the - * `SafetyService` shields. While shields enforce binary allow/block - * decisions during response generation, the Moderations API returns - * per-category scores enabling nuanced handling (logging, UI warnings, - * conditional blocking based on score thresholds). - * - * Usage example: - * ```ts - * const result = await moderationService.moderate(client, { - * input: 'User message to classify', - * model: 'text-moderation-latest', - * }); - * if (result.results[0]?.flagged) { - * // handle flagged content - * } - * ``` - */ -export class ModerationService { - constructor(private readonly logger: LoggerService) {} - - /** - * Classify content using the Llama Stack Moderations API. - * - * @param client - Authenticated API client - * @param options - Moderation request options - * @returns Moderation response with per-category classifications - */ - async moderate( - client: ResponsesApiClient, - options: { - input: string | string[]; - model?: string; - }, - ): Promise { - const body: Record = { - input: options.input, - }; - if (options.model) { - body.model = options.model; - } - - this.logger.debug( - `[ModerationService] Classifying ${Array.isArray(options.input) ? options.input.length : 1} input(s)`, - ); - - const response = await client.requestWithRetry( - '/v1/moderations', - { method: 'POST', body: JSON.stringify(body) }, - ); - - const flaggedCount = response.results.filter(r => r.flagged).length; - if (flaggedCount > 0) { - this.logger.info( - `[ModerationService] ${flaggedCount}/${response.results.length} input(s) flagged`, - ); - } - - return response; - } - - /** - * Convenience method to check if any input is flagged. - */ - async isFlagged( - client: ResponsesApiClient, - input: string, - model?: string, - ): Promise { - try { - const response = await this.moderate(client, { input, model }); - return response.results.some(r => r.flagged); - } catch (error) { - this.logger.warn( - `[ModerationService] Moderation check failed, defaulting to not flagged: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - return false; - } - } - - /** - * Get detailed category scores for content classification. - */ - async getScores( - client: ResponsesApiClient, - input: string, - model?: string, - ): Promise> { - const response = await this.moderate(client, { input, model }); - return response.results[0]?.category_scores ?? {}; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/OrchestratorInitializer.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/OrchestratorInitializer.test.ts deleted file mode 100644 index 512dd45829d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/OrchestratorInitializer.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ConfigLoader } from './ConfigLoader'; -import type { ConfigResolutionService } from './ConfigResolutionService'; -import type { ClientManager } from './ClientManager'; -import type { VectorStoreFacade } from './VectorStoreFacade'; -import type { ConversationFacade } from './ConversationFacade'; -import type { AdminConfigService } from '../../services/AdminConfigService'; -import type { LlamaStackConfig } from '../../types'; -import { createMockLogger } from '../../test-utils'; -import { - initializeOrchestrator, - OrchestratorInitDeps, -} from './OrchestratorInitializer'; - -const mockResolverResolve = jest.fn(); -const mockDocSyncInitialize = jest.fn(); - -jest.mock('../../services/RuntimeConfigResolver', () => ({ - RuntimeConfigResolver: jest.fn().mockImplementation(() => ({ - resolve: mockResolverResolve, - invalidateCache: jest.fn(), - })), -})); - -jest.mock('./DocumentSyncService', () => ({ - DocumentSyncService: jest.fn().mockImplementation(() => ({ - initialize: mockDocSyncInitialize, - })), -})); - -const DEFAULT_LLAMA_STACK_CONFIG: LlamaStackConfig = { - baseUrl: 'http://localhost:8321', - model: 'test-model', - vectorStoreIds: ['vs-1'], - vectorStoreName: 'test-store', - embeddingModel: 'test-embed', - embeddingDimension: 384, - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, -}; - -function createMockConfigLoader( - overrides?: Partial<{ - validateRequiredConfig: jest.Mock; - loadSecurityConfig: jest.Mock; - loadLlamaStackConfig: jest.Mock; - loadMcpAuthConfigs: jest.Mock; - loadDocumentsConfig: jest.Mock; - loadMcpServerConfigs: jest.Mock; - loadWorkflows: jest.Mock; - loadQuickActions: jest.Mock; - loadPromptGroups: jest.Mock; - loadToolExecutionMode: jest.Mock; - }>, -): jest.Mocked { - return { - validateRequiredConfig: jest.fn(), - loadSecurityConfig: jest - .fn() - .mockReturnValue({ mode: 'plugin-only' as const }), - loadLlamaStackConfig: jest.fn().mockReturnValue(DEFAULT_LLAMA_STACK_CONFIG), - loadMcpAuthConfigs: jest.fn().mockReturnValue(new Map()), - loadDocumentsConfig: jest.fn().mockReturnValue(null), - loadMcpServerConfigs: jest.fn().mockReturnValue([]), - loadWorkflows: jest.fn().mockReturnValue([]), - loadQuickActions: jest.fn().mockReturnValue([]), - loadPromptGroups: jest.fn().mockReturnValue([]), - loadToolExecutionMode: jest.fn().mockReturnValue('direct'), - ...overrides, - } as unknown as jest.Mocked; -} - -function createMockConfigResolution(): jest.Mocked { - return { - setLlamaStackConfig: jest.fn(), - setResolver: jest.fn(), - setLastResolvedModel: jest.fn(), - setLastResolvedVerboseLogging: jest.fn(), - getLastResolvedModel: jest.fn().mockReturnValue('test-model'), - } as unknown as jest.Mocked; -} - -function createMockClientManager(): jest.Mocked { - const mockClient = { - request: jest.fn(), - getConfig: jest.fn().mockReturnValue(DEFAULT_LLAMA_STACK_CONFIG), - }; - return { - getClient: jest.fn().mockReturnValue(mockClient), - getExistingClient: jest.fn().mockReturnValue(mockClient), - probeServerVersion: jest.fn().mockResolvedValue(undefined), - } as unknown as jest.Mocked; -} - -function createMockVectorStoreFacade(): jest.Mocked { - return { - setServices: jest.fn(), - } as unknown as jest.Mocked; -} - -function createMockConversationFacade(): jest.Mocked { - return { - setConversations: jest.fn(), - } as unknown as jest.Mocked; -} - -function createBaseDeps(): OrchestratorInitDeps { - const logger = createMockLogger(); - const clientManager = createMockClientManager(); - const configResolution = createMockConfigResolution(); - return { - configLoader: createMockConfigLoader(), - configResolution, - clientManager, - logger, - vectorStoreFacade: createMockVectorStoreFacade(), - conversationFacade: createMockConversationFacade(), - }; -} - -describe('initializeOrchestrator', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockDocSyncInitialize.mockResolvedValue(undefined); - mockResolverResolve.mockResolvedValue({ - model: 'test-model', - verboseStreamLogging: false, - }); - }); - - describe('successful initialization', () => { - it('creates all services, loads config, and sets up facades', async () => { - const deps = createBaseDeps(); - - const result = await initializeOrchestrator(deps); - - expect(deps.configLoader.validateRequiredConfig).toHaveBeenCalledTimes(1); - expect(deps.configLoader.loadSecurityConfig).toHaveBeenCalledTimes(1); - expect(deps.configLoader.loadLlamaStackConfig).toHaveBeenCalledTimes(1); - expect(deps.configResolution.setLlamaStackConfig).toHaveBeenCalledWith( - DEFAULT_LLAMA_STACK_CONFIG, - ); - - expect(result.securityConfig).toEqual({ mode: 'plugin-only' }); - expect(result.ingestionService).toBeDefined(); - expect(result.mcpAuth).toBeDefined(); - expect(result.vectorStore).toBeDefined(); - expect(result.docSync).toBeDefined(); - expect(result.documentsConfig).toBeNull(); - expect(result.conversations).toBeDefined(); - expect(result.mcpServers).toEqual([]); - - expect(deps.vectorStoreFacade.setServices).toHaveBeenCalledWith( - result.vectorStore, - result.docSync, - ); - expect(deps.conversationFacade.setConversations).toHaveBeenCalledWith( - result.conversations, - ); - }); - - it('loads documents config when sources are configured', async () => { - const docsConfig = { - sources: [{ type: 'url' as const, url: 'https://example.com' }], - }; - const deps = createBaseDeps(); - deps.configLoader.loadDocumentsConfig = jest - .fn() - .mockReturnValue(docsConfig); - - const result = await initializeOrchestrator(deps); - - expect(result.documentsConfig).toEqual(docsConfig); - }); - - it('loads MCP servers', async () => { - const mcpServers = [ - { id: 'mcp-1', name: 'Test MCP', type: 'stdio', url: 'cmd://test' }, - ]; - - const deps = createBaseDeps(); - deps.configLoader.loadMcpServerConfigs = jest - .fn() - .mockReturnValue(mcpServers); - - const result = await initializeOrchestrator(deps); - - expect(result.mcpServers).toEqual(mcpServers); - }); - }); - - describe('config resolution fallback', () => { - it('falls back to YAML config when resolver.resolve() throws', async () => { - mockResolverResolve.mockRejectedValueOnce(new Error('DB unavailable')); - - const deps = createBaseDeps(); - deps.adminConfig = {} as AdminConfigService; - - const result = await initializeOrchestrator(deps); - - expect(deps.configResolution.setResolver).toHaveBeenCalled(); - expect(deps.configResolution.setLastResolvedModel).toHaveBeenCalledWith( - DEFAULT_LLAMA_STACK_CONFIG.model, - ); - expect( - deps.configResolution.setLastResolvedVerboseLogging, - ).toHaveBeenCalledWith( - DEFAULT_LLAMA_STACK_CONFIG.verboseStreamLogging ?? false, - ); - expect(result).toBeDefined(); - }); - - it('uses resolved model when resolver.resolve() succeeds', async () => { - const resolvedModel = 'override-model'; - mockResolverResolve.mockResolvedValueOnce({ - model: resolvedModel, - verboseStreamLogging: true, - }); - - const deps = createBaseDeps(); - deps.adminConfig = {} as AdminConfigService; - - await initializeOrchestrator(deps); - - expect(deps.configResolution.setLastResolvedModel).toHaveBeenCalledWith( - resolvedModel, - ); - expect( - deps.configResolution.setLastResolvedVerboseLogging, - ).toHaveBeenCalledWith(true); - }); - }); - - describe('partial failure', () => { - it('when docSync.initialize() throws, earlier steps are set up and error propagates', async () => { - const initError = new Error('Document sync init failed'); - mockDocSyncInitialize.mockRejectedValueOnce(initError); - - const deps = createBaseDeps(); - - await expect(initializeOrchestrator(deps)).rejects.toThrow( - 'Document sync init failed', - ); - - expect(deps.configResolution.setLlamaStackConfig).toHaveBeenCalled(); - expect(deps.vectorStoreFacade.setServices).not.toHaveBeenCalled(); - }); - }); - - describe('backendToolExecutor singleton', () => { - it('creates BackendToolExecutor when toolExecutionMode is backend', async () => { - const deps = createBaseDeps(); - (deps.configLoader as any).loadToolExecutionMode = jest - .fn() - .mockReturnValue('backend'); - - const result = await initializeOrchestrator(deps); - - expect(result.backendToolExecutor).toBeDefined(); - expect(result.backendToolExecutor).not.toBeNull(); - }); - - it('does not create BackendToolExecutor when toolExecutionMode is direct', async () => { - const deps = createBaseDeps(); - - const result = await initializeOrchestrator(deps); - - expect(result.backendToolExecutor).toBeNull(); - }); - }); - - describe('error propagation', () => { - it('when configLoader.validateRequiredConfig() throws, error propagates', async () => { - const configError = new Error('Llama Stack not configured'); - const deps = createBaseDeps(); - deps.configLoader.validateRequiredConfig = jest - .fn() - .mockImplementation(() => { - throw configError; - }); - - await expect(initializeOrchestrator(deps)).rejects.toThrow( - 'Llama Stack not configured', - ); - - expect(deps.configLoader.loadSecurityConfig).not.toHaveBeenCalled(); - expect(deps.configResolution.setLlamaStackConfig).not.toHaveBeenCalled(); - expect(deps.vectorStoreFacade.setServices).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/OrchestratorInitializer.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/OrchestratorInitializer.ts deleted file mode 100644 index 751c4af1b63..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/OrchestratorInitializer.ts +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - LoggerService, - DatabaseService, -} from '@backstage/backend-plugin-api'; -import type { - MCPServerConfig, - DocumentsConfig, - SecurityConfig, -} from '../../types'; -import type { ConfigLoader } from './ConfigLoader'; -import type { ConfigResolutionService } from './ConfigResolutionService'; -import type { ClientManager } from './ClientManager'; -import { McpAuthService } from './McpAuthService'; -import { BackendToolExecutor } from './BackendToolExecutor'; -import { VectorStoreService } from './VectorStoreService'; -import { DocumentSyncService } from './DocumentSyncService'; -import { ConversationService } from './ConversationService'; -import type { ConversationClientAccessor } from './conversationTypes'; -import type { VectorStoreFacade } from './VectorStoreFacade'; -import type { ConversationFacade } from './ConversationFacade'; -import { DocumentIngestionService } from '../../services/DocumentIngestionService'; -import { RuntimeConfigResolver } from '../../services/RuntimeConfigResolver'; -import type { AdminConfigService } from '../../services/AdminConfigService'; -import { - createToolScopeService, - type ToolScopeService, -} from '../../services/toolscope'; - -export interface OrchestratorInitDeps { - configLoader: ConfigLoader; - configResolution: ConfigResolutionService; - clientManager: ClientManager; - logger: LoggerService; - database?: DatabaseService; - adminConfig?: AdminConfigService; - vectorStoreFacade: VectorStoreFacade; - conversationFacade: ConversationFacade; -} - -export interface OrchestratorState { - securityConfig: SecurityConfig; - ingestionService: DocumentIngestionService; - mcpAuth: McpAuthService; - vectorStore: VectorStoreService; - docSync: DocumentSyncService; - documentsConfig: DocumentsConfig | null; - conversations: ConversationService; - mcpServers: MCPServerConfig[]; - toolScopeService: ToolScopeService; - backendToolExecutor: BackendToolExecutor | null; -} - -/** - * Runs all initialization steps in order and returns the assembled state. - * The orchestrator calls this from initialize() and stores the result. - * - * Steps execute sequentially because later steps depend on earlier ones - * (e.g. clientAndAuth must run before vectorStore/conversations). - */ -export async function initializeOrchestrator( - deps: OrchestratorInitDeps, -): Promise { - const { configLoader, configResolution, clientManager, logger } = deps; - - configLoader.validateRequiredConfig(); - - const securityConfig = configLoader.loadSecurityConfig(); - logger.info(`Augment security mode: ${securityConfig.mode}`); - - const llamaStackConfig = configLoader.loadLlamaStackConfig(); - configResolution.setLlamaStackConfig(llamaStackConfig); - logger.info( - `Augment configured with Llama Stack at ${llamaStackConfig.baseUrl}`, - ); - - const ingestionService = new DocumentIngestionService({ - logger, - skipTlsVerify: llamaStackConfig.skipTlsVerify, - }); - - // Runtime config resolver (DB admin overrides) - if (deps.adminConfig) { - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig: deps.adminConfig, - logger, - }); - configResolution.setResolver(resolver); - - try { - const initial = await resolver.resolve(); - configResolution.setLastResolvedModel(initial.model); - configResolution.setLastResolvedVerboseLogging( - initial.verboseStreamLogging, - ); - } catch (error) { - logger.debug( - 'Config resolution failed during model test, using last resolved model', - error as Error, - ); - configResolution.setLastResolvedModel(llamaStackConfig.model); - configResolution.setLastResolvedVerboseLogging( - llamaStackConfig.verboseStreamLogging ?? false, - ); - } - } - - // Client & server version probe - const client = clientManager.getClient(llamaStackConfig); - await clientManager.probeServerVersion(client); - - const mcpAuthConfigs = configLoader.loadMcpAuthConfigs(); - const mcpAuth = new McpAuthService( - securityConfig, - mcpAuthConfigs, - logger, - llamaStackConfig.skipTlsVerify, - ); - if (mcpAuthConfigs.size > 0) { - logger.info( - `Augment loaded ${mcpAuthConfigs.size} named MCP auth config(s): ${[ - ...mcpAuthConfigs.keys(), - ].join(', ')}`, - ); - } - - // Vector store & documents - const vectorStore = new VectorStoreService(client, llamaStackConfig, logger); - - const documentsConfig = configLoader.loadDocumentsConfig(); - if (documentsConfig && documentsConfig.sources.length > 0) { - logger.info( - `Augment loaded ${documentsConfig.sources.length} document source(s)`, - ); - } - - const docSync = new DocumentSyncService( - vectorStore, - ingestionService, - documentsConfig, - logger, - deps.database, - ); - await docSync.initialize(); - deps.vectorStoreFacade.setServices(vectorStore, docSync); - - // Conversations - const mcpServers = configLoader.loadMcpServerConfigs(); - if (mcpServers.length > 0) { - logger.info( - `Augment loaded ${mcpServers.length} MCP server(s): ${mcpServers - .map(s => s.name) - .join(', ')}`, - ); - } - - const clientAccessor: ConversationClientAccessor = { - getClient: () => clientManager.getExistingClient(), - getModel: () => - configResolution.getLastResolvedModel() ?? llamaStackConfig.model, - }; - const conversations = new ConversationService( - clientAccessor, - mcpAuth, - mcpServers, - logger, - deps.database, - ); - conversations.setSafetyContextProvider(() => { - const resolver = configResolution.getResolver(); - const cached = resolver?.getCachedConfig?.(); - const lsCfg = configResolution.getLlamaStackConfig(); - return { - guardrails: cached?.guardrails ?? undefined, - safetyIdentifier: cached?.safetyIdentifier ?? lsCfg?.safetyIdentifier, - reasoning: cached?.reasoning ?? lsCfg?.reasoning, - }; - }); - await conversations.initializeDatabase(); - deps.conversationFacade.setConversations(conversations); - - // Tool scoping - const toolScopeService = createToolScopeService(logger); - - // Backend tool executor (singleton for 'backend' execution mode) - const toolExecutionMode = configLoader.loadToolExecutionMode(); - logger.info( - `[Init] toolExecutionMode resolved to '${toolExecutionMode}' ` + - `(mcpServers=${mcpServers.length}: [${mcpServers.map(s => `${s.id}@${s.url}`).join(', ')}])`, - ); - let backendToolExecutor: BackendToolExecutor | null = null; - if (toolExecutionMode === 'backend') { - backendToolExecutor = new BackendToolExecutor( - mcpAuth, - logger, - llamaStackConfig.skipTlsVerify ?? false, - ); - logger.info( - `[Init] BackendToolExecutor singleton created (skipTlsVerify=${llamaStackConfig.skipTlsVerify ?? false})`, - ); - } else { - logger.info( - `[Init] BackendToolExecutor NOT created — toolExecutionMode='${toolExecutionMode}' (set augment.toolExecutionMode: backend to enable)`, - ); - } - - return { - securityConfig, - ingestionService, - mcpAuth, - vectorStore, - docSync, - documentsConfig, - conversations, - mcpServers, - toolScopeService, - backendToolExecutor, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ProviderModelOps.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ProviderModelOps.ts deleted file mode 100644 index e5da7ff96aa..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ProviderModelOps.ts +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ResponsesApiCoordinator } from './ResponsesApiCoordinator'; -import type { PromptCapabilities } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { - DEFAULT_EMBEDDING_DIMENSION, - DEFAULT_EMBEDDING_MODEL, - DEFAULT_CHUNK_SIZE, - DEFAULT_MODEL, -} from '../../constants'; -import { buildMetaPrompt } from './promptGeneration'; - -export interface ModelsCache { - data: Array<{ id: string; owned_by?: string; model_type?: string }>; - expiresAt: number; -} - -export const MODELS_CACHE_TTL_MS = 60_000; - -export async function listModels( - orchestrator: ResponsesApiCoordinator, - cache: { current: ModelsCache | null }, -): Promise> { - if (cache.current && Date.now() < cache.current.expiresAt) { - return cache.current.data; - } - - const client = orchestrator.getClientManager().getExistingClient(); - const response = await client.request<{ - data: Array<{ - id: string; - object?: string; - owned_by?: string; - model_type?: string; - custom_metadata?: Record; - }>; - }>('/v1/models', { method: 'GET' }); - - const models = (response.data || []) - .filter(m => typeof m.id === 'string' && m.id.length > 0) - .map(m => { - const modelType = - m.model_type ?? (m.custom_metadata?.model_type as string | undefined); - return { - id: m.id, - ...(m.owned_by ? { owned_by: m.owned_by } : {}), - ...(modelType ? { model_type: modelType } : {}), - }; - }); - - cache.current = { - data: models, - expiresAt: Date.now() + MODELS_CACHE_TTL_MS, - }; - - return models; -} - -export async function testModel( - orchestrator: ResponsesApiCoordinator, - modelOverride?: string, -): Promise<{ - connected: boolean; - modelFound: boolean; - canGenerate: boolean; - error?: string; -}> { - let models: Array<{ id: string }>; - try { - const client = orchestrator.getClientManager().getExistingClient(); - const response = await client.request<{ - data: Array<{ id: string }>; - }>('/v1/models', { method: 'GET' }); - models = response.data || []; - } catch (err) { - return { - connected: false, - modelFound: false, - canGenerate: false, - error: err instanceof Error ? err.message : 'Connection failed', - }; - } - - const resolver = orchestrator.getResolver(); - const config = resolver ? await resolver.resolve() : null; - const targetModel = modelOverride || config?.model; - - if (!targetModel) { - return { - connected: true, - modelFound: false, - canGenerate: false, - error: 'No model configured', - }; - } - - const modelFound = models.some(m => m.id === targetModel); - if (!modelFound) { - return { - connected: true, - modelFound: false, - canGenerate: false, - error: `Model "${targetModel}" not found on server`, - }; - } - - const inferenceResult = await runMinimalInference(orchestrator, targetModel); - return { - connected: true, - modelFound: true, - canGenerate: inferenceResult.canGenerate, - ...(inferenceResult.error ? { error: inferenceResult.error } : {}), - }; -} - -async function runMinimalInference( - orchestrator: ResponsesApiCoordinator, - model: string, -): Promise<{ canGenerate: boolean; error?: string }> { - const client = orchestrator.getClientManager().getExistingClient(); - try { - const response = await client.request<{ - output: Array<{ - type: string; - content?: Array<{ type: string; text?: string }>; - }>; - usage?: { output_tokens?: number }; - }>('/v1/responses', { - method: 'POST', - body: JSON.stringify({ input: 'Hi', model, store: false }), - }); - - const outputTokens = response.usage?.output_tokens ?? 0; - const hasText = response.output?.some( - item => - item.type === 'message' && - item.content?.some(b => b.type === 'output_text' && b.text), - ); - - return { - canGenerate: hasText || outputTokens > 0, - ...(!hasText && outputTokens === 0 - ? { error: 'Model returned 0 output tokens' } - : {}), - }; - } catch (err) { - return { - canGenerate: false, - error: err instanceof Error ? err.message : 'Inference failed', - }; - } -} - -export async function generateSystemPrompt( - orchestrator: ResponsesApiCoordinator, - description: string, - modelOverride?: string, - capabilities?: PromptCapabilities, - _logger?: LoggerService, -): Promise { - const resolver = orchestrator.getResolver(); - const config = resolver ? await resolver.resolve() : null; - - const { instructions, input } = buildMetaPrompt( - description, - config ?? { - model: 'unknown', - baseUrl: '', - systemPrompt: '', - vectorStoreIds: [], - vectorStoreName: 'default-vector-store', - embeddingModel: DEFAULT_EMBEDDING_MODEL, - embeddingDimension: DEFAULT_EMBEDDING_DIMENSION, - chunkingStrategy: 'auto', - maxChunkSizeTokens: DEFAULT_CHUNK_SIZE, - chunkOverlapTokens: 50, - enableWebSearch: false, - enableCodeInterpreter: false, - skipTlsVerify: false, - zdrMode: false, - verboseStreamLogging: false, - }, - capabilities, - ); - - const client = orchestrator.getClientManager().getExistingClient(); - const model = modelOverride || config?.model || DEFAULT_MODEL; - - const response = await client.request<{ - output: Array<{ - type: string; - content?: Array<{ type: string; text?: string }>; - }>; - }>('/v1/responses', { - method: 'POST', - body: JSON.stringify({ - input, - instructions, - model, - store: false, - }), - }); - - for (const item of response.output) { - if (item.type === 'message' && item.content) { - for (const block of item.content) { - if (block.type === 'output_text' && block.text) { - return block.text.trim(); - } - } - } - } - - throw new Error('LLM returned no text in the response'); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponseChainWalker.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponseChainWalker.test.ts deleted file mode 100644 index 249270f1892..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponseChainWalker.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createMockLogger } from '../../test-utils/mocks'; -import { - walkResponseChain, - fetchResponsesFromApi, - type ResponseListApiResult, -} from './ResponseChainWalker'; -import type { ConversationDetails } from './conversationTypes'; -import type { ConversationClientAccessor } from './conversationTypes'; - -describe('walkResponseChain', () => { - const logger = createMockLogger(); - - it('returns empty array when getConversation returns null', async () => { - const getConversation = jest.fn().mockResolvedValue(null); - const result = await walkResponseChain('resp-1', getConversation, logger); - expect(result).toEqual([]); - expect(getConversation).toHaveBeenCalledWith('resp-1'); - }); - - it('returns single user+assistant pair for one response', async () => { - const response: ConversationDetails = { - id: 'resp-1', - model: 'test', - status: 'completed', - createdAt: new Date(), - input: [{ type: 'message', role: 'user', content: 'Hello' }], - output: [ - { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'Hi there' }], - }, - ], - }; - const getConversation = jest.fn().mockResolvedValue(response); - const result = await walkResponseChain('resp-1', getConversation, logger); - expect(result).toEqual([ - { role: 'user', text: 'Hello' }, - { role: 'assistant', text: 'Hi there' }, - ]); - }); - - it('walks chain via previousResponseId', async () => { - const resp1: ConversationDetails = { - id: 'resp-1', - model: 'test', - status: 'completed', - createdAt: new Date(), - input: [{ type: 'input_text', text: 'Second message' }], - output: [ - { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'Second reply' }], - }, - ], - previousResponseId: 'resp-0', - }; - const resp0: ConversationDetails = { - id: 'resp-0', - model: 'test', - status: 'completed', - createdAt: new Date(), - input: [{ type: 'input_text', text: 'First message' }], - output: [ - { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'First reply' }], - }, - ], - }; - const getConversation = jest - .fn() - .mockResolvedValueOnce(resp1) - .mockResolvedValueOnce(resp0) - .mockResolvedValue(null); - const result = await walkResponseChain('resp-1', getConversation, logger); - expect(result).toEqual([ - { role: 'user', text: 'First message' }, - { role: 'assistant', text: 'First reply' }, - { role: 'user', text: 'Second message' }, - { role: 'assistant', text: 'Second reply' }, - ]); - }); - - it('stops on cycle and logs warning', async () => { - const resp: ConversationDetails = { - id: 'resp-1', - model: 'test', - status: 'completed', - createdAt: new Date(), - input: [{ type: 'input_text', text: 'Hi' }], - output: [ - { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'Hello' }], - }, - ], - previousResponseId: 'resp-1', - }; - const getConversation = jest.fn().mockResolvedValue(resp); - const result = await walkResponseChain('resp-1', getConversation, logger); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('Cycle detected'), - ); - expect(result).toHaveLength(2); - }); - - it('stops on fetch error and logs debug', async () => { - const getConversation = jest - .fn() - .mockResolvedValueOnce({ - id: 'resp-1', - model: 'test', - status: 'completed', - createdAt: new Date(), - input: [], - output: [ - { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'Ok' }], - }, - ], - previousResponseId: 'resp-0', - }) - .mockRejectedValue(new Error('Network error')); - const result = await walkResponseChain('resp-1', getConversation, logger); - expect(logger.debug).toHaveBeenCalledWith( - expect.stringContaining('fetch error'), - ); - expect(result).toHaveLength(1); - }); - - it('skips empty user/assistant text', async () => { - const response: ConversationDetails = { - id: 'resp-1', - model: 'test', - status: 'completed', - createdAt: new Date(), - input: [], - output: [ - { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: ' ' }], - }, - ], - }; - const getConversation = jest.fn().mockResolvedValue(response); - const result = await walkResponseChain('resp-1', getConversation, logger); - expect(result).toEqual([]); - }); -}); - -describe('fetchResponsesFromApi', () => { - const logger = createMockLogger(); - - it('fetches responses with correct params', async () => { - const mockResult: ResponseListApiResult = { - data: [ - { - id: 'resp-1', - model: 'test', - status: 'completed', - created_at: 1234567890, - input: [], - output: [], - }, - ], - has_more: false, - }; - const requestMock = jest.fn().mockResolvedValue(mockResult); - const clientAccessor: ConversationClientAccessor = { - getClient: () => - ({ - request: requestMock, - }) as unknown as ConversationClientAccessor['getClient'] extends () => infer C - ? C - : never, - getModel: () => 'test-model', - }; - - const result = await fetchResponsesFromApi( - clientAccessor, - 10, - 'desc', - undefined, - logger, - ); - - expect(result).toEqual(mockResult); - expect(requestMock).toHaveBeenCalledWith( - '/v1/responses?limit=10&order=desc', - { method: 'GET' }, - ); - }); - - it('includes after param when provided', async () => { - const mockResult: ResponseListApiResult = { - data: [], - has_more: false, - }; - const requestMock = jest.fn().mockResolvedValue(mockResult); - const clientAccessor: ConversationClientAccessor = { - getClient: () => - ({ - request: requestMock, - }) as unknown as ConversationClientAccessor['getClient'] extends () => infer C - ? C - : never, - getModel: () => 'test-model', - }; - - await fetchResponsesFromApi( - clientAccessor, - 20, - 'asc', - 'last-id-123', - logger, - ); - - expect(requestMock).toHaveBeenCalledWith( - '/v1/responses?limit=20&order=asc&after=last-id-123', - { method: 'GET' }, - ); - }); - - it('throws on request error', async () => { - const requestMock = jest.fn().mockRejectedValue(new Error('Network error')); - const clientAccessor: ConversationClientAccessor = { - getClient: () => - ({ - request: requestMock, - }) as unknown as ConversationClientAccessor['getClient'] extends () => infer C - ? C - : never, - getModel: () => 'test-model', - }; - - await expect( - fetchResponsesFromApi(clientAccessor, 10, 'desc', undefined, logger), - ).rejects.toThrow('Network error'); - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining('Failed to fetch conversations'), - ); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponseChainWalker.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponseChainWalker.ts deleted file mode 100644 index 039b1a055ee..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponseChainWalker.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export * from '../responses-api/conversations/ResponseChainWalker'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiClient.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiClient.ts deleted file mode 100644 index 51b775cfc55..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiClient.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../../services/ResponsesApiClient' instead */ -export { - ResponsesApiClient, - ResponsesApiError, - LlamaStackApiError, - type ResponsesApiRequestOptions, -} from '../../services/ResponsesApiClient'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiCoordinator.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiCoordinator.test.ts deleted file mode 100644 index f5f1a4060dc..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiCoordinator.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - LoggerService, - RootConfigService, -} from '@backstage/backend-plugin-api'; -import { ResponsesApiCoordinator } from './ResponsesApiCoordinator'; -import { createMockLogger } from '../../test-utils/mocks'; - -jest.mock('./ConfigLoader'); - -function createMockConfig(): RootConfigService { - return { - getOptionalConfig: jest.fn().mockReturnValue(undefined), - getConfig: jest.fn().mockReturnValue({ - getOptionalString: jest.fn().mockReturnValue(undefined), - getOptional: jest.fn().mockReturnValue(undefined), - getOptionalConfig: jest.fn().mockReturnValue(undefined), - getOptionalConfigArray: jest.fn().mockReturnValue(undefined), - getOptionalStringArray: jest.fn().mockReturnValue(undefined), - }), - getOptionalString: jest.fn().mockReturnValue(undefined), - getOptional: jest.fn().mockReturnValue(undefined), - getOptionalConfigArray: jest.fn().mockReturnValue(undefined), - getOptionalStringArray: jest.fn().mockReturnValue(undefined), - getString: jest.fn().mockReturnValue(''), - has: jest.fn().mockReturnValue(false), - keys: jest.fn().mockReturnValue([]), - } as unknown as RootConfigService; -} - -describe('ResponsesApiCoordinator', () => { - let mockLogger: ReturnType; - let mockConfig: RootConfigService; - - beforeEach(() => { - jest.clearAllMocks(); - mockLogger = createMockLogger(); - mockConfig = createMockConfig(); - }); - - describe('constructor', () => { - it('creates an instance without errors', () => { - const orchestrator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - expect(orchestrator).toBeDefined(); - }); - }); - - describe('pre-initialization state', () => { - it('getSecurityConfig returns default plugin-only mode', () => { - const orchestrator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - expect(orchestrator.getSecurityConfig()).toEqual({ mode: 'plugin-only' }); - }); - - it('isVerboseStreamLoggingEnabled returns false', () => { - const orchestrator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - expect(orchestrator.isVerboseStreamLoggingEnabled()).toBe(false); - }); - - it('getDefaultVectorStoreId returns undefined via facade', () => { - const orchestrator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - expect( - orchestrator.getVectorStoreFacade().getDefaultVectorStoreId(), - ).toBeUndefined(); - }); - - it('getSyncSchedule returns undefined via facade', () => { - const orchestrator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - expect( - orchestrator.getVectorStoreFacade().getSyncSchedule(), - ).toBeUndefined(); - }); - - it('conversations getter returns null', () => { - const orchestrator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - expect( - (orchestrator as unknown as { conversations: unknown }).conversations, - ).toBeNull(); - }); - }); - - describe('ensureInitialized guard (via facades)', () => { - it('listConversations throws when not initialized', async () => { - const orchestrator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - await expect( - orchestrator.getConversationFacade().listConversations(), - ).rejects.toThrow('not initialized'); - }); - - it('getConversation throws when not initialized', async () => { - const orchestrator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - await expect( - orchestrator.getConversationFacade().getConversation('resp_1'), - ).rejects.toThrow('not initialized'); - }); - - it('syncDocuments throws when not initialized', async () => { - const orchestrator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - await expect( - orchestrator.getVectorStoreFacade().syncDocuments(), - ).rejects.toThrow('not initialized'); - }); - }); - - describe('getStatus', () => { - it('returns not-configured status when Llama Stack is not configured', async () => { - const orchestrator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - - const status = await orchestrator.getStatus(); - expect(status.ready).toBe(false); - expect(status.provider.connected).toBe(false); - expect(status.provider.model).toBe('not configured'); - expect(status.configurationErrors).toEqual( - expect.arrayContaining([expect.stringContaining('not configured')]), - ); - expect(status.securityMode).toBe('plugin-only'); - expect(status.timestamp).toBeDefined(); - }); - }); - - describe('initialize idempotency', () => { - it('skips re-initialization if already initialized', async () => { - const { ConfigLoader } = require('./ConfigLoader'); - ConfigLoader.mockImplementation(() => ({ - validateRequiredConfig: jest.fn().mockImplementation(() => { - throw new Error('Config error on first call'); - }), - loadSecurityConfig: jest.fn(), - loadSystemPrompt: jest.fn().mockReturnValue('test prompt'), - loadPostToolInstructions: jest.fn().mockReturnValue(undefined), - })); - - const orchestrator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - - // First call fails because validateRequiredConfig throws - await expect(orchestrator.initialize()).rejects.toThrow( - 'Config error on first call', - ); - - // The orchestrator is not marked as initialized, so calling again - // should attempt initialization again (not silently return) - await expect(orchestrator.initialize()).rejects.toThrow( - 'Config error on first call', - ); - }); - }); - - describe('invalidateRuntimeConfig', () => { - it('calls invalidateToolCache on orchestrator when initialized', () => { - const coordinator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - - const mockInvalidateToolCache = jest.fn(); - const mockInvalidateGraphManager = jest.fn(); - - (coordinator as any).orchestrator = { - invalidateToolCache: mockInvalidateToolCache, - }; - (coordinator as any).agentGraphManager = { - invalidate: mockInvalidateGraphManager, - }; - - coordinator.invalidateRuntimeConfig(); - - expect(mockInvalidateToolCache).toHaveBeenCalledTimes(1); - expect(mockInvalidateGraphManager).toHaveBeenCalledTimes(1); - }); - - it('does not throw when adkOrchestrator is null', () => { - const coordinator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - - expect(() => coordinator.invalidateRuntimeConfig()).not.toThrow(); - }); - }); - - describe('chat delegation', () => { - it('throws when AdkOrchestrator is not initialized', async () => { - const coordinator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - - await expect( - coordinator.chat({ messages: [{ role: 'user', content: 'hi' }] }), - ).rejects.toThrow('not initialized'); - }); - - it('throws on chatStream when AdkOrchestrator is not initialized', async () => { - const coordinator = new ResponsesApiCoordinator({ - logger: mockLogger as unknown as LoggerService, - config: mockConfig, - }); - - await expect( - coordinator.chatStream( - { messages: [{ role: 'user', content: 'hi' }] }, - jest.fn(), - ), - ).rejects.toThrow('not initialized'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiCoordinator.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiCoordinator.ts deleted file mode 100644 index f944172cb33..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiCoordinator.ts +++ /dev/null @@ -1,355 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - LoggerService, - RootConfigService, - DatabaseService, -} from '@backstage/backend-plugin-api'; -import { - ChatRequest, - ChatResponse, - AugmentStatus, - MCPServerConfig, - SecurityConfig, -} from '../../types'; - -import { ConfigLoader } from './ConfigLoader'; -import { ClientManager } from './ClientManager'; -import { ConfigResolutionService } from './ConfigResolutionService'; -import { coordinatorChat, coordinatorChatStream } from './CoordinatorChatOps'; -import type { ChatOpsContext } from './CoordinatorChatOps'; -import { - getCoordinatorStatus, - postInitialize as doPostInitialize, - initializeBackendApprovalHandler as doInitApprovalHandler, -} from './CoordinatorLifecycleOps'; -import { ResponsesApiService } from './ResponsesApiService'; -import { ChatDepsBuilder } from './ChatDepsBuilder'; -import { StatusService } from './StatusService'; -import { McpAuthService } from './McpAuthService'; -import type { ConversationService } from './ConversationService'; -import { - VectorStoreFacade, - VectorStoreFacadeContext, -} from './VectorStoreFacade'; -import { - ConversationFacade, - ConversationFacadeContext, -} from './ConversationFacade'; -import { initializeOrchestrator } from './OrchestratorInitializer'; -import { OpenAIAgentsOrchestrator } from './openai-agents-adapters/OpenAIAgentsOrchestrator'; -import { AgentGraphManager } from './AgentGraphManager'; -import { BackendApprovalStore } from './BackendApprovalStore'; -import { BackendToolExecutor } from './BackendToolExecutor'; -import type { RuntimeConfigResolver } from '../../services/RuntimeConfigResolver'; -import type { AdminConfigService } from '../../services/AdminConfigService'; -import type { ToolScopeService } from '../../services/toolscope'; -import { toErrorMessage } from '../../services/utils'; -import { resolveCapabilities } from './ServerCapabilities'; - -export class ResponsesApiCoordinator { - private readonly logger: LoggerService; - private readonly database?: DatabaseService; - private readonly adminConfig?: AdminConfigService; - - private configLoader: ConfigLoader; - private chatService: ResponsesApiService; - private statusService: StatusService; - private clientManager: ClientManager; - private readonly configResolution: ConfigResolutionService; - private mcpAuth: McpAuthService | null = null; - private conversations: ConversationService | null = null; - - private readonly vectorStoreFacade: VectorStoreFacade; - private readonly conversationFacade: ConversationFacade; - - private mcpServers: MCPServerConfig[] = []; - private securityConfig: SecurityConfig = { mode: 'plugin-only' }; - - private readonly chatDepsBuilder: ChatDepsBuilder; - private orchestrator: OpenAIAgentsOrchestrator | null = null; - private agentGraphManager: AgentGraphManager | null = null; - private backendToolExecutor: BackendToolExecutor | null = null; - private readonly backendApprovalStore = new BackendApprovalStore(); - private toolScopeService: ToolScopeService | null = null; - - private initialized = false; - private vectorStoreReady = false; - - constructor(options: { - logger: LoggerService; - config: RootConfigService; - database?: DatabaseService; - adminConfig?: AdminConfigService; - }) { - this.logger = options.logger; - this.database = options.database; - this.adminConfig = options.adminConfig; - - this.configLoader = new ConfigLoader(options.config, options.logger); - this.clientManager = new ClientManager(options.logger); - this.chatService = new ResponsesApiService(options.logger); - this.statusService = new StatusService(); - - this.configResolution = new ConfigResolutionService(this.clientManager); - this.configResolution.setSystemPrompt(this.configLoader.loadSystemPrompt()); - this.configResolution.setPostToolInstructions( - this.configLoader.loadPostToolInstructions(), - ); - - this.chatDepsBuilder = new ChatDepsBuilder({ - logger: this.logger, - configResolution: this.configResolution, - clientManager: this.clientManager, - getMcpAuth: () => this.mcpAuth, - getConversations: () => this.conversations, - getMcpServers: () => this.mcpServers, - ensureInitialized: () => this.ensureInitialized(), - getBackendToolExecutor: () => this.backendToolExecutor, - }); - - const vectorStoreContext: VectorStoreFacadeContext = { - ensureInitialized: () => this.ensureInitialized(), - configResolution: this.configResolution, - getVectorStoreReady: () => this.vectorStoreReady, - setVectorStoreReady: ready => { - this.vectorStoreReady = ready; - }, - getInitialized: () => this.initialized, - }; - this.vectorStoreFacade = new VectorStoreFacade({ - vectorStore: null, - docSync: null, - logger: options.logger, - context: vectorStoreContext, - }); - - const conversationContext: ConversationFacadeContext = { - ensureInitialized: () => this.ensureInitialized(), - }; - this.conversationFacade = new ConversationFacade({ - conversations: null, - context: conversationContext, - logger: options.logger, - }); - } - - getClientManager(): ClientManager { - return this.clientManager; - } - getResolver(): RuntimeConfigResolver | null { - return this.configResolution.getResolver(); - } - getVectorStoreFacade(): VectorStoreFacade { - return this.vectorStoreFacade; - } - getConversationFacade(): ConversationFacade { - return this.conversationFacade; - } - getConfiguredModel(): string { - return ( - this.configResolution.getLastResolvedModel() ?? - this.configResolution.getLlamaStackConfig()?.model ?? - 'default' - ); - } - - async initialize(): Promise { - if (this.initialized) { - return; - } - - try { - const state = await initializeOrchestrator({ - configLoader: this.configLoader, - configResolution: this.configResolution, - clientManager: this.clientManager, - logger: this.logger, - database: this.database, - adminConfig: this.adminConfig, - vectorStoreFacade: this.vectorStoreFacade, - conversationFacade: this.conversationFacade, - }); - - const capOverrides = this.configLoader.loadServerCapabilities(); - this.chatService.setCapabilityProvider(() => - resolveCapabilities( - this.clientManager.serverVersion, - capOverrides, - this.logger, - ), - ); - - this.securityConfig = state.securityConfig; - this.mcpAuth = state.mcpAuth; - this.conversations = state.conversations; - this.mcpServers = state.mcpServers; - this.backendToolExecutor = state.backendToolExecutor; - this.toolScopeService = state.toolScopeService; - - this.agentGraphManager = new AgentGraphManager( - this.configResolution, - this.configLoader, - this.logger, - ); - this.orchestrator = new OpenAIAgentsOrchestrator({ - chatService: this.chatService, - logger: this.logger, - backendApprovalStore: this.backendApprovalStore, - toolScopeService: this.toolScopeService, - }); - this.initializeBackendApprovalHandler(); - - this.initialized = true; - this.logger.info( - 'Augment service initialization complete ' + - '(vector store and document sync deferred to first use)', - ); - } catch (error) { - this.logger.error( - `Failed to initialize Augment: ${toErrorMessage(error)}`, - ); - throw error; - } - } - - private ensureInitialized(): void { - if (!this.initialized) - throw new Error('ResponsesApiCoordinator not initialized'); - } - - async postInitialize(): Promise { - return doPostInitialize({ - configResolution: this.configResolution, - mcpServers: this.mcpServers, - orchestrator: this.orchestrator, - chatDepsBuilder: this.chatDepsBuilder, - logger: this.logger, - getStatus: () => this.getStatus(), - }); - } - - getSecurityConfig(): SecurityConfig { - return this.securityConfig; - } - - invalidateRuntimeConfig(): void { - this.configResolution.invalidateCache(); - this.agentGraphManager?.invalidate(); - this.orchestrator?.invalidateToolCache(); - - if (this.orchestrator && this.mcpServers.length > 0) { - this.chatDepsBuilder - .buildChatDeps() - .then(deps => this.orchestrator!.warmUpToolCache(deps)) - .catch(err => - this.logger.warn( - `Background tool re-warm after config invalidation failed: ${toErrorMessage(err)}`, - ), - ); - } - } - - async chat(request: ChatRequest): Promise { - return coordinatorChat(this.buildChatOpsContext(), request); - } - - async chatStream( - request: ChatRequest, - onEvent: (event: string) => void, - signal?: AbortSignal, - ): Promise { - return coordinatorChatStream( - this.buildChatOpsContext(), - request, - onEvent, - signal, - ); - } - - private buildChatOpsContext(): ChatOpsContext { - return { - logger: this.logger, - chatService: this.chatService, - chatDepsBuilder: this.chatDepsBuilder, - getOrchestrator: () => this.getOrchestrator(), - requireAgentGraphManager: () => this.requireAgentGraphManager(), - ensureInitialized: () => this.ensureInitialized(), - }; - } - - private getOrchestrator(): OpenAIAgentsOrchestrator { - if (!this.orchestrator) - throw new Error('OpenAIAgentsOrchestrator not initialized'); - return this.orchestrator; - } - - getAgentGraphManager(): AgentGraphManager | null { - return this.agentGraphManager; - } - - private requireAgentGraphManager(): AgentGraphManager { - if (!this.agentGraphManager) - throw new Error('AgentGraphManager not initialized'); - return this.agentGraphManager; - } - - private initializeBackendApprovalHandler(): void { - if (this.configLoader.loadToolExecutionMode() !== 'backend') return; - if (!this.backendToolExecutor) return; - doInitApprovalHandler({ - conversationFacade: this.conversationFacade, - backendApprovalStore: this.backendApprovalStore, - backendToolExecutor: this.backendToolExecutor, - chatService: this.chatService, - clientManager: this.clientManager, - configResolution: this.configResolution, - getConversations: () => this.conversations, - getAgentGraphManager: () => this.agentGraphManager, - getMcpServers: () => this.mcpServers ?? [], - logger: this.logger, - }); - } - - isVerboseStreamLoggingEnabled(): boolean { - return this.configResolution.isVerboseStreamLoggingEnabled(); - } - - async getStatus(): Promise { - return getCoordinatorStatus({ - configResolution: this.configResolution, - clientManager: this.clientManager, - configLoader: this.configLoader, - mcpAuth: this.mcpAuth, - mcpServers: this.mcpServers, - securityConfig: this.securityConfig, - vectorStoreReady: this.vectorStoreReady, - statusService: this.statusService, - agentGraphManager: this.agentGraphManager, - logger: this.logger, - ensureInitialized: () => this.ensureInitialized(), - }); - } - - async shutdown(): Promise { - if (this.backendToolExecutor) { - this.logger.info( - '[ResponsesApiCoordinator] Shutting down — closing MCP clients', - ); - await this.backendToolExecutor.closeAllClients(); - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiProvider.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiProvider.test.ts deleted file mode 100644 index 62b9c87facb..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiProvider.test.ts +++ /dev/null @@ -1,993 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createMockLogger } from '../../test-utils'; -import type { NormalizedStreamEvent } from '../types'; - -const mockVectorStoreFacade = { - listDocuments: jest.fn().mockResolvedValue([]), - listVectorStores: jest.fn().mockResolvedValue([]), - getDefaultVectorStoreId: jest.fn().mockReturnValue('vs-1'), - getActiveVectorStoreIds: jest.fn().mockResolvedValue(['vs-1']), - syncDocuments: jest.fn().mockResolvedValue({ - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [] as string[], - }), - uploadDocument: jest.fn().mockResolvedValue({ - fileId: 'f1', - fileName: 'f1.txt', - status: 'completed', - }), - deleteDocument: jest.fn().mockResolvedValue({ success: true }), - searchVectorStore: jest.fn().mockResolvedValue({ - query: '', - chunks: [], - vectorStoreId: 'vs-1', - totalResults: 0, - }), - getVectorStoreConfig: jest.fn().mockResolvedValue(null), - createVectorStoreWithConfig: jest.fn().mockResolvedValue({ - vectorStoreId: 'vs-1', - vectorStoreName: 'store', - created: true, - embeddingModel: 'model', - embeddingDimension: 384, - }), - getVectorStoreStatus: jest.fn().mockResolvedValue({ - exists: true, - vectorStoreId: 'vs-1', - ready: true, - }), - addVectorStoreId: jest.fn(), - removeVectorStoreId: jest.fn(), - deleteVectorStore: jest.fn().mockResolvedValue(undefined), - getSyncSchedule: jest.fn().mockReturnValue(undefined), -}; - -const mockConversationFacade = { - createConversation: jest.fn().mockResolvedValue('conv-1'), - listConversations: jest - .fn() - .mockResolvedValue({ conversations: [], nextCursor: undefined }), - getConversation: jest.fn().mockResolvedValue(null), - getConversationInputs: jest.fn().mockResolvedValue({ items: [] }), - walkResponseChain: jest.fn().mockResolvedValue([]), - getProcessedMessages: jest.fn().mockResolvedValue([]), - deleteConversation: jest.fn().mockResolvedValue(undefined), - continueAfterApproval: jest - .fn() - .mockResolvedValue({ responseId: 'r1', output: [] }), -}; - -const mockOrchestrator = { - initialize: jest.fn().mockResolvedValue(undefined), - postInitialize: jest.fn().mockResolvedValue(undefined), - invalidateRuntimeConfig: jest.fn(), - getResolver: jest.fn().mockReturnValue(null), - getClientManager: jest.fn().mockReturnValue({ - getExistingClient: jest.fn().mockReturnValue({ - request: jest.fn().mockResolvedValue({ data: [], output: [] }), - }), - }), - getVectorStoreFacade: jest.fn().mockReturnValue(mockVectorStoreFacade), - getConversationFacade: jest.fn().mockReturnValue(mockConversationFacade), - getStatus: jest.fn().mockResolvedValue({ ready: true }), - chat: jest.fn().mockResolvedValue({ responseId: 'r1', output: [] }), - chatStream: jest.fn().mockResolvedValue(undefined), -}; - -const mockSafetyService = { - initialize: jest.fn().mockResolvedValue(undefined), - isEnabled: jest.fn().mockReturnValue(true), - getAvailableShields: jest.fn().mockReturnValue([{ identifier: 'shield1' }]), - checkInput: jest.fn().mockResolvedValue(null), - checkOutput: jest.fn().mockResolvedValue(null), - applyDynamicOverrides: jest.fn(), -}; - -const mockEvaluationService = { - initialize: jest.fn().mockResolvedValue(undefined), - isEnabled: jest.fn().mockReturnValue(true), - getAvailableScoringFunctions: jest - .fn() - .mockReturnValue([{ identifier: 'score1' }]), - scoreResponse: jest.fn().mockResolvedValue(null), - applyDynamicOverrides: jest.fn(), -}; - -jest.mock('./ResponsesApiCoordinator', () => ({ - ResponsesApiCoordinator: jest.fn().mockImplementation(() => mockOrchestrator), -})); - -jest.mock('./SafetyService', () => ({ - SafetyService: jest.fn().mockImplementation(() => mockSafetyService), -})); - -jest.mock('./EvaluationService', () => ({ - EvaluationService: jest.fn().mockImplementation(() => mockEvaluationService), -})); - -import { ResponsesApiProvider } from './ResponsesApiProvider'; - -function createProvider( - overrides: { - logger?: ReturnType; - config?: unknown; - database?: unknown; - adminConfig?: unknown; - } = {}, -) { - const logger = overrides.logger ?? createMockLogger(); - return new ResponsesApiProvider({ - logger, - config: (overrides.config ?? - {}) as import('@backstage/backend-plugin-api').RootConfigService, - database: overrides.database as - | import('@backstage/backend-plugin-api').DatabaseService - | undefined, - adminConfig: overrides.adminConfig as - | import('../../services/AdminConfigService').AdminConfigService - | undefined, - }); -} - -beforeEach(() => { - jest.clearAllMocks(); - mockOrchestrator.getResolver.mockReturnValue(null); - mockOrchestrator.getClientManager.mockReturnValue({ - getExistingClient: jest.fn().mockReturnValue({ - request: jest.fn().mockResolvedValue({ data: [], output: [] }), - }), - }); -}); - -describe('ResponsesApiProvider', () => { - describe('constructor', () => { - it('creates orchestrator, safety, and evaluation services', () => { - const { ResponsesApiCoordinator } = require('./ResponsesApiCoordinator'); - const { SafetyService } = require('./SafetyService'); - const { EvaluationService } = require('./EvaluationService'); - - const logger = createMockLogger(); - createProvider({ logger }); - - expect(ResponsesApiCoordinator).toHaveBeenCalledWith( - expect.objectContaining({ logger }), - ); - expect(SafetyService).toHaveBeenCalledWith( - expect.objectContaining({ - logger, - getClient: expect.any(Function), - }), - ); - expect(EvaluationService).toHaveBeenCalledWith( - expect.objectContaining({ - logger, - getClient: expect.any(Function), - }), - ); - }); - }); - - describe('initialize', () => { - it('calls initialize on all 3 services', async () => { - const provider = createProvider(); - await provider.initialize(); - - expect(mockOrchestrator.initialize).toHaveBeenCalled(); - expect(mockSafetyService.initialize).toHaveBeenCalled(); - expect(mockEvaluationService.initialize).toHaveBeenCalled(); - }); - }); - - describe('postInitialize', () => { - it('delegates to orchestrator', async () => { - const provider = createProvider(); - await provider.postInitialize(); - - expect(mockOrchestrator.postInitialize).toHaveBeenCalled(); - }); - }); - - describe('invalidateRuntimeConfig', () => { - it('delegates to orchestrator', () => { - const provider = createProvider(); - provider.invalidateRuntimeConfig(); - - expect(mockOrchestrator.invalidateRuntimeConfig).toHaveBeenCalled(); - }); - }); - - describe('getEffectiveConfig', () => { - it('returns empty object when resolver is null', async () => { - mockOrchestrator.getResolver.mockReturnValue(null); - const provider = createProvider(); - - const config = await provider.getEffectiveConfig(); - - expect(config).toEqual({}); - }); - - it('returns config when resolver is set', async () => { - const resolvedConfig = { model: 'test-model', baseUrl: 'https://test' }; - mockOrchestrator.getResolver.mockReturnValue({ - resolve: jest.fn().mockResolvedValue(resolvedConfig), - }); - const provider = createProvider(); - - const config = await provider.getEffectiveConfig(); - - expect(config).toEqual(resolvedConfig); - }); - }); - - describe('getStatus', () => { - it('delegates to orchestrator', async () => { - const status = { ready: true, provider: { id: 'llamastack' } }; - mockOrchestrator.getStatus.mockResolvedValue(status); - const provider = createProvider(); - - const result = await provider.getStatus(); - - expect(mockOrchestrator.getStatus).toHaveBeenCalled(); - expect(result).toEqual(status); - }); - }); - - describe('chat', () => { - it('delegates to orchestrator', async () => { - const request = { messages: [], model: 'test' }; - const response = { responseId: 'r1', output: [] }; - mockOrchestrator.chat.mockResolvedValue(response); - const provider = createProvider(); - - const result = await provider.chat(request); - - expect(mockOrchestrator.chat).toHaveBeenCalledWith(request); - expect(result).toEqual(response); - }); - }); - - describe('chatStream', () => { - it('delegates to orchestrator', async () => { - const request = { messages: [], model: 'test' }; - const onEvent = jest.fn(); - const provider = createProvider(); - - await provider.chatStream(request, onEvent); - - expect(mockOrchestrator.chatStream).toHaveBeenCalledWith( - request, - expect.any(Function), - undefined, - ); - }); - - it('normalizes events and forwards to onEvent', async () => { - const request = { messages: [], model: 'test' }; - const onEvent = jest.fn(); - const provider = createProvider(); - - mockOrchestrator.chatStream.mockImplementation( - async ( - _req: unknown, - rawCallback: (raw: string) => void, - _signal?: AbortSignal, - ) => { - rawCallback( - JSON.stringify({ - type: 'response.output_text.delta', - delta: 'hello', - }), - ); - rawCallback( - JSON.stringify({ type: 'response.created', response_id: 'resp-1' }), - ); - }, - ); - - await provider.chatStream(request, onEvent); - - expect(onEvent).toHaveBeenCalled(); - const textDeltaCall = onEvent.mock.calls.find( - (c: [NormalizedStreamEvent]) => c[0].type === 'stream.text.delta', - ); - expect(textDeltaCall).toBeDefined(); - expect(textDeltaCall[0]).toEqual({ - type: 'stream.text.delta', - delta: 'hello', - }); - }); - - it('passes through Runner-emitted stream.tool.approval events', async () => { - const request = { messages: [], model: 'test' }; - const onEvent = jest.fn(); - const logger = createMockLogger(); - const provider = createProvider({ logger }); - - mockOrchestrator.chatStream.mockImplementation( - async ( - _req: unknown, - rawCallback: (raw: string) => void, - _signal?: AbortSignal, - ) => { - rawCallback( - JSON.stringify({ - type: 'response.created', - response: { id: 'resp-456' }, - }), - ); - // Runner-emitted event (already normalized, not a LlamaStack event) - rawCallback( - JSON.stringify({ - type: 'stream.tool.approval', - callId: 'call-backend-1', - name: 'list_pods', - serverLabel: 'ocp-mcp', - arguments: '{"namespace":"default"}', - }), - ); - }, - ); - - await provider.chatStream(request, onEvent); - - const approvalEvent = onEvent.mock.calls.find( - (c: [NormalizedStreamEvent]) => - c[0].type === 'stream.tool.approval' && - (c[0] as unknown as Record).callId === - 'call-backend-1', - ); - expect(approvalEvent).toBeDefined(); - expect(approvalEvent[0]).toMatchObject({ - type: 'stream.tool.approval', - callId: 'call-backend-1', - name: 'list_pods', - serverLabel: 'ocp-mcp', - responseId: 'resp-456', - }); - }); - - it('passes through Runner-emitted stream.backend_tool.executing events', async () => { - const request = { messages: [], model: 'test' }; - const onEvent = jest.fn(); - const provider = createProvider(); - - mockOrchestrator.chatStream.mockImplementation( - async ( - _req: unknown, - rawCallback: (raw: string) => void, - _signal?: AbortSignal, - ) => { - rawCallback( - JSON.stringify({ - type: 'stream.backend_tool.executing', - toolCount: 2, - tools: ['ocp__list_pods', 'sn__find_tickets'], - }), - ); - }, - ); - - await provider.chatStream(request, onEvent); - - const execEvent = onEvent.mock.calls.find( - (c: [NormalizedStreamEvent]) => - c[0].type === 'stream.backend_tool.executing', - ); - expect(execEvent).toBeDefined(); - expect(execEvent[0]).toMatchObject({ - type: 'stream.backend_tool.executing', - toolCount: 2, - }); - }); - - it('passes through Runner-emitted stream.error events', async () => { - const request = { messages: [], model: 'test' }; - const onEvent = jest.fn(); - const provider = createProvider(); - - mockOrchestrator.chatStream.mockImplementation( - async ( - _req: unknown, - rawCallback: (raw: string) => void, - _signal?: AbortSignal, - ) => { - rawCallback( - JSON.stringify({ - type: 'stream.error', - error: 'Max turns exceeded', - }), - ); - }, - ); - - await provider.chatStream(request, onEvent); - - const errorEvent = onEvent.mock.calls.find( - (c: [NormalizedStreamEvent]) => c[0].type === 'stream.error', - ); - expect(errorEvent).toBeDefined(); - expect(errorEvent[0]).toMatchObject({ - type: 'stream.error', - error: 'Max turns exceeded', - }); - }); - - it('handles HITL approval events and backfills responseId', async () => { - const request = { messages: [], model: 'test' }; - const onEvent = jest.fn(); - const logger = createMockLogger(); - const provider = createProvider({ logger }); - - mockOrchestrator.chatStream.mockImplementation( - async ( - _req: unknown, - rawCallback: (raw: string) => void, - _signal?: AbortSignal, - ) => { - rawCallback( - JSON.stringify({ - type: 'response.created', - response: { id: 'resp-123' }, - }), - ); - rawCallback( - JSON.stringify({ - type: 'response.output_item.added', - item: { - type: 'mcp_approval_request', - id: 'call-1', - name: 'tool1', - server_label: 'Server1', - }, - }), - ); - }, - ); - - await provider.chatStream(request, onEvent); - - const approvalEvent = onEvent.mock.calls.find( - (c: [NormalizedStreamEvent]) => - c[0].type === 'stream.tool.approval' && 'responseId' in c[0], - ); - expect(approvalEvent).toBeDefined(); - expect(approvalEvent[0]).toMatchObject({ - type: 'stream.tool.approval', - callId: 'call-1', - name: 'tool1', - serverLabel: 'Server1', - responseId: 'resp-123', - }); - expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining('[HITL] Emitting stream.tool.approval'), - ); - }); - }); - - describe('conversations capability', () => { - it('create delegates to conversation facade', async () => { - mockConversationFacade.createConversation.mockResolvedValue('conv-1'); - const provider = createProvider(); - - const result = await provider.conversations.create(); - - expect(mockConversationFacade.createConversation).toHaveBeenCalled(); - expect(result).toEqual({ conversationId: 'conv-1' }); - }); - - it('list delegates to conversation facade', async () => { - const listResult = { conversations: [], nextCursor: 'c1' }; - mockConversationFacade.listConversations.mockResolvedValue(listResult); - const provider = createProvider(); - - const result = await provider.conversations.list(10, 'after-1'); - - expect(mockConversationFacade.listConversations).toHaveBeenCalledWith( - 10, - 'desc', - 'after-1', - ); - expect(result).toEqual(listResult); - }); - - it('get throws on not found', async () => { - mockConversationFacade.getConversation.mockResolvedValue(null); - const provider = createProvider(); - - await expect(provider.conversations.get('nonexistent')).rejects.toThrow( - 'Conversation not found: nonexistent', - ); - }); - - it('get returns details when found', async () => { - const details = { - responseId: 'r1', - conversationId: 'c1', - model: 'test', - createdAt: '2025-01-01', - }; - mockConversationFacade.getConversation.mockResolvedValue(details); - const provider = createProvider(); - - const result = await provider.conversations.get('r1'); - - expect(result).toEqual(details); - }); - - it('getInputs delegates to conversation facade', async () => { - const inputs = { items: [] }; - mockConversationFacade.getConversationInputs.mockResolvedValue(inputs); - const provider = createProvider(); - - const result = await provider.conversations.getInputs('r1'); - - expect(mockConversationFacade.getConversationInputs).toHaveBeenCalledWith( - 'r1', - ); - expect(result).toEqual(inputs); - }); - - it('getByResponseChain delegates to conversation facade', async () => { - const chainItems = [{ role: 'user', text: 'hi' }]; - mockConversationFacade.walkResponseChain.mockResolvedValue(chainItems); - const provider = createProvider(); - - // eslint-disable-next-line testing-library/no-await-sync-queries - const result = await provider.conversations.getByResponseChain('r1'); - - expect(mockConversationFacade.walkResponseChain).toHaveBeenCalledWith( - 'r1', - ); - expect(result.items).toEqual([ - { type: 'message', role: 'user', content: 'hi' }, - ]); - }); - - it('getProcessedMessages delegates to conversation facade', async () => { - const messages = [{ role: 'user', content: 'hi' }]; - mockConversationFacade.getProcessedMessages.mockResolvedValue(messages); - const provider = createProvider(); - - const result = await provider.conversations.getProcessedMessages('c1'); - - expect(mockConversationFacade.getProcessedMessages).toHaveBeenCalledWith( - 'c1', - ); - expect(result).toEqual(messages); - }); - - it('delete delegates to conversation facade', async () => { - const provider = createProvider(); - - await provider.conversations.delete('r1'); - - expect(mockConversationFacade.deleteConversation).toHaveBeenCalledWith( - 'r1', - undefined, - ); - }); - - it('submitApproval delegates to conversation facade', async () => { - const approval = { - responseId: 'r1', - callId: 'call-1', - approved: true, - toolName: 'tool1', - toolArguments: '{}', - }; - mockConversationFacade.continueAfterApproval.mockResolvedValue({ - responseId: 'r1', - output: [], - }); - const provider = createProvider(); - - await provider.conversations.submitApproval(approval); - - expect(mockConversationFacade.continueAfterApproval).toHaveBeenCalledWith( - 'r1', - 'call-1', - true, - 'tool1', - '{}', - ); - }); - }); - - describe('rag capability', () => { - it('listDocuments delegates to vector store facade', async () => { - const docs = [{ id: 'd1', fileName: 'f1' }]; - mockVectorStoreFacade.listDocuments.mockResolvedValue(docs); - const provider = createProvider(); - - const result = await provider.rag.listDocuments('vs-1'); - - expect(mockVectorStoreFacade.listDocuments).toHaveBeenCalledWith('vs-1'); - expect(result).toEqual(docs); - }); - - it('listVectorStores delegates to vector store facade', async () => { - const stores = [{ id: 'vs-1', name: 'store' }]; - mockVectorStoreFacade.listVectorStores.mockResolvedValue(stores); - const provider = createProvider(); - - const result = await provider.rag.listVectorStores(); - - expect(mockVectorStoreFacade.listVectorStores).toHaveBeenCalled(); - expect(result).toEqual(stores); - }); - - it('getDefaultVectorStoreId delegates to vector store facade', () => { - mockVectorStoreFacade.getDefaultVectorStoreId.mockReturnValue( - 'vs-default', - ); - const provider = createProvider(); - - const result = provider.rag.getDefaultVectorStoreId(); - - expect(result).toBe('vs-default'); - }); - - it('syncDocuments delegates to vector store facade', async () => { - const syncResult = { - added: 2, - updated: 1, - removed: 0, - failed: 0, - unchanged: 5, - errors: [] as string[], - }; - mockVectorStoreFacade.syncDocuments.mockResolvedValue(syncResult); - const provider = createProvider(); - - const result = await provider.rag.syncDocuments(); - - expect(mockVectorStoreFacade.syncDocuments).toHaveBeenCalled(); - expect(result).toEqual(syncResult); - }); - - it('uploadDocument delegates to vector store facade', async () => { - const provider = createProvider(); - - const result = await provider.rag.uploadDocument!( - 'file.txt', - Buffer.from('content'), - 'vs-1', - ); - - expect(mockVectorStoreFacade.uploadDocument).toHaveBeenCalledWith( - 'file.txt', - expect.any(Buffer), - 'vs-1', - ); - expect(result).toEqual({ - fileId: 'f1', - fileName: 'f1.txt', - status: 'completed', - }); - }); - - it('deleteDocument delegates to vector store facade', async () => { - const provider = createProvider(); - - const result = await provider.rag.deleteDocument!('f1', 'vs-1'); - - expect(mockVectorStoreFacade.deleteDocument).toHaveBeenCalledWith( - 'f1', - 'vs-1', - ); - expect(result).toEqual({ success: true }); - }); - - it('searchVectorStore delegates to vector store facade', async () => { - const searchResult = { - query: 'test', - chunks: [{ text: 'chunk1' }], - vectorStoreId: 'vs-1', - totalResults: 1, - }; - mockVectorStoreFacade.searchVectorStore.mockResolvedValue(searchResult); - const provider = createProvider(); - - const result = await provider.rag.searchVectorStore!('test', 10, 'vs-1', [ - 'vs-1', - ]); - - expect(mockVectorStoreFacade.searchVectorStore).toHaveBeenCalledWith( - 'test', - 10, - 'vs-1', - ['vs-1'], - ); - expect(result).toEqual(searchResult); - }); - }); - - describe('safety capability', () => { - it('isEnabled returns safety service state', () => { - mockSafetyService.isEnabled.mockReturnValue(true); - const provider = createProvider(); - - expect(provider.safety.isEnabled()).toBe(true); - - mockSafetyService.isEnabled.mockReturnValue(false); - expect(provider.safety.isEnabled()).toBe(false); - }); - - it('getStatus returns enabled and shields', async () => { - mockSafetyService.isEnabled.mockReturnValue(true); - mockSafetyService.getAvailableShields.mockReturnValue([ - { identifier: 's1' }, - { identifier: 's2' }, - ]); - const provider = createProvider(); - - const result = await provider.safety.getStatus(); - - expect(result).toEqual({ - enabled: true, - shields: ['s1', 's2'], - }); - }); - - it('checkInput returns safe when no violation', async () => { - mockSafetyService.checkInput.mockResolvedValue(null); - const provider = createProvider(); - - const result = await provider.safety.checkInput('safe text'); - - expect(result).toEqual({ safe: true }); - }); - - it('checkInput returns unsafe when violation', async () => { - mockSafetyService.checkInput.mockResolvedValue({ - user_message: 'Blocked content', - violation_level: 'high', - }); - const provider = createProvider(); - - const result = await provider.safety.checkInput('bad text'); - - expect(result).toEqual({ - safe: false, - violation: 'Blocked content', - category: 'high', - }); - }); - - it('checkOutput returns safe when no violation', async () => { - mockSafetyService.checkOutput.mockResolvedValue(null); - const provider = createProvider(); - - const result = await provider.safety.checkOutput('safe output'); - - expect(result).toEqual({ safe: true }); - }); - - it('checkOutput returns unsafe when violation', async () => { - mockSafetyService.checkOutput.mockResolvedValue({ - user_message: 'Harmful output', - violation_level: 'medium', - }); - const provider = createProvider(); - - const result = await provider.safety.checkOutput('bad output'); - - expect(result).toEqual({ - safe: false, - violation: 'Harmful output', - category: 'medium', - }); - }); - }); - - describe('evaluation capability', () => { - it('isEnabled returns evaluation service state', () => { - mockEvaluationService.isEnabled.mockReturnValue(true); - const provider = createProvider(); - - expect(provider.evaluation.isEnabled()).toBe(true); - - mockEvaluationService.isEnabled.mockReturnValue(false); - expect(provider.evaluation.isEnabled()).toBe(false); - }); - - it('getStatus returns enabled and scoring functions', async () => { - mockEvaluationService.isEnabled.mockReturnValue(true); - mockEvaluationService.getAvailableScoringFunctions.mockReturnValue([ - { identifier: 'f1' }, - ]); - const provider = createProvider(); - - const result = await provider.evaluation.getStatus(); - - expect(result).toEqual({ - enabled: true, - scoringFunctions: ['f1'], - }); - }); - - it('evaluateResponse returns skipped result when no result', async () => { - mockEvaluationService.scoreResponse.mockResolvedValue(null); - const provider = createProvider(); - - const result = await provider.evaluation.evaluateResponse( - 'user msg', - 'assistant msg', - ); - - expect(result.skipped).toBe(true); - expect(result.overallScore).toBe(0); - expect(result.passedThreshold).toBe(true); - }); - - it('evaluateResponse returns result when scoreResponse returns data', async () => { - const evalResult = { - overallScore: 0.9, - scores: { relevance: 0.95 }, - passedThreshold: true, - qualityLevel: 'good' as const, - evaluatedAt: '2025-01-01T00:00:00Z', - }; - mockEvaluationService.scoreResponse.mockResolvedValue(evalResult); - const provider = createProvider(); - - const result = await provider.evaluation.evaluateResponse( - 'user msg', - 'assistant msg', - ['ctx1'], - ); - - expect(mockEvaluationService.scoreResponse).toHaveBeenCalledWith( - 'user msg', - 'assistant msg', - ['ctx1'], - ); - expect(result).toEqual(evalResult); - }); - }); - - describe('testModel', () => { - it('returns connection failure when listAvailableModels throws', async () => { - const mockRequest = jest - .fn() - .mockRejectedValue(new Error('Connection refused')); - mockOrchestrator.getClientManager.mockReturnValue({ - getExistingClient: jest.fn().mockReturnValue({ request: mockRequest }), - }); - const provider = createProvider(); - - const result = await provider.testModel(); - - expect(result).toEqual({ - connected: false, - modelFound: false, - canGenerate: false, - error: 'Connection refused', - }); - }); - - it('returns model not found when target model not in list', async () => { - mockOrchestrator.getResolver.mockReturnValue({ - resolve: jest.fn().mockResolvedValue({ model: 'missing-model' }), - }); - mockOrchestrator.getClientManager.mockReturnValue({ - getExistingClient: jest.fn().mockReturnValue({ - request: jest - .fn() - .mockResolvedValueOnce({ data: [{ id: 'other-model' }] }) - .mockResolvedValueOnce({ output: [], usage: { output_tokens: 1 } }), - }), - }); - const provider = createProvider(); - - const result = await provider.testModel(); - - expect(result).toEqual({ - connected: true, - modelFound: false, - canGenerate: false, - error: 'Model "missing-model" not found on server', - }); - }); - - it('returns no model configured when resolver has no model', async () => { - mockOrchestrator.getResolver.mockReturnValue({ - resolve: jest.fn().mockResolvedValue({}), - }); - mockOrchestrator.getClientManager.mockReturnValue({ - getExistingClient: jest.fn().mockReturnValue({ - request: jest.fn().mockResolvedValue({ data: [{ id: 'model-1' }] }), - }), - }); - const provider = createProvider(); - - const result = await provider.testModel(); - - expect(result).toEqual({ - connected: true, - modelFound: false, - canGenerate: false, - error: 'No model configured', - }); - }); - - it('returns successful when model found and inference works', async () => { - mockOrchestrator.getResolver.mockReturnValue({ - resolve: jest.fn().mockResolvedValue({ model: 'model-1' }), - }); - mockOrchestrator.getClientManager.mockReturnValue({ - getExistingClient: jest.fn().mockReturnValue({ - request: jest - .fn() - .mockResolvedValueOnce({ data: [{ id: 'model-1' }] }) - .mockResolvedValueOnce({ - output: [ - { - type: 'message', - content: [{ type: 'output_text', text: 'hi' }], - }, - ], - usage: { output_tokens: 5 }, - }), - }), - }); - const provider = createProvider(); - - const result = await provider.testModel(); - - expect(result).toEqual({ - connected: true, - modelFound: true, - canGenerate: true, - }); - }); - - it('uses modelOverride when provided', async () => { - mockOrchestrator.getResolver.mockReturnValue(null); - mockOrchestrator.getClientManager.mockReturnValue({ - getExistingClient: jest.fn().mockReturnValue({ - request: jest - .fn() - .mockResolvedValueOnce({ data: [{ id: 'override-model' }] }) - .mockResolvedValueOnce({ - output: [ - { - type: 'message', - content: [{ type: 'output_text', text: 'ok' }], - }, - ], - usage: { output_tokens: 2 }, - }), - }), - }); - const provider = createProvider(); - - const result = await provider.testModel('override-model'); - - expect(result).toEqual({ - connected: true, - modelFound: true, - canGenerate: true, - }); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiProvider.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiProvider.ts deleted file mode 100644 index ba669a26220..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiProvider.ts +++ /dev/null @@ -1,335 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - LoggerService, - RootConfigService, - DatabaseService, -} from '@backstage/backend-plugin-api'; -import type { AdminConfigService } from '../../services/AdminConfigService'; -import type { - AgenticProvider, - AgenticProviderStatus, - NormalizedStreamEvent, - ConversationCapability, - RAGCapability, - SafetyCapability, - EvaluationCapability, - ChatRequest, - ChatResponse, -} from '../types'; -import { ResponsesApiCoordinator } from './ResponsesApiCoordinator'; -import { SafetyService } from './SafetyService'; -import { EvaluationService } from './EvaluationService'; -import { normalizeLlamaStackEvent } from './StreamEventNormalizer'; -import { - type PromptCapabilities, - type ChatAgent, - deriveRoleFromTopology, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { - buildConversationsCapability, - buildRagCapability, - buildSafetyCapability, - buildEvaluationCapability, -} from './CapabilityBuilders'; -import { - listModels as modelOpsListModels, - testModel as modelOpsTestModel, - generateSystemPrompt as modelOpsGenPrompt, -} from './ProviderModelOps'; -import type { ModelsCache } from './ProviderModelOps'; - -export class ResponsesApiProvider implements AgenticProvider { - readonly id = 'llamastack'; - readonly displayName = 'Responses API (LlamaStack)'; - - private readonly orchestrator: ResponsesApiCoordinator; - private readonly safetyService: SafetyService; - private readonly evaluationService: EvaluationService; - private readonly logger: LoggerService; - - private _conversations?: ConversationCapability; - private _rag?: RAGCapability; - private _safety?: SafetyCapability; - private _evaluation?: EvaluationCapability; - - private _modelsCache: { current: ModelsCache | null } = { current: null }; - - constructor(options: { - logger: LoggerService; - config: RootConfigService; - database?: DatabaseService; - adminConfig?: AdminConfigService; - }) { - this.logger = options.logger; - this.orchestrator = new ResponsesApiCoordinator(options); - - const clientAccessor = () => - this.orchestrator.getClientManager().getExistingClient(); - - this.safetyService = new SafetyService({ - ...options, - getClient: clientAccessor, - }); - this.evaluationService = new EvaluationService({ - ...options, - getClient: clientAccessor, - }); - } - - async shutdown(): Promise { - this.logger.info('Shutting down Responses API provider'); - await this.orchestrator.shutdown(); - } - - async initialize(): Promise { - this.logger.info('Initializing Responses API provider'); - await this.orchestrator.initialize(); - await this.safetyService.initialize(); - await this.evaluationService.initialize(); - this.logger.info('Responses API provider initialized'); - } - - async postInitialize(): Promise { - await this.orchestrator.postInitialize(); - } - - invalidateRuntimeConfig(): void { - this.orchestrator.invalidateRuntimeConfig(); - } - - async getEffectiveConfig(): Promise> { - const resolver = this.orchestrator.getResolver(); - if (!resolver) return {}; - const config = await resolver.resolve(); - return { ...config }; - } - - async listModels(): Promise< - Array<{ id: string; owned_by?: string; model_type?: string }> - > { - return modelOpsListModels(this.orchestrator, this._modelsCache); - } - - async testModel( - modelOverride?: string, - _baseUrl?: string, - ): Promise<{ - connected: boolean; - modelFound: boolean; - canGenerate: boolean; - error?: string; - }> { - return modelOpsTestModel(this.orchestrator, modelOverride); - } - - async generateSystemPrompt( - description: string, - modelOverride?: string, - capabilities?: PromptCapabilities, - ): Promise { - return modelOpsGenPrompt( - this.orchestrator, - description, - modelOverride, - capabilities, - this.logger, - ); - } - - async refreshDynamicConfig(): Promise { - const resolver = this.orchestrator.getResolver(); - if (!resolver) return; - - try { - const config = await resolver.resolve(); - this.safetyService.applyDynamicOverrides({ - safetyEnabled: config.safetyEnabled, - inputShields: config.inputShields, - outputShields: config.outputShields, - safetyOnError: config.safetyOnError, - }); - this.evaluationService.applyDynamicOverrides({ - evaluationEnabled: config.evaluationEnabled, - scoringFunctions: config.scoringFunctions, - minScoreThreshold: config.minScoreThreshold, - evaluationOnError: config.evaluationOnError, - }); - } catch (err) { - this.logger.warn( - `Failed to refresh dynamic config for safety/eval: ${ - err instanceof Error ? err.message : 'Unknown error' - }`, - ); - } - } - - async getStatus(): Promise { - return this.orchestrator.getStatus(); - } - - async listAgents(): Promise { - const snapshot = await this.orchestrator - .getAgentGraphManager() - ?.getSnapshot(); - if (!snapshot?.agents?.size) return []; - - const topologyMap: Record< - string, - { handoffs?: string[]; asTools?: string[] } - > = {}; - for (const [key, resolved] of snapshot.agents) { - topologyMap[key] = { - handoffs: resolved.config.handoffs, - asTools: resolved.config.asTools, - }; - } - - const agents: ChatAgent[] = []; - for (const [key, resolved] of snapshot.agents) { - const role = deriveRoleFromTopology(key, topologyMap); - if (role === 'specialist') continue; - agents.push({ - id: key, - name: resolved.config.name ?? key, - description: resolved.config.instructions?.slice(0, 200), - status: 'config', - isDefault: key === snapshot.defaultAgentKey, - providerType: 'llamastack', - framework: 'llamastack', - source: 'orchestration', - agentRole: role, - }); - } - return agents; - } - - async chat(request: ChatRequest): Promise { - return this.orchestrator.chat(request); - } - - async chatStream( - request: ChatRequest, - onEvent: (event: NormalizedStreamEvent) => void, - signal?: AbortSignal, - ): Promise { - let streamResponseId: string | undefined; - let currentAgentName: string | undefined; - - await this.orchestrator.chatStream( - request, - (rawEventJson: string) => { - let parsed: Record | undefined; - try { - parsed = JSON.parse(rawEventJson); - } catch { - /* non-JSON */ - } - - if (parsed?.type === 'stream.agent.handoff') { - currentAgentName = parsed.toAgent as string; - onEvent({ - type: 'stream.agent.handoff', - fromAgent: parsed.fromAgent as string | undefined, - toAgent: currentAgentName, - reason: parsed.reason as string | undefined, - } as NormalizedStreamEvent); - return; - } - - if (parsed?.type === '__agent.responding') { - currentAgentName = parsed.agentName as string; - return; - } - - if ( - parsed && - typeof parsed.type === 'string' && - parsed.type.startsWith('stream.') - ) { - const event = parsed as { type: string } & Record; - if (event.type === 'stream.started' && event.responseId) { - streamResponseId = event.responseId as string; - } - if (event.type === 'stream.completed' && currentAgentName) { - event.agentName = currentAgentName; - } - if (event.type === 'stream.tool.approval') { - if (!event.responseId && streamResponseId) - event.responseId = streamResponseId; - this.logger.info( - `[HITL] Emitting stream.tool.approval: callId=${event.callId}, name=${event.name}, serverLabel=${event.serverLabel}, responseId=${event.responseId}`, - ); - } - onEvent(event as NormalizedStreamEvent); - return; - } - - const normalized = normalizeLlamaStackEvent(rawEventJson, type => { - this.logger.debug(`[Stream] Unknown event type: ${type}`); - }); - for (const event of normalized) { - if (event.type === 'stream.started' && event.responseId) { - streamResponseId = event.responseId; - } - if (event.type === 'stream.tool.approval') { - if (!event.responseId && streamResponseId) - event.responseId = streamResponseId; - this.logger.info( - `[HITL] Emitting stream.tool.approval: callId=${event.callId}, name=${event.name}, serverLabel=${event.serverLabel}, responseId=${event.responseId}`, - ); - } - if (event.type === 'stream.completed' && currentAgentName) { - event.agentName = currentAgentName; - } - onEvent(event); - } - }, - signal, - ); - } - - get conversations(): ConversationCapability { - if (this._conversations) return this._conversations; - this._conversations = buildConversationsCapability( - this.orchestrator.getConversationFacade(), - ); - return this._conversations; - } - - get rag(): RAGCapability { - if (this._rag) return this._rag; - const orch = this.orchestrator; - this._rag = buildRagCapability(orch.getVectorStoreFacade(), { - getClient: () => orch.getClientManager().getExistingClient(), - getModel: () => orch.getConfiguredModel(), - }); - return this._rag; - } - - get safety(): SafetyCapability { - if (this._safety) return this._safety; - this._safety = buildSafetyCapability(this.safetyService); - return this._safety; - } - - get evaluation(): EvaluationCapability { - if (this._evaluation) return this._evaluation; - this._evaluation = buildEvaluationCapability(this.evaluationService); - return this._evaluation; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiService.test.ts deleted file mode 100644 index 305e4d640e1..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiService.test.ts +++ /dev/null @@ -1,411 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ResponsesApiClient } from './ResponsesApiClient'; -import type { - EffectiveConfig, - ChatRequest, - ResponsesApiResponse, -} from '../../types'; -import { ResponsesApiService } from './ResponsesApiService'; -import { createMockLogger, createMockClient } from '../../test-utils/mocks'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function createBaseConfig( - overrides: Partial = {}, -): EffectiveConfig { - return { - model: 'test-model', - baseUrl: 'http://localhost:8321', - systemPrompt: 'You are a helpful assistant.', - enableWebSearch: false, - enableCodeInterpreter: false, - vectorStoreIds: [], - vectorStoreName: 'default', - embeddingModel: 'all-MiniLM-L6-v2', - embeddingDimension: 384, - chunkingStrategy: 'auto', - maxChunkSizeTokens: 800, - chunkOverlapTokens: 400, - skipTlsVerify: false, - zdrMode: false, - verboseStreamLogging: false, - ...overrides, - }; -} - -function simpleApiResponse(text = 'Hi there'): ResponsesApiResponse { - return { - id: 'resp-1', - object: 'response', - created_at: Date.now(), - model: 'test-model', - status: 'completed', - output: [ - { - type: 'message', - id: 'msg-1', - role: 'assistant', - status: 'completed', - content: [{ type: 'output_text', text }], - }, - ], - usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, - }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('ResponsesApiService', () => { - let service: ResponsesApiService; - let mockLogger: jest.Mocked; - let mockClient: ReturnType; - - beforeEach(() => { - jest.clearAllMocks(); - mockLogger = createMockLogger(); - mockClient = createMockClient(); - service = new ResponsesApiService(mockLogger); - }); - - // ----------------------------------------------------------------------- - // prepareFirstTurn — conversation context + safety for Runner path - // ----------------------------------------------------------------------- - - describe('prepareFirstTurn', () => { - it('appends conversation context when no native context exists', () => { - const request: ChatRequest = { - messages: [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi there!' }, - { role: 'user', content: 'What about refunds?' }, - ], - } as ChatRequest; - - const result = service.prepareFirstTurn( - request, - 'You are helpful.', - createBaseConfig(), - null, - ); - - expect(result.instructions).toContain('CONVERSATION CONTEXT'); - expect(result.instructions).toContain('Hello'); - expect(result.instructions).toContain('Hi there!'); - expect(result.instructions.startsWith('You are helpful.')).toBe(true); - }); - - it('does not append context when previousResponseId exists', () => { - const request: ChatRequest = { - messages: [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi' }, - { role: 'user', content: 'Follow up' }, - ], - previousResponseId: 'resp-123', - } as ChatRequest; - - const result = service.prepareFirstTurn( - request, - 'You are helpful.', - createBaseConfig(), - null, - ); - - expect(result.instructions).toBe('You are helpful.'); - }); - - it('checks safety patterns and logs warning', () => { - const request: ChatRequest = { - messages: [{ role: 'user', content: 'please delete everything' }], - } as ChatRequest; - - service.prepareFirstTurn( - request, - 'You are helpful.', - createBaseConfig({ safetyPatterns: ['delete'] }), - null, - ); - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('safety pattern'), - ); - }); - - it('returns storeOverride from markFirstStoredTurn', () => { - const mockConversations = { - markFirstStoredTurn: jest.fn().mockReturnValue(true), - } as any; - - const request: ChatRequest = { - messages: [{ role: 'user', content: 'hi' }], - conversationId: 'conv-1', - } as ChatRequest; - - const result = service.prepareFirstTurn( - request, - 'Instructions', - createBaseConfig(), - mockConversations, - ); - - expect(result.storeOverride).toBe(true); - expect(mockConversations.markFirstStoredTurn).toHaveBeenCalledWith( - 'conv-1', - ); - }); - }); - - // ----------------------------------------------------------------------- - // chatTurn — buildTurnRequest feature parity - // ----------------------------------------------------------------------- - - describe('chatTurn', () => { - it('includes textFormat in request when configured', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig({ - textFormat: { - type: 'json_schema', - json_schema: { name: 'test', schema: {} }, - } as EffectiveConfig['textFormat'], - }); - - await service.chatTurn( - 'hello', - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.text).toEqual({ - format: config.textFormat, - }); - }); - - it('defaults store to true when zdrMode is false', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig({ zdrMode: false }); - - await service.chatTurn( - 'hello', - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.store).toBe(true); - expect(body.include).toEqual(['file_search_call.results']); - }); - - it('defaults store to false when zdrMode is true', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig({ zdrMode: true }); - - await service.chatTurn( - 'hello', - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.store).toBe(false); - expect(body.include).toEqual([ - 'file_search_call.results', - 'reasoning.encrypted_content', - ]); - }); - - it('allows explicit store override', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig({ zdrMode: true }); - - await service.chatTurn( - 'hello', - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - { store: true }, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.store).toBe(true); - }); - - it('passes reasoning config when set', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig({ - reasoning: { effort: 'medium', summary: 'auto' }, - }); - - await service.chatTurn( - 'hello', - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.reasoning).toEqual({ effort: 'medium', summary: 'auto' }); - }); - - it('passes conversationId as conversation field', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig(); - - await service.chatTurn( - 'hello', - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - { conversationId: 'conv-123' }, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.conversation).toBe('conv-123'); - expect(body.previous_response_id).toBeUndefined(); - }); - - it('passes previousResponseId when no conversationId', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig(); - - await service.chatTurn( - 'hello', - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - { previousResponseId: 'resp-prev' }, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.previous_response_id).toBe('resp-prev'); - expect(body.conversation).toBeUndefined(); - }); - - it('accepts array input items', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig(); - - await service.chatTurn( - [{ type: 'function_call_output', call_id: 'c1', output: 'done' }], - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(Array.isArray(body.input)).toBe(true); - expect(body.input[0].type).toBe('function_call_output'); - }); - }); - - describe('CapabilityInfo integration', () => { - it('isResponsesParamSupported returns false when capability is disabled', () => { - service.setCapabilityProvider(() => ({ - functionTools: true, - strictField: false, - maxOutputTokens: false, - mcpTools: true, - parallelToolCalls: true, - truncation: true, - })); - expect(service.isResponsesParamSupported('max_output_tokens')).toBe( - false, - ); - expect(service.isResponsesParamSupported('strict')).toBe(false); - }); - - it('isResponsesParamSupported returns true when capability is enabled', () => { - service.setCapabilityProvider(() => ({ - functionTools: true, - strictField: true, - maxOutputTokens: true, - mcpTools: true, - parallelToolCalls: true, - truncation: true, - })); - expect(service.isResponsesParamSupported('max_output_tokens')).toBe(true); - expect(service.isResponsesParamSupported('strict')).toBe(true); - }); - - it('getCapabilities delegates to the injected provider', () => { - const caps = { - functionTools: false, - strictField: false, - maxOutputTokens: false, - mcpTools: false, - parallelToolCalls: false, - truncation: false, - }; - service.setCapabilityProvider(() => caps); - expect(service.getCapabilities()).toBe(caps); - }); - - it('setCapabilityProvider replaces the previous provider', () => { - service.setCapabilityProvider(() => ({ - functionTools: true, - strictField: false, - maxOutputTokens: false, - mcpTools: true, - parallelToolCalls: true, - truncation: true, - })); - expect(service.getCapabilities().strictField).toBe(false); - - service.setCapabilityProvider(() => ({ - functionTools: true, - strictField: true, - maxOutputTokens: true, - mcpTools: true, - parallelToolCalls: true, - truncation: true, - })); - expect(service.getCapabilities().strictField).toBe(true); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiService.ts deleted file mode 100644 index e3e1bd3c8ea..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ResponsesApiService.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/chat/ResponsesApiService' instead */ -export { - ResponsesApiService, - type ChatDeps, -} from '../responses-api/chat/ResponsesApiService'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SafetyService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SafetyService.test.ts deleted file mode 100644 index d8e6a764b4c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SafetyService.test.ts +++ /dev/null @@ -1,595 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { RootConfigService } from '@backstage/backend-plugin-api'; -import { SafetyService } from './SafetyService'; -import type { ResponsesApiClient } from './ResponsesApiClient'; -import { createMockLogger } from '../../test-utils/mocks'; - -function createMockClient(): jest.Mocked { - return { - request: jest.fn(), - requestWithRetry: jest.fn(), - streamRequest: jest.fn(), - } as unknown as jest.Mocked; -} - -function makeConfig(overrides: Record = {}) { - const safetyValues: Record = { - enabled: overrides.enabled ?? false, - inputShields: overrides.inputShields, - outputShields: overrides.outputShields, - onError: overrides.onError, - }; - - const registerShieldsData = overrides.registerShields as - | Array<{ shieldId: string; providerId: string; providerShieldId: string }> - | undefined; - - const safetyConfig = { - getOptionalBoolean: jest.fn((key: string) => - key === 'enabled' ? safetyValues.enabled : undefined, - ), - getOptionalStringArray: jest.fn((key: string) => safetyValues[key]), - getOptionalConfigArray: jest.fn(() => - registerShieldsData?.map(s => ({ - getString: jest.fn((key: string) => { - if (key === 'shieldId') return s.shieldId; - if (key === 'providerId') return s.providerId; - if (key === 'providerShieldId') return s.providerShieldId; - return ''; - }), - })), - ), - getOptionalString: jest.fn((key: string) => safetyValues[key]), - }; - - const llamaStackConfig = { - getString: jest.fn((key: string) => { - if (key === 'baseUrl') return 'http://localhost:8321'; - return ''; - }), - getOptionalString: jest.fn(() => undefined), - getOptionalBoolean: jest.fn(() => false), - }; - - return { - getConfig: jest.fn(() => llamaStackConfig), - getOptionalConfig: jest.fn((key: string) => { - if (key === 'augment.llamaStack') return llamaStackConfig; - if (key === 'augment.safety') return safetyConfig; - return undefined; - }), - } as unknown as RootConfigService; -} - -describe('SafetyService', () => { - let mockLogger: ReturnType; - let mockClient: jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - mockLogger = createMockLogger(); - mockClient = createMockClient(); - }); - - // --------------------------------------------------------------------------- - // Basic state - // --------------------------------------------------------------------------- - - describe('isEnabled', () => { - it('returns false before initialization', () => { - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({}), - }); - expect(service.isEnabled()).toBe(false); - }); - }); - - describe('getAvailableShields', () => { - it('returns empty array before initialization', () => { - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({}), - }); - expect(service.getAvailableShields()).toEqual([]); - }); - }); - - // --------------------------------------------------------------------------- - // Initialization - // --------------------------------------------------------------------------- - - describe('initialize', () => { - it('handles missing llamaStack config gracefully', async () => { - const configMock = { - getOptionalConfig: jest.fn(() => undefined), - } as unknown as RootConfigService; - - const service = new SafetyService({ - logger: mockLogger, - config: configMock, - }); - - await service.initialize(); - expect(service.isEnabled()).toBe(false); - expect(mockLogger.info).toHaveBeenCalledWith( - 'augment.llamaStack not configured, safety service disabled', - ); - }); - - it('is idempotent — second call is a no-op', async () => { - const configMock = makeConfig({ enabled: false }); - const service = new SafetyService({ - logger: mockLogger, - config: configMock, - }); - - await service.initialize(); - await service.initialize(); - - expect(configMock.getOptionalConfig).toHaveBeenCalledTimes(2); - }); - - it('does not throw on config errors (safety is optional)', async () => { - const configMock = { - getOptionalConfig: jest.fn(() => undefined), - } as unknown as RootConfigService; - - const service = new SafetyService({ - logger: mockLogger, - config: configMock, - }); - - await expect(service.initialize()).resolves.not.toThrow(); - expect(service.isEnabled()).toBe(false); - }); - - it('warns when safety is enabled but no client accessor provided', async () => { - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - }); - - await service.initialize(); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('ResponsesApiClient accessor'), - ); - }); - - it('loads shields from Llama Stack on init when enabled', async () => { - const shields = [ - { - identifier: 'content_safety', - provider_id: 'llama-guard', - provider_resource_id: 'meta-llama/Llama-Guard-3-8B', - type: 'shield', - }, - ]; - mockClient.request.mockResolvedValueOnce(shields); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - - await service.initialize(); - expect(service.isEnabled()).toBe(true); - expect(service.getAvailableShields()).toEqual(shields); - expect(mockClient.request).toHaveBeenCalledWith('/v1/shields', { - method: 'GET', - }); - }); - - it('handles {data: [...]} response format for shields', async () => { - const shields = [ - { - identifier: 'shield1', - provider_id: 'p1', - provider_resource_id: 'pr1', - type: 'shield', - }, - ]; - mockClient.request.mockResolvedValueOnce({ data: shields }); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - - await service.initialize(); - expect(service.getAvailableShields()).toEqual(shields); - }); - - it('registers configured shields when none found on server', async () => { - mockClient.request - .mockResolvedValueOnce([]) // first GET /v1/shields - empty - .mockResolvedValueOnce({}) // POST /v1/shields/register - .mockResolvedValueOnce([ - // reload GET /v1/shields - { - identifier: 'my_shield', - provider_id: 'llama-guard', - provider_resource_id: 'lg', - type: 'shield', - }, - ]); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - registerShields: [ - { - shieldId: 'my_shield', - providerId: 'llama-guard', - providerShieldId: 'lg', - }, - ], - }), - getClient: () => mockClient, - }); - - await service.initialize(); - expect(mockClient.request).toHaveBeenCalledWith('/v1/shields/register', { - method: 'POST', - body: { - shield_id: 'my_shield', - provider_id: 'llama-guard', - provider_shield_id: 'lg', - }, - }); - expect(service.getAvailableShields()).toHaveLength(1); - }); - - it('handles shield registration failure gracefully', async () => { - mockClient.request - .mockResolvedValueOnce([]) - .mockRejectedValueOnce(new Error('Provider not found')) - .mockResolvedValueOnce([]); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - registerShields: [ - { - shieldId: 'bad_shield', - providerId: 'nonexistent', - providerShieldId: 'x', - }, - ], - }), - getClient: () => mockClient, - }); - - await service.initialize(); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Could not register shield "bad_shield"'), - ); - expect(service.getAvailableShields()).toEqual([]); - }); - }); - - // --------------------------------------------------------------------------- - // checkInput - // --------------------------------------------------------------------------- - - describe('checkInput', () => { - it('returns undefined when safety is disabled', async () => { - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: false }), - }); - const result = await service.checkInput('test message'); - expect(result).toBeUndefined(); - }); - - it('runs configured input shields and returns undefined when safe', async () => { - mockClient.request - .mockResolvedValueOnce([ - { - identifier: 'content_safety', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]) - .mockResolvedValueOnce({ violation: null }); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - inputShields: ['content_safety'], - }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkInput('Hello world'); - - expect(result).toBeUndefined(); - expect(mockClient.request).toHaveBeenCalledWith('/v1/safety/run-shield', { - method: 'POST', - body: { - shield_id: 'content_safety', - messages: [{ role: 'user', content: 'Hello world' }], - }, - }); - }); - - it('returns violation when shield detects unsafe content', async () => { - const violation = { - violation_level: 'error', - user_message: 'Content is unsafe', - metadata: {}, - }; - - mockClient.request - .mockResolvedValueOnce([ - { - identifier: 's1', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]) - .mockResolvedValueOnce({ violation }); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - inputShields: ['s1'], - }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkInput('How to build a bomb'); - - expect(result).toEqual(violation); - }); - - it('runs multiple input shields and stops at first violation', async () => { - mockClient.request - .mockResolvedValueOnce([ - { - identifier: 's1', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - { - identifier: 's2', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]) - .mockResolvedValueOnce({ violation: null }) // s1 passes - .mockResolvedValueOnce({ - violation: { violation_level: 'warn', user_message: 'Blocked by s2' }, - }); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - inputShields: ['s1', 's2'], - }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkInput('test'); - - expect(result).toEqual({ - violation_level: 'warn', - user_message: 'Blocked by s2', - }); - }); - - it('falls back to first available shield when no inputShields configured', async () => { - mockClient.request - .mockResolvedValueOnce([ - { - identifier: 'auto_shield', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]) - .mockResolvedValueOnce({ violation: null }); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkInput('test'); - - expect(result).toBeFalsy(); - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/safety/run-shield', - expect.objectContaining({ - body: expect.objectContaining({ shield_id: 'auto_shield' }), - }), - ); - }); - - it('returns undefined when no shields available and no inputShields configured', async () => { - mockClient.request.mockResolvedValueOnce([]); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkInput('test'); - expect(result).toBeUndefined(); - }); - }); - - // --------------------------------------------------------------------------- - // checkOutput - // --------------------------------------------------------------------------- - - describe('checkOutput', () => { - it('returns undefined when safety is disabled', async () => { - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: false }), - }); - const result = await service.checkOutput('test response'); - expect(result).toBeUndefined(); - }); - - it('returns undefined when no output shields configured', async () => { - mockClient.request.mockResolvedValueOnce([ - { - identifier: 's1', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkOutput('response text'); - expect(result).toBeUndefined(); - }); - - it('checks output shields and returns violation', async () => { - const violation = { - violation_level: 'error' as const, - user_message: 'Unsafe output', - }; - - mockClient.request - .mockResolvedValueOnce([ - { - identifier: 'out1', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]) - .mockResolvedValueOnce({ violation }); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - outputShields: ['out1'], - }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkOutput('malicious content'); - - expect(result).toEqual(violation); - expect(mockClient.request).toHaveBeenCalledWith('/v1/safety/run-shield', { - method: 'POST', - body: { - shield_id: 'out1', - messages: [{ role: 'user', content: 'malicious content' }], - }, - }); - }); - }); - - // --------------------------------------------------------------------------- - // onError behavior (fail-closed / fail-open) - // --------------------------------------------------------------------------- - - describe('onError behavior', () => { - it('blocks message on shield API failure when onError is "block" (default)', async () => { - mockClient.request - .mockResolvedValueOnce([ - { - identifier: 's1', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]) - .mockRejectedValueOnce(new Error('Connection refused')); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - inputShields: ['s1'], - }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkInput('test'); - - expect(result).toBeDefined(); - expect(result!.violation_level).toBe('error'); - expect(result!.user_message).toContain('Safety check unavailable'); - expect(result!.metadata?.error).toContain('Connection refused'); - }); - - it('allows message on shield API failure when onError is "allow"', async () => { - mockClient.request - .mockResolvedValueOnce([ - { - identifier: 's1', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]) - .mockRejectedValueOnce(new Error('Connection refused')); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - inputShields: ['s1'], - onError: 'allow', - }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkInput('test'); - - expect(result).toBeUndefined(); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('allowing message (onError: allow)'), - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SafetyService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SafetyService.ts deleted file mode 100644 index 58ccaf9e763..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SafetyService.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './safety/SafetyService'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ServerCapabilities.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ServerCapabilities.ts deleted file mode 100644 index d8b5633db42..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ServerCapabilities.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './config/ServerCapabilities'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SessionStore.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SessionStore.ts deleted file mode 100644 index 53405103060..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SessionStore.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export * from '../responses-api/conversations/SessionStore'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusAggregator.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusAggregator.test.ts deleted file mode 100644 index 15f065ef692..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusAggregator.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { aggregateStatus, type StatusAggregatorDeps } from './StatusAggregator'; -import { createMockLogger } from '../../test-utils'; - -function createBaseDeps( - overrides: Partial = {}, -): StatusAggregatorDeps { - const mockStatus = { - providerId: 'llamastack', - provider: { - id: 'llamastack', - model: 'test-model', - baseUrl: 'https://localhost:8321', - connected: true, - }, - vectorStore: { id: 'default', connected: true }, - mcpServers: [], - securityMode: 'plugin-only' as const, - timestamp: '2025-01-01T00:00:00Z', - ready: true, - configurationErrors: [], - }; - - return { - llamaStackConfig: { - baseUrl: 'https://localhost:8321', - model: 'test-model', - vectorStoreIds: ['vs-1'], - vectorStoreName: 'test-store', - embeddingModel: 'all-MiniLM-L6-v2', - embeddingDimension: 384, - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - }, - resolved: { - baseUrl: 'https://localhost:8321', - model: 'test-model', - vectorStoreIds: ['vs-1'], - vectorStoreName: 'test-store', - embeddingModel: 'all-MiniLM-L6-v2', - embeddingDimension: 384, - systemPrompt: '', - enableWebSearch: false, - enableCodeInterpreter: false, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - skipTlsVerify: false, - zdrMode: false, - verboseStreamLogging: false, - }, - clientManager: {} as StatusAggregatorDeps['clientManager'], - mcpAuth: null, - mcpServers: [], - yamlMcpServers: [], - securityConfig: { mode: 'plugin-only' as const }, - vectorStoreReady: true, - statusService: { - getStatus: jest.fn().mockResolvedValue(mockStatus), - } as unknown as StatusAggregatorDeps['statusService'], - logger: createMockLogger(), - ...overrides, - }; -} - -describe('StatusAggregator', () => { - describe('aggregateStatus', () => { - it('delegates to statusService when llamaStackConfig is null', async () => { - const deps = createBaseDeps({ llamaStackConfig: null }); - await aggregateStatus(deps); - - expect(deps.statusService.getStatus).toHaveBeenCalledWith( - expect.objectContaining({ - config: null, - mcpServers: deps.yamlMcpServers, - }), - ); - }); - - it('merges resolved config into statusConfig when llamaStackConfig is set', async () => { - const deps = createBaseDeps(); - await aggregateStatus(deps); - - expect(deps.statusService.getStatus).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - model: 'test-model', - baseUrl: 'https://localhost:8321', - vectorStoreIds: ['vs-1'], - vectorStoreName: 'test-store', - embeddingModel: 'all-MiniLM-L6-v2', - }), - }), - ); - }); - - it('uses resolved model/baseUrl over llamaStackConfig values', async () => { - const deps = createBaseDeps({ - llamaStackConfig: { - baseUrl: 'https://yaml-url:8321', - model: 'yaml-model', - vectorStoreIds: [], - vectorStoreName: 'yaml-store', - embeddingModel: 'yaml-embed', - embeddingDimension: 384, - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - }, - resolved: { - baseUrl: 'https://admin-override:8321', - model: 'admin-model', - vectorStoreIds: ['vs-admin'], - vectorStoreName: 'admin-store', - embeddingModel: 'admin-embed', - embeddingDimension: 768, - systemPrompt: '', - enableWebSearch: false, - enableCodeInterpreter: false, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - skipTlsVerify: false, - zdrMode: false, - verboseStreamLogging: false, - }, - }); - - await aggregateStatus(deps); - - const call = (deps.statusService.getStatus as jest.Mock).mock.calls[0][0]; - expect(call.config.model).toBe('admin-model'); - expect(call.config.baseUrl).toBe('https://admin-override:8321'); - expect(call.config.vectorStoreIds).toEqual(['vs-admin']); - expect(call.config.vectorStoreName).toBe('admin-store'); - expect(call.config.embeddingModel).toBe('admin-embed'); - }); - - it('passes yamlServerIds derived from yamlMcpServers', async () => { - const yamlServers = [ - { id: 'server-1', name: 'S1', url: 'https://s1' }, - { id: 'server-2', name: 'S2', url: 'https://s2' }, - ] as StatusAggregatorDeps['yamlMcpServers']; - - const deps = createBaseDeps({ yamlMcpServers: yamlServers }); - await aggregateStatus(deps); - - const call = (deps.statusService.getStatus as jest.Mock).mock.calls[0][0]; - expect(call.yamlServerIds).toEqual(new Set(['server-1', 'server-2'])); - }); - - it('passes vectorStoreReady and securityConfig through', async () => { - const deps = createBaseDeps({ - vectorStoreReady: false, - securityConfig: { mode: 'full' as const }, - }); - - await aggregateStatus(deps); - - const call = (deps.statusService.getStatus as jest.Mock).mock.calls[0][0]; - expect(call.vectorStoreReady).toBe(false); - expect(call.securityConfig.mode).toBe('full'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusAggregator.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusAggregator.ts deleted file mode 100644 index 75af8b0965a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusAggregator.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './status/StatusAggregator'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusService.test.ts deleted file mode 100644 index 94aa22036da..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusService.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { StatusService, StatusDeps } from './StatusService'; -import type { ClientManager } from './ClientManager'; -import type { McpAuthService } from './McpAuthService'; -import type { LlamaStackConfig, MCPServerConfig } from '../../types'; - -jest.mock('../../services/utils/mcpClient', () => ({ - listMcpServerTools: jest.fn(), -})); - -import { listMcpServerTools } from '../../services/utils/mcpClient'; - -const mockListMcpServerTools = listMcpServerTools as jest.MockedFunction< - typeof listMcpServerTools ->; - -function createMockClient(overrides?: { - healthOk?: boolean; - healthError?: Error; - vectorStoreOk?: boolean; - vectorStoreTotal?: number; - models?: string[]; -}) { - return { - request: jest.fn().mockImplementation((path: string) => { - if (path === '/v1/health') { - if (overrides?.healthError) throw overrides.healthError; - if (overrides?.healthOk === false) - throw new Error('Connection refused'); - return Promise.resolve({ status: 'ok' }); - } - if (path === '/v1/models') { - if (overrides?.healthError) throw overrides.healthError; - if (overrides?.healthOk === false) - throw new Error('Connection refused'); - const modelList = overrides?.models ?? ['test-model']; - return Promise.resolve({ - data: modelList.map(id => ({ id })), - }); - } - if (path.startsWith('/v1/vector_stores/')) { - if (overrides?.vectorStoreOk === false) throw new Error('Not found'); - return Promise.resolve({ - file_counts: { total: overrides?.vectorStoreTotal ?? 5 }, - }); - } - return Promise.resolve({}); - }), - }; -} - -function createMockClientManager( - client: ReturnType, - hasClient = true, -): ClientManager { - return { - hasClient: jest.fn().mockReturnValue(hasClient), - getExistingClient: jest.fn().mockReturnValue(client), - getClient: jest.fn().mockReturnValue(client), - invalidate: jest.fn(), - } as unknown as ClientManager; // Partial mock; full ClientManager requires real LlamaStackClient -} - -const mockLogger = { - child: () => mockLogger, - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -} as unknown as import('@backstage/backend-plugin-api').LoggerService; - -function baseDeps(overrides?: Partial): StatusDeps { - const client = createMockClient(); - return { - config: { - baseUrl: 'https://llama.test', - model: 'test-model', - vectorStoreIds: ['vs_1'], - vectorStoreName: 'test-db', - embeddingModel: 'all-MiniLM-L6-v2', - embeddingDimension: 384, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 200, - chunkOverlapTokens: 50, - } as LlamaStackConfig, - clientManager: createMockClientManager(client), - mcpAuth: null, - mcpServers: [], - yamlServerIds: new Set(), - securityConfig: { mode: 'plugin-only' }, - vectorStoreReady: true, - logger: mockLogger, - ...overrides, - }; -} - -describe('StatusService', () => { - let service: StatusService; - - beforeEach(() => { - service = new StatusService(); - mockListMcpServerTools.mockReset(); - }); - - // =========================================================================== - // Not-configured state - // =========================================================================== - - it('returns not-configured when config is null', async () => { - const status = await service.getStatus(baseDeps({ config: null })); - - expect(status.ready).toBe(false); - expect(status.provider.connected).toBe(false); - expect(status.provider.error).toContain('not configured'); - expect(status.configurationErrors).toHaveLength(1); - }); - - it('returns not-configured when client manager has no client', async () => { - const client = createMockClient(); - const deps = baseDeps({ - clientManager: createMockClientManager(client, false), - }); - const status = await service.getStatus(deps); - - expect(status.ready).toBe(false); - expect(status.provider.connected).toBe(false); - }); - - // =========================================================================== - // Healthy state - // =========================================================================== - - it('returns healthy status when provider and vector store are ok', async () => { - const status = await service.getStatus(baseDeps()); - - expect(status.ready).toBe(true); - expect(status.provider.connected).toBe(true); - expect(status.provider.model).toBe('test-model'); - expect(status.vectorStore.connected).toBe(true); - expect(status.vectorStore.totalDocuments).toBe(5); - expect(status.configurationErrors).toHaveLength(0); - }); - - it('includes capabilities in healthy status', async () => { - const status = await service.getStatus(baseDeps()); - - expect(status.capabilities?.chat).toBe(true); - expect(status.capabilities?.rag.available).toBe(true); - expect(status.capabilities?.mcpTools.available).toBe(false); - expect(status.capabilities?.mcpTools.reason).toBe( - 'No MCP servers configured', - ); - }); - - // =========================================================================== - // Provider health failures - // =========================================================================== - - it('reports provider disconnected on health check failure', async () => { - const client = createMockClient({ healthOk: false }); - const deps = baseDeps({ - clientManager: createMockClientManager(client), - }); - const status = await service.getStatus(deps); - - expect(status.provider.connected).toBe(false); - expect(status.provider.error).toContain('Connection refused'); - expect(status.ready).toBe(false); - expect(status.configurationErrors).toHaveLength(1); - }); - - // =========================================================================== - // Vector store health - // =========================================================================== - - it('reports vector store not ready when not initialized', async () => { - const status = await service.getStatus( - baseDeps({ vectorStoreReady: false }), - ); - - expect(status.vectorStore.connected).toBe(false); - expect(status.vectorStore.error).toContain('Not initialized'); - }); - - it('reports vector store error when no IDs configured', async () => { - const deps = baseDeps({ - config: { - ...baseDeps().config!, - vectorStoreIds: [], - } as LlamaStackConfig, - }); - const status = await service.getStatus(deps); - - expect(status.vectorStore.connected).toBe(false); - expect(status.vectorStore.error).toContain('No vector store IDs'); - }); - - it('reports vector store error on fetch failure', async () => { - const client = createMockClient({ vectorStoreOk: false }); - const deps = baseDeps({ - clientManager: createMockClientManager(client), - }); - const status = await service.getStatus(deps); - - expect(status.vectorStore.connected).toBe(false); - }); - - // =========================================================================== - // MCP server health - // =========================================================================== - - it('reports MCP server as connected when health check succeeds', async () => { - const mcpServers: MCPServerConfig[] = [ - { - id: 'mcp-1', - name: 'Test MCP', - type: 'streamable-http', - url: 'http://mcp.test/sse', - }, - ]; - - mockListMcpServerTools.mockResolvedValue([ - { name: 'test-tool', description: 'A test tool' }, - ]); - - const status = await service.getStatus(baseDeps({ mcpServers })); - - expect(status.mcpServers).toHaveLength(1); - expect(status.mcpServers[0].connected).toBe(true); - expect(status.mcpServers[0].toolCount).toBe(1); - expect(status.mcpServers[0].tools).toEqual([ - { name: 'test-tool', description: 'A test tool' }, - ]); - expect(status.capabilities?.mcpTools.available).toBe(true); - }); - - it('reports MCP server as disconnected when health check fails', async () => { - const mcpServers: MCPServerConfig[] = [ - { - id: 'mcp-1', - name: 'Test MCP', - type: 'streamable-http', - url: 'http://mcp.test/sse', - }, - ]; - - mockListMcpServerTools.mockRejectedValue(new Error('HTTP 502')); - - const status = await service.getStatus(baseDeps({ mcpServers })); - - expect(status.mcpServers[0].connected).toBe(false); - expect(status.mcpServers[0].error).toBe('HTTP 502'); - expect(status.capabilities?.mcpTools.available).toBe(false); - expect(status.capabilities?.mcpTools.reason).toContain('not reachable'); - }); - - it('reports MCP server error on network failure', async () => { - const mcpServers: MCPServerConfig[] = [ - { - id: 'mcp-1', - name: 'Test MCP', - type: 'streamable-http', - url: 'http://mcp.test/sse', - }, - ]; - - mockListMcpServerTools.mockRejectedValue(new Error('ECONNREFUSED')); - - const status = await service.getStatus(baseDeps({ mcpServers })); - - expect(status.mcpServers[0].connected).toBe(false); - expect(status.mcpServers[0].error).toBe('ECONNREFUSED'); - }); - - it('includes MCP auth headers when mcpAuth is provided', async () => { - const mcpServers: MCPServerConfig[] = [ - { - id: 'mcp-1', - name: 'Test MCP', - type: 'streamable-http', - url: 'http://mcp.test/sse', - }, - ]; - - const mockMcpAuth = { - getServerHeaders: jest - .fn() - .mockResolvedValue({ Authorization: 'Bearer tok' }), - }; - - mockListMcpServerTools.mockResolvedValue([]); - - await service.getStatus( - baseDeps({ - mcpServers, - mcpAuth: mockMcpAuth as unknown as McpAuthService, - }), - ); - - expect(mockMcpAuth.getServerHeaders).toHaveBeenCalledWith(mcpServers[0]); - expect(mockListMcpServerTools).toHaveBeenCalledWith( - 'http://mcp.test/sse', - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: 'Bearer tok' }), - }), - ); - }); - - it('tags MCP servers with correct source', async () => { - const mcpServers: MCPServerConfig[] = [ - { - id: 'yaml-srv', - name: 'YAML', - type: 'streamable-http', - url: 'http://yaml.test', - }, - { - id: 'admin-srv', - name: 'Admin', - type: 'sse', - url: 'http://admin.test', - }, - ]; - - mockListMcpServerTools.mockResolvedValue([]); - - const status = await service.getStatus( - baseDeps({ - mcpServers, - yamlServerIds: new Set(['yaml-srv']), - }), - ); - - expect(status.mcpServers[0].source).toBe('yaml'); - expect(status.mcpServers[1].source).toBe('admin'); - }); - - // =========================================================================== - // Security mode - // =========================================================================== - - it('reflects the security mode in status', async () => { - const status = await service.getStatus( - baseDeps({ securityConfig: { mode: 'full' } }), - ); - expect(status.securityMode).toBe('full'); - }); - - // =========================================================================== - // Parallel health checks - // =========================================================================== - - it('runs all health checks in parallel', async () => { - const mcpServers: MCPServerConfig[] = [ - { - id: 'mcp-1', - name: 'A', - type: 'streamable-http', - url: 'http://a.test/sse', - }, - { - id: 'mcp-2', - name: 'B', - type: 'streamable-http', - url: 'http://b.test/sse', - }, - ]; - - mockListMcpServerTools.mockResolvedValue([]); - - const status = await service.getStatus(baseDeps({ mcpServers })); - - expect(status.provider.connected).toBe(true); - expect(status.vectorStore.connected).toBe(true); - expect(status.mcpServers).toHaveLength(2); - expect(status.mcpServers.every(s => s.connected)).toBe(true); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusService.ts deleted file mode 100644 index e4ed825ccb9..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StatusService.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './status/StatusService'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StreamEventNormalizer.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StreamEventNormalizer.test.ts deleted file mode 100644 index eaa7ab66ffb..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StreamEventNormalizer.test.ts +++ /dev/null @@ -1,527 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { normalizeLlamaStackEvent } from './StreamEventNormalizer'; - -describe('normalizeLlamaStackEvent', () => { - describe('malformed input', () => { - it('returns empty array for invalid JSON', () => { - expect(normalizeLlamaStackEvent('not json')).toEqual([]); - }); - - it('returns empty array for empty object', () => { - expect(normalizeLlamaStackEvent('{}')).toEqual([]); - }); - - it('handles error object without type field', () => { - const raw = JSON.stringify({ error: { message: 'server crashed' } }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.error', error: 'server crashed' }, - ]); - }); - - it('handles error string without type field', () => { - const raw = JSON.stringify({ error: 'boom' }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.error', error: 'boom' }, - ]); - }); - }); - - describe('response lifecycle', () => { - it('normalizes response.created', () => { - const raw = JSON.stringify({ - type: 'response.created', - response: { id: 'resp-1', model: 'llama-3' }, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.started', responseId: 'resp-1', model: 'llama-3' }, - ]); - }); - - it('normalizes response.in_progress to empty', () => { - const raw = JSON.stringify({ type: 'response.in_progress' }); - expect(normalizeLlamaStackEvent(raw)).toEqual([]); - }); - - it('normalizes response.completed with usage', () => { - const raw = JSON.stringify({ - type: 'response.completed', - response: { - id: 'resp-1', - usage: { - input_tokens: 100, - output_tokens: 50, - total_tokens: 150, - }, - }, - }); - const result = normalizeLlamaStackEvent(raw); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: 'stream.completed', - responseId: 'resp-1', - usage: { - input_tokens: 100, - output_tokens: 50, - total_tokens: 150, - }, - }); - }); - - it('normalizes response.completed without usage', () => { - const raw = JSON.stringify({ - type: 'response.completed', - response: { id: 'resp-1' }, - }); - const result = normalizeLlamaStackEvent(raw); - expect(result[0]).toMatchObject({ - type: 'stream.completed', - responseId: 'resp-1', - usage: undefined, - }); - }); - - it('normalizes response.failed with no details', () => { - const raw = JSON.stringify({ type: 'response.failed' }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.error', error: 'Response generation failed' }, - ]); - }); - - it('normalizes response.failed with error details', () => { - const raw = JSON.stringify({ - type: 'response.failed', - response: { - error: { - type: 'server_error', - message: 'Model returned 0 output tokens', - }, - }, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.error', error: 'Model returned 0 output tokens' }, - ]); - }); - - it('normalizes error event', () => { - const raw = JSON.stringify({ type: 'error', message: 'rate limited' }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.error', error: 'rate limited' }, - ]); - }); - }); - - describe('text streaming', () => { - it('normalizes text delta', () => { - const raw = JSON.stringify({ - type: 'response.output_text.delta', - delta: 'Hello', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.text.delta', delta: 'Hello' }, - ]); - }); - - it('ignores text delta with no delta', () => { - const raw = JSON.stringify({ type: 'response.output_text.delta' }); - expect(normalizeLlamaStackEvent(raw)).toEqual([]); - }); - - it('normalizes text done with part.text', () => { - const raw = JSON.stringify({ - type: 'response.output_text.done', - part: { text: 'full response' }, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.text.done', text: 'full response' }, - ]); - }); - - it('normalizes text done with text field', () => { - const raw = JSON.stringify({ - type: 'response.output_text.done', - text: 'full response', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.text.done', text: 'full response' }, - ]); - }); - }); - - describe('reasoning/thinking', () => { - it('normalizes reasoning delta', () => { - const raw = JSON.stringify({ - type: 'response.reasoning_text.delta', - delta: 'thinking...', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.reasoning.delta', delta: 'thinking...' }, - ]); - }); - - it('normalizes reasoning done', () => { - const raw = JSON.stringify({ - type: 'response.reasoning_text.done', - text: 'I concluded that...', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.reasoning.done', text: 'I concluded that...' }, - ]); - }); - }); - - describe('tool calls', () => { - it('normalizes MCP tool started via output_item.added', () => { - const raw = JSON.stringify({ - type: 'response.output_item.added', - item: { - id: 'call-1', - type: 'mcp_call', - name: 'search_docs', - server_label: 'my-server', - }, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.started', - callId: 'call-1', - name: 'search_docs', - serverLabel: 'my-server', - }, - ]); - }); - - it('normalizes function call started via output_item.added', () => { - const raw = JSON.stringify({ - type: 'response.output_item.added', - item: { id: 'call-2', type: 'function_call', name: 'get_weather' }, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.started', - callId: 'call-2', - name: 'get_weather', - serverLabel: undefined, - }, - ]); - }); - - it('normalizes tool arguments delta', () => { - const raw = JSON.stringify({ - type: 'response.function_call_arguments.delta', - item_id: 'call-1', - delta: '{"query":', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.tool.delta', callId: 'call-1', delta: '{"query":' }, - ]); - }); - - it('normalizes MCP call arguments delta', () => { - const raw = JSON.stringify({ - type: 'response.mcp_call.arguments.delta', - item_id: 'call-1', - delta: '"test"}', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.tool.delta', callId: 'call-1', delta: '"test"}' }, - ]); - }); - - it('normalizes MCP call completed', () => { - const raw = JSON.stringify({ - type: 'response.mcp_call.completed', - item_id: 'call-1', - name: 'search_docs', - server_label: 'my-server', - output: '{"results": []}', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.completed', - callId: 'call-1', - name: 'search_docs', - serverLabel: 'my-server', - output: '{"results": []}', - }, - ]); - }); - - it('normalizes MCP call failed', () => { - const raw = JSON.stringify({ - type: 'response.mcp_call.failed', - item_id: 'call-1', - name: 'search_docs', - error: 'timeout', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.failed', - callId: 'call-1', - name: 'search_docs', - serverLabel: undefined, - error: 'timeout', - }, - ]); - }); - - it('normalizes MCP call requires approval', () => { - const raw = JSON.stringify({ - type: 'response.mcp_call.requires_approval', - item_id: 'call-1', - name: 'delete_file', - server_label: 'my-server', - arguments: '{"path": "/tmp/x"}', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.approval', - callId: 'call-1', - name: 'delete_file', - serverLabel: 'my-server', - arguments: '{"path": "/tmp/x"}', - }, - ]); - }); - - it('normalizes tool completed via output_item.done', () => { - const raw = JSON.stringify({ - type: 'response.output_item.done', - item: { - id: 'call-1', - type: 'mcp_call', - name: 'tool', - output: 'result', - }, - }); - const result = normalizeLlamaStackEvent(raw); - expect(result).toEqual([ - { - type: 'stream.tool.completed', - callId: 'call-1', - name: 'tool', - serverLabel: undefined, - output: 'result', - }, - ]); - }); - - it('normalizes tool failed via output_item.done', () => { - const raw = JSON.stringify({ - type: 'response.output_item.done', - item: { - id: 'call-1', - type: 'function_call', - name: 'tool', - error: 'crashed', - }, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.failed', - callId: 'call-1', - name: 'tool', - serverLabel: undefined, - error: 'crashed', - }, - ]); - }); - }); - - describe('RAG / file search', () => { - it('normalizes file search results from output_item.done', () => { - const raw = JSON.stringify({ - type: 'response.output_item.done', - item: { - type: 'file_search_call', - results: [ - { - filename: 'doc.md', - file_id: 'f-1', - text: 'relevant text', - score: 0.95, - attributes: { - title: 'My Doc', - source_url: 'https://example.com', - }, - }, - ], - }, - }); - const result = normalizeLlamaStackEvent(raw); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: 'stream.rag.results', - sources: [ - { - filename: 'doc.md', - fileId: 'f-1', - text: 'relevant text', - score: 0.95, - title: 'My Doc', - sourceUrl: 'https://example.com', - }, - ], - filesSearched: ['doc.md'], - }); - }); - }); - - describe('MCP tool discovery', () => { - it('normalizes mcp_list_tools in_progress', () => { - const raw = JSON.stringify({ - type: 'response.mcp_list_tools.in_progress', - server_label: 'my-server', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.discovery', - serverLabel: 'my-server', - status: 'in_progress', - }, - ]); - }); - - it('normalizes mcp_list_tools completed', () => { - const raw = JSON.stringify({ - type: 'response.mcp_list_tools.completed', - server_label: 'my-server', - tool_count: 5, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.discovery', - serverLabel: 'my-server', - status: 'completed', - toolCount: 5, - }, - ]); - }); - }); - - describe('unknown events', () => { - it('returns empty for unrecognized event types', () => { - const raw = JSON.stringify({ type: 'response.some_future_event' }); - expect(normalizeLlamaStackEvent(raw)).toEqual([]); - }); - }); - - describe('approval via output_item.added', () => { - it('normalizes mcp_approval_request item', () => { - const raw = JSON.stringify({ - type: 'response.output_item.added', - item: { - id: 'approve-1', - type: 'mcp_approval_request', - name: 'delete_db', - server_label: 'my-server', - arguments: '{"table": "users"}', - }, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.approval', - callId: 'approve-1', - name: 'delete_db', - serverLabel: 'my-server', - arguments: '{"table": "users"}', - }, - ]); - }); - }); - - describe('error sanitization', () => { - it('sanitizes internal Kubernetes service URL', () => { - const raw = JSON.stringify({ - type: 'response.mcp_call.failed', - item_id: 'call-123', - name: 'tool_name', - server_label: 'my-mcp', - error: 'connect ECONNREFUSED http://mcp-server.namespace.svc:8080', - }); - const result = normalizeLlamaStackEvent(raw); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: 'stream.tool.failed', - callId: 'call-123', - name: 'tool_name', - serverLabel: 'my-mcp', - }); - expect((result[0] as any).error).not.toContain( - 'mcp-server.namespace.svc', - ); - expect((result[0] as any).error).not.toContain('8080'); - }); - - it('passes through errors without URLs unchanged', () => { - const raw = JSON.stringify({ - type: 'response.mcp_call.failed', - item_id: 'call-123', - name: 'tool_name', - error: 'Invalid JSON-RPC request', - }); - const result = normalizeLlamaStackEvent(raw); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: 'stream.tool.failed', - callId: 'call-123', - name: 'tool_name', - error: 'Invalid JSON-RPC request', - }); - }); - - it('sanitizes raw URL errors without server_label', () => { - const raw = JSON.stringify({ - type: 'response.mcp_call.failed', - item_id: 'call-123', - name: 'tool_name', - error: - 'Failed to connect to https://internal.example.com:8080/mcp: connection refused', - }); - const result = normalizeLlamaStackEvent(raw); - expect(result).toHaveLength(1); - expect((result[0] as any).error).not.toContain('internal.example.com'); - expect((result[0] as any).error).toContain('MCP server'); - expect((result[0] as any).error).toContain('unreachable'); - }); - - it('does not leak URLs across consecutive sanitization calls', () => { - const error1 = JSON.stringify({ - type: 'response.mcp_call.failed', - item_id: 'call-1', - name: 'tool1', - error: 'https://first-server.example.com/mcp: connection refused', - }); - const result1 = normalizeLlamaStackEvent(error1); - expect((result1[0] as any).error).not.toContain( - 'first-server.example.com', - ); - - const error2 = JSON.stringify({ - type: 'response.mcp_call.failed', - item_id: 'call-2', - name: 'tool2', - error: 'https://second-server.example.com/mcp: timeout', - }); - const result2 = normalizeLlamaStackEvent(error2); - expect((result2[0] as any).error).not.toContain( - 'second-server.example.com', - ); - expect((result2[0] as any).error).toContain('timed out'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StreamEventNormalizer.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StreamEventNormalizer.ts deleted file mode 100644 index 40ac22f325c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/StreamEventNormalizer.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/stream/StreamEventNormalizer' instead */ -export { normalizeLlamaStackEvent } from '../responses-api/stream/StreamEventNormalizer'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SyncCategorizer.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SyncCategorizer.test.ts deleted file mode 100644 index d4180f87e06..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SyncCategorizer.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { categorizeDocuments } from './SyncCategorizer'; -import type { FetchedDocument } from '../../types'; -import { FileFormat } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { DocumentInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -function doc( - fileName: string, - contentHash?: string, - sourceId = `source://${fileName}`, -): FetchedDocument { - return { - fileName, - content: `content of ${fileName}`, - sourceId, - sourceType: 'directory', - contentHash, - }; -} - -function existing(fileName: string, id: string): DocumentInfo { - return { - id, - fileName, - format: FileFormat.TEXT, - fileSize: 0, - uploadedAt: new Date().toISOString(), - status: 'completed', - }; -} - -describe('categorizeDocuments', () => { - it('puts new documents in toAdd', () => { - const fetched = [doc('new.md', 'hash1')]; - const existingMap = new Map(); - const cache = new Map(); - - const result = categorizeDocuments(fetched, existingMap, cache); - - expect(result.toAdd).toHaveLength(1); - expect(result.toAdd[0].fileName).toBe('new.md'); - expect(result.toUpdate).toHaveLength(0); - expect(result.unchanged).toHaveLength(0); - }); - - it('puts changed documents in toUpdate', () => { - const fetched = [doc('existing.md', 'newHash')]; - const existingMap = new Map([ - ['existing.md', existing('existing.md', 'id1')], - ]); - const cache = new Map([['source://existing.md', 'oldHash']]); - - const result = categorizeDocuments(fetched, existingMap, cache); - - expect(result.toAdd).toHaveLength(0); - expect(result.toUpdate).toHaveLength(1); - expect(result.toUpdate[0].fileName).toBe('existing.md'); - expect(result.unchanged).toHaveLength(0); - }); - - it('puts unchanged documents (matching hash) in unchanged', () => { - const fetched = [doc('existing.md', 'sameHash')]; - const existingMap = new Map([ - ['existing.md', existing('existing.md', 'id1')], - ]); - const cache = new Map([['source://existing.md', 'sameHash']]); - - const result = categorizeDocuments(fetched, existingMap, cache); - - expect(result.toAdd).toHaveLength(0); - expect(result.toUpdate).toHaveLength(0); - expect(result.unchanged).toEqual(['existing.md']); - }); - - it('puts documents without contentHash in unchanged', () => { - const fetched = [{ ...doc('existing.md'), contentHash: undefined }]; - const existingMap = new Map([ - ['existing.md', existing('existing.md', 'id1')], - ]); - const cache = new Map(); - - const result = categorizeDocuments(fetched, existingMap, cache); - - expect(result.toAdd).toHaveLength(0); - expect(result.toUpdate).toHaveLength(0); - expect(result.unchanged).toEqual(['existing.md']); - }); - - it('puts documents with no cached hash in unchanged', () => { - const fetched = [doc('existing.md', 'currentHash')]; - const existingMap = new Map([ - ['existing.md', existing('existing.md', 'id1')], - ]); - const cache = new Map(); - - const result = categorizeDocuments(fetched, existingMap, cache); - - expect(result.toAdd).toHaveLength(0); - expect(result.toUpdate).toHaveLength(0); - expect(result.unchanged).toEqual(['existing.md']); - }); - - it('categorizes mixed documents correctly', () => { - const fetched = [ - doc('new.md', 'hash1'), - doc('changed.md', 'newHash'), - doc('unchanged.md', 'sameHash'), - ]; - const existingMap = new Map([ - ['changed.md', existing('changed.md', 'id2')], - ['unchanged.md', existing('unchanged.md', 'id3')], - ]); - const cache = new Map([ - ['source://changed.md', 'oldHash'], - ['source://unchanged.md', 'sameHash'], - ]); - - const result = categorizeDocuments(fetched, existingMap, cache); - - expect(result.toAdd).toHaveLength(1); - expect(result.toAdd[0].fileName).toBe('new.md'); - expect(result.toUpdate).toHaveLength(1); - expect(result.toUpdate[0].fileName).toBe('changed.md'); - expect(result.unchanged).toEqual(['unchanged.md']); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SyncCategorizer.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SyncCategorizer.ts deleted file mode 100644 index b862dab8554..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/SyncCategorizer.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/documents/SyncCategorizer' instead */ -export * from '../responses-api/documents/SyncCategorizer'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ToolGroupService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ToolGroupService.ts deleted file mode 100644 index b12b4805ced..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ToolGroupService.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ResponsesApiClient } from './ResponsesApiClient'; - -/** - * Describes a tool group registered on the Llama Stack server. - * Matches the `GET /v1/toolgroups` response shape. - */ -export interface ToolGroupInfo { - identifier: string; - provider_id?: string; - provider_resource_id?: string; - type: string; - description?: string; - mcp_endpoint?: { uri: string }; - args?: Record; -} - -/** - * Service for discovering and loading tool groups from Llama Stack's - * `/v1/toolgroups` API. Enables deferred tool loading at runtime. - * - * Matches the OpenAI Agents SDK `ToolSearchTool` pattern: instead of - * statically listing all tools, agents can discover available tool - * groups and request the ones relevant to each query. - */ -export class ToolGroupService { - private cache: ToolGroupInfo[] | null = null; - private cacheExpiry = 0; - private readonly cacheTtlMs: number; - - constructor( - private readonly logger: LoggerService, - cacheTtlSeconds: number = 300, - ) { - this.cacheTtlMs = cacheTtlSeconds * 1000; - } - - async listToolGroups(client: ResponsesApiClient): Promise { - const now = Date.now(); - if (this.cache && now < this.cacheExpiry) { - return this.cache; - } - - try { - const response = await client.requestWithRetry< - { data: ToolGroupInfo[] } | ToolGroupInfo[] - >('/v1/toolgroups', { method: 'GET' }); - const groups = Array.isArray(response) ? response : (response.data ?? []); - this.cache = groups; - this.cacheExpiry = now + this.cacheTtlMs; - this.logger.info( - `[ToolGroupService] Discovered ${groups.length} tool group(s)`, - ); - return groups; - } catch (error) { - this.logger.warn( - `[ToolGroupService] Failed to list tool groups: ${error instanceof Error ? error.message : String(error)}`, - ); - return this.cache ?? []; - } - } - - async getToolGroup( - client: ResponsesApiClient, - identifier: string, - ): Promise { - try { - return await client.requestWithRetry( - `/v1/toolgroups/${encodeURIComponent(identifier)}`, - { method: 'GET' }, - ); - } catch (error) { - this.logger.warn( - `[ToolGroupService] Failed to get tool group "${identifier}": ${error instanceof Error ? error.message : String(error)}`, - ); - return undefined; - } - } - - invalidateCache(): void { - this.cache = null; - this.cacheExpiry = 0; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ToolsBuilder.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ToolsBuilder.test.ts deleted file mode 100644 index c80168eede1..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ToolsBuilder.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createMockLogger } from '../../test-utils/mocks'; -import { buildTools } from './ToolsBuilder'; -import type { ToolsBuilderDeps } from './ToolsBuilder'; -import type { EffectiveConfig, MCPServerConfig } from '../../types'; - -function makeConfig(overrides: Partial = {}): EffectiveConfig { - return { - model: 'test-model', - baseUrl: 'https://llama.example.com', - systemPrompt: '', - enableWebSearch: false, - enableCodeInterpreter: false, - vectorStoreIds: [], - vectorStoreName: 'default', - embeddingModel: 'test-embedding', - embeddingDimension: 384, - chunkingStrategy: 'static', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - skipTlsVerify: false, - zdrMode: false, - verboseStreamLogging: false, - ...overrides, - }; -} - -function makeMcpServer( - overrides: Partial = {}, -): MCPServerConfig { - return { - id: 'mcp-1', - name: 'Test MCP', - type: 'streamable-http', - url: 'https://mcp.example.com', - ...overrides, - }; -} - -describe('buildTools', () => { - const logger = createMockLogger(); - - it('returns empty array when RAG disabled and no MCP servers', async () => { - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig(), - mcpServers: [], - mcpAuth: null, - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools).toEqual([]); - }); - - it('skips MCP servers when mcpAuth is null', async () => { - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig(), - mcpServers: [makeMcpServer()], - mcpAuth: null, - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools).toEqual([]); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('McpAuthService not initialized'), - ); - }); - - it('adds file_search tool when RAG enabled and vectorStoreIds configured', async () => { - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig({ - vectorStoreIds: ['vs-1', 'vs-2'], - fileSearchMaxResults: 15, - fileSearchScoreThreshold: 0.5, - }), - mcpServers: [], - mcpAuth: null, - conversations: null, - }; - const tools = await buildTools(true, deps, logger); - expect(tools).toHaveLength(1); - expect(tools[0]).toEqual({ - type: 'file_search', - vector_store_ids: ['vs-1', 'vs-2'], - max_num_results: 15, - ranking_options: { score_threshold: 0.5 }, - }); - }); - - it('does not add file_search when RAG enabled but no vectorStoreIds', async () => { - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig({ vectorStoreIds: [] }), - mcpServers: [], - mcpAuth: null, - conversations: null, - }; - const tools = await buildTools(true, deps, logger); - expect(tools).toEqual([]); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('no vector store IDs configured'), - ); - }); - - it('adds MCP tools when mcpAuth is provided', async () => { - const mcpAuth = { - getApiApprovalConfig: jest.fn().mockReturnValue('never'), - getServerHeaders: jest.fn().mockResolvedValue({}), - }; - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig(), - mcpServers: [ - makeMcpServer({ id: 'mcp-a', url: 'https://mcp-a.example.com' }), - ], - mcpAuth: mcpAuth as unknown as ToolsBuilderDeps['mcpAuth'], - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools).toHaveLength(1); - expect(tools[0]).toEqual({ - type: 'mcp', - server_url: 'https://mcp-a.example.com', - server_label: 'mcp-a', - require_approval: 'never', - }); - expect(mcpAuth.getApiApprovalConfig).toHaveBeenCalledWith(undefined); - expect(mcpAuth.getServerHeaders).toHaveBeenCalledWith(deps.mcpServers[0]); - }); - - it('adds allowed_tools when MCP server has allowedTools', async () => { - const mcpAuth = { - getApiApprovalConfig: jest.fn().mockReturnValue('never'), - getServerHeaders: jest.fn().mockResolvedValue({}), - }; - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig(), - mcpServers: [ - makeMcpServer({ - id: 'mcp-filtered', - allowedTools: ['tool1', 'tool2'], - }), - ], - mcpAuth: mcpAuth as unknown as ToolsBuilderDeps['mcpAuth'], - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools[0]).toMatchObject({ - type: 'mcp', - allowed_tools: ['tool1', 'tool2'], - }); - }); - - it('adds headers when getServerHeaders returns non-empty', async () => { - const mcpAuth = { - getApiApprovalConfig: jest.fn().mockReturnValue('never'), - getServerHeaders: jest.fn().mockResolvedValue({ - Authorization: 'Bearer token123', - }), - }; - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig(), - mcpServers: [makeMcpServer()], - mcpAuth: mcpAuth as unknown as ToolsBuilderDeps['mcpAuth'], - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools[0]).toMatchObject({ - headers: { Authorization: 'Bearer token123' }, - }); - }); - - it('adds custom function tools from config', async () => { - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig({ - functions: [ - { - name: 'search_catalog', - description: 'Search the catalog', - parameters: { - type: 'object', - properties: { q: { type: 'string' } }, - }, - strict: true, - }, - ], - }), - mcpServers: [], - mcpAuth: null, - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools).toHaveLength(1); - expect(tools[0]).toEqual({ - type: 'function', - name: 'search_catalog', - description: 'Search the catalog', - parameters: { type: 'object', properties: { q: { type: 'string' } } }, - strict: true, - }); - }); - - it('adds web_search when enableWebSearch is true', async () => { - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig({ enableWebSearch: true }), - mcpServers: [], - mcpAuth: null, - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools).toContainEqual({ type: 'web_search' }); - }); - - it('adds code_interpreter when enableCodeInterpreter is true', async () => { - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig({ enableCodeInterpreter: true }), - mcpServers: [], - mcpAuth: null, - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools).toContainEqual({ type: 'code_interpreter' }); - }); - - it('combines file_search, MCP, functions, web_search, and code_interpreter', async () => { - const mcpAuth = { - getApiApprovalConfig: jest.fn().mockReturnValue('never'), - getServerHeaders: jest.fn().mockResolvedValue({}), - }; - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig({ - vectorStoreIds: ['vs-1'], - enableWebSearch: true, - enableCodeInterpreter: true, - functions: [ - { - name: 'my_func', - description: 'desc', - parameters: {}, - }, - ], - }), - mcpServers: [makeMcpServer()], - mcpAuth: mcpAuth as unknown as ToolsBuilderDeps['mcpAuth'], - conversations: null, - }; - const tools = await buildTools(true, deps, logger); - const types = tools.map(t => t.type); - expect(types).toContain('file_search'); - expect(types).toContain('mcp'); - expect(types).toContain('function'); - expect(types).toContain('web_search'); - expect(types).toContain('code_interpreter'); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ToolsBuilder.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ToolsBuilder.ts deleted file mode 100644 index 5fe766515c5..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ToolsBuilder.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/tools/ToolsBuilder' instead */ -export * from '../responses-api/tools/ToolsBuilder'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreFacade.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreFacade.test.ts deleted file mode 100644 index b3562dec610..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreFacade.test.ts +++ /dev/null @@ -1,753 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { VectorStoreFacade } from './VectorStoreFacade'; -import type { - VectorStoreFacadeContext, - VectorStoreFacadeDeps, -} from './VectorStoreFacade'; -import type { ConfigResolutionService } from './ConfigResolutionService'; -import type { VectorStoreService } from './VectorStoreService'; -import type { DocumentSyncService } from './DocumentSyncService'; -import { FileFormat } from '../../types'; -import type { - EffectiveConfig, - LlamaStackConfig, - DocumentInfo, - VectorStoreInfo, -} from '../../types'; -import { createMockLogger } from '../../test-utils/mocks'; - -const DEFAULT_EFFECTIVE_CONFIG = { - vectorStoreIds: ['vs_123'], - vectorStoreName: 'test-store', - embeddingModel: 'test-model', - embeddingDimension: 384, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - searchMode: 'semantic' as const, - bm25Weight: 0.5, - semanticWeight: 0.5, - fileSearchMaxResults: 5, - fileSearchScoreThreshold: 0.7, -} as EffectiveConfig; - -const DEFAULT_LLAMA_CONFIG = { - vectorStoreIds: ['vs_123'], - vectorStoreName: 'test-store', - embeddingModel: 'test-model', - embeddingDimension: 384, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, -} as LlamaStackConfig; - -function createMockConfigResolution(): jest.Mocked { - return { - resolve: jest.fn().mockResolvedValue(DEFAULT_EFFECTIVE_CONFIG), - buildYamlFallback: jest.fn().mockReturnValue(DEFAULT_EFFECTIVE_CONFIG), - getLlamaStackConfig: jest.fn().mockReturnValue(DEFAULT_LLAMA_CONFIG), - setLlamaStackConfig: jest.fn(), - invalidateCache: jest.fn(), - getResolver: jest.fn().mockReturnValue(null), - getLastResolvedModel: jest.fn().mockReturnValue(null), - setLastResolvedModel: jest.fn(), - setLastResolvedVerboseLogging: jest.fn(), - isVerboseStreamLoggingEnabled: jest.fn().mockReturnValue(false), - setSystemPrompt: jest.fn(), - setResolver: jest.fn(), - } as unknown as jest.Mocked; -} - -type MockedVectorStoreFacadeContext = jest.Mocked< - Omit -> & { - configResolution: jest.Mocked; -}; - -function createMockContext( - overrides?: Partial, -): MockedVectorStoreFacadeContext { - return { - ensureInitialized: jest.fn(), - configResolution: createMockConfigResolution(), - getVectorStoreReady: jest.fn().mockReturnValue(true), - setVectorStoreReady: jest.fn(), - getInitialized: jest.fn().mockReturnValue(true), - ...overrides, - } as MockedVectorStoreFacadeContext; -} - -function createMockVectorStore(): jest.Mocked { - return { - listDocuments: jest.fn().mockResolvedValue([]), - listVectorStores: jest.fn().mockResolvedValue([]), - getDefaultVectorStoreId: jest.fn().mockReturnValue('vs_123'), - updateConfig: jest.fn(), - ensureExists: jest.fn().mockResolvedValue(undefined), - getConfig: jest.fn().mockReturnValue(DEFAULT_LLAMA_CONFIG), - uploadDocuments: jest.fn().mockResolvedValue({ uploaded: [], failed: [] }), - deleteDocument: jest.fn().mockResolvedValue({ success: true }), - deleteVectorStore: jest - .fn() - .mockResolvedValue({ success: true, filesDeleted: 0 }), - searchSingle: jest.fn().mockResolvedValue([]), - createWithConfig: jest.fn().mockResolvedValue({ - vectorStoreId: 'vs_new', - vectorStoreName: 'new-store', - created: true, - embeddingModel: 'test-model', - embeddingDimension: 384, - }), - } as unknown as jest.Mocked; -} - -function createMockDocSync(): jest.Mocked { - return { - sync: jest.fn().mockResolvedValue({ - added: 0, - removed: 0, - failed: 0, - unchanged: 0, - updated: 0, - errors: [], - }), - getSyncSchedule: jest.fn().mockReturnValue('0 * * * *'), - } as unknown as jest.Mocked; -} - -describe('VectorStoreFacade', () => { - let mockLogger: ReturnType; - let mockContext: MockedVectorStoreFacadeContext; - let mockVectorStore: jest.Mocked; - let mockDocSync: jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - mockLogger = createMockLogger(); - mockContext = createMockContext(); - mockVectorStore = createMockVectorStore(); - mockDocSync = createMockDocSync(); - }); - - function createFacade( - deps?: Partial, - ): VectorStoreFacade { - return new VectorStoreFacade({ - vectorStore: mockVectorStore, - docSync: mockDocSync, - logger: mockLogger, - context: mockContext, - ...deps, - }); - } - - describe('constructor and setServices', () => { - it('creates instance with dependencies', () => { - const facade = createFacade(); - expect(facade).toBeDefined(); - }); - - it('setServices updates vector store and doc sync references', () => { - const facade = createFacade({ vectorStore: null, docSync: null }); - facade.setServices(mockVectorStore, mockDocSync); - // Delegation will now use the new services - expect(mockVectorStore).toBeDefined(); - }); - }); - - describe('listDocuments', () => { - it('delegates to vectorStore when initialized and ready', async () => { - const docs: DocumentInfo[] = [ - { - id: 'f1', - fileName: 'a.md', - format: FileFormat.MARKDOWN, - fileSize: 100, - uploadedAt: '', - status: 'completed', - }, - ]; - mockVectorStore.listDocuments.mockResolvedValue(docs); - - const facade = createFacade(); - const result = await facade.listDocuments('vs_123'); - - expect(mockContext.ensureInitialized).toHaveBeenCalled(); - expect(mockVectorStore.listDocuments).toHaveBeenCalledWith('vs_123'); - expect(result).toEqual(docs); - }); - - it('returns empty array when vectorStore is null', async () => { - const facade = createFacade({ vectorStore: null }); - const result = await facade.listDocuments(); - expect(result).toEqual([]); - expect(mockVectorStore.listDocuments).not.toHaveBeenCalled(); - }); - - it('lazy-initializes vector store when not ready and lists documents', async () => { - mockContext.getVectorStoreReady.mockReturnValue(false); - const docs: DocumentInfo[] = [ - { - id: 'f2', - fileName: 'b.md', - format: FileFormat.MARKDOWN, - fileSize: 200, - uploadedAt: '', - status: 'completed', - }, - ]; - mockVectorStore.listDocuments.mockResolvedValue(docs); - - const facade = createFacade(); - const result = await facade.listDocuments(); - - expect(mockVectorStore.ensureExists).toHaveBeenCalled(); - expect(mockContext.setVectorStoreReady).toHaveBeenCalledWith(true); - expect(mockVectorStore.listDocuments).toHaveBeenCalled(); - expect(result).toEqual(docs); - }); - - it('returns empty array when lazy init fails', async () => { - mockContext.getVectorStoreReady.mockReturnValue(false); - mockVectorStore.ensureExists.mockRejectedValue( - new Error('server unreachable'), - ); - - const facade = createFacade(); - const result = await facade.listDocuments(); - - expect(result).toEqual([]); - expect(mockVectorStore.listDocuments).not.toHaveBeenCalled(); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Vector store init failed'), - ); - }); - }); - - describe('listVectorStores', () => { - it('delegates to vectorStore', async () => { - const stores: VectorStoreInfo[] = [ - { - id: 'vs_1', - name: 'Store 1', - status: 'completed', - fileCount: 5, - createdAt: 0, - }, - ]; - mockVectorStore.listVectorStores.mockResolvedValue(stores); - - const facade = createFacade(); - const result = await facade.listVectorStores(); - - expect(mockContext.ensureInitialized).toHaveBeenCalled(); - expect(mockVectorStore.listVectorStores).toHaveBeenCalled(); - expect(result).toEqual(stores); - }); - - it('returns empty array when vectorStore is null', async () => { - const facade = createFacade({ vectorStore: null }); - const result = await facade.listVectorStores(); - expect(result).toEqual([]); - }); - }); - - describe('getDefaultVectorStoreId', () => { - it('delegates to vectorStore', () => { - mockVectorStore.getDefaultVectorStoreId.mockReturnValue('vs_default'); - const facade = createFacade(); - expect(facade.getDefaultVectorStoreId()).toBe('vs_default'); - }); - - it('returns undefined when vectorStore is null', () => { - const facade = createFacade({ vectorStore: null }); - expect(facade.getDefaultVectorStoreId()).toBeUndefined(); - }); - }); - - describe('getActiveVectorStoreIds', () => { - it('returns resolved config vectorStoreIds when resolveConfig succeeds', async () => { - mockContext.configResolution.resolve.mockResolvedValue({ - vectorStoreIds: ['vs_a', 'vs_b'], - } as EffectiveConfig); - - const facade = createFacade(); - const result = await facade.getActiveVectorStoreIds(); - - expect(result).toEqual(['vs_a', 'vs_b']); - }); - - it('falls back to config.vectorStoreIds when resolveConfig fails', async () => { - mockContext.configResolution.resolve.mockRejectedValue( - new Error('Config resolution failed'), - ); - const config = { - vectorStoreIds: ['vs_fallback'], - vectorStoreName: 'x', - } as LlamaStackConfig; - mockContext.configResolution.getLlamaStackConfig.mockReturnValue(config); - - const facade = createFacade(); - const result = await facade.getActiveVectorStoreIds(); - - expect(mockLogger.debug).toHaveBeenCalledWith( - 'Config resolution failed, using fallback vector store IDs', - expect.any(Error), - ); - expect(result).toEqual(['vs_fallback']); - }); - - it('returns empty array when config is null', async () => { - mockContext.configResolution.getLlamaStackConfig.mockReturnValue(null); - const facade = createFacade(); - const result = await facade.getActiveVectorStoreIds(); - expect(result).toEqual([]); - }); - - it('returns resolved vectorStoreIds when resolveConfig succeeds', async () => { - mockContext.configResolution.resolve.mockResolvedValue({ - vectorStoreIds: ['vs_resolved'], - } as EffectiveConfig); - - const facade = createFacade(); - const result = await facade.getActiveVectorStoreIds(); - expect(result).toEqual(['vs_resolved']); - }); - }); - - describe('addVectorStoreId and removeVectorStoreId', () => { - it('addVectorStoreId adds id and invalidates cache', () => { - const config = { - vectorStoreIds: ['vs_1'], - vectorStoreName: 'x', - } as LlamaStackConfig; - mockContext.configResolution.getLlamaStackConfig.mockReturnValue(config); - - const facade = createFacade(); - facade.addVectorStoreId('vs_2'); - - expect(config.vectorStoreIds).toContain('vs_2'); - expect(mockVectorStore.updateConfig).toHaveBeenCalledWith(config); - expect(mockContext.configResolution.invalidateCache).toHaveBeenCalled(); - }); - - it('addVectorStoreId does nothing when config is null', () => { - mockContext.configResolution.getLlamaStackConfig.mockReturnValue(null); - const facade = createFacade({ vectorStore: null }); - facade.addVectorStoreId('vs_x'); - expect( - mockContext.configResolution.invalidateCache, - ).not.toHaveBeenCalled(); - }); - - it('removeVectorStoreId removes id and invalidates cache', () => { - const config = { - vectorStoreIds: ['vs_1', 'vs_2'], - vectorStoreName: 'x', - } as LlamaStackConfig; - mockContext.configResolution.getLlamaStackConfig.mockReturnValue(config); - - const facade = createFacade(); - facade.removeVectorStoreId('vs_1'); - - expect(config.vectorStoreIds).toEqual(['vs_2']); - expect(mockVectorStore.updateConfig).toHaveBeenCalledWith(config); - expect(mockContext.configResolution.invalidateCache).toHaveBeenCalled(); - }); - }); - - describe('syncDocuments', () => { - it('delegates to docSync when services available', async () => { - const syncResult = { - added: 2, - removed: 0, - failed: 0, - unchanged: 1, - updated: 0, - errors: [], - }; - mockDocSync.sync.mockResolvedValue(syncResult); - - const facade = createFacade(); - const result = await facade.syncDocuments(); - - expect(mockContext.ensureInitialized).toHaveBeenCalled(); - expect(mockDocSync.sync).toHaveBeenCalled(); - expect(result).toEqual(syncResult); - }); - - it('returns empty sync result when docSync is null', async () => { - const facade = createFacade({ docSync: null }); - const result = await facade.syncDocuments(); - expect(result).toEqual({ - added: 0, - removed: 0, - failed: 0, - unchanged: 0, - updated: 0, - errors: [], - }); - expect(mockDocSync.sync).not.toHaveBeenCalled(); - }); - }); - - describe('uploadDocument', () => { - it('delegates to vectorStore and returns uploaded doc', async () => { - mockVectorStore.uploadDocuments.mockResolvedValue({ - uploaded: [{ id: 'f1', fileName: 'doc.md', status: 'completed' }], - failed: [], - }); - - const facade = createFacade(); - const result = await facade.uploadDocument( - 'doc.md', - Buffer.from('content'), - ); - - expect(mockContext.ensureInitialized).toHaveBeenCalled(); - expect(mockVectorStore.uploadDocuments).toHaveBeenCalledWith( - [{ fileName: 'doc.md', content: 'content' }], - undefined, - ); - expect(result).toEqual({ - fileId: 'f1', - fileName: 'doc.md', - status: 'completed', - }); - }); - - it('throws when vectorStore is null', async () => { - const facade = createFacade({ vectorStore: null }); - await expect( - facade.uploadDocument('x.md', Buffer.from('x')), - ).rejects.toThrow('Vector store service not available'); - }); - - it('throws when upload has failures', async () => { - mockVectorStore.uploadDocuments.mockResolvedValue({ - uploaded: [], - failed: [{ fileName: 'bad.md', error: 'Invalid format' }], - }); - - const facade = createFacade(); - await expect( - facade.uploadDocument('bad.md', Buffer.from('x')), - ).rejects.toThrow('Upload failed for bad.md: Invalid format'); - }); - }); - - describe('deleteDocument', () => { - it('delegates to vectorStore', async () => { - mockVectorStore.deleteDocument.mockResolvedValue({ success: true }); - const facade = createFacade(); - const result = await facade.deleteDocument('f1', 'vs_123'); - expect(mockVectorStore.deleteDocument).toHaveBeenCalledWith( - 'f1', - 'vs_123', - ); - expect(result).toEqual({ success: true }); - }); - - it('throws when vectorStore is null', async () => { - const facade = createFacade({ vectorStore: null }); - await expect(facade.deleteDocument('f1')).rejects.toThrow( - 'Vector store service not available', - ); - }); - - it('lazy-initializes vector store when not ready and deletes document', async () => { - mockContext.getVectorStoreReady.mockReturnValue(false); - mockVectorStore.deleteDocument.mockResolvedValue({ success: true }); - - const facade = createFacade(); - const result = await facade.deleteDocument('f1'); - - expect(mockVectorStore.ensureExists).toHaveBeenCalled(); - expect(mockContext.setVectorStoreReady).toHaveBeenCalledWith(true); - expect(mockVectorStore.deleteDocument).toHaveBeenCalledWith( - 'f1', - undefined, - ); - expect(result).toEqual({ success: true }); - }); - - it('throws when lazy init fails for delete', async () => { - mockContext.getVectorStoreReady.mockReturnValue(false); - mockVectorStore.ensureExists.mockRejectedValue( - new Error('server unreachable'), - ); - - const facade = createFacade(); - await expect(facade.deleteDocument('f1')).rejects.toThrow( - 'Vector store initialization failed', - ); - }); - }); - - describe('deleteVectorStore', () => { - it('delegates to vectorStore', async () => { - mockVectorStore.deleteVectorStore.mockResolvedValue({ - success: true, - filesDeleted: 5, - }); - const facade = createFacade(); - const result = await facade.deleteVectorStore('vs_123'); - expect(mockVectorStore.deleteVectorStore).toHaveBeenCalledWith('vs_123'); - expect(result).toEqual({ success: true, filesDeleted: 5 }); - }); - - it('throws when vectorStore is null', async () => { - const facade = createFacade({ vectorStore: null }); - await expect(facade.deleteVectorStore('vs_123')).rejects.toThrow( - 'Vector store service not available', - ); - }); - }); - - describe('searchVectorStore', () => { - it('delegates to vectorStore.searchSingle for each store', async () => { - mockVectorStore.searchSingle - .mockResolvedValueOnce([{ text: 'chunk1', score: 0.9 }]) - .mockResolvedValueOnce([{ text: 'chunk2', score: 0.8 }]); - - const facade = createFacade(); - const result = await facade.searchVectorStore('query', 5, undefined, [ - 'vs_1', - 'vs_2', - ]); - - expect(mockVectorStore.searchSingle).toHaveBeenCalledWith( - 'vs_1', - 'query', - 5, - ); - expect(mockVectorStore.searchSingle).toHaveBeenCalledWith( - 'vs_2', - 'query', - 5, - ); - expect(result.chunks).toHaveLength(2); - expect(result.vectorStoreId).toBe('vs_1,vs_2'); - }); - - it('throws when vectorStore is null', async () => { - const facade = createFacade({ vectorStore: null }); - await expect(facade.searchVectorStore('q')).rejects.toThrow( - 'Vector store service not available', - ); - }); - - it('throws when no vector store available to search', async () => { - mockVectorStore.getDefaultVectorStoreId.mockReturnValue(undefined); - const facade = createFacade(); - await expect(facade.searchVectorStore('q')).rejects.toThrow( - 'No vector store available to search', - ); - }); - }); - - describe('getSyncSchedule', () => { - it('returns docSync.getSyncSchedule when docSync exists', () => { - mockDocSync.getSyncSchedule.mockReturnValue('0 0 * * *'); - const facade = createFacade(); - expect(facade.getSyncSchedule()).toBe('0 0 * * *'); - }); - - it('returns undefined when docSync is null', () => { - const facade = createFacade({ docSync: null }); - expect(facade.getSyncSchedule()).toBeUndefined(); - }); - }); - - describe('getVectorStoreConfig', () => { - it('returns config when resolveConfig succeeds', async () => { - const cfg = { - vectorStoreName: 'vs', - embeddingModel: 'emb', - embeddingDimension: 384, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - searchMode: 'semantic' as const, - bm25Weight: 0.5, - semanticWeight: 0.5, - fileSearchMaxResults: 5, - fileSearchScoreThreshold: 0.7, - }; - mockContext.configResolution.resolve.mockResolvedValue( - cfg as EffectiveConfig, - ); - - const facade = createFacade(); - const result = await facade.getVectorStoreConfig(); - - expect(result).toMatchObject({ - vectorStoreName: 'vs', - embeddingModel: 'emb', - embeddingDimension: 384, - }); - }); - - it('uses buildYamlFallbackConfig when resolveConfig fails', async () => { - mockContext.configResolution.resolve.mockRejectedValue(new Error('fail')); - mockContext.configResolution.buildYamlFallback.mockReturnValue({ - vectorStoreName: 'fallback', - embeddingModel: 'emb', - embeddingDimension: 384, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - } as EffectiveConfig); - - const facade = createFacade(); - const result = await facade.getVectorStoreConfig(); - - expect(mockLogger.debug).toHaveBeenCalled(); - expect(result?.vectorStoreName).toBe('fallback'); - }); - - it('returns null when not initialized', async () => { - mockContext.getInitialized.mockReturnValue(false); - const facade = createFacade(); - const result = await facade.getVectorStoreConfig(); - expect(result).toBeNull(); - }); - }); - - describe('getVectorStoreStatus', () => { - it('returns exists: false when not initialized', async () => { - mockContext.getInitialized.mockReturnValue(false); - const facade = createFacade(); - const result = await facade.getVectorStoreStatus(); - expect(result).toEqual({ exists: false, ready: false }); - }); - - it('returns exists: false when vectorStore is null', async () => { - mockContext.configResolution.getLlamaStackConfig.mockReturnValue(null); - const facade = createFacade({ vectorStore: null }); - const result = await facade.getVectorStoreStatus(); - expect(result).toEqual({ exists: false, ready: false }); - }); - - it('uses fallback config when resolveConfig fails', async () => { - mockContext.configResolution.resolve.mockRejectedValue(new Error('fail')); - mockContext.configResolution.buildYamlFallback.mockReturnValue({ - vectorStoreName: 'fallback', - embeddingModel: 'emb', - } as EffectiveConfig); - - const facade = createFacade(); - const result = await facade.getVectorStoreStatus(); - - expect(mockLogger.debug).toHaveBeenCalled(); - expect(result.vectorStoreName).toBe('fallback'); - }); - - it('returns full status when ready and listDocuments succeeds', async () => { - mockVectorStore.listDocuments.mockResolvedValue([ - { - id: 'f1', - fileName: 'a.md', - format: FileFormat.MARKDOWN, - fileSize: 10, - uploadedAt: '', - status: 'completed', - }, - ]); - mockVectorStore.getDefaultVectorStoreId.mockReturnValue('vs_123'); - - const facade = createFacade(); - const result = await facade.getVectorStoreStatus(); - - expect(result).toEqual({ - exists: true, - ready: true, - vectorStoreId: 'vs_123', - vectorStoreName: 'test-store', - documentCount: 1, - embeddingModel: 'test-model', - }); - }); - - it('returns exists: false when ensureVectorStoreReady throws', async () => { - mockContext.getVectorStoreReady.mockReturnValue(false); - mockVectorStore.ensureExists.mockRejectedValue(new Error('not ready')); - - const facade = createFacade(); - const result = await facade.getVectorStoreStatus(); - - expect(mockLogger.debug).toHaveBeenCalledWith( - 'Vector store readiness check failed, using existing store', - expect.any(Error), - ); - expect(result).toEqual({ - exists: false, - ready: false, - vectorStoreName: 'test-store', - embeddingModel: 'test-model', - }); - }); - - it('returns status without documentCount when listDocuments throws', async () => { - mockVectorStore.listDocuments.mockRejectedValue(new Error('list failed')); - - const facade = createFacade(); - const result = await facade.getVectorStoreStatus(); - - expect(mockLogger.warn).toHaveBeenCalled(); - expect(result.exists).toBe(true); - expect(result.ready).toBe(true); - expect(result.documentCount).toBeUndefined(); - }); - }); - - describe('createVectorStoreWithConfig', () => { - it('delegates to vectorStore and merges new ID', async () => { - mockVectorStore.createWithConfig.mockResolvedValue({ - vectorStoreId: 'vs_new', - vectorStoreName: 'new-store', - created: true, - embeddingModel: 'emb', - embeddingDimension: 384, - }); - mockContext.configResolution.getLlamaStackConfig.mockReturnValue({ - vectorStoreIds: ['vs_old'], - vectorStoreName: 'x', - } as LlamaStackConfig); - mockVectorStore.getConfig.mockReturnValue({ - vectorStoreIds: ['vs_new'], - vectorStoreName: 'new-store', - } as LlamaStackConfig); - - const facade = createFacade(); - const result = await facade.createVectorStoreWithConfig({ - vectorStoreName: 'new-store', - }); - - expect(mockVectorStore.createWithConfig).toHaveBeenCalled(); - expect(result.vectorStoreId).toBe('vs_new'); - expect(result.created).toBe(true); - }); - - it('throws when vectorStore is null', async () => { - const facade = createFacade({ vectorStore: null }); - await expect(facade.createVectorStoreWithConfig({})).rejects.toThrow( - 'Vector store service not available', - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreFacade.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreFacade.ts deleted file mode 100644 index 30bb059951f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreFacade.ts +++ /dev/null @@ -1,356 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { DocumentInfo, VectorStoreInfo } from '../../types'; -import type { VectorStoreService } from './VectorStoreService'; -import type { DocumentSyncService, SyncResult } from './DocumentSyncService'; -import type { ConfigResolutionService } from './ConfigResolutionService'; -import { toErrorMessage } from '../../services/utils'; -import { - getVectorStoreConfig as queryGetConfig, - getVectorStoreStatus as queryGetStatus, - searchVectorStore as querySearch, - syncDocuments as querySyncDocs, - uploadDocument as queryUploadDoc, -} from './VectorStoreQueryOps'; - -/** - * Context provided by the orchestrator for vector store operations. - * Config resolution is delegated to ConfigResolutionService; only - * orchestrator lifecycle state is passed via callbacks. - */ -export interface VectorStoreFacadeContext { - ensureInitialized: () => void; - configResolution: ConfigResolutionService; - getVectorStoreReady: () => boolean; - setVectorStoreReady: (ready: boolean) => void; - getInitialized: () => boolean; -} - -/** - * Dependencies for VectorStoreFacade. - */ -export interface VectorStoreFacadeDeps { - vectorStore: VectorStoreService | null; - docSync: DocumentSyncService | null; - logger: LoggerService; - context: VectorStoreFacadeContext; -} - -/** - * Facade for all vector-store-related operations. - * Extracted from ResponsesApiCoordinator to separate concerns. - */ -export class VectorStoreFacade { - private vectorStore: VectorStoreService | null; - private docSync: DocumentSyncService | null; - private readonly logger: LoggerService; - private readonly ctx: VectorStoreFacadeContext; - private initPromise: Promise | null = null; - - constructor(deps: VectorStoreFacadeDeps) { - this.vectorStore = deps.vectorStore; - this.docSync = deps.docSync; - this.logger = deps.logger; - this.ctx = deps.context; - } - - /** - * Update the underlying vector store and doc sync references. - * Called by the orchestrator after initialization. - */ - setServices( - vectorStore: VectorStoreService | null, - docSync: DocumentSyncService | null, - ): void { - this.vectorStore = vectorStore; - this.docSync = docSync; - } - - /** - * Lazily ensure the vector store exists on the Llama Stack server. - * Called on first sync or RAG operation rather than at plugin startup - * so that boot time is not affected by network calls. - */ - private async ensureVectorStoreReady(): Promise { - if (this.ctx.getVectorStoreReady()) return; - if (!this.vectorStore) { - throw new Error('Vector store service not created'); - } - - // Single-flight guard: concurrent callers share one init attempt - if (this.initPromise !== null) { - await this.initPromise; - return; - } - - this.initPromise = this.doVectorStoreInit(); - try { - await this.initPromise; - } finally { - this.initPromise = null; - } - } - - private async doVectorStoreInit(): Promise { - const vs = this.vectorStore; - if (!vs) { - throw new Error('Vector store service not created'); - } - try { - await vs.ensureExists(); - this.ctx.configResolution.setLlamaStackConfig(vs.getConfig()); - this.ctx.setVectorStoreReady(true); - this.logger.info('Vector store initialized on first use'); - } catch (error) { - throw new Error( - `Vector store initialization failed: ${toErrorMessage(error)}`, - ); - } - } - - /** - * List all documents in the vector store - */ - async listDocuments(vectorStoreId?: string): Promise { - this.ctx.ensureInitialized(); - if (!this.vectorStore) { - return []; - } - try { - await this.ensureVectorStoreReady(); - } catch (error) { - this.logger.warn( - `Vector store init failed, cannot list documents: ${toErrorMessage(error)}`, - ); - return []; - } - return this.vectorStore.listDocuments(vectorStoreId); - } - - /** - * List all available vector stores - */ - async listVectorStores(): Promise { - this.ctx.ensureInitialized(); - if (!this.vectorStore) { - return []; - } - return this.vectorStore.listVectorStores(); - } - - /** - * Get the default vector store ID - */ - getDefaultVectorStoreId(): string | undefined { - return this.vectorStore?.getDefaultVectorStoreId(); - } - - async getActiveVectorStoreIds(): Promise { - const config = this.ctx.configResolution.getLlamaStackConfig(); - if (!config) return []; - try { - const resolved = await this.ctx.configResolution.resolve(); - return resolved.vectorStoreIds; - } catch (error) { - this.logger.debug( - 'Config resolution failed, using fallback vector store IDs', - error as Error, - ); - return config.vectorStoreIds ?? []; - } - } - - addVectorStoreId(id: string): void { - const config = this.ctx.configResolution.getLlamaStackConfig(); - if (!config) return; - if (!config.vectorStoreIds.includes(id)) { - config.vectorStoreIds.push(id); - if (this.vectorStore) { - this.vectorStore.updateConfig(config); - } - this.ctx.configResolution.invalidateCache(); - } - } - - removeVectorStoreId(id: string): void { - const config = this.ctx.configResolution.getLlamaStackConfig(); - if (!config) return; - config.vectorStoreIds = config.vectorStoreIds.filter(vsId => vsId !== id); - if (this.vectorStore) { - this.vectorStore.updateConfig(config); - } - this.ctx.configResolution.invalidateCache(); - } - - async syncDocuments(): Promise { - return querySyncDocs(this.vectorStore, this.docSync, this.ctx, () => - this.ensureVectorStoreReady(), - ); - } - - async uploadDocument( - fileName: string, - content: Buffer, - vectorStoreId?: string, - ): Promise<{ fileId: string; fileName: string; status: string }> { - if (!this.vectorStore) - throw new Error('Vector store service not available'); - return queryUploadDoc( - this.vectorStore, - this.ctx, - () => this.ensureVectorStoreReady(), - fileName, - content, - vectorStoreId, - ); - } - - /** - * Delete a document from the vector store. - */ - async deleteDocument( - fileId: string, - vectorStoreId?: string, - ): Promise<{ success: boolean }> { - this.ctx.ensureInitialized(); - if (!this.vectorStore) { - throw new Error('Vector store service not available'); - } - await this.ensureVectorStoreReady(); - return this.vectorStore.deleteDocument(fileId, vectorStoreId); - } - - /** - * Permanently delete a vector store and all its files from the server. - */ - async deleteVectorStore( - vectorStoreId: string, - ): Promise<{ success: boolean; filesDeleted: number }> { - this.ctx.ensureInitialized(); - if (!this.vectorStore) { - throw new Error('Vector store service not available'); - } - return this.vectorStore.deleteVectorStore(vectorStoreId); - } - - async searchVectorStore( - query: string, - maxResults: number = 5, - targetVectorStoreId?: string, - targetVectorStoreIds?: string[], - ): Promise<{ - query: string; - chunks: Array<{ - text: string; - score?: number; - fileId?: string; - fileName?: string; - vectorStoreId?: string; - }>; - vectorStoreId: string; - totalResults: number; - }> { - this.ctx.ensureInitialized(); - if (!this.vectorStore) - throw new Error('Vector store service not available'); - return querySearch( - this.vectorStore, - query, - maxResults, - targetVectorStoreId, - targetVectorStoreIds, - this.logger, - ); - } - - /** - * Get the sync schedule configuration - */ - getSyncSchedule(): string | undefined { - return this.docSync?.getSyncSchedule(); - } - - /** - * Return the vector-store-related subset of the effective config. - */ - async getVectorStoreConfig(): Promise<{ - vectorStoreName: string; - embeddingModel: string; - embeddingDimension: number; - searchMode?: 'semantic' | 'keyword' | 'hybrid'; - bm25Weight?: number; - semanticWeight?: number; - chunkingStrategy: 'auto' | 'static'; - maxChunkSizeTokens: number; - chunkOverlapTokens: number; - fileSearchMaxResults?: number; - fileSearchScoreThreshold?: number; - } | null> { - return queryGetConfig(this.ctx, this.logger); - } - - async createVectorStoreWithConfig( - overrides: Record, - ): Promise<{ - vectorStoreId: string; - vectorStoreName: string; - created: boolean; - embeddingModel: string; - embeddingDimension?: number; - }> { - this.ctx.ensureInitialized(); - if (!this.vectorStore || !this.ctx.configResolution.getLlamaStackConfig()) - throw new Error('Vector store service not available'); - const existingIds = await this.getActiveVectorStoreIds(); - const config = this.ctx.configResolution.getLlamaStackConfig()!; - const result = await this.vectorStore.createWithConfig(config, overrides); - this.ctx.configResolution.setLlamaStackConfig(this.vectorStore.getConfig()); - this.ctx.setVectorStoreReady(false); - await this.ensureVectorStoreReady(); - const newId = result.vectorStoreId; - const mergedIds = [...new Set([...existingIds, newId])]; - const updatedConfig = this.ctx.configResolution.getLlamaStackConfig()!; - updatedConfig.vectorStoreIds = mergedIds; - this.vectorStore.updateConfig(updatedConfig); - return { - vectorStoreId: newId, - vectorStoreName: result.vectorStoreName, - created: result.created, - embeddingModel: result.embeddingModel, - embeddingDimension: result.embeddingDimension, - }; - } - - /** - * Return the runtime status of the vector store. - */ - async getVectorStoreStatus(): Promise<{ - exists: boolean; - vectorStoreId?: string; - vectorStoreName?: string; - documentCount?: number; - embeddingModel?: string; - ready: boolean; - }> { - return queryGetStatus( - this.vectorStore, - this.ctx, - () => this.ensureVectorStoreReady(), - this.logger, - ); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreLifecycle.integration.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreLifecycle.integration.test.ts deleted file mode 100644 index 9dd5749da28..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreLifecycle.integration.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { VectorStoreService } from './VectorStoreService'; -import { ResponsesApiClient } from './ResponsesApiClient'; -import { LlamaStackConfig } from '../../types'; -import { LoggerService } from '@backstage/backend-plugin-api'; - -jest.mock('./ResponsesApiClient'); - -/** - * Integration test: exercises the full vector store lifecycle through - * VectorStoreService with a shared mock ResponsesApiClient. - * - * create store -> upload document -> list documents -> search -> delete doc -> delete store - */ -describe('VectorStoreService — RAG lifecycle', () => { - let client: jest.Mocked; - let logger: jest.Mocked; - let config: LlamaStackConfig; - let service: VectorStoreService; - - const STORE_ID = 'vs_lifecycle_test'; - const STORE_NAME = 'lifecycle-test-store'; - const FILE_ID = 'file-lifecycle-abc'; - - beforeEach(() => { - client = { - request: jest.fn(), - } as unknown as jest.Mocked; - - logger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - child: jest.fn().mockReturnThis(), - } as unknown as jest.Mocked; - - config = { - baseUrl: 'https://llama.example.com', - vectorStoreIds: [], - vectorStoreName: STORE_NAME, - embeddingModel: 'sentence-transformers/nomic-ai/nomic-embed-text-v1.5', - embeddingDimension: 768, - model: 'gemini/gemini-2.5-flash', - chunkingStrategy: 'static', - maxChunkSizeTokens: 200, - chunkOverlapTokens: 50, - }; - - service = new VectorStoreService(client, config, logger); - }); - - it('runs the full create → upload → list → search → delete doc → delete store lifecycle', async () => { - // --------------------------------------------------------------- - // Step 1: Create the vector store (via ensureExists) - // --------------------------------------------------------------- - - // Mock: GET /v1/vector_stores (no existing stores) - client.request.mockResolvedValueOnce({ data: [] }); - // Mock: resolveFromExistingStores → listVectorStores - client.request.mockResolvedValueOnce({ data: [] }); - // Mock: POST /v1/vector_stores (create) - client.request.mockResolvedValueOnce({ - id: STORE_ID, - name: STORE_NAME, - status: 'completed', - }); - - await service.ensureExists(); - expect(service.getConfig().vectorStoreIds).toContain(STORE_ID); - - // --------------------------------------------------------------- - // Step 2: Upload a document - // --------------------------------------------------------------- - - // Mock: POST /v1/files (upload raw file) - client.request.mockResolvedValueOnce({ - id: FILE_ID, - filename: 'test.txt', - purpose: 'assistants', - }); - // Mock: POST /v1/vector_stores/{id}/files (attach file) - client.request.mockResolvedValueOnce({ - id: FILE_ID, - status: 'completed', - vector_store_id: STORE_ID, - }); - - const uploadResult = await service.uploadDocuments( - [ - { - fileName: 'test.txt', - content: Buffer.from( - 'Developer Hub is built on Backstage', - ).toString(), - }, - ], - STORE_ID, - ); - - expect(uploadResult.uploaded).toHaveLength(1); - expect(uploadResult.uploaded[0].fileName).toBe('test.txt'); - - // --------------------------------------------------------------- - // Step 3: List documents in the store - // --------------------------------------------------------------- - - // Mock: GET /v1/vector_stores/{id}/files - client.request.mockResolvedValueOnce({ - data: [ - { - id: FILE_ID, - object: 'vector_store.file', - status: 'completed', - created_at: 1700000000, - usage_bytes: 35, - }, - ], - has_more: false, - }); - // Mock: GET /v1/files/{id} (file details) - client.request.mockResolvedValueOnce({ - id: FILE_ID, - object: 'file', - bytes: 35, - created_at: 1700000000, - filename: 'test.txt', - purpose: 'assistants', - }); - - const documents = await service.listDocuments(STORE_ID); - expect(documents).toHaveLength(1); - expect(documents[0].id).toBe(FILE_ID); - - // --------------------------------------------------------------- - // Step 4: Search the vector store - // --------------------------------------------------------------- - - // Mock: POST /v1/vector_stores/{id}/search - client.request.mockResolvedValueOnce({ - data: [ - { - file_id: FILE_ID, - filename: 'test.txt', - score: 0.92, - content: [{ text: 'Developer Hub is built on Backstage' }], - }, - ], - }); - - const searchResults = await service.searchSingle( - STORE_ID, - 'What is Developer Hub?', - 5, - ); - - expect(searchResults).toHaveLength(1); - expect(searchResults[0].score).toBe(0.92); - expect(searchResults[0].text).toContain('Backstage'); - - // --------------------------------------------------------------- - // Step 5: Delete the document - // --------------------------------------------------------------- - - // Mock: DELETE /v1/vector_stores/{id}/files/{fileId} - client.request.mockResolvedValueOnce({ - id: FILE_ID, - deleted: true, - }); - // Mock: DELETE /v1/files/{fileId} - client.request.mockResolvedValueOnce({ - id: FILE_ID, - deleted: true, - }); - - const deleteDocResult = await service.deleteDocument(FILE_ID, STORE_ID); - expect(deleteDocResult.success).toBe(true); - - // --------------------------------------------------------------- - // Step 6: Delete the vector store - // --------------------------------------------------------------- - - // Mock: GET /v1/vector_stores/{id}/files (empty after delete) - client.request.mockResolvedValueOnce({ data: [], has_more: false }); - // Mock: DELETE /v1/vector_stores/{id} - client.request.mockResolvedValueOnce({ id: STORE_ID, deleted: true }); - - const deleteStoreResult = await service.deleteVectorStore(STORE_ID); - expect(deleteStoreResult.success).toBe(true); - expect(deleteStoreResult.filesDeleted).toBe(0); - }); - - it('handles store-not-found gracefully during delete lifecycle', async () => { - // Simulate deleting a store that was already removed from the server - // Mock: fetchAllVectorStoreFiles -> 400 not found - client.request.mockRejectedValueOnce( - new Error("Vector_store 'vs_stale' not found"), - ); - // Mock: DELETE /v1/vector_stores/{id} -> 400 not found - client.request.mockRejectedValueOnce( - new Error("Vector_store 'vs_stale' not found"), - ); - - const result = await service.deleteVectorStore('vs_stale'); - expect(result.success).toBe(true); - expect(result.filesDeleted).toBe(0); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreQueryOps.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreQueryOps.ts deleted file mode 100644 index 90e0fa287e3..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreQueryOps.ts +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { EffectiveConfig } from '../../types'; -import type { VectorStoreService } from './VectorStoreService'; -import type { DocumentSyncService, SyncResult } from './DocumentSyncService'; -import type { ConfigResolutionService } from './ConfigResolutionService'; -import { toErrorMessage } from '../../services/utils'; - -export interface VectorStoreQueryContext { - getInitialized: () => boolean; - getVectorStoreReady: () => boolean; - configResolution: ConfigResolutionService; -} - -export async function getVectorStoreConfig( - ctx: VectorStoreQueryContext, - logger: LoggerService, -): Promise<{ - vectorStoreName: string; - embeddingModel: string; - embeddingDimension: number; - searchMode?: 'semantic' | 'keyword' | 'hybrid'; - bm25Weight?: number; - semanticWeight?: number; - chunkingStrategy: 'auto' | 'static'; - maxChunkSizeTokens: number; - chunkOverlapTokens: number; - fileSearchMaxResults?: number; - fileSearchScoreThreshold?: number; -} | null> { - if (!ctx.getInitialized() || !ctx.configResolution.getLlamaStackConfig()) { - return null; - } - let cfg: EffectiveConfig; - try { - cfg = await ctx.configResolution.resolve(); - } catch (error) { - logger.debug( - 'Config resolution failed, using fallback vector store IDs', - error as Error, - ); - cfg = ctx.configResolution.buildYamlFallback(); - } - return { - vectorStoreName: cfg.vectorStoreName, - embeddingModel: cfg.embeddingModel, - embeddingDimension: cfg.embeddingDimension, - searchMode: cfg.searchMode, - bm25Weight: cfg.bm25Weight, - semanticWeight: cfg.semanticWeight, - chunkingStrategy: cfg.chunkingStrategy, - maxChunkSizeTokens: cfg.maxChunkSizeTokens, - chunkOverlapTokens: cfg.chunkOverlapTokens, - fileSearchMaxResults: cfg.fileSearchMaxResults, - fileSearchScoreThreshold: cfg.fileSearchScoreThreshold, - }; -} - -export async function getVectorStoreStatus( - vectorStore: VectorStoreService | null, - ctx: VectorStoreQueryContext, - ensureVectorStoreReady: () => Promise, - logger: LoggerService, -): Promise<{ - exists: boolean; - vectorStoreId?: string; - vectorStoreName?: string; - documentCount?: number; - embeddingModel?: string; - ready: boolean; -}> { - if ( - !ctx.getInitialized() || - !vectorStore || - !ctx.configResolution.getLlamaStackConfig() - ) { - return { exists: false, ready: false }; - } - - let resolved: EffectiveConfig; - try { - resolved = await ctx.configResolution.resolve(); - } catch (error) { - logger.debug( - 'Config resolution failed, using fallback vector store IDs', - error as Error, - ); - resolved = ctx.configResolution.buildYamlFallback(); - } - - if (!ctx.getVectorStoreReady()) { - try { - await ensureVectorStoreReady(); - } catch (error) { - logger.debug( - 'Vector store readiness check failed, using existing store', - error as Error, - ); - return { - exists: false, - ready: false, - vectorStoreName: resolved.vectorStoreName, - embeddingModel: resolved.embeddingModel, - }; - } - } - - try { - const docs = await vectorStore.listDocuments(); - const vsId = vectorStore.getDefaultVectorStoreId(); - return { - exists: true, - ready: true, - vectorStoreId: vsId, - vectorStoreName: resolved.vectorStoreName, - documentCount: docs.length, - embeddingModel: resolved.embeddingModel, - }; - } catch (error) { - logger.warn( - `Failed to get vector store status: ${toErrorMessage(error, 'Unknown')}`, - ); - return { - exists: true, - ready: true, - vectorStoreId: vectorStore.getDefaultVectorStoreId(), - vectorStoreName: resolved.vectorStoreName, - embeddingModel: resolved.embeddingModel, - }; - } -} - -export async function searchVectorStore( - vectorStore: VectorStoreService, - query: string, - maxResults: number, - targetVectorStoreId: string | undefined, - targetVectorStoreIds: string[] | undefined, - logger: LoggerService, -): Promise<{ - query: string; - chunks: Array<{ - text: string; - score?: number; - fileId?: string; - fileName?: string; - vectorStoreId?: string; - }>; - vectorStoreId: string; - totalResults: number; -}> { - const ids: string[] = - targetVectorStoreIds && targetVectorStoreIds.length > 0 - ? targetVectorStoreIds - : [ - targetVectorStoreId ?? vectorStore.getDefaultVectorStoreId() ?? '', - ].filter(Boolean); - - if (ids.length === 0) { - throw new Error('No vector store available to search'); - } - - logger.info( - `searchVectorStore: query="${query.substring(0, 80)}${query.length > 80 ? '...' : ''}", storeIds=[${ids.join(',')}], maxResults=${maxResults}`, - ); - - const perStore = await Promise.all( - ids.map(storeId => vectorStore.searchSingle(storeId, query, maxResults)), - ); - const allChunks = perStore - .flat() - .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); - - logger.info( - `searchVectorStore: ${allChunks.length} results across ${ids.length} store(s), topScore=${allChunks[0]?.score ?? 'N/A'}`, - ); - - return { - query, - chunks: allChunks, - vectorStoreId: ids.join(','), - totalResults: allChunks.length, - }; -} - -export async function syncDocuments( - vectorStore: VectorStoreService | null, - docSync: DocumentSyncService | null, - ctx: VectorStoreQueryContextFull, - ensureVectorStoreReady: () => Promise, -): Promise { - ctx.ensureInitialized(); - if (!docSync) { - return { - added: 0, - removed: 0, - failed: 0, - unchanged: 0, - updated: 0, - errors: [], - }; - } - - await ensureVectorStoreReady(); - - const config = ctx.configResolution.getLlamaStackConfig(); - if (config && vectorStore) { - const cfg = await ctx.configResolution.resolve(); - vectorStore.updateConfig({ - ...config, - chunkingStrategy: cfg.chunkingStrategy ?? config.chunkingStrategy, - maxChunkSizeTokens: cfg.maxChunkSizeTokens ?? config.maxChunkSizeTokens, - chunkOverlapTokens: cfg.chunkOverlapTokens ?? config.chunkOverlapTokens, - }); - } - - return docSync.sync(); -} - -export async function uploadDocument( - vectorStore: VectorStoreService, - ctx: VectorStoreQueryContextFull, - ensureVectorStoreReady: () => Promise, - fileName: string, - content: Buffer, - vectorStoreId?: string, -): Promise<{ fileId: string; fileName: string; status: string }> { - ctx.ensureInitialized(); - await ensureVectorStoreReady(); - - const config = ctx.configResolution.getLlamaStackConfig(); - if (config) { - const cfg = await ctx.configResolution.resolve(); - vectorStore.updateConfig({ - ...config, - chunkingStrategy: cfg.chunkingStrategy ?? config.chunkingStrategy, - maxChunkSizeTokens: cfg.maxChunkSizeTokens ?? config.maxChunkSizeTokens, - chunkOverlapTokens: cfg.chunkOverlapTokens ?? config.chunkOverlapTokens, - }); - } - - const result = await vectorStore.uploadDocuments( - [{ fileName, content: content.toString('utf-8') }], - vectorStoreId, - ); - - if (result.failed.length > 0) { - throw new Error(`Upload failed for ${fileName}: ${result.failed[0].error}`); - } - if (result.uploaded.length === 0) { - throw new Error(`Upload produced no result for ${fileName}`); - } - - const doc = result.uploaded[0]; - return { fileId: doc.id, fileName: doc.fileName, status: doc.status }; -} - -export interface VectorStoreQueryContextFull extends VectorStoreQueryContext { - configResolution: ConfigResolutionService; - ensureInitialized: () => void; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreService.test.ts deleted file mode 100644 index 4782ca2cde9..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreService.test.ts +++ /dev/null @@ -1,371 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { VectorStoreService } from './VectorStoreService'; -import { ResponsesApiClient } from './ResponsesApiClient'; -import { LlamaStackConfig } from '../../types'; -import { LoggerService } from '@backstage/backend-plugin-api'; - -// Mock the ResponsesApiClient -jest.mock('./ResponsesApiClient'); - -describe('VectorStoreService', () => { - let mockClient: jest.Mocked; - let mockLogger: jest.Mocked; - let baseConfig: LlamaStackConfig; - - beforeEach(() => { - mockClient = { - request: jest.fn(), - } as unknown as jest.Mocked; - - mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - child: jest.fn().mockReturnThis(), - } as unknown as jest.Mocked; - - baseConfig = { - baseUrl: 'https://llama-stack.example.com', - vectorStoreIds: [], - vectorStoreName: 'test-vector-store', - embeddingModel: 'sentence-transformers/all-MiniLM-L6-v2', - embeddingDimension: 384, - model: 'gemini/gemini-2.5-flash', - chunkingStrategy: 'auto', - maxChunkSizeTokens: 200, - chunkOverlapTokens: 50, - }; - }); - - describe('ensureExists', () => { - it('should throw error when vectorStoreName is not configured', async () => { - const configWithoutName = { - ...baseConfig, - vectorStoreName: '', - }; - const service = new VectorStoreService( - mockClient, - configWithoutName, - mockLogger, - ); - - await expect(service.ensureExists()).rejects.toThrow( - 'CONFIGURATION ERROR: augment.llamaStack.vectorStoreName is required', - ); - }); - - it('should use existing vectorStoreId if it validates successfully', async () => { - const configWithId = { - ...baseConfig, - vectorStoreIds: ['vs_existing_123'], - }; - const service = new VectorStoreService( - mockClient, - configWithId, - mockLogger, - ); - - // Mock successful validation - mockClient.request.mockResolvedValueOnce({ - id: 'vs_existing_123', - name: 'test-vector-store', - status: 'completed', - file_counts: { total: 5 }, - }); - - await service.ensureExists(); - - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/vector_stores/vs_existing_123', - { method: 'GET' }, - ); - expect(service.getConfig().vectorStoreIds).toEqual(['vs_existing_123']); - }); - - it('should fall back to name-based lookup when configured vectorStoreId is invalid', async () => { - const configWithInvalidId = { - ...baseConfig, - vectorStoreIds: ['vs_invalid_id'], - }; - const service = new VectorStoreService( - mockClient, - configWithInvalidId, - mockLogger, - ); - - // Mock failed validation (vector store not found) - mockClient.request - .mockRejectedValueOnce(new Error('Not found')) - // Mock listing vector stores - find by name - .mockResolvedValueOnce({ - data: [ - { - id: 'vs_found_by_name', - name: 'test-vector-store', - status: 'completed', - file_counts: { total: 3 }, - }, - ], - }); - - await service.ensureExists(); - - // Should have tried validation first, then fell back to name lookup - expect(mockClient.request).toHaveBeenCalledTimes(2); - expect(service.getConfig().vectorStoreIds).toEqual(['vs_found_by_name']); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('vs_invalid_id'), - ); - }); - - it('should find existing vector store by name when no vectorStoreIds configured', async () => { - const service = new VectorStoreService( - mockClient, - baseConfig, - mockLogger, - ); - - // Mock listing vector stores - returns matching name - mockClient.request.mockResolvedValueOnce({ - data: [ - { - id: 'vs_other', - name: 'other-store', - status: 'completed', - file_counts: { total: 0 }, - }, - { - id: 'vs_matched', - name: 'test-vector-store', - status: 'completed', - file_counts: { total: 10 }, - }, - ], - }); - - await service.ensureExists(); - - expect(service.getConfig().vectorStoreIds).toEqual(['vs_matched']); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Found existing vector store'), - ); - }); - - it('should create new vector store when not found by name', async () => { - const service = new VectorStoreService( - mockClient, - baseConfig, - mockLogger, - ); - - // Mock listing vector stores - no match - mockClient.request - .mockResolvedValueOnce({ data: [] }) - // Mock resolveFromExistingStores → listVectorStores - .mockResolvedValueOnce({ data: [] }) - // Mock creation - .mockResolvedValueOnce({ - id: 'vs_newly_created', - name: 'test-vector-store', - status: 'completed', - }); - - await service.ensureExists(); - - expect(service.getConfig().vectorStoreIds).toEqual(['vs_newly_created']); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Created new vector store'), - ); - }); - - it('should include hybrid search config when creating vector store', async () => { - const hybridConfig = { - ...baseConfig, - searchMode: 'hybrid' as const, - bm25Weight: 0.3, - semanticWeight: 0.7, - }; - const service = new VectorStoreService( - mockClient, - hybridConfig, - mockLogger, - ); - - // Mock listing - no match - mockClient.request - .mockResolvedValueOnce({ data: [] }) - // Mock resolveFromExistingStores → listVectorStores - .mockResolvedValueOnce({ data: [] }) - // Mock creation - .mockResolvedValueOnce({ - id: 'vs_hybrid', - name: 'test-vector-store', - status: 'completed', - }); - - await service.ensureExists(); - - // Verify create was called with hybrid search params - expect(mockClient.request).toHaveBeenLastCalledWith('/v1/vector_stores', { - method: 'POST', - body: { - name: 'test-vector-store', - embedding_model: 'sentence-transformers/all-MiniLM-L6-v2', - embedding_dimension: 384, - search_mode: 'hybrid', - bm25_weight: 0.3, - semantic_weight: 0.7, - }, - }); - }); - - it('should handle API errors gracefully during creation', async () => { - const service = new VectorStoreService( - mockClient, - baseConfig, - mockLogger, - ); - - // Mock listing - no match - mockClient.request - .mockResolvedValueOnce({ data: [] }) - // Mock resolveFromExistingStores → listVectorStores - .mockResolvedValueOnce({ data: [] }) - // Mock creation failure - .mockRejectedValueOnce(new Error('API rate limit exceeded')); - - await expect(service.ensureExists()).rejects.toThrow( - 'Failed to create vector store "test-vector-store": API rate limit exceeded', - ); - }); - - it('should handle deleted and recreated vector store scenario', async () => { - // Scenario: User has old ID in config, but that store was deleted - // Plugin should fall back to name and find/create the new one - const configWithOldId = { - ...baseConfig, - vectorStoreIds: ['vs_old_deleted'], - }; - const service = new VectorStoreService( - mockClient, - configWithOldId, - mockLogger, - ); - - // Mock: old ID validation fails - mockClient.request - .mockRejectedValueOnce(new Error('Vector store not found')) - // Mock: list shows the recreated store with same name but new ID - .mockResolvedValueOnce({ - data: [ - { - id: 'vs_new_recreated', - name: 'test-vector-store', - status: 'completed', - file_counts: { total: 0 }, - }, - ], - }); - - await service.ensureExists(); - - // Should now use the new ID - expect(service.getConfig().vectorStoreIds).toEqual(['vs_new_recreated']); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('vs_old_deleted'), - ); - }); - }); - - describe('deleteVectorStore', () => { - let service: VectorStoreService; - - beforeEach(() => { - service = new VectorStoreService(mockClient, baseConfig, mockLogger); - }); - - it('should delete all files then the store itself', async () => { - mockClient.request - .mockResolvedValueOnce({ - data: [ - { id: 'file-1', status: 'completed' }, - { id: 'file-2', status: 'completed' }, - ], - has_more: false, - }) - .mockResolvedValueOnce({ success: true }) - .mockResolvedValueOnce({ success: true }) - .mockResolvedValueOnce({ success: true }) - .mockResolvedValueOnce({ success: true }) - .mockResolvedValueOnce({ id: 'vs_123', deleted: true }); - - const result = await service.deleteVectorStore('vs_123'); - - expect(result).toEqual({ success: true, filesDeleted: 2 }); - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/vector_stores/vs_123', - { method: 'DELETE' }, - ); - }); - - it('should treat store-not-found as successful deletion', async () => { - // Mock 1: fetchAllVectorStoreFiles → not found (caught, warned) - // Mock 2: DELETE /v1/vector_stores/{id} → not found (treated as success) - mockClient.request - .mockRejectedValueOnce( - new Error('Llama Stack API error: 400 - Vector_store not found'), - ) - .mockRejectedValueOnce( - new Error( - "Llama Stack API error: 400 - Vector_store 'vs_gone' not found", - ), - ); - - const result = await service.deleteVectorStore('vs_gone'); - - expect(result).toEqual({ success: true, filesDeleted: 0 }); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('not found on server'), - ); - }); - - it('should treat 404 as successful deletion', async () => { - mockClient.request - .mockRejectedValueOnce(new Error('404 Not Found')) - .mockRejectedValueOnce(new Error('404 Not Found')); - - const result = await service.deleteVectorStore('vs_404'); - - expect(result).toEqual({ success: true, filesDeleted: 0 }); - }); - - it('should throw on real server errors', async () => { - mockClient.request - .mockRejectedValueOnce(new Error('connection refused')) - .mockRejectedValueOnce( - new Error('Llama Stack API error: 500 Internal Server Error'), - ); - - await expect(service.deleteVectorStore('vs_err')).rejects.toThrow( - 'Failed to delete vector store vs_err', - ); - expect(mockLogger.error).toHaveBeenCalled(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreService.ts deleted file mode 100644 index c1f6c7bb6ca..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/VectorStoreService.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../../services/VectorStoreService' instead */ -export * from '../../services/VectorStoreService'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/__tests__/multiAgent.e2e.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/__tests__/multiAgent.e2e.test.ts deleted file mode 100644 index 5a192f97147..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/__tests__/multiAgent.e2e.test.ts +++ /dev/null @@ -1,1454 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * End-to-end integration tests for multi-agent orchestration. - * - * These tests validate the FULL chain: - * ResponsesApiCoordinator -> AdkOrchestrator -> ResponsesApiService -> ResponsesApiClient -> HTTP - * - * Two modes: - * 1. Mock LlamaStack server (deterministic, CI-friendly) - * 2. Real LlamaStack instance (when LLAMASTACK_URL is set) - */ - -import * as http from 'http'; -import type { AddressInfo } from 'net'; -import type { - LoggerService, - RootConfigService, -} from '@backstage/backend-plugin-api'; -import { ResponsesApiCoordinator } from '../ResponsesApiCoordinator'; -import type { ChatRequest, ChatResponse } from '../../../types'; - -// --------------------------------------------------------------------------- -// Mock LlamaStack Server -// --------------------------------------------------------------------------- - -interface RecordedCall { - url: string; - method: string; - body: Record; - headers: Record; -} - -interface MockLlamaStackServer { - listen: () => Promise; - close: () => Promise; - getCalls: () => RecordedCall[]; - getResponsesCalls: () => RecordedCall[]; - clearCalls: () => void; - port: () => number; -} - -let responseIdCounter = 0; - -function nextResponseId(): string { - return `resp-e2e-${++responseIdCounter}`; -} - -function makeTextResponse( - text: string, - model: string, -): Record { - return { - id: nextResponseId(), - object: 'response', - created_at: Math.floor(Date.now() / 1000), - model, - status: 'completed', - output: [ - { - type: 'message', - id: `msg-${Date.now()}`, - role: 'assistant', - status: 'completed', - content: [{ type: 'output_text', text }], - }, - ], - }; -} - -function makeHandoffResponse( - targetFunctionName: string, - model: string, - callId = `call-${Date.now()}`, -): Record { - return { - id: nextResponseId(), - object: 'response', - created_at: Math.floor(Date.now() / 1000), - model, - status: 'completed', - output: [ - { - type: 'function_call', - id: `fc-${Date.now()}`, - call_id: callId, - name: `transfer_to_${targetFunctionName}`, - arguments: '{}', - status: 'completed', - }, - ], - }; -} - -function makeAgentToolCallResponse( - targetKey: string, - model: string, - callId = `call-tool-${Date.now()}`, -): Record { - return { - id: nextResponseId(), - object: 'response', - created_at: Math.floor(Date.now() / 1000), - model, - status: 'completed', - output: [ - { - type: 'function_call', - id: `fc-tool-${Date.now()}`, - call_id: callId, - name: `delegate_to_agent_${targetKey}`, - arguments: 'Please help the user.', - status: 'completed', - }, - ], - }; -} - -function makeStreamEvents(text: string, model: string): string { - const respId = nextResponseId(); - const events = [ - { - type: 'response.created', - response: { id: respId, model, status: 'in_progress' }, - }, - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'message', id: 'msg-s1', role: 'assistant' }, - }, - { - type: 'response.content_part.added', - output_index: 0, - content_index: 0, - part: { type: 'output_text', text: '' }, - }, - { - type: 'response.output_text.delta', - output_index: 0, - content_index: 0, - delta: text, - }, - { - type: 'response.output_text.done', - output_index: 0, - content_index: 0, - text, - }, - { - type: 'response.content_part.done', - output_index: 0, - content_index: 0, - part: { type: 'output_text', text }, - }, - { - type: 'response.output_item.done', - output_index: 0, - item: { - type: 'message', - id: 'msg-s1', - role: 'assistant', - status: 'completed', - content: [{ type: 'output_text', text }], - }, - }, - { - type: 'response.completed', - response: { - id: respId, - model, - status: 'completed', - output: [ - { - type: 'message', - id: 'msg-s1', - role: 'assistant', - status: 'completed', - content: [{ type: 'output_text', text }], - }, - ], - }, - }, - ]; - return events.map(e => `data: ${JSON.stringify(e)}\n\n`).join(''); -} - -function makeStreamHandoffEvents( - targetFunctionName: string, - model: string, -): string { - const respId = nextResponseId(); - const callId = `call-stream-${Date.now()}`; - const events = [ - { - type: 'response.created', - response: { id: respId, model, status: 'in_progress' }, - }, - { - type: 'response.output_item.added', - output_index: 0, - item: { - type: 'function_call', - id: `fc-s-${Date.now()}`, - call_id: callId, - name: `transfer_to_${targetFunctionName}`, - }, - }, - { - type: 'response.output_item.done', - output_index: 0, - item: { - type: 'function_call', - id: `fc-s-${Date.now()}`, - call_id: callId, - name: `transfer_to_${targetFunctionName}`, - arguments: '{}', - status: 'completed', - }, - }, - { - type: 'response.completed', - response: { - id: respId, - model, - status: 'completed', - output: [ - { - type: 'function_call', - id: `fc-s-${Date.now()}`, - call_id: callId, - name: `transfer_to_${targetFunctionName}`, - arguments: '{}', - status: 'completed', - }, - ], - }, - }, - ]; - return events.map(e => `data: ${JSON.stringify(e)}\n\n`).join(''); -} - -function makeStreamEventsWithReasoning( - reasoningText: string, - outputText: string, - model: string, -): string { - const respId = nextResponseId(); - const events = [ - { - type: 'response.created', - response: { id: respId, model, status: 'in_progress' }, - }, - { type: 'response.reasoning_summary_text.delta', delta: reasoningText }, - { type: 'response.reasoning_summary_text.done', text: reasoningText }, - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'message', id: 'msg-r1', role: 'assistant' }, - }, - { - type: 'response.content_part.added', - output_index: 0, - content_index: 0, - part: { type: 'output_text', text: '' }, - }, - { - type: 'response.output_text.delta', - output_index: 0, - content_index: 0, - delta: outputText, - }, - { - type: 'response.output_text.done', - output_index: 0, - content_index: 0, - text: outputText, - }, - { - type: 'response.content_part.done', - output_index: 0, - content_index: 0, - part: { type: 'output_text', text: outputText }, - }, - { - type: 'response.output_item.done', - output_index: 0, - item: { - type: 'message', - id: 'msg-r1', - role: 'assistant', - status: 'completed', - content: [{ type: 'output_text', text: outputText }], - }, - }, - { - type: 'response.completed', - response: { - id: respId, - model, - status: 'completed', - output: [ - { - type: 'message', - id: 'msg-r1', - role: 'assistant', - status: 'completed', - content: [{ type: 'output_text', text: outputText }], - }, - ], - }, - }, - ]; - return events.map(e => `data: ${JSON.stringify(e)}\n\n`).join(''); -} - -/** - * Creates a lightweight HTTP server that simulates LlamaStack's - * Responses API. Routes responses based on the `instructions` field - * in the request body so tests get deterministic, inspectable behavior. - */ -function createMockLlamaStackServer(): MockLlamaStackServer { - const calls: RecordedCall[] = []; - let server: http.Server; - let serverPort = 0; - - const handler = (req: http.IncomingMessage, res: http.ServerResponse) => { - let body = ''; - req.on('data', chunk => { - body += chunk; - }); - req.on('end', () => { - const url = req.url ?? ''; - const method = req.method ?? 'GET'; - let parsedBody: Record = {}; - try { - parsedBody = JSON.parse(body); - } catch { - /* empty body for GET */ - } - - const headers: Record = {}; - for (const [k, v] of Object.entries(req.headers)) { - if (typeof v === 'string') headers[k] = v; - } - - calls.push({ url, method, body: parsedBody, headers }); - - if (url === '/v1/models') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - data: [{ id: 'test-model', object: 'model', owned_by: 'test' }], - }), - ); - return; - } - - if (url === '/v1/responses' && method === 'POST') { - const instructions = (parsedBody.instructions as string) ?? ''; - const model = (parsedBody.model as string) ?? 'test-model'; - const isStream = parsedBody.stream === true; - const input = parsedBody.input; - const inputStr = - typeof input === 'string' ? input : JSON.stringify(input); - - // Agent-as-tool error simulation: return HTTP 500 for "error-trigger" agents - if (instructions.toLowerCase().includes('error-trigger')) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Simulated sub-agent failure' })); - return; - } - - const isTriage = instructions.toLowerCase().includes('triage'); - const isBilling = instructions.toLowerCase().includes('billing'); - const isTechnical = instructions.toLowerCase().includes('technical'); - const isOrchestrator = instructions - .toLowerCase() - .includes('orchestrator'); - const wantsReasoning = instructions - .toLowerCase() - .includes('use reasoning'); - - const wantsBillingHandoff = - isTriage && - (inputStr.toLowerCase().includes('refund') || - inputStr.toLowerCase().includes('billing') || - inputStr.toLowerCase().includes('invoice')); - - const wantsTechnicalHandoff = - isTriage && - (inputStr.toLowerCase().includes('error') || - inputStr.toLowerCase().includes('deploy') || - inputStr.toLowerCase().includes('technical')); - - // Agent-as-tool: orchestrator calls call_helper, then gets error back - const hasToolOutput = - Array.isArray(input) && - (input as Array>).some( - (i: Record) => i.type === 'function_call_output', - ); - - const wantsAgentToolCall = - isOrchestrator && - !hasToolOutput && - inputStr.toLowerCase().includes('run helper'); - - if (isStream) { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }); - - let sseData: string; - if (wantsBillingHandoff) { - sseData = makeStreamHandoffEvents('Billing_Agent', model); - } else if (wantsTechnicalHandoff) { - sseData = makeStreamHandoffEvents('Technical_Agent', model); - } else if (isBilling) { - sseData = makeStreamEvents( - 'Your billing issue has been resolved. The refund will be processed within 3-5 business days.', - model, - ); - } else if (isTechnical) { - sseData = makeStreamEvents( - 'I have identified the deployment error. Please check your container logs.', - model, - ); - } else if (wantsReasoning) { - sseData = makeStreamEventsWithReasoning( - 'Let me think about this carefully...', - 'Here is my well-reasoned answer.', - model, - ); - } else { - sseData = makeStreamEvents( - 'Hello! How can I help you today?', - model, - ); - } - - res.write(sseData); - res.end(); - return; - } - - let response: Record; - if (wantsAgentToolCall) { - response = makeAgentToolCallResponse('helper', model); - } else if (isOrchestrator && hasToolOutput) { - response = makeTextResponse( - 'The helper agent reported an issue but I can still help you.', - model, - ); - } else if (wantsBillingHandoff) { - response = makeHandoffResponse('Billing_Agent', model); - } else if (wantsTechnicalHandoff) { - response = makeHandoffResponse('Technical_Agent', model); - } else if (isBilling) { - response = makeTextResponse( - 'Your billing issue has been resolved. The refund will be processed within 3-5 business days.', - model, - ); - } else if (isTechnical) { - response = makeTextResponse( - 'I have identified the deployment error. Please check your container logs.', - model, - ); - } else { - response = makeTextResponse( - 'Hello! How can I help you today?', - model, - ); - } - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - return; - } - - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Not found' })); - }); - }; - - return { - listen: () => - new Promise(resolve => { - server = http.createServer(handler); - server.listen(0, '127.0.0.1', () => { - serverPort = (server.address() as AddressInfo).port; - resolve(serverPort); - }); - }), - close: () => - new Promise((resolve, reject) => { - if (!server) { - resolve(); - return; - } - server.close(err => (err ? reject(err) : resolve())); - }), - getCalls: () => calls, - getResponsesCalls: () => calls.filter(c => c.url === '/v1/responses'), - clearCalls: () => { - calls.length = 0; - }, - port: () => serverPort, - }; -} - -// --------------------------------------------------------------------------- -// Test Config Helper -// --------------------------------------------------------------------------- - -function createTestConfig(options: { - baseUrl: string; - model?: string; - systemPrompt?: string; - zdrMode?: boolean; - safetyPatterns?: string[]; - agents?: Record>; - defaultAgent?: string; - maxAgentTurns?: number; -}): RootConfigService { - const llamaStack: Record = { - baseUrl: options.baseUrl, - model: options.model ?? 'test-model', - vectorStoreName: 'test-vs', - embeddingModel: 'test-embed', - embeddingDimension: 384, - chunkingStrategy: 'auto', - maxChunkSizeTokens: 800, - chunkOverlapTokens: 400, - skipTlsVerify: false, - }; - - if (options.zdrMode) { - llamaStack.zdrMode = true; - } - - const augment: Record = { - llamaStack, - systemPrompt: options.systemPrompt ?? 'You are a helpful assistant.', - proxyBaseUrl: 'http://localhost:7007/api/augment', - }; - - if (options.safetyPatterns) { - augment.safetyPatterns = options.safetyPatterns; - } - - if (options.agents) { - augment.agents = options.agents; - if (options.defaultAgent) augment.defaultAgent = options.defaultAgent; - if (options.maxAgentTurns) augment.maxAgentTurns = options.maxAgentTurns; - } - - const data: Record = { augment }; - - return buildConfigService(data); -} - -/** - * Builds a minimal RootConfigService from a plain data object. - * Supports nested dot-path lookups like `config.getOptionalString('a.b.c')`. - */ -function buildConfigService(data: Record): RootConfigService { - function getByPath(obj: unknown, path: string): unknown { - const parts = path.split('.'); - let current: unknown = obj; - for (const part of parts) { - if ( - current === null || - current === undefined || - typeof current !== 'object' - ) - return undefined; - current = (current as Record)[part]; - } - return current; - } - - function makeConfigApi(root: unknown): RootConfigService { - const api: RootConfigService = { - has: (key: string) => getByPath(root, key) !== undefined, - keys: () => (root && typeof root === 'object' ? Object.keys(root) : []), - get: (key?: string) => { - const val = key ? getByPath(root, key) : root; - if (val === undefined) - throw new Error(`Missing required config: ${key}`); - return val; - }, - getOptional: (key?: string) => (key ? getByPath(root, key) : root), - getString: (key: string) => { - const val = getByPath(root, key); - if (typeof val !== 'string') - throw new Error(`Missing required string config: ${key}`); - return val; - }, - getOptionalString: (key: string) => { - const val = getByPath(root, key); - return typeof val === 'string' ? val : undefined; - }, - getNumber: (key: string) => { - const val = getByPath(root, key); - if (typeof val !== 'number') - throw new Error(`Missing required number config: ${key}`); - return val; - }, - getOptionalNumber: (key: string) => { - const val = getByPath(root, key); - return typeof val === 'number' ? val : undefined; - }, - getBoolean: (key: string) => { - const val = getByPath(root, key); - if (typeof val !== 'boolean') - throw new Error(`Missing required boolean config: ${key}`); - return val; - }, - getOptionalBoolean: (key: string) => { - const val = getByPath(root, key); - return typeof val === 'boolean' ? val : undefined; - }, - getStringArray: (key: string) => { - const val = getByPath(root, key); - if (!Array.isArray(val)) - throw new Error(`Missing required string array config: ${key}`); - return val as string[]; - }, - getOptionalStringArray: (key: string) => { - const val = getByPath(root, key); - return Array.isArray(val) ? (val as string[]) : undefined; - }, - getConfig: (key: string) => { - const val = getByPath(root, key); - if (val === null || val === undefined || typeof val !== 'object') { - throw new Error(`Missing required config section: ${key}`); - } - return makeConfigApi(val); - }, - getOptionalConfig: (key: string) => { - const val = getByPath(root, key); - if (val === null || val === undefined || typeof val !== 'object') - return undefined; - return makeConfigApi(val); - }, - getConfigArray: (key: string) => { - const val = getByPath(root, key); - if (!Array.isArray(val)) - throw new Error(`Missing required config array: ${key}`); - return val.map((item: unknown) => makeConfigApi(item)); - }, - getOptionalConfigArray: (key: string) => { - const val = getByPath(root, key); - if (!Array.isArray(val)) return undefined; - return val.map((item: unknown) => makeConfigApi(item)); - }, - } as unknown as RootConfigService; - return api; - } - - return makeConfigApi(data); -} - -// --------------------------------------------------------------------------- -// Logger -// --------------------------------------------------------------------------- - -function createTestLogger(): LoggerService & { warnCalls: string[] } { - const warnCalls: string[] = []; - return { - info: jest.fn(), - warn: jest.fn((...args: unknown[]) => { - warnCalls.push(String(args[0])); - }), - error: jest.fn(), - debug: jest.fn(), - child: jest.fn().mockReturnThis(), - warnCalls, - } as unknown as LoggerService & { warnCalls: string[] }; -} - -// --------------------------------------------------------------------------- -// Test Helpers -// --------------------------------------------------------------------------- - -function chatRequest( - content: string, - extras?: Partial, -): ChatRequest { - return { - messages: [{ role: 'user' as const, content }], - ...extras, - } as ChatRequest; -} - -function multiMessageRequest( - messages: Array<{ role: string; content: string }>, -): ChatRequest { - return { messages } as ChatRequest; -} - -// --------------------------------------------------------------------------- -// Tests: Mock LlamaStack Server -// --------------------------------------------------------------------------- - -// TODO: Re-enable once @openai/agents-core Zod schema for callId is resolved -describe.skip('Multi-Agent E2E (mock server)', () => { - let mockServer: MockLlamaStackServer; - - beforeAll(async () => { - responseIdCounter = 0; - mockServer = createMockLlamaStackServer(); - await mockServer.listen(); - }); - - afterAll(async () => { - await mockServer.close(); - }); - - beforeEach(() => { - mockServer.clearCalls(); - responseIdCounter = 0; - }); - - // ----------------------------------------------------------------------- - // Scenario 1: Single-agent (auto-synthesized) - // ----------------------------------------------------------------------- - - describe('single-agent (auto-synthesized)', () => { - it('sends a chat request and gets a response through the full chain', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - systemPrompt: 'You are a helpful assistant.', - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - const response = await orchestrator.chat(chatRequest('Hello')); - - expect(response).toBeDefined(); - expect(response.content).toBe('Hello! How can I help you today?'); - expect(response.role).toBe('assistant'); - - const apiCalls = mockServer.getResponsesCalls(); - expect(apiCalls.length).toBe(1); - expect(apiCalls[0].body.model).toBe('test-model'); - expect(apiCalls[0].body.instructions).toContain('helpful assistant'); - }); - }); - - // ----------------------------------------------------------------------- - // Scenario 2: Multi-agent handoff (triage -> billing) - // ----------------------------------------------------------------------- - - describe('multi-agent handoff (triage -> billing)', () => { - let orchestrator: ResponsesApiCoordinator; - - beforeEach(async () => { - mockServer.clearCalls(); - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - agents: { - triage: { - name: 'Triage Agent', - instructions: - 'You are a triage agent. Route billing to billing, technical to technical.', - handoffs: ['billing', 'technical'], - }, - billing: { - name: 'Billing Agent', - instructions: 'You are a billing specialist.', - handoffDescription: 'Handles refunds, payments, invoices.', - }, - technical: { - name: 'Technical Agent', - instructions: 'You are a technical support specialist.', - handoffDescription: - 'Handles errors, deployments, technical issues.', - }, - }, - defaultAgent: 'triage', - maxAgentTurns: 6, - }); - - orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - }); - - it('hands off from triage to billing agent', async () => { - const response = await orchestrator.chat(chatRequest('I need a refund')); - - expect(response.content).toContain('refund'); - expect(response.content).toContain('3-5 business days'); - expect(response.responseId).toBeDefined(); - - const apiCalls = mockServer.getResponsesCalls(); - expect(apiCalls.length).toBe(2); - - expect(apiCalls[0].body.instructions).toContain('triage'); - expect(apiCalls[1].body.instructions).toContain('billing'); - - expect(apiCalls[1].body.previous_response_id).toBeDefined(); - const secondInput = apiCalls[1].body.input; - expect(Array.isArray(secondInput)).toBe(true); - expect((secondInput as Array>)[0].type).toBe( - 'function_call_output', - ); - }); - }); - - // ----------------------------------------------------------------------- - // Scenario 3: Multi-agent handoff (triage -> technical) - // ----------------------------------------------------------------------- - - describe('multi-agent handoff (triage -> technical)', () => { - it('hands off from triage to technical agent', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - agents: { - triage: { - name: 'Triage Agent', - instructions: - 'You are a triage agent. Route billing to billing, technical to technical.', - handoffs: ['billing', 'technical'], - }, - billing: { - name: 'Billing Agent', - instructions: 'You are a billing specialist.', - handoffDescription: 'Handles billing.', - }, - technical: { - name: 'Technical Agent', - instructions: 'You are a technical support specialist.', - handoffDescription: 'Handles technical issues.', - }, - }, - defaultAgent: 'triage', - maxAgentTurns: 6, - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - const response = await orchestrator.chat( - chatRequest('I have a deployment error'), - ); - - expect(response.content).toContain('deployment error'); - expect(response.content).toContain('container logs'); - - const apiCalls = mockServer.getResponsesCalls(); - expect(apiCalls.length).toBe(2); - expect(apiCalls[0].body.instructions).toContain('triage'); - expect(apiCalls[1].body.instructions).toContain('technical'); - }); - }); - - // ----------------------------------------------------------------------- - // Scenario 4: Streaming single-agent - // ----------------------------------------------------------------------- - - describe('streaming single-agent', () => { - it('streams events through the full chain', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - systemPrompt: 'You are helpful.', - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - const events: string[] = []; - await orchestrator.chatStream(chatRequest('Hello'), (event: string) => - events.push(event), - ); - - expect(events.length).toBeGreaterThan(0); - - const parsed = events - .map(e => { - try { - return JSON.parse(e); - } catch { - return null; - } - }) - .filter(Boolean); - const started = parsed.find( - (e: Record) => e.type === 'stream.started', - ); - const created = parsed.find( - (e: Record) => e.type === 'response.created', - ); - const hasStart = started ?? created; - expect(hasStart).toBeDefined(); - // eslint-disable-next-line jest/no-conditional-expect - if (hasStart?.responseId) expect(hasStart.responseId).toBeDefined(); - // eslint-disable-next-line jest/no-conditional-expect - if (hasStart?.response?.id) expect(hasStart.response.id).toBeDefined(); - - const textDelta = parsed.find( - (e: Record) => - e.type === 'stream.text.delta' || - e.type === 'response.output_text.delta', - ); - expect(textDelta).toBeDefined(); - expect((textDelta?.delta as string) ?? '').toContain('Hello'); - }); - - it('forwards reasoning events before text events in correct order', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - systemPrompt: 'You are helpful. Use reasoning to think carefully.', - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - const events: string[] = []; - await orchestrator.chatStream( - chatRequest('Explain quantum computing'), - (event: string) => events.push(event), - ); - - expect(events.length).toBeGreaterThan(0); - - const parsed = events - .map(e => { - try { - return JSON.parse(e); - } catch { - return null; - } - }) - .filter(Boolean); - const types = parsed.map((e: Record) => e.type); - - const createdIdx = types.indexOf('stream.started'); - const reasoningDeltaIdx = types.indexOf('stream.reasoning.delta'); - const reasoningDoneIdx = types.indexOf('stream.reasoning.done'); - const textDeltaIdx = types.indexOf('stream.text.delta'); - const completedIdx = types.indexOf('stream.completed'); - - expect(createdIdx).toBeGreaterThanOrEqual(0); - expect(reasoningDeltaIdx).toBeGreaterThanOrEqual(0); - expect(reasoningDoneIdx).toBeGreaterThanOrEqual(0); - expect(textDeltaIdx).toBeGreaterThanOrEqual(0); - expect(completedIdx).toBeGreaterThanOrEqual(0); - - expect(createdIdx).toBeLessThan(reasoningDeltaIdx); - expect(reasoningDeltaIdx).toBeLessThan(reasoningDoneIdx); - expect(reasoningDoneIdx).toBeLessThan(textDeltaIdx); - expect(textDeltaIdx).toBeLessThan(completedIdx); - - const reasoningEvent = parsed.find( - (e: Record) => e.type === 'stream.reasoning.delta', - ); - expect((reasoningEvent?.delta as string) ?? '').toContain('think'); - }); - }); - - // ----------------------------------------------------------------------- - // Scenario 5: Streaming multi-agent handoff - // ----------------------------------------------------------------------- - - describe('streaming multi-agent handoff', () => { - it('emits handoff event and streams specialist response', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - agents: { - triage: { - name: 'Triage Agent', - instructions: 'You are a triage agent. Route billing to billing.', - handoffs: ['billing'], - }, - billing: { - name: 'Billing Agent', - instructions: 'You are a billing specialist.', - handoffDescription: 'Handles billing.', - }, - }, - defaultAgent: 'triage', - maxAgentTurns: 6, - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - const events: string[] = []; - await orchestrator.chatStream( - chatRequest('I need a refund please'), - (event: string) => events.push(event), - ); - - expect(events.length).toBeGreaterThan(0); - - const parsed = events - .map(e => { - try { - return JSON.parse(e); - } catch { - return null; - } - }) - .filter(Boolean); - - const handoffEvent = parsed.find( - (e: Record) => e.type === 'stream.agent.handoff', - ); - expect(handoffEvent).toBeDefined(); - expect(handoffEvent.toAgent).toBe('Billing Agent'); - - const textDelta = parsed.find( - (e: Record) => - e.type === 'stream.text.delta' || - e.type === 'response.output_text.delta', - ); - expect(textDelta).toBeDefined(); - expect((textDelta?.delta as string) ?? '').toContain('refund'); - }); - }); - - // ----------------------------------------------------------------------- - // Scenario 6: prepareFirstTurn integration - // ----------------------------------------------------------------------- - - describe('prepareFirstTurn integration', () => { - // eslint-disable-next-line jest/no-disabled-tests - it.skip('includes conversation context from multi-message requests', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - systemPrompt: 'You are helpful.', - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - await orchestrator.chat( - multiMessageRequest([ - { role: 'user', content: 'What is Kubernetes?' }, - { - role: 'assistant', - content: 'Kubernetes is a container orchestration platform.', - }, - { role: 'user', content: 'How do I deploy to it?' }, - ]), - ); - - const apiCalls = mockServer.getResponsesCalls(); - expect(apiCalls.length).toBe(1); - expect(apiCalls[0].body.instructions).toContain('CONVERSATION CONTEXT'); - expect(apiCalls[0].body.instructions).toContain('Kubernetes'); - }); - - // eslint-disable-next-line jest/no-disabled-tests - it.skip('does not include conversation context when previousResponseId is present', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - systemPrompt: 'You are helpful.', - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - await orchestrator.chat( - multiMessageRequest([ - { role: 'user', content: 'What is Kubernetes?' }, - { role: 'assistant', content: 'It is a container platform.' }, - { role: 'user', content: 'Tell me more' }, - ]), - ); - - const withContext = mockServer.getResponsesCalls(); - expect(withContext[0].body.instructions).toContain( - 'CONVERSATION CONTEXT', - ); - - mockServer.clearCalls(); - - await orchestrator.chat({ - messages: [{ role: 'user', content: 'Follow up' }], - previousResponseId: 'resp-existing', - } as ChatRequest); - - const withoutContext = mockServer.getResponsesCalls(); - expect(String(withoutContext[0].body.instructions)).not.toContain( - 'CONVERSATION CONTEXT', - ); - expect(withoutContext[0].body.previous_response_id).toBe('resp-existing'); - }); - }); - - // ----------------------------------------------------------------------- - // Scenario 7: ZDR mode behavior - // ----------------------------------------------------------------------- - - describe('ZDR mode behavior', () => { - it('sends store:false and limits to 1 turn', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - systemPrompt: 'You are helpful.', - zdrMode: true, - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - const response = await orchestrator.chat(chatRequest('Hello')); - expect(response.content).toBeDefined(); - - const apiCalls = mockServer.getResponsesCalls(); - expect(apiCalls.length).toBe(1); - expect(apiCalls[0].body.store).toBe(false); - expect(apiCalls[0].body.include).toEqual( - expect.arrayContaining(['reasoning.encrypted_content']), - ); - }); - }); - - // ----------------------------------------------------------------------- - // Scenario: handoff tools are registered - // ----------------------------------------------------------------------- - - describe('agent tool registration', () => { - it('registers handoff function tools in API calls', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - agents: { - triage: { - name: 'Triage Agent', - instructions: 'You are a triage agent.', - handoffs: ['billing'], - }, - billing: { - name: 'Billing Agent', - instructions: 'You are a billing specialist.', - handoffDescription: 'Handles billing.', - }, - }, - defaultAgent: 'triage', - maxAgentTurns: 6, - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - await orchestrator.chat(chatRequest('Hello, general question')); - - const apiCalls = mockServer.getResponsesCalls(); - const firstCall = apiCalls[0]; - const tools = firstCall.body.tools as Array>; - - const handoffTool = tools?.find( - (t: Record) => t.name === 'transfer_to_Billing_Agent', - ); - expect(handoffTool).toBeDefined(); - expect(handoffTool!.type).toBe('function'); - }); - }); - - // ----------------------------------------------------------------------- - // Scenario B1: Streaming maxTurns exceeded emits stream.error - // ----------------------------------------------------------------------- - - describe('streaming maxTurns exceeded (B1)', () => { - // eslint-disable-next-line jest/no-disabled-tests - it.skip('emits stream.error with max_turns_exceeded code', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - agents: { - triage: { - name: 'Triage Agent', - instructions: 'You are a triage agent. Route billing to billing.', - handoffs: ['billing'], - }, - billing: { - name: 'Billing Agent', - instructions: 'You are a billing specialist.', - handoffDescription: 'Handles billing.', - }, - }, - defaultAgent: 'triage', - maxAgentTurns: 1, - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - const events: string[] = []; - await orchestrator.chatStream( - chatRequest('I need a refund please'), - (event: string) => events.push(event), - ); - - const parsed = events - .map(e => { - try { - return JSON.parse(e); - } catch { - return null; - } - }) - .filter(Boolean); - - const errorEvent = parsed.find( - (e: Record) => e.type === 'stream.error', - ); - expect(errorEvent).toBeDefined(); - expect(errorEvent.code).toBe('max_turns_exceeded'); - expect(errorEvent.error).toContain('maximum turns'); - }); - }); - - // ----------------------------------------------------------------------- - // Scenario B2: Agent-as-tool error is caught (non-streaming) - // ----------------------------------------------------------------------- - - describe('agent-as-tool error handling (B2)', () => { - it('catches sub-agent error and feeds it back to the parent', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - agents: { - orchestrator: { - name: 'Orchestrator Agent', - instructions: - 'You are an orchestrator agent. Use call_helper when asked.', - asTools: ['helper'], - }, - helper: { - name: 'Helper Agent', - instructions: 'You are an error-trigger agent.', - handoffDescription: 'Runs helper tasks.', - }, - }, - defaultAgent: 'orchestrator', - maxAgentTurns: 6, - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - const response = await orchestrator.chat( - chatRequest('Please run helper for me'), - ); - - expect(response).toBeDefined(); - expect(response.content).toBeDefined(); - expect(response.content).toContain('helper agent reported an issue'); - - const apiCalls = mockServer.getResponsesCalls(); - expect(apiCalls.length).toBe(3); - expect(apiCalls[0].body.instructions).toContain('orchestrator'); - expect(apiCalls[1].body.instructions).toContain('error-trigger'); - - const thirdInput = apiCalls[2].body.input as Array< - Record - >; - expect(thirdInput[0].type).toBe('function_call_output'); - expect(thirdInput[0].output).toContain('encountered an error'); - }); - }); - - // ----------------------------------------------------------------------- - // Scenario B3: ChatResponse includes agentName and handoffPath - // ----------------------------------------------------------------------- - - describe('ChatResponse agent metadata (B3)', () => { - it('includes agentName and handoffPath after handoff', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - agents: { - triage: { - name: 'Triage Agent', - instructions: 'You are a triage agent. Route billing to billing.', - handoffs: ['billing'], - }, - billing: { - name: 'Billing Agent', - instructions: 'You are a billing specialist.', - handoffDescription: 'Handles billing.', - }, - }, - defaultAgent: 'triage', - maxAgentTurns: 6, - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - const response: ChatResponse = await orchestrator.chat( - chatRequest('I need a refund'), - ); - - expect(response.agentName).toBe('Billing Agent'); - }); - - it('includes agentName but no handoffPath for single-agent', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - systemPrompt: 'You are a helpful assistant.', - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - const response: ChatResponse = await orchestrator.chat( - chatRequest('Hello'), - ); - - expect(response.agentName).toBeDefined(); - expect(response.handoffPath).toBeUndefined(); - }); - - it('includes agentName on maxTurns fallback response', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: `http://127.0.0.1:${mockServer.port()}`, - agents: { - triage: { - name: 'Triage Agent', - instructions: 'You are a triage agent. Route billing to billing.', - handoffs: ['billing'], - }, - billing: { - name: 'Billing Agent', - instructions: 'You are a billing specialist.', - handoffDescription: 'Handles billing.', - }, - }, - defaultAgent: 'triage', - maxAgentTurns: 1, - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - const response: ChatResponse = await orchestrator.chat( - chatRequest('I need a refund'), - ); - - expect(response.agentName).toBeDefined(); - }); - }); -}); - -// --------------------------------------------------------------------------- -// Tests: Real LlamaStack Instance (conditional) -// --------------------------------------------------------------------------- - -const LLAMASTACK_URL = process.env.LLAMASTACK_URL; -const LLAMASTACK_MODEL = - process.env.LLAMASTACK_MODEL ?? 'llama-4-scout-17b-16e-w4a16'; - -const describeReal = LLAMASTACK_URL ? describe : describe.skip; - -describeReal('Multi-Agent E2E (real LlamaStack)', () => { - // ----------------------------------------------------------------------- - // Scenario 8: Real single-agent chat - // ----------------------------------------------------------------------- - - it('single-agent chat returns a response', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: LLAMASTACK_URL!, - model: LLAMASTACK_MODEL, - systemPrompt: 'You are a helpful assistant. Be concise.', - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - const response = await orchestrator.chat( - chatRequest('What is 2+2? Reply with just the number.'), - ); - - expect(response).toBeDefined(); - expect(response.content).toBeDefined(); - expect(response.content.length).toBeGreaterThan(0); - expect(response.responseId).toBeDefined(); - }, 30000); - - // ----------------------------------------------------------------------- - // Scenario 9: Real multi-agent handoff - // ----------------------------------------------------------------------- - - it('multi-agent handoff completes without error', async () => { - const logger = createTestLogger(); - const config = createTestConfig({ - baseUrl: LLAMASTACK_URL!, - model: LLAMASTACK_MODEL, - agents: { - triage: { - name: 'Triage Agent', - instructions: `You are a triage agent. Your ONLY job is to route the user: -- For billing/refund/payment questions: call transfer_to_billing -- For everything else: answer directly -Do NOT answer billing questions yourself. Always hand off.`, - handoffs: ['billing'], - }, - billing: { - name: 'Billing Agent', - instructions: - 'You are a billing specialist. Help with refunds and payments. Be concise.', - handoffDescription: - 'Handles refunds, payments, invoices, and billing questions.', - }, - }, - defaultAgent: 'triage', - maxAgentTurns: 6, - }); - - const orchestrator = new ResponsesApiCoordinator({ logger, config }); - await orchestrator.initialize(); - - const response = await orchestrator.chat( - chatRequest('I need a refund for my last order'), - ); - - expect(response).toBeDefined(); - expect(response.content).toBeDefined(); - expect(response.content.length).toBeGreaterThan(0); - expect(response.responseId).toBeDefined(); - }, 60000); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/approvalContinuation.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/approvalContinuation.ts deleted file mode 100644 index 46d53c6975f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/approvalContinuation.ts +++ /dev/null @@ -1,291 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { BackendApprovalStore } from './BackendApprovalStore'; -import type { BackendToolExecutor } from './BackendToolExecutor'; -import type { ResponsesApiService } from './ResponsesApiService'; -import type { ClientManager } from './ClientManager'; -import type { ConfigResolutionService } from './ConfigResolutionService'; -import type { ConversationService } from './ConversationService'; -import type { ApprovalResult } from './conversationTypes'; -import type { AgentGraphManager } from './AgentGraphManager'; -import type { MCPServerConfig } from '../../types'; -import { toErrorMessage } from '../../services/utils'; - -export interface ToolCallInfo { - callId: string; - name: string; - arguments: string; -} - -export interface ContinuationDeps { - backendApprovalStore: BackendApprovalStore; - backendToolExecutor: BackendToolExecutor; - chatService: ResponsesApiService; - clientManager: ClientManager; - configResolution: ConfigResolutionService; - getConversations: () => ConversationService | null; - getAgentGraphManager?: () => AgentGraphManager | null; - getMcpServers: () => MCPServerConfig[]; - logger: LoggerService; -} - -export function partitionByApproval( - calls: ToolCallInfo[], - mcpServers: MCPServerConfig[], - backendToolExecutor: BackendToolExecutor, -): { autoExec: ToolCallInfo[]; needsApproval: ToolCallInfo[] } { - const autoExec: ToolCallInfo[] = []; - const needsApproval: ToolCallInfo[] = []; - - for (const call of calls) { - const info = backendToolExecutor.getToolServerInfo(call.name); - if (!info) { - autoExec.push(call); - continue; - } - const server = mcpServers.find(s => s.id === info.serverId); - if (!server?.requireApproval || server.requireApproval === 'never') { - autoExec.push(call); - } else { - needsApproval.push(call); - } - } - return { autoExec, needsApproval }; -} - -export async function continueWithFunctionCallOutput( - deps: ContinuationDeps, - previousResponseId: string, - callId: string, - output: string, - conversationId?: string, - functionCall?: { name: string; arguments: string }, - agentKey?: string, -): Promise { - const { - chatService, - clientManager, - configResolution, - backendToolExecutor, - backendApprovalStore, - logger, - } = deps; - - const client = clientManager.getExistingClient(); - const lsCfg = configResolution.getLlamaStackConfig(); - const effectiveCfg = configResolution.getResolver()?.getCachedConfig(); - - let model = effectiveCfg?.model ?? lsCfg?.model ?? 'default'; - let maxOutputTokens = effectiveCfg?.maxOutputTokens ?? lsCfg?.maxOutputTokens; - let temperature = effectiveCfg?.temperature ?? lsCfg?.temperature; - let instructions = effectiveCfg?.postToolInstructions; - let guardrails = effectiveCfg?.guardrails; - const truncation = effectiveCfg?.truncation ?? 'auto'; - const safetyIdentifier = - effectiveCfg?.safetyIdentifier ?? lsCfg?.safetyIdentifier; - if (agentKey) { - try { - const graphManager = deps.getAgentGraphManager?.(); - const snapshot = await graphManager?.getSnapshot(); - const agent = snapshot?.agents.get(agentKey); - if (agent) { - model = agent.config.model ?? model; - maxOutputTokens = agent.config.maxOutputTokens ?? maxOutputTokens; - temperature = agent.config.temperature ?? temperature; - guardrails = agent.config.guardrails ?? guardrails; - instructions = agent.config.instructions; - logger.info( - `[BackendApproval] Resolved agent "${agentKey}" config for continuation (model=${model}, maxOutputTokens=${maxOutputTokens})`, - ); - } - } catch { - logger.warn( - `[BackendApproval] Could not resolve agent "${agentKey}" — using global config`, - ); - } - } - - const mcpServers = deps.getMcpServers(); - const toolDefs = await backendToolExecutor.ensureToolsDiscovered(mcpServers); - - let currentCallId = callId; - let currentOutput = output; - let currentFunctionCall = functionCall; - let currentResponseId = previousResponseId; - let currentAdditionalOutputs: - | Array<{ - callId: string; - output: string; - functionCall?: { name: string; arguments: string }; - }> - | undefined; - let accumulatedText = ''; - const firstToolOutput = output; - const firstOutputTruncated = output.includes('[... OUTPUT TRUNCATED:'); - - for (let iteration = 0; ; iteration++) { - logger.info( - `[BackendApproval] Continuation iteration ${iteration + 1} (responseId=${currentResponseId})`, - ); - - const result = await chatService.continueFunctionCallOutput({ - client, - model, - callId: currentCallId, - output: currentOutput, - previousResponseId: currentResponseId, - guardrails, - safetyIdentifier, - functionCall: currentFunctionCall, - conversationId, - maxOutputTokens, - temperature, - instructions, - truncation, - tools: toolDefs, - additionalToolOutputs: currentAdditionalOutputs, - }); - - currentAdditionalOutputs = undefined; - - if (conversationId && result.responseId) { - const conversations = deps.getConversations(); - await conversations?.registerResponse(conversationId, result.responseId); - } - - accumulatedText = result.text; - currentResponseId = result.responseId; - - if (result.functionCalls.length === 0) { - logger.info( - `[BackendApproval] Continuation complete — final text response (iteration ${iteration + 1})`, - ); - break; - } - - const { autoExec, needsApproval } = partitionByApproval( - result.functionCalls, - mcpServers, - backendToolExecutor, - ); - - if (needsApproval.length > 0) { - const call = needsApproval[0]; - const info = backendToolExecutor.getToolServerInfo(call.name); - - backendApprovalStore.store({ - responseId: currentResponseId, - callId: call.callId, - functionName: call.name, - argumentsJson: call.arguments, - serverId: info?.serverId ?? '', - serverUrl: mcpServers.find(s => s.id === info?.serverId)?.url ?? '', - originalToolName: info?.originalName ?? call.name, - conversationId, - createdAt: Date.now(), - agentKey, - }); - - logger.info( - `[BackendApproval] Chained approval required for tool "${info?.originalName ?? call.name}" (iteration ${iteration + 1})`, - ); - - return { - content: accumulatedText || 'Tool executed successfully.', - responseId: currentResponseId, - toolExecuted: true, - toolOutput: firstToolOutput, - outputTruncated: firstOutputTruncated, - pendingApproval: { - approvalRequestId: call.callId, - toolName: info?.originalName ?? call.name, - serverLabel: info?.serverId, - arguments: call.arguments, - }, - }; - } - - logger.info( - `[BackendApproval] Auto-executing ${autoExec.length} chained tool(s) (iteration ${iteration + 1})`, - ); - - const toolResults = await Promise.all( - autoExec.map(async call => { - const info = backendToolExecutor.getToolServerInfo(call.name); - const displayName = info?.originalName ?? call.name; - - logger.info(`[BackendApproval] Executing chained tool: ${displayName}`); - - try { - const toolOutput = await backendToolExecutor.executeTool( - call.name, - call.arguments, - ); - return { - callId: call.callId, - name: call.name, - output: toolOutput, - error: false, - }; - } catch (err) { - const errMsg = toErrorMessage(err); - logger.error( - `[BackendApproval] Chained tool "${displayName}" failed: ${errMsg}`, - ); - return { - callId: call.callId, - name: call.name, - output: JSON.stringify({ error: errMsg }), - error: true, - }; - } - }), - ); - - const primary = toolResults[0]; - currentCallId = primary.callId; - currentOutput = primary.output; - currentFunctionCall = { - name: primary.name, - arguments: - autoExec.find(c => c.callId === primary.callId)?.arguments ?? '{}', - }; - - if (toolResults.length > 1) { - currentAdditionalOutputs = toolResults.slice(1).map(r => ({ - callId: r.callId, - output: r.output, - functionCall: { - name: r.name, - arguments: - autoExec.find(c => c.callId === r.callId)?.arguments ?? '{}', - }, - })); - } else { - currentAdditionalOutputs = undefined; - } - } - - return { - content: accumulatedText || 'Tool executed successfully.', - responseId: currentResponseId, - toolExecuted: true, - toolOutput: firstToolOutput, - outputTruncated: firstOutputTruncated, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/auth/McpAuthService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/auth/McpAuthService.test.ts deleted file mode 100644 index fe0732b012c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/auth/McpAuthService.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { MCPAuthConfig } from '../../../types'; -import { McpAuthService } from './McpAuthService'; -import { createMockLogger } from '../../../test-utils/mocks'; - -describe('McpAuthService', () => { - const mockLogger = createMockLogger(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('constructor', () => { - it('creates an instance with no MCP auth configs', () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - expect(service).toBeDefined(); - }); - - it('creates an instance with security mode full', () => { - const service = new McpAuthService( - { mode: 'full' }, - new Map([ - [ - 'mcp-1', - { - type: 'oauth', - tokenUrl: 'https://keycloak/token', - clientId: 'c', - clientSecret: 's', - }, - ], - ]), - mockLogger as unknown as LoggerService, - false, - ); - expect(service).toBeDefined(); - }); - }); - - describe('getServerHeaders', () => { - it('returns empty headers when mode is none', async () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - const headers = await service.getServerHeaders({ - id: 'test', - name: 'Test', - url: 'https://localhost:3000', - type: 'streamable-http', - }); - expect(headers).toEqual({}); - }); - - it('returns empty headers when no auth config matches the server', async () => { - const service = new McpAuthService( - { mode: 'full' }, - new Map([ - [ - 'other-server', - { - type: 'oauth', - tokenUrl: 'https://keycloak/token', - clientId: 'c', - clientSecret: 's', - }, - ], - ]), - mockLogger as unknown as LoggerService, - false, - ); - const headers = await service.getServerHeaders({ - id: 'test', - name: 'Test', - url: 'https://localhost:3000', - type: 'streamable-http', - }); - expect(headers).toEqual({}); - }); - }); - - describe('getServerHeaders - null token warnings', () => { - let originalFetch: typeof global.fetch; - - beforeEach(() => { - originalFetch = global.fetch; - }); - - afterEach(() => { - global.fetch = originalFetch; - }); - - it('logs warning when authRef OAuth token returns null', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: false, - status: 401, - text: () => Promise.resolve('Unauthorized'), - json: () => Promise.resolve({}), - }); - - const service = new McpAuthService( - { mode: 'none' }, - new Map([ - [ - 'my-auth', - { - type: 'oauth', - tokenUrl: 'https://keycloak/token', - clientId: 'c', - clientSecret: 's', - }, - ], - ]), - mockLogger as unknown as LoggerService, - false, - ); - - const headers = await service.getServerHeaders({ - id: 'srv-1', - name: 'Server 1', - url: 'https://mcp.example.com', - type: 'streamable-http', - authRef: 'my-auth', - }); - - expect(headers.Authorization).toBeUndefined(); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'OAuth token via authRef "my-auth" returned null', - ), - ); - }); - - it('logs warning when inline OAuth token returns null', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: false, - status: 500, - text: () => Promise.resolve('Server Error'), - json: () => Promise.resolve({}), - }); - - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - - const headers = await service.getServerHeaders({ - id: 'srv-2', - name: 'Server 2', - url: 'https://mcp.example.com', - type: 'streamable-http', - oauth: { - tokenUrl: 'https://keycloak/token', - clientId: 'c', - clientSecret: 's', - }, - }); - - expect(headers.Authorization).toBeUndefined(); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('inline OAuth token returned null'), - ); - }); - - it('sets Authorization header when OAuth token succeeds', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - text: () => - Promise.resolve( - JSON.stringify({ access_token: 'test-token', expires_in: 300 }), - ), - json: () => - Promise.resolve({ access_token: 'test-token', expires_in: 300 }), - }); - - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - - const headers = await service.getServerHeaders({ - id: 'srv-3', - name: 'Server 3', - url: 'https://mcp.example.com', - type: 'streamable-http', - oauth: { - tokenUrl: 'https://keycloak/token', - clientId: 'c', - clientSecret: 's', - }, - }); - - expect(headers.Authorization).toBe('Bearer test-token'); - expect(mockLogger.warn).not.toHaveBeenCalledWith( - expect.stringContaining('returned null'), - ); - }); - - it('caches OAuth token on subsequent calls', async () => { - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - text: () => - Promise.resolve( - JSON.stringify({ access_token: 'cached-token', expires_in: 3600 }), - ), - json: () => - Promise.resolve({ access_token: 'cached-token', expires_in: 3600 }), - }); - global.fetch = mockFetch; - - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - - const server = { - id: 'srv-cache', - name: 'Cache Server', - url: 'https://mcp.example.com', - type: 'streamable-http' as const, - oauth: { - tokenUrl: 'https://keycloak/token', - clientId: 'c', - clientSecret: 's', - }, - }; - - await service.getServerHeaders(server); - await service.getServerHeaders(server); - - // Only one actual fetch should occur (second should hit cache) - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it('deduplicates concurrent OAuth token requests', async () => { - let resolveToken: (v: unknown) => void; - const tokenPromise = new Promise(resolve => { - resolveToken = resolve; - }); - - const mockFetch = jest.fn().mockImplementation(() => tokenPromise); - global.fetch = mockFetch; - - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - - const server = { - id: 'srv-dedup', - name: 'Dedup Server', - url: 'https://mcp.example.com', - type: 'streamable-http' as const, - oauth: { - tokenUrl: 'https://keycloak/token', - clientId: 'c', - clientSecret: 's', - }, - }; - - // Fire two concurrent requests - const p1 = service.getServerHeaders(server); - const p2 = service.getServerHeaders(server); - - // Resolve the shared promise - resolveToken!({ - ok: true, - status: 200, - text: () => - Promise.resolve( - JSON.stringify({ access_token: 'dedup-token', expires_in: 300 }), - ), - json: () => - Promise.resolve({ access_token: 'dedup-token', expires_in: 300 }), - }); - - const [h1, h2] = await Promise.all([p1, p2]); - - expect(h1.Authorization).toBe('Bearer dedup-token'); - expect(h2.Authorization).toBe('Bearer dedup-token'); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it('logs warning for unknown authRef', async () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - - await service.getServerHeaders({ - id: 'srv-missing', - name: 'Missing Auth', - url: 'https://mcp.example.com', - type: 'streamable-http', - authRef: 'nonexistent', - }); - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('references unknown auth config: nonexistent'), - ); - }); - - it('preserves static headers from server config', async () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - - const headers = await service.getServerHeaders({ - id: 'srv-static', - name: 'Static Headers', - url: 'https://mcp.example.com', - type: 'streamable-http', - headers: { 'X-Custom': 'value' }, - }); - - expect(headers['X-Custom']).toBe('value'); - }); - }); - - describe('getApiApprovalConfig', () => { - it('returns "never" when no config is provided', () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - expect(service.getApiApprovalConfig(undefined)).toBe('never'); - }); - - it('returns "always" when configured as string', () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - expect(service.getApiApprovalConfig('always')).toBe('always'); - }); - - it('returns "never" when configured as string', () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - expect(service.getApiApprovalConfig('never')).toBe('never'); - }); - - it('falls back to "never" for object format (Llama Stack limitation) and logs warning', () => { - const service = new McpAuthService( - { mode: 'none' }, - new Map(), - mockLogger as unknown as LoggerService, - false, - ); - const result = service.getApiApprovalConfig({ - always: ['tool1'], - never: ['tool2'], - }); - expect(result).toBe('never'); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('does not support per-tool require_approval'), - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/auth/McpAuthService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/auth/McpAuthService.ts deleted file mode 100644 index 3f8dba6d648..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/auth/McpAuthService.ts +++ /dev/null @@ -1,290 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { LoggerService } from '@backstage/backend-plugin-api'; -import { - MAX_TOKEN_CACHE_SIZE, - TOKEN_EXPIRY_BUFFER_S, -} from '../../../constants'; -import { fetchWithTlsControl } from '../../../services/utils/http'; -import { toErrorMessage } from '../../../services/utils'; -import { MCPAuthConfig, MCPServerConfig, SecurityConfig } from '../../../types'; -import { getApiApprovalConfig } from '../config/McpConfigLoader'; -import { - fetchOAuthToken, - fetchOAuthClientCredentials, - fetchServiceAccountToken, -} from './mcpTokenFetchers'; -import type { TokenCacheEntry } from './mcpTokenFetchers'; - -export class McpAuthService { - private readonly securityConfig: SecurityConfig; - private readonly mcpAuthConfigs: Map; - private readonly logger: LoggerService; - private readonly skipTlsVerify: boolean; - - private readonly oauthTokenCache: Map = new Map(); - private readonly serviceAccountTokenCache: Map = - new Map(); - - private readonly inflightTokenRequests: Map> = - new Map(); - - private mcpOAuthToken: string | null = null; - private mcpOAuthTokenExpiry: number | null = null; - - constructor( - securityConfig: SecurityConfig, - mcpAuthConfigs: Map, - logger: LoggerService, - skipTlsVerify: boolean = false, - ) { - this.securityConfig = securityConfig; - this.mcpAuthConfigs = mcpAuthConfigs; - this.logger = logger; - this.skipTlsVerify = skipTlsVerify; - } - - private evictExpiredEntries(cache: Map): void { - if (cache.size <= MAX_TOKEN_CACHE_SIZE) return; - const now = Date.now(); - for (const [key, entry] of cache) { - if (entry.expiresAt <= now) { - cache.delete(key); - } - } - if (cache.size > MAX_TOKEN_CACHE_SIZE) { - const firstKey = cache.keys().next().value; - if (firstKey !== undefined) cache.delete(firstKey); - } - } - - private makeFetchFn() { - const skipTls = this.skipTlsVerify; - return ( - url: string, - options: { - method: string; - headers: Record; - body: string; - }, - ) => - fetchWithTlsControl(url, { - method: options.method, - headers: options.headers, - body: options.body, - skipTlsVerify: skipTls, - }); - } - - async getServerHeaders( - server: MCPServerConfig, - ): Promise> { - const headers: Record = { ...server.headers }; - - if (server.authRef) { - const authConfig = this.mcpAuthConfigs.get(server.authRef); - if (!authConfig) { - this.logger.warn( - `MCP server ${server.id} references unknown auth config: ${server.authRef}`, - ); - } else if (authConfig.type === 'oauth') { - const token = await this.getOAuthToken(server.id, authConfig); - if (token) { - headers.Authorization = `Bearer ${token}`; - } else { - this.logger.warn( - `MCP server ${server.id}: OAuth token via authRef "${server.authRef}" returned null — requests will be unauthenticated`, - ); - } - } else if (authConfig.type === 'serviceAccount') { - const token = await this.getServiceAccountToken(server.id, authConfig); - if (token) { - headers.Authorization = `Bearer ${token}`; - } else { - this.logger.warn( - `MCP server ${server.id}: ServiceAccount token via authRef "${server.authRef}" returned null — requests will be unauthenticated`, - ); - } - } - return headers; - } - - if (server.oauth) { - const token = await this.getOAuthToken(server.id, server.oauth); - if (token) { - headers.Authorization = `Bearer ${token}`; - } else { - this.logger.warn( - `MCP server ${server.id}: inline OAuth token returned null — requests will be unauthenticated`, - ); - } - return headers; - } - - if (server.serviceAccount) { - const token = await this.getServiceAccountToken( - server.id, - server.serviceAccount, - ); - if (token) { - headers.Authorization = `Bearer ${token}`; - } else { - this.logger.warn( - `MCP server ${server.id}: ServiceAccount token returned null — requests will be unauthenticated`, - ); - } - return headers; - } - - if (this.securityConfig.mode === 'full' && this.securityConfig.mcpOAuth) { - const token = await this.getSecurityMcpOAuthToken(); - if (token) { - headers.Authorization = `Bearer ${token}`; - this.logger.debug( - `MCP server ${server.id} using global security.mcpOAuth token`, - ); - } else { - this.logger.warn( - `MCP server ${server.id}: global security.mcpOAuth token returned null — requests will be unauthenticated`, - ); - } - return headers; - } - - return headers; - } - - getApiApprovalConfig( - configApproval: Parameters[0], - ): ReturnType { - return getApiApprovalConfig(configApproval, this.logger); - } - - private async getOAuthToken( - serverId: string, - oauth: import('../../../types').OAuthClientConfig, - ): Promise { - if (!oauth) { - return null; - } - - const cached = this.oauthTokenCache.get(serverId); - if (cached && cached.expiresAt > Date.now()) { - this.logger.debug(`Using cached OAuth token for MCP server ${serverId}`); - return cached.token; - } - - const inflightKey = `oauth:${serverId}`; - const inflight = this.inflightTokenRequests.get(inflightKey); - if (inflight !== undefined) { - this.logger.debug( - `Awaiting in-flight OAuth token request for ${serverId}`, - ); - return inflight; - } - - const request = fetchOAuthToken( - serverId, - oauth, - this.oauthTokenCache, - this.makeFetchFn(), - cache => this.evictExpiredEntries(cache), - this.logger, - ); - this.inflightTokenRequests.set(inflightKey, request); - - try { - return await request; - } finally { - this.inflightTokenRequests.delete(inflightKey); - } - } - - private async getServiceAccountToken( - serverId: string, - saConfig: import('../../../types').MCPServerServiceAccountConfig, - ): Promise { - return fetchServiceAccountToken( - serverId, - saConfig, - this.serviceAccountTokenCache, - cache => this.evictExpiredEntries(cache), - this.logger, - ); - } - - private async getSecurityMcpOAuthToken(): Promise { - if (this.securityConfig.mode !== 'full' || !this.securityConfig.mcpOAuth) { - return null; - } - - if ( - this.mcpOAuthToken && - this.mcpOAuthTokenExpiry && - Date.now() < this.mcpOAuthTokenExpiry - TOKEN_EXPIRY_BUFFER_S * 1000 - ) { - return this.mcpOAuthToken; - } - - const inflightKey = 'security:mcpOAuth'; - const inflight = this.inflightTokenRequests.get(inflightKey); - if (inflight !== undefined) { - this.logger.debug('Awaiting in-flight security mcpOAuth token request'); - return inflight; - } - - const request = this.fetchSecurityMcpOAuthToken(); - this.inflightTokenRequests.set(inflightKey, request); - - try { - return await request; - } finally { - this.inflightTokenRequests.delete(inflightKey); - } - } - - private async fetchSecurityMcpOAuthToken(): Promise { - const { tokenUrl, clientId, clientSecret, scopes } = - this.securityConfig.mcpOAuth!; - - this.logger.info(`Fetching security OAuth token for MCP servers`); - - try { - const result = await fetchOAuthClientCredentials( - tokenUrl, - clientId, - clientSecret, - scopes || ['openid'], - this.makeFetchFn(), - ); - - this.mcpOAuthToken = result.token; - this.mcpOAuthTokenExpiry = Date.now() + result.expiresIn * 1000; - - this.logger.info( - `Security OAuth token obtained, expires in ${result.expiresIn}s`, - ); - - return this.mcpOAuthToken; - } catch (error) { - this.logger.error( - `Failed to get security OAuth token: ${toErrorMessage(error)}`, - ); - return null; - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/auth/mcpTokenFetchers.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/auth/mcpTokenFetchers.ts deleted file mode 100644 index a96d4386029..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/auth/mcpTokenFetchers.ts +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import * as fs from 'fs'; -import { - MIN_TOKEN_LIFETIME_S, - TOKEN_EXPIRY_BUFFER_S, - DEFAULT_TOKEN_EXPIRATION_S, -} from '../../../constants'; -import { toErrorMessage } from '../../../services/utils'; -import type { - OAuthClientConfig, - MCPServerServiceAccountConfig, -} from '../../../types'; - -export interface TokenCacheEntry { - token: string; - expiresAt: number; -} - -export type FetchWithTlsControlFn = ( - url: string, - options: { method: string; headers: Record; body: string }, -) => Promise<{ - ok: boolean; - status: number; - text: () => Promise; - json: () => Promise; -}>; - -export async function fetchOAuthClientCredentials( - url: string, - clientId: string, - clientSecret: string, - scopes: string[], - fetchFn: FetchWithTlsControlFn, -): Promise<{ token: string; expiresIn: number }> { - const body = new URLSearchParams({ - grant_type: 'client_credentials', - client_id: clientId, - client_secret: clientSecret, - scope: scopes.join(' '), - }); - - const response = await fetchFn(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: body.toString(), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `OAuth token request failed: HTTP ${response.status}${ - errorText ? ` - ${errorText}` : '' - }`, - ); - } - - const data = (await response.json()) as { - access_token: string; - expires_in?: number; - }; - const token = data.access_token; - const expiresIn = data.expires_in || MIN_TOKEN_LIFETIME_S; - return { token, expiresIn }; -} - -export async function fetchOAuthToken( - serverId: string, - oauth: OAuthClientConfig, - oauthTokenCache: Map, - fetchFn: FetchWithTlsControlFn, - evictExpiredEntries: (cache: Map) => void, - logger: LoggerService, -): Promise { - try { - logger.info(`Fetching OAuth token for MCP server ${serverId}`); - - const scopes = oauth.scopes || ['openid']; - const result = await fetchOAuthClientCredentials( - oauth.tokenUrl, - oauth.clientId, - oauth.clientSecret, - scopes, - fetchFn, - ); - - const { token, expiresIn } = result; - evictExpiredEntries(oauthTokenCache); - oauthTokenCache.set(serverId, { - token, - expiresAt: Date.now() + (expiresIn - TOKEN_EXPIRY_BUFFER_S) * 1000, - }); - - logger.info( - `OAuth token obtained for ${serverId}, expires in ${expiresIn}s`, - ); - return token; - } catch (error) { - logger.error( - `Failed to fetch OAuth token for ${serverId}: ${toErrorMessage(error)}`, - ); - return null; - } -} - -export async function fetchServiceAccountToken( - serverId: string, - saConfig: MCPServerServiceAccountConfig, - serviceAccountTokenCache: Map, - evictExpiredEntries: (cache: Map) => void, - logger: LoggerService, -): Promise { - const cacheKey = `${saConfig.namespace || 'default'}/${saConfig.name}`; - const cached = serviceAccountTokenCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - logger.debug(`Using cached ServiceAccount token for ${serverId}`); - return cached.token; - } - - try { - const inClusterTokenPath = - '/var/run/secrets/kubernetes.io/serviceaccount/token'; - - if (fs.existsSync(inClusterTokenPath)) { - const namespaceFile = - '/var/run/secrets/kubernetes.io/serviceaccount/namespace'; - const podNamespace = fs.existsSync(namespaceFile) - ? (await fs.promises.readFile(namespaceFile, 'utf8')).trim() - : 'default'; - - const requestedNamespace = saConfig.namespace || podNamespace; - - logger.info( - `Getting ServiceAccount token for ${requestedNamespace}/${saConfig.name}`, - ); - - const k8sHost = process.env.KUBERNETES_SERVICE_HOST; - const k8sPort = process.env.KUBERNETES_SERVICE_PORT; - - if (k8sHost && k8sPort) { - const podToken = ( - await fs.promises.readFile(inClusterTokenPath, 'utf8') - ).trim(); - - const tokenRequestUrl = `https://${k8sHost}:${k8sPort}/api/v1/namespaces/${requestedNamespace}/serviceaccounts/${saConfig.name}/token`; - - const tokenRequest = { - apiVersion: 'authentication.k8s.io/v1', - kind: 'TokenRequest', - spec: { - audiences: ['mcp-server'], - expirationSeconds: DEFAULT_TOKEN_EXPIRATION_S, - }, - }; - - const response = await fetch(tokenRequestUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${podToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(tokenRequest), - signal: AbortSignal.timeout(15_000), - }); - - if (!response.ok) { - const errorText = await response.text(); - logger.error( - `Failed to create token for ServiceAccount ${requestedNamespace}/${saConfig.name}: ${response.status} ${errorText}`, - ); - return null; - } - - const data = (await response.json()) as { - status: { token: string; expirationTimestamp: string }; - }; - const token = data.status.token; - - const expiresAt = - new Date(data.status.expirationTimestamp).getTime() - - TOKEN_EXPIRY_BUFFER_S * 1000; - evictExpiredEntries(serviceAccountTokenCache); - serviceAccountTokenCache.set(cacheKey, { token, expiresAt }); - - logger.info( - `ServiceAccount token obtained for ${requestedNamespace}/${saConfig.name}`, - ); - return token; - } - } - - logger.warn( - `Cannot get ServiceAccount token for ${serverId}: not running in-cluster`, - ); - return null; - } catch (error) { - logger.error( - `Failed to get ServiceAccount token for ${serverId}: ${toErrorMessage( - error, - )}`, - ); - return null; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/chatUtils.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/chatUtils.ts deleted file mode 100644 index e0af6ff8186..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/chatUtils.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/chat/chatUtils' instead */ -export * from '../responses-api/chat/chatUtils'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/AgentConfigParser.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/AgentConfigParser.ts deleted file mode 100644 index 47db2c63396..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/AgentConfigParser.ts +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - LoggerService, - RootConfigService, -} from '@backstage/backend-plugin-api'; -import type { AgentConfig, ToolChoiceConfig } from '../../../types'; -import { parseTruncation } from './ConfigParsers'; - -function asStringArray(val: unknown): string[] | undefined { - return Array.isArray(val) ? (val as string[]) : undefined; -} - -function warnIfNotArray( - raw: Record, - key: string, - field: string, - logger: LoggerService, -): void { - if (raw[field] && !Array.isArray(raw[field])) - logger.warn( - `[MultiAgent] Agent "${key}": ${field} should be an array, got ${typeof raw[field]}. Ignoring.`, - ); -} - -export function parseSingleAgent( - key: string, - value: unknown, - logger: LoggerService, -): AgentConfig | undefined { - if (!value || typeof value !== 'object') { - logger.warn(`[MultiAgent] Skipping invalid agent config: "${key}"`); - return undefined; - } - const raw = value as Record; - if (typeof raw.name !== 'string' || typeof raw.instructions !== 'string') { - logger.warn( - `[MultiAgent] Agent "${key}" missing required 'name' or 'instructions', skipping`, - ); - return undefined; - } - warnIfNotArray(raw, key, 'mcpServers', logger); - warnIfNotArray(raw, key, 'handoffs', logger); - warnIfNotArray(raw, key, 'asTools', logger); - warnIfNotArray(raw, key, 'vectorStoreIds', logger); - return { - name: raw.name, - instructions: raw.instructions, - handoffDescription: raw.handoffDescription as string | undefined, - model: raw.model as string | undefined, - mcpServers: asStringArray(raw.mcpServers), - handoffs: asStringArray(raw.handoffs), - asTools: asStringArray(raw.asTools), - enableRAG: raw.enableRAG as boolean | undefined, - vectorStoreIds: asStringArray(raw.vectorStoreIds), - enableWebSearch: raw.enableWebSearch as boolean | undefined, - enableCodeInterpreter: raw.enableCodeInterpreter as boolean | undefined, - functions: raw.functions as AgentConfig['functions'] | undefined, - toolChoice: raw.toolChoice as ToolChoiceConfig | undefined, - reasoning: raw.reasoning as AgentConfig['reasoning'] | undefined, - inheritSystemPrompt: raw.inheritSystemPrompt as boolean | undefined, - handoffInputSchema: raw.handoffInputSchema as - | Record - | undefined, - handoffInputFilter: raw.handoffInputFilter as - | AgentConfig['handoffInputFilter'] - | undefined, - toolUseBehavior: raw.toolUseBehavior as - | AgentConfig['toolUseBehavior'] - | undefined, - outputSchema: raw.outputSchema as AgentConfig['outputSchema'] | undefined, - enabled: raw.enabled as boolean | undefined, - toolGuardrails: Array.isArray(raw.toolGuardrails) - ? (raw.toolGuardrails as AgentConfig['toolGuardrails']) - : undefined, - guardrails: asStringArray(raw.guardrails), - maxToolCalls: - typeof raw.maxToolCalls === 'number' ? raw.maxToolCalls : undefined, - maxOutputTokens: - typeof raw.maxOutputTokens === 'number' ? raw.maxOutputTokens : undefined, - temperature: - typeof raw.temperature === 'number' ? raw.temperature : undefined, - truncation: parseTruncation(raw.truncation as string | undefined), - publishAs: (() => { - if (raw.publishAs === undefined) return undefined; - if ( - typeof raw.publishAs === 'string' && - ['router', 'specialist', 'standalone'].includes(raw.publishAs) - ) - return raw.publishAs as 'router' | 'specialist' | 'standalone'; - logger.warn( - `[MultiAgent] Agent "${key}": publishAs must be "router", "specialist", or "standalone". Ignoring.`, - ); - return undefined; - })(), - }; -} - -export function parseAgentConfigs( - config: RootConfigService, - logger: LoggerService, -): - | { - agents: Record; - defaultAgent: string; - maxAgentTurns?: number; - } - | undefined { - const agentsRaw = config.getOptional('augment.agents') as - | Record - | undefined; - if (!agentsRaw || typeof agentsRaw !== 'object') return undefined; - const agents: Record = {}; - for (const [key, value] of Object.entries(agentsRaw)) { - const agent = parseSingleAgent(key, value, logger); - if (agent) agents[key] = agent; - } - if (Object.keys(agents).length === 0) { - logger.info('[MultiAgent] No valid agent definitions found'); - return undefined; - } - const defaultAgent = - (config.getOptionalString('augment.defaultAgent') as string) || - Object.keys(agents)[0]; - const maxAgentTurns = config.getOptionalNumber('augment.maxAgentTurns'); - logger.info( - `[MultiAgent] Loaded ${Object.keys(agents).length} agent(s): [${Object.keys(agents).join(', ')}], default="${defaultAgent}", maxTurns=${maxAgentTurns ?? 'unlimited'}`, - ); - return { agents, defaultAgent, maxAgentTurns }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/BrandingConfigLoader.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/BrandingConfigLoader.ts deleted file mode 100644 index bbacfde42b3..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/BrandingConfigLoader.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Config } from '@backstage/config'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { BrandingConfig } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -/** - * Load branding overrides from app-config.yaml (augment.branding). - * Returns only the fields explicitly set in YAML; consumers merge - * with DEFAULT_BRANDING to get a complete BrandingConfig. - * - * @param config - Backstage config (e.g. RootConfigService) - * @param _logger - Logger (reserved for future use) - * @returns Partial branding overrides, or empty object if not configured - */ -export function loadBrandingOverrides( - config: Config, - _logger: LoggerService, -): Partial { - const bc = config.getOptionalConfig('augment.branding'); - if (!bc) return {}; - - const result: Partial = {}; - const str = (k: string) => bc.getOptionalString(k); - - const appName = str('appName'); - if (appName) result.appName = appName; - const tagline = str('tagline'); - if (tagline) result.tagline = tagline; - const inputPlaceholder = str('inputPlaceholder'); - if (inputPlaceholder) result.inputPlaceholder = inputPlaceholder; - const primaryColor = str('primaryColor'); - if (primaryColor) result.primaryColor = primaryColor; - const secondaryColor = str('secondaryColor'); - if (secondaryColor) result.secondaryColor = secondaryColor; - const successColor = str('successColor'); - if (successColor) result.successColor = successColor; - const warningColor = str('warningColor'); - if (warningColor) result.warningColor = warningColor; - const errorColor = str('errorColor'); - if (errorColor) result.errorColor = errorColor; - const infoColor = str('infoColor'); - if (infoColor) result.infoColor = infoColor; - const logoUrl = str('logoUrl'); - if (logoUrl) result.logoUrl = logoUrl; - const faviconUrl = str('faviconUrl'); - if (faviconUrl) result.faviconUrl = faviconUrl; - const botAvatarUrl = str('botAvatarUrl'); - if (botAvatarUrl) result.botAvatarUrl = botAvatarUrl; - - const themePreset = str('themePreset'); - if (themePreset) result.themePreset = themePreset; - - return result; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigLoader.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigLoader.test.ts deleted file mode 100644 index ba205725d0e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigLoader.test.ts +++ /dev/null @@ -1,505 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ConfigReader, type JsonObject } from '@backstage/config'; -import { ConfigLoader, ConfigValidationError } from './ConfigLoader'; -import { createMockLogger } from '../../../test-utils'; - -function createConfigLoader(yamlConfig: JsonObject) { - const config = new ConfigReader(yamlConfig); - const logger = createMockLogger(); - return { loader: new ConfigLoader(config, logger), logger }; -} - -describe('ConfigLoader', () => { - describe('validateRequiredConfig', () => { - it('passes when baseUrl and model are provided', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - baseUrl: 'http://localhost:8321', - model: 'test-model', - }, - }, - }); - expect(() => loader.validateRequiredConfig()).not.toThrow(); - }); - - it('throws ConfigValidationError when llamaStack section is missing', () => { - const { loader } = createConfigLoader({ augment: {} }); - expect(() => loader.validateRequiredConfig()).toThrow( - ConfigValidationError, - ); - }); - - it('throws when baseUrl is missing', () => { - const { loader } = createConfigLoader({ - augment: { llamaStack: { model: 'test-model' } }, - }); - expect(() => loader.validateRequiredConfig()).toThrow( - ConfigValidationError, - ); - }); - - it('throws when model is missing', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { baseUrl: 'http://localhost:8321' }, - }, - }); - expect(() => loader.validateRequiredConfig()).toThrow( - ConfigValidationError, - ); - }); - }); - - describe('loadLlamaStackConfig', () => { - const minimalConfig = { - augment: { - llamaStack: { - baseUrl: 'http://localhost:8321', - model: 'test-model', - }, - }, - }; - - it('loads minimal config with defaults', () => { - const { loader } = createConfigLoader(minimalConfig); - const result = loader.loadLlamaStackConfig(); - - expect(result.baseUrl).toBe('http://localhost:8321'); - expect(result.model).toBe('test-model'); - expect(result.vectorStoreIds).toEqual([]); - expect(result.vectorStoreName).toBe('augment-knowledge-base'); - expect(result.embeddingModel).toBe( - 'sentence-transformers/all-MiniLM-L6-v2', - ); - expect(result.embeddingDimension).toBe(384); - expect(result.chunkingStrategy).toBe('auto'); - expect(result.maxChunkSizeTokens).toBe(512); - expect(result.chunkOverlapTokens).toBe(50); - expect(result.skipTlsVerify).toBe(false); - expect(result.verboseStreamLogging).toBe(false); - expect(result.zdrMode).toBe(false); - expect(result.enableWebSearch).toBe(false); - expect(result.enableCodeInterpreter).toBe(false); - }); - - it('loads vectorStoreIds array', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - vectorStoreIds: ['vs-1', 'vs-2'], - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.vectorStoreIds).toEqual(['vs-1', 'vs-2']); - }); - - it('falls back to single vectorStoreId for backward compat', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - vectorStoreId: 'legacy-vs', - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.vectorStoreIds).toEqual(['legacy-vs']); - }); - - it('loads token when provided', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - token: 'secret-token', - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.token).toBe('secret-token'); - }); - - it('parses toolChoice as string', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - toolChoice: 'required', - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.toolChoice).toBe('required'); - }); - - it('parses toolChoice as function object', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - toolChoice: { type: 'function', name: 'my_func' }, - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.toolChoice).toEqual({ type: 'function', name: 'my_func' }); - }); - - it('parses reasoning config with effort and summary', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - reasoning: { effort: 'high', summary: 'concise' }, - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.reasoning).toEqual({ - effort: 'high', - summary: 'concise', - }); - }); - - it('parses reasoning config as shorthand string', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - reasoning: 'medium', - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.reasoning).toEqual({ effort: 'medium' }); - }); - - it('ignores invalid reasoning string', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - reasoning: 'invalid', - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.reasoning).toBeUndefined(); - }); - - it('loads hybrid search config', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { - ...minimalConfig.augment.llamaStack, - searchMode: 'hybrid', - bm25Weight: 0.3, - semanticWeight: 0.7, - }, - }, - }); - const result = loader.loadLlamaStackConfig(); - expect(result.searchMode).toBe('hybrid'); - expect(result.bm25Weight).toBe(0.3); - expect(result.semanticWeight).toBe(0.7); - }); - }); - - describe('loadSecurityConfig', () => { - it('defaults to plugin-only mode', () => { - const { loader } = createConfigLoader({}); - const result = loader.loadSecurityConfig(); - expect(result.mode).toBe('plugin-only'); - }); - - it('loads full mode with mcpOAuth', () => { - const { loader } = createConfigLoader({ - augment: { - security: { - mode: 'full', - mcpOAuth: { - tokenUrl: 'https://auth.example.com/token', - clientId: 'client-1', - clientSecret: 'secret-1', - scopes: ['openid', 'mcp'], - }, - }, - }, - }); - const result = loader.loadSecurityConfig(); - expect(result.mode).toBe('full'); - expect(result.mcpOAuth).toBeDefined(); - expect(result.mcpOAuth!.tokenUrl).toBe('https://auth.example.com/token'); - expect(result.mcpOAuth!.scopes).toEqual(['openid', 'mcp']); - }); - - it('falls back to plugin-only when full mode lacks mcpOAuth', () => { - const { loader, logger } = createConfigLoader({ - augment: { security: { mode: 'full' } }, - }); - const result = loader.loadSecurityConfig(); - expect(result.mode).toBe('plugin-only'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('Falling back'), - ); - }); - - it('loads none mode', () => { - const { loader } = createConfigLoader({ - augment: { security: { mode: 'none' } }, - }); - const result = loader.loadSecurityConfig(); - expect(result.mode).toBe('none'); - }); - }); - - describe('loadDocumentsConfig', () => { - it('returns null when no documents section', () => { - const { loader } = createConfigLoader({}); - expect(loader.loadDocumentsConfig()).toBeNull(); - }); - - it('returns null when sources is empty', () => { - const { loader } = createConfigLoader({ - augment: { documents: { sources: [] } }, - }); - expect(loader.loadDocumentsConfig()).toBeNull(); - }); - - it('loads directory source', () => { - const { loader } = createConfigLoader({ - augment: { - documents: { - sources: [ - { - type: 'directory', - path: '/docs', - patterns: ['**/*.md'], - }, - ], - }, - }, - }); - const result = loader.loadDocumentsConfig(); - expect(result).not.toBeNull(); - expect(result!.sources[0]).toEqual({ - type: 'directory', - path: '/docs', - patterns: ['**/*.md'], - }); - }); - - it('loads url source', () => { - const { loader } = createConfigLoader({ - augment: { - documents: { - sources: [ - { - type: 'url', - urls: ['https://example.com/doc.md'], - }, - ], - }, - }, - }); - const result = loader.loadDocumentsConfig(); - expect(result!.sources[0]).toEqual({ - type: 'url', - urls: ['https://example.com/doc.md'], - headers: undefined, - }); - }); - - it('loads github source', () => { - const { loader } = createConfigLoader({ - augment: { - documents: { - sources: [ - { - type: 'github', - repo: 'org/repo', - branch: 'main', - path: 'docs/', - }, - ], - }, - }, - }); - const result = loader.loadDocumentsConfig(); - expect(result!.sources[0]).toEqual({ - type: 'github', - repo: 'org/repo', - branch: 'main', - path: 'docs/', - patterns: undefined, - token: undefined, - }); - }); - - it('defaults syncMode to append', () => { - const { loader } = createConfigLoader({ - augment: { - documents: { - sources: [{ type: 'directory', path: '/docs' }], - }, - }, - }); - const result = loader.loadDocumentsConfig(); - expect(result!.syncMode).toBe('append'); - }); - }); - - describe('loadSystemPrompt', () => { - it('returns configured prompt', () => { - const { loader } = createConfigLoader({ - augment: { systemPrompt: 'You are a helpful assistant' }, - }); - expect(loader.loadSystemPrompt()).toBe('You are a helpful assistant'); - }); - - it('returns empty string and warns when not configured', () => { - const { loader, logger } = createConfigLoader({}); - expect(loader.loadSystemPrompt()).toBe(''); - expect(logger.warn).toHaveBeenCalled(); - }); - }); - - describe('loadWorkflows', () => { - it('returns empty array when no workflows configured', () => { - const { loader } = createConfigLoader({}); - expect(loader.loadWorkflows()).toEqual([]); - }); - - it('parses workflow with steps', () => { - const { loader } = createConfigLoader({ - augment: { - workflows: [ - { - id: 'wf-1', - name: 'Migration', - description: 'Migrate an app', - steps: [ - { title: 'Step 1', prompt: 'Analyze the app' }, - { title: 'Step 2', prompt: 'Create a plan' }, - ], - }, - ], - }, - }); - const result = loader.loadWorkflows(); - expect(result).toHaveLength(1); - expect(result[0].id).toBe('wf-1'); - expect(result[0].steps).toHaveLength(2); - }); - }); - - describe('loadPromptGroups', () => { - it('returns empty array when no prompt groups configured', () => { - const { loader } = createConfigLoader({}); - expect(loader.loadPromptGroups()).toEqual([]); - }); - - it('parses prompt groups with cards', () => { - const { loader } = createConfigLoader({ - augment: { - promptGroups: [ - { - id: 'lane-1', - title: 'Getting Started', - cards: [{ title: 'Hello', prompt: 'Say hello' }], - }, - ], - }, - }); - const result = loader.loadPromptGroups(); - expect(result).toHaveLength(1); - expect(result[0].cards).toHaveLength(1); - }); - - it('sorts prompt groups by order', () => { - const { loader } = createConfigLoader({ - augment: { - promptGroups: [ - { id: 'b', title: 'B', order: 2, cards: [] }, - { id: 'a', title: 'A', order: 1, cards: [] }, - { id: 'c', title: 'C', order: 3, cards: [] }, - ], - }, - }); - const result = loader.loadPromptGroups(); - expect(result.map(l => l.id)).toEqual(['a', 'b', 'c']); - }); - }); - - describe('loadToolExecutionMode', () => { - it('returns backend when not configured (default)', () => { - const { loader } = createConfigLoader({ - augment: { - llamaStack: { baseUrl: 'http://localhost', model: 'test' }, - }, - }); - expect(loader.loadToolExecutionMode()).toBe('backend'); - }); - - it('returns backend when configured as backend', () => { - const { loader } = createConfigLoader({ - augment: { - toolExecutionMode: 'backend', - llamaStack: { baseUrl: 'http://localhost', model: 'test' }, - }, - }); - expect(loader.loadToolExecutionMode()).toBe('backend'); - }); - - it('returns backend case-insensitively', () => { - const { loader } = createConfigLoader({ - augment: { - toolExecutionMode: 'Backend', - llamaStack: { baseUrl: 'http://localhost', model: 'test' }, - }, - }); - expect(loader.loadToolExecutionMode()).toBe('backend'); - }); - - it('returns direct for explicit direct value', () => { - const { loader } = createConfigLoader({ - augment: { - toolExecutionMode: 'direct', - llamaStack: { baseUrl: 'http://localhost', model: 'test' }, - }, - }); - expect(loader.loadToolExecutionMode()).toBe('direct'); - }); - - it('warns and falls back to direct for invalid values', () => { - const { loader, logger } = createConfigLoader({ - augment: { - toolExecutionMode: 'proxy', - llamaStack: { baseUrl: 'http://localhost', model: 'test' }, - }, - }); - expect(loader.loadToolExecutionMode()).toBe('direct'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining("Invalid toolExecutionMode 'proxy'"), - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigLoader.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigLoader.ts deleted file mode 100644 index 5a650ddaf6e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigLoader.ts +++ /dev/null @@ -1,329 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - LoggerService, - RootConfigService, -} from '@backstage/backend-plugin-api'; -import { toErrorMessage } from '../../../services/utils'; -import { - DEFAULT_CHUNK_OVERLAP, - DEFAULT_CHUNK_SIZE, - DEFAULT_EMBEDDING_DIMENSION, - DEFAULT_EMBEDDING_MODEL, - DEFAULT_VECTOR_STORE_NAME, -} from '../../../constants'; -import { loadBrandingOverrides as loadBrandingOverridesFromModule } from './BrandingConfigLoader'; -import { - loadMcpAuthConfigs as loadMcpAuthConfigsFromModule, - loadMcpServerConfigs as loadMcpServerConfigsFromModule, -} from './McpConfigLoader'; -import { - AgentConfig, - LlamaStackConfig, - DocumentsConfig, - MCPAuthConfig, - MCPServerConfig, - SecurityConfig, - SecurityMode, - OAuthClientConfig, - Workflow, - QuickAction, - PromptGroup, -} from '../../../types'; -import type { ServerCapabilityOverrides } from './ServerCapabilities'; -import { - parseTruncation, - parseToolChoiceConfig, - parseReasoningConfig, - parseToolScopingConfig, - parseHybridSearchConfig, - parseDocumentSources, -} from './ConfigParsers'; -import { parseAgentConfigs } from './AgentConfigParser'; -import { - loadWorkflows, - loadQuickActions, - loadPromptGroups, -} from './UiConfigLoader'; - -export class ConfigValidationError extends Error { - constructor( - message: string, - public readonly configPath: string, - ) { - super(`Configuration error at '${configPath}': ${message}`); - this.name = 'ConfigValidationError'; - } -} - -export class ConfigLoader { - private readonly config: RootConfigService; - private readonly logger: LoggerService; - private cachedAgentConfigs: - | { - agents: Record; - defaultAgent: string; - maxAgentTurns?: number; - } - | undefined - | null = null; - - constructor(config: RootConfigService, logger: LoggerService) { - this.config = config; - this.logger = logger; - } - - validateRequiredConfig(): void { - const errors: string[] = []; - if (!this.config.has('augment.llamaStack')) { - errors.push("Missing required config section 'augment.llamaStack'"); - } else { - if (!this.config.has('augment.llamaStack.baseUrl')) - errors.push("Missing 'augment.llamaStack.baseUrl'"); - if (!this.config.has('augment.llamaStack.model')) - errors.push("Missing 'augment.llamaStack.model'"); - } - if (errors.length > 0) { - this.logger.error( - `Augment configuration validation failed: ${errors.join('; ')}`, - ); - throw new ConfigValidationError(errors.join('; '), 'augment'); - } - this.logger.info('Augment configuration validated successfully'); - } - - loadLlamaStackConfig(): LlamaStackConfig { - const ls = this.config.getConfig('augment.llamaStack'); - let vectorStoreIds: string[] = []; - const arr = ls.getOptionalStringArray('vectorStoreIds'); - if (arr && arr.length > 0) { - vectorStoreIds = arr; - } else { - const single = ls.getOptionalString('vectorStoreId'); - if (single) vectorStoreIds = [single]; - } - const vectorStoreName = - ls.getOptionalString('vectorStoreName') || DEFAULT_VECTOR_STORE_NAME; - this.logger.info(`RAG vector store name: "${vectorStoreName}"`); - const { searchMode, bm25Weight, semanticWeight } = parseHybridSearchConfig( - ls, - this.logger, - ); - return { - baseUrl: ls.getString('baseUrl'), - vectorStoreIds, - vectorStoreName, - embeddingModel: - ls.getOptionalString('embeddingModel') || DEFAULT_EMBEDDING_MODEL, - embeddingDimension: - ls.getOptionalNumber('embeddingDimension') || - DEFAULT_EMBEDDING_DIMENSION, - searchMode, - bm25Weight, - semanticWeight, - model: ls.getString('model'), - token: ls.getOptionalString('token'), - chunkingStrategy: - (ls.getOptionalString('chunkingStrategy') as 'auto' | 'static') || - 'auto', - maxChunkSizeTokens: - ls.getOptionalNumber('maxChunkSizeTokens') || DEFAULT_CHUNK_SIZE, - chunkOverlapTokens: - ls.getOptionalNumber('chunkOverlapTokens') || DEFAULT_CHUNK_OVERLAP, - skipTlsVerify: ls.getOptionalBoolean('skipTlsVerify') ?? false, - verboseStreamLogging: - ls.getOptionalBoolean('verboseStreamLogging') ?? false, - toolChoice: parseToolChoiceConfig(ls), - parallelToolCalls: ls.getOptionalBoolean('parallelToolCalls'), - textFormat: ls.getOptional('textFormat') as - | LlamaStackConfig['textFormat'] - | undefined, - functions: ls.getOptional('functions') as - | LlamaStackConfig['functions'] - | undefined, - zdrMode: ls.getOptionalBoolean('zdrMode') ?? false, - enableWebSearch: ls.getOptionalBoolean('enableWebSearch') ?? false, - enableCodeInterpreter: - ls.getOptionalBoolean('enableCodeInterpreter') ?? false, - fileSearchMaxResults: - ls.getOptionalNumber('fileSearchMaxResults') || undefined, - fileSearchScoreThreshold: - ls.getOptionalNumber('fileSearchScoreThreshold') || undefined, - reasoning: parseReasoningConfig(ls, this.logger), - toolScoping: parseToolScopingConfig(ls, this.logger), - maxToolCalls: ls.getOptionalNumber('maxToolCalls') || undefined, - maxOutputTokens: ls.getOptionalNumber('maxOutputTokens') || undefined, - temperature: ls.getOptionalNumber('temperature') ?? undefined, - safetyIdentifier: ls.getOptionalString('safetyIdentifier') || undefined, - truncation: parseTruncation(ls.getOptionalString('truncation')), - }; - } - - loadSecurityConfig(): SecurityConfig { - const securityMode = - (this.config.getOptionalString( - 'augment.security.mode', - ) as SecurityMode) || 'plugin-only'; - const accessDeniedMessage = this.config.getOptionalString( - 'augment.security.accessDeniedMessage', - ); - let mcpOAuth: OAuthClientConfig | undefined; - const mcpOAuthConfig = this.config.getOptionalConfig( - 'augment.security.mcpOAuth', - ); - if (mcpOAuthConfig) { - mcpOAuth = { - tokenUrl: mcpOAuthConfig.getString('tokenUrl'), - clientId: mcpOAuthConfig.getString('clientId'), - clientSecret: mcpOAuthConfig.getString('clientSecret'), - scopes: mcpOAuthConfig.getOptionalStringArray('scopes') || ['openid'], - }; - } - if (securityMode === 'full' && !mcpOAuth) { - this.logger.warn( - `Security mode 'full' requires mcpOAuth. Falling back to 'plugin-only'.`, - ); - return { mode: 'plugin-only', accessDeniedMessage }; - } - return { mode: securityMode, accessDeniedMessage, mcpOAuth }; - } - - loadDocumentsConfig(): DocumentsConfig | null { - try { - const dc = this.config.getOptionalConfig('augment.documents'); - if (!dc) return null; - const syncMode = - (dc.getOptionalString('syncMode') as 'full' | 'append') || 'append'; - const syncSchedule = dc.getOptionalString('syncSchedule'); - const sourcesConfig = dc.getOptionalConfigArray('sources'); - if (!sourcesConfig || sourcesConfig.length === 0) return null; - return { - syncMode, - syncSchedule, - sources: parseDocumentSources(sourcesConfig, this.logger), - }; - } catch (error) { - this.logger.warn( - `Failed to load documents configuration: ${toErrorMessage(error)}. Document sync will be disabled.`, - ); - return null; - } - } - - loadMcpAuthConfigs(): Map { - return loadMcpAuthConfigsFromModule(this.config, this.logger); - } - loadMcpServerConfigs(): MCPServerConfig[] { - return loadMcpServerConfigsFromModule( - this.config, - this.logger, - this.loadMcpAuthConfigs(), - ); - } - loadWorkflows(): Workflow[] { - return loadWorkflows(this.config, this.logger); - } - loadQuickActions(): QuickAction[] { - return loadQuickActions(this.config, this.logger); - } - loadPromptGroups(): PromptGroup[] { - return loadPromptGroups(this.config, this.logger); - } - - loadGuardrailShieldIds(): string[] | undefined { - try { - const sc = this.config.getOptionalConfig('augment.safety'); - if (!sc || sc.getOptionalBoolean('enabled') !== true) return undefined; - const merged = [ - ...new Set([ - ...(sc.getOptionalStringArray('inputShields') ?? []), - ...(sc.getOptionalStringArray('outputShields') ?? []), - ]), - ]; - return merged.length > 0 ? merged : undefined; - } catch { - return undefined; - } - } - - loadPostToolInstructions(): string | undefined { - return ( - this.config.getOptionalString('augment.postToolInstructions') || undefined - ); - } - - loadToolExecutionMode(): 'direct' | 'backend' { - const mode = this.config.getOptionalString('augment.toolExecutionMode'); - if (!mode) return 'backend'; - const n = mode.trim().toLowerCase(); - if (n === 'backend') return 'backend'; - if (n !== 'direct') - this.logger.warn( - `Invalid toolExecutionMode '${mode}' — falling back to 'direct'.`, - ); - return 'direct'; - } - - loadServerCapabilities(): ServerCapabilityOverrides | undefined { - const cc = this.config.getOptionalConfig('augment.serverCapabilities'); - if (!cc) return undefined; - const o: ServerCapabilityOverrides = {}; - const b = (k: string) => cc.getOptionalBoolean(k); - const ft = b('functionTools'); - const sf = b('strictField'); - const mo = b('maxOutputTokens'); - const mt = b('mcpTools'); - const pt = b('parallelToolCalls'); - if (ft !== undefined) o.functionTools = ft; - if (sf !== undefined) o.strictField = sf; - if (mo !== undefined) o.maxOutputTokens = mo; - if (mt !== undefined) o.mcpTools = mt; - if (pt !== undefined) o.parallelToolCalls = pt; - if (Object.keys(o).length === 0) return undefined; - this.logger.info( - `[ConfigLoader] Server capability overrides: ${JSON.stringify(o)}`, - ); - return o; - } - - loadSystemPrompt(): string { - const p = this.config.getOptionalString('augment.systemPrompt'); - if (!p) - this.logger.warn( - 'No augment.systemPrompt configured - model will use default behavior', - ); - return p || ''; - } - - loadAgentConfigs(): - | { - agents: Record; - defaultAgent: string; - maxAgentTurns?: number; - } - | undefined { - if (this.cachedAgentConfigs !== null) return this.cachedAgentConfigs; - const result = parseAgentConfigs(this.config, this.logger); - this.cachedAgentConfigs = result; - return result; - } - - loadBrandingOverrides(): Partial< - import('@red-hat-developer-hub/backstage-plugin-augment-common').BrandingConfig - > { - return loadBrandingOverridesFromModule(this.config, this.logger); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigParsers.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigParsers.ts deleted file mode 100644 index 2ebb611219d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigParsers.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { Config } from '@backstage/config'; -import type { - LlamaStackConfig, - ReasoningConfig, - ToolScopingConfig, - AllowedToolSpec, - DocumentSource, -} from '../../../types'; - -export function parseTruncation( - value: string | undefined, -): 'auto' | 'disabled' | undefined { - if (value === 'auto' || value === 'disabled') return value; - return undefined; -} - -export function parseToolChoiceConfig( - config: Config, -): LlamaStackConfig['toolChoice'] { - const toolChoiceConfig = config.getOptional('toolChoice'); - if (!toolChoiceConfig) return undefined; - if (typeof toolChoiceConfig === 'string') - return toolChoiceConfig as 'auto' | 'required' | 'none'; - if (typeof toolChoiceConfig === 'object' && toolChoiceConfig !== null) { - const configObj = toolChoiceConfig as Record; - if (configObj.type === 'function' && typeof configObj.name === 'string') - return { type: 'function', name: configObj.name }; - if (configObj.type === 'allowed_tools' && Array.isArray(configObj.tools)) - return { - type: 'allowed_tools', - mode: (configObj.mode as 'auto' | 'required') || 'auto', - tools: configObj.tools as AllowedToolSpec[], - }; - } - return undefined; -} - -export function parseReasoningConfig( - config: Config, - logger: LoggerService, -): ReasoningConfig | undefined { - const raw = config.getOptional('reasoning'); - if (!raw) return undefined; - if (typeof raw === 'string') { - const effort = raw as ReasoningConfig['effort']; - if (effort === 'low' || effort === 'medium' || effort === 'high') - return { effort }; - logger.warn(`Invalid reasoning effort value: "${raw}", ignoring`); - return undefined; - } - if (typeof raw === 'object' && raw !== null) { - const obj = raw as Record; - const result: ReasoningConfig = {}; - const effort = obj.effort as string | undefined; - if (effort === 'low' || effort === 'medium' || effort === 'high') - result.effort = effort; - const summary = obj.summary as string | undefined; - if ( - summary === 'auto' || - summary === 'concise' || - summary === 'detailed' || - summary === 'none' - ) - result.summary = summary; - if (Object.keys(result).length > 0) { - logger.info( - `Reasoning config: effort=${result.effort ?? 'default'}, summary=${result.summary ?? 'default'}`, - ); - return result; - } - } - return undefined; -} - -export function parseToolScopingConfig( - config: Config, - logger: LoggerService, -): ToolScopingConfig | undefined { - const raw = config.getOptional('toolScoping'); - if (!raw || typeof raw !== 'object') return undefined; - const obj = raw as Record; - const enabled = typeof obj.enabled === 'boolean' ? obj.enabled : false; - const maxToolsPerTurn = - typeof obj.maxToolsPerTurn === 'number' - ? obj.maxToolsPerTurn - : Number.MAX_SAFE_INTEGER; - const activationThreshold = - typeof obj.activationThreshold === 'number' ? obj.activationThreshold : 10; - const minScore = typeof obj.minScore === 'number' ? obj.minScore : 0.1; - if (enabled) - logger.info( - `Tool scoping enabled: maxToolsPerTurn=${maxToolsPerTurn}, activationThreshold=${activationThreshold}, minScore=${minScore}`, - ); - return { enabled, maxToolsPerTurn, activationThreshold, minScore }; -} - -export function parseHybridSearchConfig( - config: Config, - logger: LoggerService, -): { - searchMode: 'semantic' | 'keyword' | 'hybrid' | undefined; - bm25Weight: number | undefined; - semanticWeight: number | undefined; -} { - const searchMode = config.getOptionalString('searchMode') as - | 'semantic' - | 'keyword' - | 'hybrid' - | undefined; - const bm25Weight = config.getOptionalNumber('bm25Weight'); - const semanticWeight = config.getOptionalNumber('semanticWeight'); - if (searchMode === 'hybrid') - logger.info( - `Hybrid search enabled: bm25Weight=${bm25Weight ?? 0.5}, semanticWeight=${semanticWeight ?? 0.5}`, - ); - return { searchMode, bm25Weight, semanticWeight }; -} - -export function parseDocumentSources( - sourcesConfig: Config[], - logger: LoggerService, -): DocumentSource[] { - const sources: DocumentSource[] = []; - for (const sourceConfig of sourcesConfig) { - const type = sourceConfig.getString('type') as - | 'directory' - | 'url' - | 'github'; - switch (type) { - case 'directory': - sources.push({ - type: 'directory', - path: sourceConfig.getString('path'), - patterns: sourceConfig.getOptionalStringArray('patterns'), - }); - break; - case 'url': - sources.push({ - type: 'url', - urls: sourceConfig.getStringArray('urls'), - headers: sourceConfig.getOptional('headers') as - | Record - | undefined, - }); - break; - case 'github': - sources.push({ - type: 'github', - repo: sourceConfig.getString('repo'), - branch: sourceConfig.getOptionalString('branch'), - path: sourceConfig.getOptionalString('path'), - patterns: sourceConfig.getOptionalStringArray('patterns'), - token: sourceConfig.getOptionalString('token'), - }); - break; - default: - logger.warn(`Unknown document source type: ${type}`); - } - } - return sources; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigResolutionService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigResolutionService.test.ts deleted file mode 100644 index e79cc2e921c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigResolutionService.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { ConfigResolutionService } from './ConfigResolutionService'; -import type { ClientManager } from '../ClientManager'; -import type { RuntimeConfigResolver } from '../../../services/RuntimeConfigResolver'; -import type { LlamaStackConfig } from '../../../types'; - -function createMockClientManager(): jest.Mocked { - return { - getClient: jest.fn().mockReturnValue({}), - getExistingClient: jest.fn().mockReturnValue({}), - } as unknown as jest.Mocked; -} - -function createBasicConfig(): LlamaStackConfig { - return { - baseUrl: 'https://localhost:8321', - model: 'test-model', - vectorStoreIds: ['vs-1'], - vectorStoreName: 'test-store', - embeddingModel: 'test-embed', - embeddingDimension: 384, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - } as LlamaStackConfig; -} - -describe('ConfigResolutionService', () => { - let service: ConfigResolutionService; - let mockClientManager: jest.Mocked; - - beforeEach(() => { - mockClientManager = createMockClientManager(); - service = new ConfigResolutionService(mockClientManager); - }); - - describe('llamaStackConfig management', () => { - it('returns null before config is set', () => { - expect(service.getLlamaStackConfig()).toBeNull(); - }); - - it('stores and retrieves config', () => { - const config = createBasicConfig(); - service.setLlamaStackConfig(config); - expect(service.getLlamaStackConfig()).toBe(config); - }); - }); - - describe('resolver management', () => { - it('returns null before resolver is set', () => { - expect(service.getResolver()).toBeNull(); - }); - - it('stores and retrieves resolver', () => { - const resolver = { - resolve: jest.fn(), - invalidateCache: jest.fn(), - } as unknown as RuntimeConfigResolver; - service.setResolver(resolver); - expect(service.getResolver()).toBe(resolver); - }); - - it('invalidateCache is safe when resolver is null', () => { - expect(() => service.invalidateCache()).not.toThrow(); - }); - - it('invalidateCache delegates to resolver', () => { - const resolver = { - resolve: jest.fn(), - invalidateCache: jest.fn(), - } as unknown as RuntimeConfigResolver; - service.setResolver(resolver); - service.invalidateCache(); - expect(resolver.invalidateCache).toHaveBeenCalled(); - }); - }); - - describe('resolve()', () => { - it('throws when llamaStackConfig is not set', async () => { - await expect(service.resolve()).rejects.toThrow( - 'Llama Stack not configured', - ); - }); - - it('returns YAML fallback when no resolver is set', async () => { - service.setLlamaStackConfig(createBasicConfig()); - service.setSystemPrompt('test prompt'); - - const result = await service.resolve(); - - expect(result.model).toBe('test-model'); - expect(result.baseUrl).toBe('https://localhost:8321'); - expect(result.systemPrompt).toBe('test prompt'); - }); - - it('delegates to resolver when available', async () => { - const resolvedConfig = { - model: 'override-model', - baseUrl: 'https://override:8321', - verboseStreamLogging: true, - skipTlsVerify: false, - token: 'tok', - }; - const resolver = { - resolve: jest.fn().mockResolvedValue(resolvedConfig), - invalidateCache: jest.fn(), - } as unknown as RuntimeConfigResolver; - service.setLlamaStackConfig(createBasicConfig()); - service.setResolver(resolver); - - const result = await service.resolve(); - - expect(result).toEqual(resolvedConfig); - expect(resolver.resolve).toHaveBeenCalled(); - }); - - it('updates lastResolvedModel on resolve', async () => { - service.setLlamaStackConfig(createBasicConfig()); - expect(service.getLastResolvedModel()).toBeNull(); - - await service.resolve(); - - expect(service.getLastResolvedModel()).toBe('test-model'); - }); - - it('updates client via clientManager on resolve', async () => { - const config = createBasicConfig(); - service.setLlamaStackConfig(config); - - await service.resolve(); - - expect(mockClientManager.getClient).toHaveBeenCalledWith( - expect.objectContaining({ - baseUrl: 'https://localhost:8321', - model: 'test-model', - }), - ); - }); - }); - - describe('buildYamlFallback()', () => { - it('throws when llamaStackConfig is not set', () => { - expect(() => service.buildYamlFallback()).toThrow( - 'Llama Stack not configured', - ); - }); - - it('builds config from YAML values', () => { - service.setLlamaStackConfig(createBasicConfig()); - service.setSystemPrompt('fallback prompt'); - - const result = service.buildYamlFallback(); - - expect(result.model).toBe('test-model'); - expect(result.systemPrompt).toBe('fallback prompt'); - expect(result.vectorStoreIds).toEqual(['vs-1']); - expect(result.skipTlsVerify).toBe(false); - expect(result.verboseStreamLogging).toBe(false); - }); - - it('copies vectorStoreIds to prevent mutation', () => { - const config = createBasicConfig(); - service.setLlamaStackConfig(config); - - const result = service.buildYamlFallback(); - result.vectorStoreIds.push('vs-new'); - - expect(config.vectorStoreIds).toEqual(['vs-1']); - }); - }); - - describe('isVerboseStreamLoggingEnabled()', () => { - it('returns false by default', () => { - expect(service.isVerboseStreamLoggingEnabled()).toBe(false); - }); - - it('returns resolved value when set', () => { - service.setLastResolvedVerboseLogging(true); - expect(service.isVerboseStreamLoggingEnabled()).toBe(true); - }); - - it('falls back to llamaStackConfig when resolved is null', () => { - const config: LlamaStackConfig = { - ...createBasicConfig(), - verboseStreamLogging: true, - }; - service.setLlamaStackConfig(config); - - expect(service.isVerboseStreamLoggingEnabled()).toBe(true); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigResolutionService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigResolutionService.ts deleted file mode 100644 index 4eebb3390e9..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ConfigResolutionService.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LlamaStackConfig, EffectiveConfig } from '../../../types'; -import { DEFAULT_EMBEDDING_DIMENSION } from '../../../constants'; -import type { ClientManager } from '../ClientManager'; -import type { RuntimeConfigResolver } from '../../../services/RuntimeConfigResolver'; - -/** - * Encapsulates effective-config resolution (YAML + DB admin overrides) - * and the side-effects of keeping lastResolvedModel, verbose-logging - * flag, and the HTTP client in sync. - * - * Extracted from ResponsesApiCoordinator so config-resolution logic - * lives in a single, testable place instead of being interleaved with - * initialization and chat orchestration. - */ -export class ConfigResolutionService { - private llamaStackConfig: LlamaStackConfig | null = null; - private systemPrompt: string = ''; - private postToolInstructions?: string; - private resolver: RuntimeConfigResolver | null = null; - private lastResolvedModel: string | null = null; - private lastResolvedVerboseLogging: boolean | null = null; - private lastResolvedConfig: EffectiveConfig | null = null; - - constructor(private readonly clientManager: ClientManager) {} - - getLlamaStackConfig(): LlamaStackConfig | null { - return this.llamaStackConfig; - } - - setLlamaStackConfig(config: LlamaStackConfig): void { - this.llamaStackConfig = config; - } - - setSystemPrompt(prompt: string): void { - this.systemPrompt = prompt; - } - - setPostToolInstructions(instructions: string | undefined): void { - this.postToolInstructions = instructions; - } - - setResolver(resolver: RuntimeConfigResolver): void { - this.resolver = resolver; - } - - getResolver(): RuntimeConfigResolver | null { - return this.resolver; - } - - getLastResolvedModel(): string | null { - return this.lastResolvedModel; - } - - setLastResolvedModel(model: string | null): void { - this.lastResolvedModel = model; - } - - setLastResolvedVerboseLogging(value: boolean | null): void { - this.lastResolvedVerboseLogging = value; - } - - isVerboseStreamLoggingEnabled(): boolean { - return ( - this.lastResolvedVerboseLogging ?? - this.llamaStackConfig?.verboseStreamLogging ?? - false - ); - } - - /** - * Return the most recently resolved config without triggering a - * new resolution. Returns null if `resolve()` has never been called. - */ - getCachedConfig(): EffectiveConfig | null { - return this.lastResolvedConfig; - } - - invalidateCache(): void { - this.resolver?.invalidateCache(); - } - - /** - * Resolve the effective configuration (YAML + DB admin overrides). - * - * Side-effects: - * - Updates lastResolvedModel / lastResolvedVerboseLogging - * - Passes resolved connection fields to ClientManager, which recreates - * the HTTP client when baseUrl, token, or skipTlsVerify change. - */ - async resolve(): Promise { - if (!this.llamaStackConfig) { - throw new Error('Llama Stack not configured'); - } - - const config = this.resolver - ? await this.resolver.resolve() - : this.buildYamlFallback(); - - this.lastResolvedModel = config.model; - this.lastResolvedVerboseLogging = config.verboseStreamLogging; - this.lastResolvedConfig = config; - - this.clientManager.getClient({ - ...this.llamaStackConfig, - baseUrl: config.baseUrl, - token: config.token, - skipTlsVerify: config.skipTlsVerify, - }); - - return config; - } - - /** - * Build an EffectiveConfig from YAML-only values (no DB). - * Used as fallback when no RuntimeConfigResolver is available. - */ - buildYamlFallback(): EffectiveConfig { - if (!this.llamaStackConfig) { - throw new Error('Llama Stack not configured'); - } - const cfg = this.llamaStackConfig; - return { - model: cfg.model, - baseUrl: cfg.baseUrl, - systemPrompt: this.systemPrompt, - toolChoice: cfg.toolChoice, - parallelToolCalls: cfg.parallelToolCalls, - textFormat: cfg.textFormat, - enableWebSearch: cfg.enableWebSearch ?? false, - enableCodeInterpreter: cfg.enableCodeInterpreter ?? false, - fileSearchMaxResults: cfg.fileSearchMaxResults, - fileSearchScoreThreshold: cfg.fileSearchScoreThreshold, - vectorStoreIds: [...cfg.vectorStoreIds], - vectorStoreName: cfg.vectorStoreName, - embeddingModel: cfg.embeddingModel, - embeddingDimension: cfg.embeddingDimension ?? DEFAULT_EMBEDDING_DIMENSION, - searchMode: cfg.searchMode, - bm25Weight: cfg.bm25Weight, - semanticWeight: cfg.semanticWeight, - chunkingStrategy: cfg.chunkingStrategy, - maxChunkSizeTokens: cfg.maxChunkSizeTokens, - chunkOverlapTokens: cfg.chunkOverlapTokens, - skipTlsVerify: cfg.skipTlsVerify ?? false, - zdrMode: cfg.zdrMode ?? false, - functions: cfg.functions, - token: cfg.token, - verboseStreamLogging: cfg.verboseStreamLogging ?? false, - maxToolCalls: cfg.maxToolCalls, - maxOutputTokens: cfg.maxOutputTokens, - temperature: cfg.temperature, - safetyIdentifier: cfg.safetyIdentifier, - postToolInstructions: this.postToolInstructions, - }; - } - - /** - * Return the stored LlamaStackConfig, so callers can check if - * initialization has happened. - */ - isConfigured(): boolean { - return this.llamaStackConfig !== null; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/McpConfigLoader.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/McpConfigLoader.test.ts deleted file mode 100644 index d7f3e6d8f35..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/McpConfigLoader.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ConfigReader, type JsonObject } from '@backstage/config'; -import { loadMcpServerConfigs } from './McpConfigLoader'; -import { createMockLogger } from '../../../test-utils'; - -function loadServers(yamlConfig: JsonObject) { - const config = new ConfigReader(yamlConfig); - const logger = createMockLogger(); - return { servers: loadMcpServerConfigs(config, logger), logger }; -} - -describe('loadMcpServerConfigs', () => { - it('returns empty array when no MCP servers configured', () => { - const { servers } = loadServers({}); - expect(servers).toEqual([]); - }); - - it('loads a streamable-http server with required fields', () => { - const { servers } = loadServers({ - augment: { - mcpServers: [ - { - id: 'ocp-mcp', - name: 'OCP MCP', - type: 'streamable-http', - url: 'https://mcp.example.com/mcp', - }, - ], - }, - }); - - expect(servers).toHaveLength(1); - expect(servers[0].id).toBe('ocp-mcp'); - expect(servers[0].type).toBe('streamable-http'); - expect(servers[0].url).toBe('https://mcp.example.com/mcp'); - }); - - describe('requireApproval', () => { - it('parses string requireApproval "always"', () => { - const { servers } = loadServers({ - augment: { - mcpServers: [ - { - id: 'srv', - name: 'Test', - type: 'sse', - url: 'https://mcp.test/sse', - requireApproval: 'always', - }, - ], - }, - }); - - expect(servers[0].requireApproval).toBe('always'); - }); - - it('omits requireApproval when not set', () => { - const { servers } = loadServers({ - augment: { - mcpServers: [ - { - id: 'srv', - name: 'Test', - type: 'sse', - url: 'https://mcp.test/sse', - }, - ], - }, - }); - - expect(servers[0].requireApproval).toBeUndefined(); - }); - }); - - it('skips servers with unsupported type', () => { - const { servers, logger } = loadServers({ - augment: { - mcpServers: [ - { - id: 'srv', - name: 'Test', - type: 'stdio', - url: 'cmd://test', - }, - ], - }, - }); - - expect(servers).toHaveLength(0); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('unsupported type'), - ); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/McpConfigLoader.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/McpConfigLoader.ts deleted file mode 100644 index b848aaa024a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/McpConfigLoader.ts +++ /dev/null @@ -1,265 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Config } from '@backstage/config'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { - MCPAuthConfig, - MCPServerConfig, - ApprovalFilter, - ResponsesApiMcpTool, -} from '../../../types'; -import { toErrorMessage } from '../../../services/utils'; - -/** - * Convert MCPServerConfig's requireApproval to Llama Stack API format - * - * KNOWN LIMITATION: Llama Stack's object format for require_approval (with always/never arrays) - * does NOT work as expected - it requires approval for ALL tools when an object is passed. - * - * Until this is fixed in Llama Stack, we only support string values: - * - "never" - All tools auto-execute (default) - * - "always" - All tools require user approval - * - * The object format config is preserved in app-config.yaml for future use. - * - * @param configApproval - The approval config from app-config.yaml - * @param logger - Logger for warning when object format is used - * @returns "always" or "never" (object format not supported by Llama Stack) - */ -export function getApiApprovalConfig( - configApproval: 'always' | 'never' | ApprovalFilter | undefined, - logger: LoggerService, -): ResponsesApiMcpTool['require_approval'] { - if (!configApproval) { - return 'never'; // Default: auto-execute all tools - } - - if (typeof configApproval === 'string') { - return configApproval; // 'always' or 'never' - } - - /** - * LLAMA STACK API LIMITATION: - * Object format (always/never arrays) is NOT supported by Llama Stack currently. - * When an object is passed, Llama Stack incorrectly requires approval for ALL tools. - * - * Workaround: Default to 'never' (auto-execute all) when object format is configured. - * Users who need approval should set requireApproval: "always" for all tools. - * - * Llama Stack currently only supports string-valued require_approval - * ("always" | "never"). Per-tool approval filters (object format) will - * need Llama Stack to support the `include` / `exclude` filter syntax - * in `require_approval`. Monitor Llama Stack release notes for this feature. - */ - logger.warn( - 'Llama Stack does not support per-tool require_approval (object format). ' + - 'Using "never" (auto-execute all). Set requireApproval: "always" to require approval for ALL tools.', - ); - return 'never'; -} - -/** - * Load named MCP auth configurations from app-config. - * - * @param config - Backstage config (e.g. RootConfigService) - * @param logger - Logger for debug/warn messages - * @returns Map of auth config name to MCPAuthConfig - */ -export function loadMcpAuthConfigs( - config: Config, - logger: LoggerService, -): Map { - const configs = new Map(); - - try { - const mcpAuthConfig = config.getOptionalConfig('augment.mcpAuth'); - if (!mcpAuthConfig) { - return configs; - } - - const keys = mcpAuthConfig.keys(); - for (const key of keys) { - const authConfig = mcpAuthConfig.getConfig(key); - const authType = authConfig.getString('type') as - | 'oauth' - | 'serviceAccount'; - - if (authType === 'oauth') { - configs.set(key, { - type: 'oauth', - tokenUrl: authConfig.getString('tokenUrl'), - clientId: authConfig.getString('clientId'), - clientSecret: authConfig.getString('clientSecret'), - scopes: authConfig.getOptionalStringArray('scopes'), - }); - logger.debug(`Loaded OAuth auth config: ${key}`); - } else if (authType === 'serviceAccount') { - configs.set(key, { - type: 'serviceAccount', - name: authConfig.getString('name'), - namespace: authConfig.getOptionalString('namespace'), - }); - logger.debug(`Loaded ServiceAccount auth config: ${key}`); - } - } - } catch (error) { - const errorMsg = toErrorMessage(error); - logger.warn( - `Failed to load MCP auth configurations: ${errorMsg}. MCP servers will not have authentication.`, - ); - } - - return configs; -} - -function parseRequireApproval( - serverConfig: Config, - logger: LoggerService, -): MCPServerConfig['requireApproval'] { - try { - const approvalConfigObj = serverConfig.getOptionalConfig('requireApproval'); - if (approvalConfigObj) { - const alwaysTools = approvalConfigObj.getOptionalStringArray('always'); - const neverTools = approvalConfigObj.getOptionalStringArray('never'); - if (alwaysTools || neverTools) { - logger.info( - `MCP server requireApproval parsed: always=${JSON.stringify(alwaysTools)}, never=${JSON.stringify(neverTools)}`, - ); - return { always: alwaysTools, never: neverTools }; - } - } - } catch { - logger.debug( - 'MCP server config parse failed, falling through to string parsing', - ); - } - - try { - const approvalString = serverConfig.getOptionalString('requireApproval'); - if (approvalString) { - logger.info(`MCP server requireApproval: ${approvalString}`); - return approvalString as 'always' | 'never'; - } - } catch (e) { - logger.warn(`Failed to parse requireApproval config: ${e}`); - } - - return undefined; -} - -function parseServerAuth( - serverConfig: Config, - server: MCPServerConfig, - logger: LoggerService, -): void { - const authRef = serverConfig.getOptionalString('authRef'); - if (authRef) { - server.authRef = authRef; - logger.info(`MCP server ${server.id} using auth config: ${authRef}`); - } - - const oauthConfig = serverConfig.getOptionalConfig('oauth'); - if (oauthConfig) { - server.oauth = { - tokenUrl: oauthConfig.getString('tokenUrl'), - clientId: oauthConfig.getString('clientId'), - clientSecret: oauthConfig.getString('clientSecret'), - scopes: oauthConfig.getOptionalStringArray('scopes'), - }; - logger.info( - `MCP server ${server.id} configured with inline OAuth (client: ${server.oauth.clientId})`, - ); - } - - const saConfig = serverConfig.getOptionalConfig('serviceAccount'); - if (saConfig) { - server.serviceAccount = { - name: saConfig.getString('name'), - namespace: saConfig.getOptionalString('namespace'), - }; - logger.info( - `MCP server ${server.id} configured with ServiceAccount: ${ - server.serviceAccount.namespace || 'default' - }/${server.serviceAccount.name}`, - ); - } -} - -/** - * Load MCP server configurations from app-config. - * - * @param config - Backstage config (e.g. RootConfigService) - * @param logger - Logger for info/warn messages - * @param _authConfigs - Optional map of named auth configs (reserved for future validation) - * @returns Array of MCPServerConfig for streamable-http and sse servers - */ -export function loadMcpServerConfigs( - config: Config, - logger: LoggerService, - _authConfigs?: Map, -): MCPServerConfig[] { - const servers: MCPServerConfig[] = []; - - try { - const mcpServersConfig = - config.getOptionalConfigArray('augment.mcpServers'); - if (!mcpServersConfig) { - return servers; - } - - for (const serverConfig of mcpServersConfig) { - const type = serverConfig.getString('type') as 'streamable-http' | 'sse'; - - if (type !== 'streamable-http' && type !== 'sse') { - logger.warn( - `MCP server ${serverConfig.getString('id')} has unsupported type ${type} - only streamable-http and sse work with Responses API`, - ); - continue; - } - - const server: MCPServerConfig = { - id: serverConfig.getString('id'), - name: serverConfig.getString('name'), - type, - url: serverConfig.getString('url'), - headers: serverConfig.getOptional('headers') as - | Record - | undefined, - allowedTools: serverConfig.getOptionalStringArray('allowedTools'), - }; - - parseServerAuth(serverConfig, server, logger); - - const requireApproval = parseRequireApproval(serverConfig, logger); - if (requireApproval) { - server.requireApproval = requireApproval; - logger.info( - `MCP server ${server.id} HITL config: ${JSON.stringify(requireApproval)}`, - ); - } - - servers.push(server); - } - } catch (error) { - const errorMsg = toErrorMessage(error); - logger.warn( - `Failed to load MCP server configurations: ${errorMsg}. MCP tools will not be available.`, - ); - } - - return servers; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ServerCapabilities.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ServerCapabilities.test.ts deleted file mode 100644 index fef771d6f13..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ServerCapabilities.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createMockLogger } from '../../../test-utils/mocks'; -import { - parseVersion, - resolveCapabilities, - isToolCompatibilityError, -} from './ServerCapabilities'; -import type { ServerCapabilityOverrides } from './ServerCapabilities'; - -describe('ServerCapabilities', () => { - const logger = createMockLogger(); - - describe('parseVersion', () => { - it('parses a standard semver string', () => { - expect(parseVersion('0.3.3')).toEqual([0, 3, 3]); - }); - - it('parses a two-part version', () => { - expect(parseVersion('1.2')).toEqual([1, 2, 0]); - }); - - it('returns null for undefined', () => { - expect(parseVersion(undefined)).toBeNull(); - }); - - it('returns null for empty string', () => { - expect(parseVersion('')).toBeNull(); - }); - - it('returns null for non-numeric', () => { - expect(parseVersion('abc.def')).toBeNull(); - }); - - it('returns null for single number', () => { - expect(parseVersion('1')).toBeNull(); - }); - - it('strips pre-release suffixes', () => { - expect(parseVersion('0.3.3-rc1')).toEqual([0, 3, 3]); - expect(parseVersion('1.0.0-beta.2')).toEqual([1, 0, 0]); - expect(parseVersion('0.6.0-dev+build123')).toEqual([0, 6, 0]); - }); - }); - - describe('resolveCapabilities', () => { - it('assumes all capabilities for unknown version', () => { - const caps = resolveCapabilities(undefined, undefined, logger); - expect(caps.functionTools).toBe(true); - expect(caps.strictField).toBe(true); - expect(caps.maxOutputTokens).toBe(true); - expect(caps.mcpTools).toBe(true); - expect(caps.parallelToolCalls).toBe(true); - }); - - it('disables strictField and maxOutputTokens for Llama Stack 0.3.x', () => { - const caps = resolveCapabilities('0.3.3', undefined, logger); - expect(caps.functionTools).toBe(true); - expect(caps.strictField).toBe(false); - expect(caps.maxOutputTokens).toBe(false); - expect(caps.mcpTools).toBe(true); - }); - - it('disables strictField and maxOutputTokens for Llama Stack 0.5.x', () => { - const caps = resolveCapabilities('0.5.2', undefined, logger); - expect(caps.strictField).toBe(false); - expect(caps.maxOutputTokens).toBe(false); - }); - - it('enables all for Llama Stack 0.6.0+', () => { - const caps = resolveCapabilities('0.6.0', undefined, logger); - expect(caps.strictField).toBe(true); - expect(caps.maxOutputTokens).toBe(true); - expect(caps.functionTools).toBe(true); - }); - - it('enables all for Llama Stack 1.0.0+', () => { - const caps = resolveCapabilities('1.0.0', undefined, logger); - expect(caps.strictField).toBe(true); - expect(caps.maxOutputTokens).toBe(true); - }); - - it('applies explicit overrides over version defaults', () => { - const overrides: ServerCapabilityOverrides = { - strictField: true, - }; - const caps = resolveCapabilities('0.3.3', overrides, logger); - expect(caps.strictField).toBe(true); - expect(caps.maxOutputTokens).toBe(false); - }); - - it('override can disable a capability even for new versions', () => { - const overrides: ServerCapabilityOverrides = { - functionTools: false, - }; - const caps = resolveCapabilities('0.6.0', overrides, logger); - expect(caps.functionTools).toBe(false); - }); - - it('undefined override values fall through to version defaults', () => { - const overrides: ServerCapabilityOverrides = { - strictField: undefined, - }; - const caps = resolveCapabilities('0.3.3', overrides, logger); - expect(caps.strictField).toBe(false); - }); - }); - - describe('isToolCompatibilityError', () => { - it('matches "unsupported tool type"', () => { - expect(isToolCompatibilityError('unsupported tool type: function')).toBe( - true, - ); - }); - - it('matches "unknown tool type"', () => { - expect(isToolCompatibilityError('unknown tool type: function')).toBe( - true, - ); - }); - - it('matches "function tool type"', () => { - expect( - isToolCompatibilityError('function tool type is not supported'), - ).toBe(true); - }); - - it('matches Pydantic strict/tool validation', () => { - expect(isToolCompatibilityError('extra input strict tool')).toBe(true); - }); - - it('matches "validation error tool"', () => { - expect( - isToolCompatibilityError('validation error for tool definition'), - ).toBe(true); - }); - - it('does not match unrelated errors', () => { - expect(isToolCompatibilityError('connection refused')).toBe(false); - }); - - it('does not match bare "tool" without type context', () => { - expect(isToolCompatibilityError('tool execution timed out')).toBe(false); - }); - - it('does not match bare "strict" without tool context', () => { - expect(isToolCompatibilityError('strict mode is enabled')).toBe(false); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ServerCapabilities.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ServerCapabilities.ts deleted file mode 100644 index 2ea5343eb1d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/ServerCapabilities.ts +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; - -/** - * Describes what the connected Llama Stack server supports. - * - * Capabilities are resolved once via `resolveCapabilities()` using a - * three-layer strategy: - * 1. Version-based hints from `GET /v1/version` - * 2. Explicit overrides from `app-config.yaml` - * 3. Runtime downgrade when a request fails with a validation error - * that mentions the unsupported feature (handled by callers) - * - * Unknown or unparseable versions default to "assume latest" so that - * the plugin works with custom/forked Llama Stack builds. - */ -export interface ServerCapabilities { - /** Can send `{ type: 'function' }` tools */ - functionTools: boolean; - /** Can include the `strict` field on function tools */ - strictField: boolean; - /** Can send `max_output_tokens` in the request */ - maxOutputTokens: boolean; - /** Can send `{ type: 'mcp' }` tools */ - mcpTools: boolean; - /** Supports `parallel_tool_calls` parameter */ - parallelToolCalls: boolean; - /** Supports `truncation` parameter for context window management */ - truncation: boolean; -} - -/** Optional overrides from app-config.yaml `augment.serverCapabilities`. */ -export interface ServerCapabilityOverrides { - functionTools?: boolean; - strictField?: boolean; - maxOutputTokens?: boolean; - mcpTools?: boolean; - parallelToolCalls?: boolean; - truncation?: boolean; -} - -/** - * Parse a semver-style version string into [major, minor, patch]. - * Returns `null` if the version is missing or unparseable. - */ -export function parseVersion( - version: string | undefined, -): [number, number, number] | null { - if (!version) return null; - const match = version.match(/^(\d+)\.(\d+)(?:\.(\d+))?/); - if (!match) return null; - return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)]; -} - -/** - * Derive default capabilities from a server version. - * - * Version timeline (from upstream Llama Stack PRs): - * - 0.3.x: function tools supported (PR #2094, May 2025), but `strict` - * field can cause null-serialization errors (fixed in 0.6.0) - * - 0.4.0: Tool calling stability fix (PR #3385, Oct 2025) - * - 0.6.0: `strict` field fix, `max_output_tokens` Pydantic fix - */ -function defaultsForVersion( - parsed: [number, number, number] | null, -): ServerCapabilities { - if (!parsed) { - return { - functionTools: true, - strictField: true, - maxOutputTokens: true, - mcpTools: true, - parallelToolCalls: true, - truncation: false, - }; - } - - const [major, minor] = parsed; - const atLeast = (m: number, n: number) => - major > m || (major === m && minor >= n); - - return { - functionTools: true, - strictField: atLeast(0, 6), - maxOutputTokens: atLeast(0, 6), - mcpTools: true, - parallelToolCalls: true, - truncation: atLeast(0, 6), - }; -} - -/** - * Resolve server capabilities by merging version-based defaults with - * explicit overrides from configuration. Override values take precedence; - * `undefined` override entries fall through to the version-based default. - */ -export function resolveCapabilities( - serverVersion: string | undefined, - overrides: ServerCapabilityOverrides | undefined, - logger: LoggerService, -): ServerCapabilities { - const parsed = parseVersion(serverVersion); - const defaults = defaultsForVersion(parsed); - - const caps: ServerCapabilities = { ...defaults }; - - if (overrides) { - for (const key of Object.keys(overrides) as Array< - keyof ServerCapabilityOverrides - >) { - if (overrides[key] !== undefined) { - (caps as unknown as Record)[key] = overrides[key]!; - } - } - } - - logger.debug( - `[ServerCapabilities] version=${serverVersion ?? 'unknown'}, caps=${JSON.stringify(caps)}${overrides ? `, overrides=${JSON.stringify(overrides)}` : ''}`, - ); - - if (parsed && !caps.strictField) { - logger.info( - `[ServerCapabilities] strict field disabled — function tools will omit 'strict' to avoid validation errors on Llama Stack ${serverVersion}`, - ); - } - - return caps; -} - -/** - * Check if a Llama Stack API error indicates an unsupported tool type - * or schema validation failure related to tools. Used for runtime - * capability downgrade. - */ -export function isToolCompatibilityError(errorMessage: string): boolean { - return /unsupported.*tool.*type|tool.*type.*unsupported|function.*tool.*type|unknown.*tool.*type|extra.*input.*strict.*tool|validation.*error.*tool/i.test( - errorMessage, - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/UiConfigLoader.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/UiConfigLoader.ts deleted file mode 100644 index 387c15a7df5..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/config/UiConfigLoader.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - LoggerService, - RootConfigService, -} from '@backstage/backend-plugin-api'; -import { toErrorMessage } from '../../../services/utils'; -import type { Workflow, QuickAction, PromptGroup } from '../../../types'; - -export function loadWorkflows( - config: RootConfigService, - logger: LoggerService, -): Workflow[] { - try { - const wfCfg = config.getOptionalConfigArray('augment.workflows'); - if (!wfCfg || wfCfg.length === 0) return []; - return wfCfg.map(wf => ({ - id: wf.getString('id'), - name: wf.getString('name'), - description: wf.getOptionalString('description') || '', - icon: wf.getOptionalString('icon'), - category: wf.getOptionalString('category'), - comingSoon: wf.getOptionalBoolean('comingSoon'), - comingSoonLabel: wf.getOptionalString('comingSoonLabel'), - steps: (wf.getOptionalConfigArray('steps') || []).map(s => ({ - title: s.getString('title'), - prompt: s.getString('prompt'), - description: s.getOptionalString('description'), - })), - })); - } catch (error) { - logger.warn(`Failed to load workflows: ${toErrorMessage(error)}`); - return []; - } -} - -export function loadQuickActions( - config: RootConfigService, - logger: LoggerService, -): QuickAction[] { - try { - const qpCfg = config.getOptionalConfigArray('augment.quickPrompts'); - if (!qpCfg || qpCfg.length === 0) return []; - return qpCfg.map(qp => ({ - title: qp.getString('title'), - description: qp.getOptionalString('description'), - prompt: qp.getString('prompt'), - icon: qp.getOptionalString('icon'), - category: qp.getOptionalString('category'), - comingSoon: qp.getOptionalBoolean('comingSoon'), - comingSoonLabel: qp.getOptionalString('comingSoonLabel'), - })); - } catch (error) { - logger.warn(`Failed to load quick prompts: ${toErrorMessage(error)}`); - return []; - } -} - -export function loadPromptGroups( - config: RootConfigService, - logger: LoggerService, -): PromptGroup[] { - try { - const groupsConfig = config.getOptionalConfigArray('augment.promptGroups'); - if (!groupsConfig || groupsConfig.length === 0) return []; - const groups = groupsConfig.map((sl, index) => { - const cardsConfig = sl.getOptionalConfigArray('cards') || []; - return { - id: sl.getString('id'), - title: sl.getString('title'), - description: sl.getOptionalString('description'), - icon: sl.getOptionalString('icon'), - color: sl.getOptionalString('color'), - order: sl.getOptionalNumber('order') ?? index + 1, - cards: cardsConfig.map(card => ({ - title: card.getString('title'), - description: card.getOptionalString('description'), - prompt: card.getString('prompt'), - icon: card.getOptionalString('icon'), - comingSoon: card.getOptionalBoolean('comingSoon'), - comingSoonLabel: card.getOptionalString('comingSoonLabel'), - })), - }; - }); - return groups.sort((a, b) => (a.order ?? 999) - (b.order ?? 999)); - } catch (error) { - logger.warn(`Failed to load prompt groups: ${toErrorMessage(error)}`); - return []; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/conversationTypes.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/conversationTypes.ts deleted file mode 100644 index 122dd2c3dc9..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/conversationTypes.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export * from '../responses-api/conversations/conversationTypes'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/eventTypes.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/eventTypes.ts deleted file mode 100644 index aba64722898..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/eventTypes.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../responses-api/stream/eventTypes' instead */ -export { - hasResponse, - hasItem, - hasPart, - hasError, -} from '../responses-api/stream/eventTypes'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/index.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/index.ts deleted file mode 100644 index 7e8aa4df6f9..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/index.ts +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// --------------------------------------------------------------------------- -// Provider implementation -// --------------------------------------------------------------------------- - -export { ResponsesApiProvider } from './ResponsesApiProvider'; -export { ResponsesApiProvider as LlamaStackProvider } from './ResponsesApiProvider'; - -export { ResponsesApiCoordinator } from './ResponsesApiCoordinator'; -export { ResponsesApiCoordinator as LlamaStackOrchestrator } from './ResponsesApiCoordinator'; - -// --------------------------------------------------------------------------- -// LlamaStack platform services (config, safety, status, auth) -// --------------------------------------------------------------------------- - -export { ConfigLoader } from './config/ConfigLoader'; -export { ConfigResolutionService } from './config/ConfigResolutionService'; -export { - getApiApprovalConfig, - loadMcpAuthConfigs, - loadMcpServerConfigs, -} from './config/McpConfigLoader'; -export { loadBrandingOverrides } from './config/BrandingConfigLoader'; -export { - resolveCapabilities, - parseVersion, - isToolCompatibilityError, - type ServerCapabilities, - type ServerCapabilityOverrides, -} from './config/ServerCapabilities'; - -export { SafetyService } from './safety/SafetyService'; -export { EvaluationService } from './safety/EvaluationService'; - -export { StatusService } from './status/StatusService'; -export { aggregateStatus } from './status/StatusAggregator'; - -export { McpAuthService } from './auth/McpAuthService'; - -// --------------------------------------------------------------------------- -// Provider wiring (root-level) -// --------------------------------------------------------------------------- - -export { ClientManager } from './ClientManager'; -export { ChatDepsBuilder } from './ChatDepsBuilder'; -export { AgentGraphManager } from './AgentGraphManager'; -export { BackendApprovalHandler } from './BackendApprovalHandler'; -export { - buildConversationsCapability, - buildRagCapability, - buildSafetyCapability, - buildEvaluationCapability, -} from './CapabilityBuilders'; -export { - type OrchestratorInitDeps, - type OrchestratorState, - initializeOrchestrator, -} from './OrchestratorInitializer'; -export { - VectorStoreFacade, - type VectorStoreFacadeContext, -} from './VectorStoreFacade'; - -// --------------------------------------------------------------------------- -// Re-exports from responses-api toolkit (backward compatibility) -// --------------------------------------------------------------------------- - -export { normalizeLlamaStackEvent } from '../responses-api/stream/StreamEventNormalizer'; -export { - ResponsesApiClient, - ResponsesApiError, - LlamaStackApiError, -} from '../responses-api/client/ResponsesApiClient'; -export { - ResponsesApiService, - type ChatDeps, -} from '../responses-api/chat/ResponsesApiService'; -export { ConversationService } from '../responses-api/conversations/ConversationService'; -export { - ConversationFacade, - type ConversationFacadeContext, -} from '../responses-api/conversations/ConversationFacade'; -export type { - ConversationDetails, - ApprovalResult, - ConversationClientAccessor, -} from '../responses-api/conversations/conversationTypes'; -export { VectorStoreService } from '../responses-api/documents/VectorStoreService'; -export { DocumentSyncService } from '../responses-api/documents/DocumentSyncService'; -export { - resolveAgentGraph, - type ResolvedAgent, - type AgentGraphSnapshot, - type BuildDepsForAgent, - toFunctionToolName, -} from '../responses-api/agents/agentGraph'; -export { - buildTools, - sanitizeToolsForServer, - type ToolsBuilderDeps, -} from '../responses-api/tools/ToolsBuilder'; -export { BackendToolExecutor } from '../responses-api/tools/BackendToolExecutor'; -export { BackendApprovalStore } from '../responses-api/tools/BackendApprovalStore'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/LlamaStackModel.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/LlamaStackModel.ts deleted file mode 100644 index 0ff80478d2c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/LlamaStackModel.ts +++ /dev/null @@ -1,270 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Usage } from '@openai/agents-core'; -import type { Model, ModelRequest, ModelResponse } from '@openai/agents-core'; -import type { StreamEvent } from '@openai/agents-core/types'; -import type { ResponsesApiService } from '../../responses-api/chat/ResponsesApiService'; -import type { ResponsesApiClient } from '../../../services/ResponsesApiClient'; -import type { - EffectiveConfig, - ResponsesApiInputItem, - ResponsesApiTool, - ResponsesApiResponse, -} from '../../../types'; - -/** - * Adapts the existing ResponsesApiService + ResponsesApiClient to the - * `@openai/agents-core` Model interface. - * - * This is the key bridge: the agents-core Runner calls getResponse() and - * getStreamedResponse() on this adapter; internally we delegate to the - * existing Backstage services that know how to call LlamaStack's - * `/v1/responses` endpoint. - */ -export class LlamaStackModel implements Model { - constructor( - private readonly chatService: ResponsesApiService, - private readonly client: ResponsesApiClient, - private readonly effectiveConfig: EffectiveConfig, - ) {} - - async getResponse(request: ModelRequest): Promise { - const { input, instructions, tools, options } = - this.mapRequestToServiceArgs(request); - - const result = await this.chatService.chatTurn( - input, - instructions, - tools, - this.effectiveConfig, - this.client, - options, - ); - - return this.mapResponseToModelResponse(result); - } - - async *getStreamedResponse( - request: ModelRequest, - ): AsyncIterable { - const { input, instructions, tools, options } = - this.mapRequestToServiceArgs(request); - - const events: string[] = []; - let resolveNext: (() => void) | null = null; - let done = false; - - const streamPromise = this.chatService.chatTurnStream( - input, - instructions, - tools, - this.effectiveConfig, - this.client, - (eventData: string) => { - events.push(eventData); - resolveNext?.(); - }, - options, - request.signal, - ); - - streamPromise - .then(() => { - done = true; - resolveNext?.(); - }) - .catch(() => { - done = true; - resolveNext?.(); - }); - - const waitForNext = () => - new Promise(resolve => { - resolveNext = resolve; - }); - - while (!done || events.length > 0) { - if (events.length > 0) { - const eventData = events.shift()!; - const parsed = this.parseStreamEvent(eventData); - if (parsed) { - yield parsed; - } - } else { - await waitForNext(); - resolveNext = null; - } - } - - await streamPromise; - } - - private mapRequestToServiceArgs(request: ModelRequest): { - input: string | ResponsesApiInputItem[]; - instructions: string; - tools: ResponsesApiTool[]; - options: { - previousResponseId?: string; - conversationId?: string; - store?: boolean; - }; - } { - const input = - typeof request.input === 'string' - ? request.input - : (request.input as unknown as ResponsesApiInputItem[]); - - const instructions = request.systemInstructions ?? ''; - - const tools = this.mapToolsToResponsesApi(request.tools, request.handoffs); - - return { - input, - instructions, - tools, - options: { - previousResponseId: request.previousResponseId, - conversationId: request.conversationId, - store: request.modelSettings.store, - }, - }; - } - - private mapToolsToResponsesApi( - tools: ModelRequest['tools'], - handoffs: ModelRequest['handoffs'], - ): ResponsesApiTool[] { - const mapped: ResponsesApiTool[] = []; - - for (const tool of tools) { - if (tool.type === 'function') { - mapped.push({ - type: 'function', - name: tool.name, - description: tool.description, - parameters: tool.parameters as Record, - strict: tool.strict, - } as ResponsesApiTool); - } else { - mapped.push(tool as unknown as ResponsesApiTool); - } - } - - for (const handoff of handoffs) { - mapped.push({ - type: 'function', - name: handoff.toolName, - description: handoff.toolDescription, - parameters: handoff.inputJsonSchema as Record, - strict: handoff.strictJsonSchema, - } as ResponsesApiTool); - } - - return mapped; - } - - private mapResponseToModelResponse( - result: ResponsesApiResponse, - ): ModelResponse { - const output: ModelResponse['output'] = []; - const resultAny = result as unknown as Record; - - if (result.output) { - for (const item of result.output) { - output.push(item as unknown as ModelResponse['output'][0]); - } - } else if (typeof resultAny.content === 'string' && resultAny.content) { - output.push({ - type: 'message', - role: 'assistant', - status: 'completed', - content: [{ type: 'output_text', text: resultAny.content }], - } as unknown as ModelResponse['output'][0]); - } - - const requestUsage = new Usage(); - const otherUsage = new Usage(); - otherUsage.inputTokens = result.usage?.input_tokens ?? 0; - otherUsage.outputTokens = result.usage?.output_tokens ?? 0; - otherUsage.totalTokens = - (result.usage?.input_tokens ?? 0) + (result.usage?.output_tokens ?? 0); - requestUsage.add(otherUsage); - - return { - usage: requestUsage, - output, - responseId: result.id, - }; - } - - /** - * Parse a raw SSE data string from LlamaStack into an agents-core - * StreamEvent. We yield `output_text_delta` for text chunks and - * `response_done` for the completed response, plus `model` events - * for anything else the frontend may need. - */ - private parseStreamEvent(eventData: string): StreamEvent | null { - try { - const parsed = JSON.parse(eventData); - - switch (parsed.type) { - case 'response.output_text.delta': - return { - type: 'output_text_delta', - delta: parsed.delta ?? '', - }; - - case 'response.completed': - case 'response.done': - return { - type: 'response_done', - response: { - id: parsed.response?.id ?? '', - usage: { - inputTokens: parsed.response?.usage?.input_tokens ?? 0, - outputTokens: parsed.response?.usage?.output_tokens ?? 0, - totalTokens: - (parsed.response?.usage?.input_tokens ?? 0) + - (parsed.response?.usage?.output_tokens ?? 0), - }, - output: parsed.response?.output ?? [], - }, - }; - - case 'response.created': - return { type: 'response_started' }; - - case 'response.output_item.added': - case 'response.output_item.done': - case 'response.function_call_arguments.delta': - case 'response.function_call_arguments.done': - case 'response.web_search_call.completed': - case 'response.file_search_call.completed': - case 'response.code_interpreter_call_code.delta': - case 'response.code_interpreter_call_code.done': - case 'response.code_interpreter_call.completed': - case 'response.mcp_call_arguments.delta': - case 'response.mcp_call_arguments.done': - return { type: 'model', event: parsed }; - - default: - return { type: 'model', event: parsed }; - } - } catch { - return null; - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/LlamaStackProvider.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/LlamaStackProvider.ts deleted file mode 100644 index 2ae5f6c5bbf..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/LlamaStackProvider.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { ModelProvider, Model } from '@openai/agents-core'; -import type { ResponsesApiService } from '../../responses-api/chat/ResponsesApiService'; -import type { ResponsesApiClient } from '../../../services/ResponsesApiClient'; -import type { EffectiveConfig } from '../../../types'; -import { LlamaStackModel } from './LlamaStackModel'; - -/** - * ModelProvider that creates LlamaStackModel instances backed by the - * existing ResponsesApiService + ResponsesApiClient HTTP layer. - * - * The agents-core Runner calls `getModel(name)` once per agent turn; - * we return a LlamaStackModel configured with the current effective - * config and HTTP client. - */ -export class LlamaStackProvider implements ModelProvider { - constructor( - private readonly chatService: ResponsesApiService, - private readonly client: ResponsesApiClient, - private readonly getEffectiveConfig: () => EffectiveConfig, - ) {} - - getModel(modelName?: string): Model { - const config = this.getEffectiveConfig(); - if (modelName && modelName !== config.model) { - return new LlamaStackModel(this.chatService, this.client, { - ...config, - model: modelName, - }); - } - return new LlamaStackModel(this.chatService, this.client, config); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/OpenAIAgentsOrchestrator.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/OpenAIAgentsOrchestrator.ts deleted file mode 100644 index e7dc36519a8..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/OpenAIAgentsOrchestrator.ts +++ /dev/null @@ -1,270 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { Runner } from '@openai/agents-core'; -import type { ChatRequest, ChatResponse } from '../../../types'; -import type { ResponsesApiService } from '../../responses-api/chat/ResponsesApiService'; -import type { ChatDeps } from '../../responses-api/chat/ResponsesApiService'; -import type { BackendApprovalStore } from '../../responses-api/tools/BackendApprovalStore'; -import { LlamaStackProvider } from './LlamaStackProvider'; -import { buildAgentGraph } from './configMapper'; -import { toChatResponse } from './responseMapper'; -import { mapRunStreamEventToFrontend } from './streamMapper'; -import { requireLastUserMessage } from '../../responses-api/chat/chatUtils'; -import { - discoverBackendTools, - ensureToolMetaCached, - buildAgentToolFilter, -} from './orchestratorToolDiscovery'; -import type { ToolMetaCache } from './orchestratorToolDiscovery'; - -export class OpenAIAgentsOrchestrator { - private readonly logger: LoggerService; - private readonly chatService: ResponsesApiService; - readonly backendApprovalStore?: BackendApprovalStore; - - private readonly toolCache: ToolMetaCache = { - cachedToolMeta: null, - cachedToolMetaKey: '', - cachedDiscoveryGeneration: -1, - }; - - private static readonly MAX_CONVERSATION_STATES = 500; - private readonly conversationAgents = new Map(); - - constructor(options: { - chatService: ResponsesApiService; - logger: LoggerService; - backendApprovalStore?: BackendApprovalStore; - toolScopeService?: unknown; - }) { - this.chatService = options.chatService; - this.logger = options.logger; - this.backendApprovalStore = options.backendApprovalStore; - } - - invalidateToolCache(): void { - this.toolCache.cachedToolMeta = null; - this.toolCache.cachedToolMetaKey = ''; - this.toolCache.cachedDiscoveryGeneration = -1; - } - - async discoverBackendTools(deps: ChatDeps) { - return discoverBackendTools(deps, this.toolCache, this.logger); - } - - warmUpToolCache(deps: ChatDeps): void { - ensureToolMetaCached(deps, this.toolCache, this.logger).catch(err => { - this.logger.warn( - '[OpenAIAgentsOrchestrator] Background tool cache warm-up failed', - { error: err instanceof Error ? err.message : String(err) }, - ); - }); - } - - async chat( - request: ChatRequest, - agentsConfig: Record>, - defaultAgentKey: string, - maxTurns: number | undefined, - deps: ChatDeps, - ): Promise { - try { - const userInput = requireLastUserMessage( - request, - '[OpenAIAgentsOrchestrator] ', - ); - const backendTools = await discoverBackendTools( - deps, - this.toolCache, - this.logger, - ); - const toolFilter = buildAgentToolFilter(agentsConfig, deps); - const { defaultAgent } = buildAgentGraph( - agentsConfig as Record, - this.resolveStartAgent( - request.conversationId, - defaultAgentKey, - request.model, - ), - backendTools, - toolFilter, - ); - - const provider = new LlamaStackProvider( - this.chatService, - deps.client, - () => deps.config, - ); - - const runner = new Runner({ modelProvider: provider }); - const result = await runner.run(defaultAgent, userInput, { - maxTurns: maxTurns ?? 10, - }); - - this.saveConversationAgent( - request.conversationId, - result.lastAgent?.name, - ); - - return toChatResponse(result); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.logger.error('[OpenAIAgentsOrchestrator] chat() failed', { - error: message, - }); - throw error; - } - } - - async chatStream( - request: ChatRequest, - agentsConfig: Record>, - defaultAgentKey: string, - maxTurns: number | undefined, - onEvent: (event: string) => void, - deps: ChatDeps, - signal?: AbortSignal, - ): Promise { - try { - const userInput = requireLastUserMessage( - request, - '[OpenAIAgentsOrchestrator] ', - ); - const backendTools = await discoverBackendTools( - deps, - this.toolCache, - this.logger, - ); - const toolFilter = buildAgentToolFilter(agentsConfig, deps); - const { defaultAgent } = buildAgentGraph( - agentsConfig as Record, - this.resolveStartAgent( - request.conversationId, - defaultAgentKey, - request.model, - ), - backendTools, - toolFilter, - ); - - const provider = new LlamaStackProvider( - this.chatService, - deps.client, - () => deps.config, - ); - - const runner = new Runner({ modelProvider: provider }); - const streamed = await runner.run(defaultAgent, userInput, { - stream: true, - maxTurns: maxTurns ?? 10, - signal, - }); - - for await (const event of streamed) { - for (const fe of mapRunStreamEventToFrontend(event)) { - onEvent(fe); - } - } - - this.saveConversationAgent( - request.conversationId, - streamed.lastAgent?.name, - ); - - const resultData = streamed as unknown as { - usage?: { - inputTokens?: number; - outputTokens?: number; - totalTokens?: number; - }; - lastAgent?: { name?: string }; - lastResponseId?: string; - }; - - onEvent( - JSON.stringify({ - type: 'stream.completed', - usage: resultData.usage - ? { - input_tokens: resultData.usage.inputTokens ?? 0, - output_tokens: resultData.usage.outputTokens ?? 0, - total_tokens: resultData.usage.totalTokens ?? 0, - } - : undefined, - agentName: resultData.lastAgent?.name, - responseId: resultData.lastResponseId, - }), - ); - } catch (error) { - if (signal?.aborted) { - this.logger.info('[OpenAIAgentsOrchestrator] Stream aborted by client'); - return; - } - const message = error instanceof Error ? error.message : String(error); - this.logger.error('[OpenAIAgentsOrchestrator] Stream error', { - error: message, - }); - onEvent( - JSON.stringify({ - type: 'stream.error', - error: message, - code: 'orchestrator_error', - }), - ); - } - } - - private resolveStartAgent( - conversationId: string | undefined, - defaultAgentKey: string, - requestedAgent?: string, - ): string { - if (requestedAgent) { - return requestedAgent; - } - if (conversationId) { - const stored = this.conversationAgents.get(conversationId); - if (stored) { - this.logger.info( - `[OpenAIAgentsOrchestrator] Resuming from agent "${stored}" for conversation ${conversationId}`, - ); - return stored; - } - } - return defaultAgentKey; - } - - private saveConversationAgent( - conversationId: string | undefined, - agentName: string | undefined, - ): void { - if (!conversationId || !agentName) return; - - this.conversationAgents.set(conversationId, agentName); - - if ( - this.conversationAgents.size > - OpenAIAgentsOrchestrator.MAX_CONVERSATION_STATES - ) { - const oldest = this.conversationAgents.keys().next().value; - if (oldest) { - this.conversationAgents.delete(oldest); - } - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/configMapper.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/configMapper.ts deleted file mode 100644 index c6b3ff4d36c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/configMapper.ts +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Agent } from '@openai/agents-core'; -import type { - FunctionTool as AgentsFunctionTool, - Tool, -} from '@openai/agents-core'; -import type { AgentConfig } from '../../../types/config'; - -/** - * Build the full agent map from an agents config record. - * - * Two-pass algorithm: - * 1. Create all Agent instances with instructions, model, and backend tools - * 2. Wire handoffs and asTools references between the live Agent instances - * - * This ensures handoff targets and agent-as-tool delegates are fully - * configured Agent objects (not stubs), enabling the Runner to execute - * the complete graph with proper instructions and tools on each agent. - */ -export function buildAgentGraph( - agentsConfig: Record, - defaultAgentKey: string, - backendTools: AgentsFunctionTool[], - agentToolFilter?: ( - agentKey: string, - tools: AgentsFunctionTool[], - ) => AgentsFunctionTool[], -): { - agents: Map; - defaultAgent: Agent; -} { - const allKeys = Object.keys(agentsConfig); - const agents = new Map(); - - // Pass 1: create agents without cross-references - for (const [key, config] of Object.entries(agentsConfig)) { - const filteredTools = agentToolFilter - ? agentToolFilter(key, backendTools) - : backendTools; - - agents.set( - key, - new Agent({ - name: config.name ?? key, - instructions: config.instructions ?? '', - model: config.model, - tools: [...filteredTools], - modelSettings: { - temperature: config.temperature, - maxTokens: config.maxOutputTokens, - toolChoice: config.toolChoice as - | 'auto' - | 'required' - | 'none' - | undefined, - }, - handoffDescription: config.handoffDescription, - resetToolChoice: config.resetToolChoice, - toolUseBehavior: - config.toolUseBehavior === 'stop_on_first_tool' - ? 'stop_on_first_tool' - : undefined, - }), - ); - } - - // Pass 2: wire handoffs and asTools between live Agent instances - for (const [key, config] of Object.entries(agentsConfig)) { - const agent = agents.get(key); - if (!agent) continue; - - const handoffs: Agent[] = []; - if (config.handoffs) { - for (const handoffKey of config.handoffs) { - const target = agents.get(handoffKey); - if (target && handoffKey !== key) { - handoffs.push(target); - } - } - } - - const asToolInstances: Tool[] = []; - if (config.asTools) { - for (const delegateKey of config.asTools) { - const delegate = agents.get(delegateKey); - if (delegate && delegateKey !== key) { - asToolInstances.push( - delegate.asTool({ - toolName: `delegate_to_${sanitizeKey(delegateKey)}`, - toolDescription: - delegate.handoffDescription || - `Delegate a sub-task to the ${delegate.name} agent and get back a result.`, - }), - ); - } - } - } - - if (handoffs.length > 0 || asToolInstances.length > 0) { - const existingTools = - (agent as unknown as { _tools?: Tool[] })._tools ?? []; - const merged = new Agent({ - name: agent.name, - instructions: config.instructions ?? '', - model: config.model, - tools: [...existingTools, ...asToolInstances] as AgentsFunctionTool[], - handoffs: handoffs.length > 0 ? handoffs : undefined, - modelSettings: { - temperature: config.temperature, - maxTokens: config.maxOutputTokens, - toolChoice: config.toolChoice as - | 'auto' - | 'required' - | 'none' - | undefined, - }, - handoffDescription: config.handoffDescription, - resetToolChoice: config.resetToolChoice, - toolUseBehavior: - config.toolUseBehavior === 'stop_on_first_tool' - ? 'stop_on_first_tool' - : undefined, - }); - agents.set(key, merged); - } - } - - // Resolve default agent - if (!agents.has(defaultAgentKey) && agents.size > 0) { - const firstKey = allKeys[0]; - return { agents, defaultAgent: agents.get(firstKey)! }; - } - - const defaultAgent = agents.get(defaultAgentKey); - if (!defaultAgent) { - const fallback = new Agent({ - name: 'default', - instructions: 'You are a helpful assistant.', - }); - agents.set('default', fallback); - return { agents, defaultAgent: fallback }; - } - - return { agents, defaultAgent }; -} - -/** - * Build a single Agent from config (utility for single-agent mode). - */ -export function buildAgentFromConfig( - key: string, - config: AgentConfig, - _allAgentKeys: string[], - backendTools: AgentsFunctionTool[], -): Agent { - return new Agent({ - name: config.name ?? key, - instructions: config.instructions ?? '', - model: config.model, - tools: [...backendTools], - modelSettings: { - temperature: config.temperature, - maxTokens: config.maxOutputTokens, - toolChoice: config.toolChoice as 'auto' | 'required' | 'none' | undefined, - }, - handoffDescription: config.handoffDescription, - }); -} - -function sanitizeKey(key: string): string { - return key.replace(/[^a-zA-Z0-9_]/g, '_'); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/index.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/index.ts deleted file mode 100644 index 47605339cad..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { OpenAIAgentsOrchestrator } from './OpenAIAgentsOrchestrator'; -export { LlamaStackModel } from './LlamaStackModel'; -export { LlamaStackProvider } from './LlamaStackProvider'; -export { mapRunStreamEventToFrontend } from './streamMapper'; -export { toChatResponse } from './responseMapper'; -export { buildAgentGraph, buildAgentFromConfig } from './configMapper'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/orchestratorToolDiscovery.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/orchestratorToolDiscovery.ts deleted file mode 100644 index b8dd8f24cfb..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/orchestratorToolDiscovery.ts +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { tool as createTool } from '@openai/agents-core'; -import type { FunctionTool as AgentsFunctionTool } from '@openai/agents-core'; -import type { ChatDeps } from '../../responses-api/chat/ResponsesApiService'; - -export interface CachedToolMeta { - name: string; - description: string; - parameters: Record; -} - -export interface ToolMetaCache { - cachedToolMeta: CachedToolMeta[] | null; - cachedToolMetaKey: string; - cachedDiscoveryGeneration: number; -} - -export const DISCOVERY_TIMEOUT_MS = 10_000; - -export async function ensureToolMetaCached( - deps: ChatDeps, - cache: ToolMetaCache, - logger: LoggerService, -): Promise { - const executor = deps.backendToolExecutor; - if (!executor) return []; - - const cacheKey = deps.mcpServers - .map(s => s.id) - .sort((a, b) => a.localeCompare(b)) - .join(','); - const currentGen = executor.getDiscoveryGeneration(); - - if ( - cache.cachedToolMeta && - cache.cachedToolMetaKey === cacheKey && - cache.cachedDiscoveryGeneration === currentGen - ) { - return cache.cachedToolMeta; - } - - let discovered: Array<{ - name: string; - description?: string; - parameters?: Record; - }>; - - try { - discovered = await Promise.race([ - executor.ensureToolsDiscovered(deps.mcpServers), - new Promise((_, reject) => - setTimeout( - () => reject(new Error('Tool discovery timed out')), - DISCOVERY_TIMEOUT_MS, - ), - ), - ]); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - logger.warn('[OpenAIAgentsOrchestrator] Tool discovery failed', { - error: msg, - }); - if (cache.cachedToolMeta) { - return cache.cachedToolMeta; - } - return []; - } - - logger.info('[OpenAIAgentsOrchestrator] Discovered backend MCP tools', { - count: discovered.length, - servers: deps.mcpServers.map(s => s.id), - }); - - const meta: CachedToolMeta[] = discovered.map(apiTool => ({ - name: apiTool.name, - description: apiTool.description ?? apiTool.name, - parameters: apiTool.parameters ?? { type: 'object', properties: {} }, - })); - - cache.cachedToolMeta = meta; - cache.cachedToolMetaKey = cacheKey; - cache.cachedDiscoveryGeneration = executor.getDiscoveryGeneration(); - return meta; -} - -export async function discoverBackendTools( - deps: ChatDeps, - cache: ToolMetaCache, - logger: LoggerService, -): Promise { - if (!deps.backendToolExecutor) return []; - const toolExecutor = deps.backendToolExecutor; - - const meta = await ensureToolMetaCached(deps, cache, logger); - - return meta.map( - t => - createTool({ - name: t.name, - description: t.description, - parameters: t.parameters as any, - execute: async (input: unknown) => { - const args = - typeof input === 'string' ? input : JSON.stringify(input ?? {}); - try { - return await toolExecutor.executeTool(t.name, args); - } catch (execError) { - const msg = - execError instanceof Error - ? execError.message - : String(execError); - logger.error('[OpenAIAgentsOrchestrator] Tool execution failed', { - tool: t.name, - error: msg, - }); - return JSON.stringify({ error: msg }); - } - }, - }) as unknown as AgentsFunctionTool, - ); -} - -export function buildAgentToolFilter( - agentsConfig: Record>, - deps: ChatDeps, -): - | ((agentKey: string, tools: AgentsFunctionTool[]) => AgentsFunctionTool[]) - | undefined { - const hasScoping = Object.values(agentsConfig).some( - c => Array.isArray(c.mcpServers) && (c.mcpServers as string[]).length > 0, - ); - if (!hasScoping || !deps.backendToolExecutor) return undefined; - - const executor = deps.backendToolExecutor; - return (agentKey: string, tools: AgentsFunctionTool[]) => { - const config = agentsConfig[agentKey]; - if (!config) return tools; - const allowedServers = config.mcpServers as string[] | undefined; - if (!allowedServers || allowedServers.length === 0) return tools; - - return tools.filter(t => { - const serverInfo = executor.getToolServerInfo(t.name); - if (!serverInfo) return true; - return allowedServers.includes(serverInfo.serverId); - }); - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/responseMapper.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/responseMapper.ts deleted file mode 100644 index ee6dfb5296f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/responseMapper.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { RunResult, RunItem } from '@openai/agents-core'; -import type { ChatResponse } from '../../../types'; -import type { ToolCallInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -/** - * Maps an agents-core RunResult to the plugin's ChatResponse format. - * - * Extracts the final text output, agent name, tool call info, - * reasoning summaries, and approval data from the RunResult items. - */ -export function toChatResponse(result: RunResult): ChatResponse { - const textOutput = extractTextFromItems(result.newItems); - const toolCallsArr = extractToolCalls(result.newItems); - const reasoning = extractReasoning(result.newItems); - const approvalItems = extractApprovals(result.newItems); - - const usage = extractUsage(result); - - let content: string; - if (result.finalOutput) { - content = - typeof result.finalOutput === 'string' - ? result.finalOutput - : JSON.stringify(result.finalOutput); - } else { - content = textOutput; - } - - return { - role: 'assistant', - content, - agentName: result.lastAgent?.name, - toolCalls: - toolCallsArr && toolCallsArr.length > 0 ? toolCallsArr : undefined, - reasoning: reasoning.length > 0 ? reasoning : undefined, - usage, - pendingApprovals: approvalItems.length > 0 ? approvalItems : undefined, - }; -} - -function extractUsage(result: RunResult): ChatResponse['usage'] { - let inputTokens = 0; - let outputTokens = 0; - for (const resp of result.rawResponses) { - if (resp.usage) { - inputTokens += resp.usage.inputTokens; - outputTokens += resp.usage.outputTokens; - } - } - if (inputTokens === 0 && outputTokens === 0) return undefined; - return { - input_tokens: inputTokens, - output_tokens: outputTokens, - total_tokens: inputTokens + outputTokens, - }; -} - -function extractTextFromItems(items: RunItem[]): string { - const texts: string[] = []; - for (const item of items) { - if (item.type === 'message_output_item') { - const raw = item.rawItem as Record | undefined; - if (Array.isArray(raw?.content)) { - const parts = raw.content as Array>; - texts.push( - parts.map(c => (typeof c.text === 'string' ? c.text : '')).join(''), - ); - } else if (typeof raw?.text === 'string') { - texts.push(raw.text); - } - } - } - return texts.join(''); -} - -function extractToolCalls(items: RunItem[]): ToolCallInfo[] { - const calls: ToolCallInfo[] = []; - for (const item of items) { - if (item.type === 'tool_call_item') { - const rawItem = item.rawItem as Record | undefined; - calls.push({ - id: - (rawItem?.id as string) ?? - (rawItem?.call_id as string) ?? - `tc-${calls.length}`, - name: (rawItem?.name as string) ?? 'unknown', - serverLabel: 'function', - arguments: (rawItem?.arguments as string) ?? '', - output: (rawItem?.output as string) ?? undefined, - }); - } - } - return calls; -} - -function extractReasoning( - items: RunItem[], -): NonNullable { - const reasoning: NonNullable = []; - for (const item of items) { - if (item.type === 'reasoning_item' && item.rawItem) { - const raw = item.rawItem as Record; - const content = raw.content as Array> | undefined; - const text = - content - ?.map(c => (typeof c.text === 'string' ? c.text : '')) - .join('') ?? ''; - reasoning.push({ - id: (raw.id as string) ?? `reasoning-${reasoning.length}`, - text, - }); - } - } - return reasoning; -} - -function extractApprovals( - items: RunItem[], -): NonNullable { - const approvals: NonNullable = []; - for (const item of items) { - if (item.type === 'tool_approval_item') { - const raw = item.rawItem as Record | undefined; - approvals.push({ - approvalRequestId: (raw?.id as string) ?? '', - toolName: (raw?.name as string) ?? 'unknown', - arguments: (raw?.arguments as string) ?? '', - }); - } - } - return approvals; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/streamMapper.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/streamMapper.ts deleted file mode 100644 index d157ecfb64c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/openai-agents-adapters/streamMapper.ts +++ /dev/null @@ -1,320 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { RunStreamEvent } from '@openai/agents-core'; -import type { NormalizedStreamEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -/** - * Maps `@openai/agents-core` RunStreamEvent instances to zero or more - * JSON-encoded NormalizedStreamEvent strings that the existing frontend - * reducer can consume without modification. - * - * This is the critical compatibility bridge: the agents-core Runner - * emits its own event types (RunRawModelStreamEvent, RunItemStreamEvent, - * RunAgentUpdatedStreamEvent) and we translate them to the `stream.*` - * event taxonomy already understood by the frontend. - */ -export function mapRunStreamEventToFrontend(event: RunStreamEvent): string[] { - switch (event.type) { - case 'raw_model_stream_event': - return mapRawModelEvent(event.data as unknown); - - case 'run_item_stream_event': - return mapItemEvent( - event as Extract, - ); - - case 'agent_updated_stream_event': - return [ - JSON.stringify({ - type: 'stream.agent.handoff', - toAgent: event.agent.name, - } satisfies NormalizedStreamEvent), - ]; - - default: - return []; - } -} - -function mapRawModelEvent(data: unknown): string[] { - if (!data || typeof data !== 'object') return []; - - const eventType = (data as { type?: string }).type; - - switch (eventType) { - case 'output_text_delta': - return [ - JSON.stringify({ - type: 'stream.text.delta', - delta: (data as { delta?: string }).delta ?? '', - } satisfies NormalizedStreamEvent), - ]; - - case 'response_started': - return [ - JSON.stringify({ - type: 'stream.started', - responseId: '', - } satisfies NormalizedStreamEvent), - ]; - - case 'response_done': { - const resp = (data as { response?: Record }).response; - return [ - JSON.stringify({ - type: 'stream.text.done', - text: extractTextFromOutput(resp), - } satisfies NormalizedStreamEvent), - ]; - } - - case 'model': { - const inner = (data as { event?: Record }).event; - if (inner) { - return mapLlamaStackRawEvent(inner); - } - return []; - } - - default: - return []; - } -} - -/** - * Handle raw LlamaStack SSE events that come through as `model`-type - * StreamEvents. These are the native Responses API event shapes. - */ -function mapLlamaStackRawEvent(event: Record): string[] { - const eventType = event.type as string | undefined; - - switch (eventType) { - case 'response.output_text.delta': - return [ - JSON.stringify({ - type: 'stream.text.delta', - delta: (event.delta as string) ?? '', - } satisfies NormalizedStreamEvent), - ]; - - case 'response.reasoning.delta': - case 'response.reasoning_summary_text.delta': - return [ - JSON.stringify({ - type: 'stream.reasoning.delta', - delta: (event.delta as string) ?? '', - } satisfies NormalizedStreamEvent), - ]; - - case 'response.reasoning.done': - case 'response.reasoning_summary_text.done': - return [ - JSON.stringify({ - type: 'stream.reasoning.done', - text: (event.text as string) ?? '', - } satisfies NormalizedStreamEvent), - ]; - - case 'response.output_text.done': - return [ - JSON.stringify({ - type: 'stream.text.done', - text: (event.text as string) ?? '', - } satisfies NormalizedStreamEvent), - ]; - - case 'response.function_call_arguments.delta': - return [ - JSON.stringify({ - type: 'stream.tool.delta', - callId: (event.call_id as string) ?? (event.item_id as string) ?? '', - delta: (event.delta as string) ?? '', - } satisfies NormalizedStreamEvent), - ]; - - case 'response.function_call_arguments.done': { - const name = - (event.name as string) ?? (event.function_name as string) ?? ''; - const callId = - (event.call_id as string) ?? (event.item_id as string) ?? ''; - return [ - JSON.stringify({ - type: 'stream.tool.started', - callId, - name, - serverLabel: 'function', - } satisfies NormalizedStreamEvent), - ]; - } - - case 'response.web_search_call.completed': - return [ - JSON.stringify({ - type: 'stream.tool.completed', - callId: (event.item_id as string) ?? '', - name: 'web_search', - serverLabel: 'web_search', - output: '', - } satisfies NormalizedStreamEvent), - ]; - - case 'response.file_search_call.completed': - return [ - JSON.stringify({ - type: 'stream.tool.completed', - callId: (event.item_id as string) ?? '', - name: 'file_search', - serverLabel: 'file_search', - output: '', - } satisfies NormalizedStreamEvent), - ]; - - case 'response.created': - return [ - JSON.stringify({ - type: 'stream.started', - responseId: - ((event.response as Record)?.id as string) ?? '', - model: (event.response as Record)?.model as string, - } satisfies NormalizedStreamEvent), - ]; - - case 'response.completed': - case 'response.done': - return []; - - default: - return []; - } -} - -function mapItemEvent( - event: Extract, -): string[] { - const item = event.item; - - switch (event.name) { - case 'tool_called': { - const rawItem = item.rawItem as Record | undefined; - return [ - JSON.stringify({ - type: 'stream.tool.started', - callId: (rawItem?.id as string) ?? (rawItem?.callId as string) ?? '', - name: (rawItem?.name as string) ?? '', - serverLabel: 'function', - } satisfies NormalizedStreamEvent), - ]; - } - - case 'tool_output': { - const rawItem2 = item.rawItem as Record | undefined; - const output = (item as { output?: string | unknown }).output; - const outputStr = - typeof output === 'string' ? output : JSON.stringify(output ?? ''); - const isError = isToolOutputError(outputStr); - - if (isError) { - return [ - JSON.stringify({ - type: 'stream.tool.failed', - callId: - (rawItem2?.id as string) ?? (rawItem2?.callId as string) ?? '', - name: (rawItem2?.name as string) ?? '', - serverLabel: 'function', - error: outputStr, - } satisfies NormalizedStreamEvent), - ]; - } - return [ - JSON.stringify({ - type: 'stream.tool.completed', - callId: - (rawItem2?.id as string) ?? (rawItem2?.callId as string) ?? '', - name: (rawItem2?.name as string) ?? '', - serverLabel: 'function', - output: outputStr, - } satisfies NormalizedStreamEvent), - ]; - } - - case 'tool_approval_requested': { - const rawItem3 = item.rawItem as Record | undefined; - return [ - JSON.stringify({ - type: 'stream.tool.approval', - callId: (rawItem3?.id as string) ?? '', - name: (item as { name?: string }).name ?? '', - serverLabel: 'function', - arguments: (item as { arguments?: string }).arguments, - } satisfies NormalizedStreamEvent), - ]; - } - - case 'handoff_requested': - case 'handoff_occurred': { - const handoffItem = item as { - targetAgent?: { name: string }; - sourceAgent?: { name: string }; - }; - return [ - JSON.stringify({ - type: 'stream.agent.handoff', - fromAgent: handoffItem.sourceAgent?.name, - toAgent: handoffItem.targetAgent?.name ?? '', - } satisfies NormalizedStreamEvent), - ]; - } - - case 'message_output_created': - case 'reasoning_item_created': - case 'tool_search_called': - case 'tool_search_output_created': - return []; - - default: - return []; - } -} - -function isToolOutputError(output: string): boolean { - try { - const parsed = JSON.parse(output); - return typeof parsed === 'object' && parsed !== null && 'error' in parsed; - } catch { - return false; - } -} - -function extractTextFromOutput( - response: Record | undefined, -): string { - if (!response?.output) return ''; - const output = response.output as Array>; - const texts: string[] = []; - for (const item of output) { - if (item.role === 'assistant' && Array.isArray(item.content)) { - for (const part of item.content) { - if ( - (part as Record).type === 'output_text' && - typeof (part as Record).text === 'string' - ) { - texts.push((part as Record).text); - } - } - } - } - return texts.join(''); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/promptGeneration.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/promptGeneration.test.ts deleted file mode 100644 index d46f0f448b8..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/promptGeneration.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { EffectiveConfig } from '../../types'; -import { - buildMetaPrompt, - extractToolContext, - PROMPT_ENGINEER_INSTRUCTIONS, - type PromptCapabilities, -} from './promptGeneration'; - -function makeConfig(overrides: Partial = {}): EffectiveConfig { - return { - model: 'test-model', - baseUrl: 'https://localhost:8321', - systemPrompt: '', - enableWebSearch: false, - enableCodeInterpreter: false, - vectorStoreIds: [], - vectorStoreName: 'vs', - embeddingModel: 'em', - embeddingDimension: 384, - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - skipTlsVerify: false, - zdrMode: false, - verboseStreamLogging: false, - ...overrides, - }; -} - -describe('promptGeneration', () => { - describe('extractToolContext', () => { - it('lists model and disabled tools by default', () => { - const ctx = extractToolContext(makeConfig()); - expect(ctx).toContain('Model: test-model'); - expect(ctx).toContain('Web Search: disabled'); - expect(ctx).toContain('Code Interpreter: disabled'); - expect(ctx).toContain('MCP Servers: none configured'); - expect(ctx).toContain('not configured'); - expect(ctx).toContain('Safety Shields: disabled'); - }); - - it('reflects enabled capabilities', () => { - const ctx = extractToolContext( - makeConfig({ - enableWebSearch: true, - enableCodeInterpreter: true, - mcpServers: [ - { id: 's1', name: 'GitHub', type: 'sse', url: 'https://gh' }, - { - id: 's2', - name: 'Jira', - type: 'streamable-http', - url: 'https://j', - }, - ], - vectorStoreIds: ['vs1', 'vs2'], - safetyEnabled: true, - inputShields: ['content-safety'], - outputShields: [], - evaluationEnabled: true, - }), - ); - - expect(ctx).toContain('Web Search: enabled'); - expect(ctx).toContain('Code Interpreter: enabled'); - expect(ctx).toContain('GitHub, Jira'); - expect(ctx).toContain('2 active vector store(s)'); - expect(ctx).toContain('Safety Shields: enabled (content-safety)'); - expect(ctx).toContain('Response Evaluation: enabled'); - }); - }); - - describe('buildMetaPrompt', () => { - it('returns { instructions, input } with separated concerns', () => { - const result = buildMetaPrompt( - 'Help with Kubernetes', - makeConfig({ model: 'llama-3' }), - ); - - expect(result).toHaveProperty('instructions'); - expect(result).toHaveProperty('input'); - expect(result.instructions).toBe(PROMPT_ENGINEER_INSTRUCTIONS); - expect(result.input).toContain('Help with Kubernetes'); - expect(result.input).toContain('Model: llama-3'); - expect(result.input).toContain('Generate the system prompt now'); - }); - - it('keeps prompt engineer role in instructions, not in input', () => { - const result = buildMetaPrompt('Help with Kubernetes', makeConfig()); - - expect(result.instructions).toContain('expert AI prompt engineer'); - expect(result.input).not.toContain('expert AI prompt engineer'); - }); - - it('keeps user description in input, not in instructions', () => { - const result = buildMetaPrompt('Help with Kubernetes', makeConfig()); - - expect(result.input).toContain('Help with Kubernetes'); - expect(result.instructions).not.toContain('Help with Kubernetes'); - }); - - it('includes requirement guidelines in instructions', () => { - const result = buildMetaPrompt('Do stuff', makeConfig()); - expect(result.instructions).toContain('clear, concise role definition'); - expect(result.instructions).toContain('150 and 500 words'); - expect(result.instructions).toContain('no meta-commentary'); - }); - - it('uses basic extractToolContext when no capabilities provided', () => { - const result = buildMetaPrompt('Help with K8s', makeConfig()); - expect(result.input).toContain('Web Search: disabled'); - expect(result.input).not.toContain('CONNECTED TOOLS'); - }); - - it('uses detailed capabilities section when capabilities are provided', () => { - const caps: PromptCapabilities = { - enableWebSearch: true, - enableCodeInterpreter: false, - tools: [ - { - name: 'get_pod_logs', - description: 'Retrieve logs from a Kubernetes pod', - serverLabel: 'k8s-mcp', - }, - { - name: 'list_deployments', - description: 'List all deployments', - serverLabel: 'k8s-mcp', - }, - { - name: 'create_ticket', - description: 'Create a JIRA ticket', - serverLabel: 'jira-mcp', - }, - ], - ragEnabled: true, - vectorStoreNames: ['docs-store'], - safetyEnabled: true, - safetyShields: ['content-safety'], - }; - - const result = buildMetaPrompt( - 'Help with Kubernetes', - makeConfig({ model: 'llama-3' }), - caps, - ); - - expect(result.input).toContain('CONNECTED TOOLS'); - expect(result.input).toContain('[k8s-mcp]'); - expect(result.input).toContain('get_pod_logs'); - expect(result.input).toContain('Retrieve logs from a Kubernetes pod'); - expect(result.input).toContain('[jira-mcp]'); - expect(result.input).toContain('create_ticket'); - expect(result.input).toContain('KNOWLEDGE BASE (RAG)'); - expect(result.input).toContain('docs-store'); - expect(result.input).toContain('SAFETY: enabled'); - expect(result.input).toContain('BUILT-IN TOOLS: Web Search'); - expect(result.input).not.toContain('Code Interpreter'); - }); - - it('falls back to extractToolContext when capabilities have no tools', () => { - const result = buildMetaPrompt( - 'Just chat', - makeConfig({ enableWebSearch: true }), - { enableWebSearch: false, enableCodeInterpreter: false }, - ); - expect(result.input).toContain('Web Search: enabled'); - expect(result.input).not.toContain('CONNECTED TOOLS'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/promptGeneration.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/promptGeneration.ts deleted file mode 100644 index 39f6823c477..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/promptGeneration.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @deprecated Import from '../../services/promptGeneration' instead */ -export * from '../../services/promptGeneration'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ragCapabilityBuilder.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ragCapabilityBuilder.ts deleted file mode 100644 index 2e88c6ee67d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/ragCapabilityBuilder.ts +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { RAGCapability, DocumentInfo, VectorStoreInfo } from '../types'; -import type { SyncResult } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { VectorStoreFacade } from './VectorStoreFacade'; - -export interface RagInferenceAccessor { - getClient: () => { - request: (path: string, opts: Record) => Promise; - }; - getModel: () => string; -} - -export function buildRagCapability( - facade: VectorStoreFacade, - inference?: RagInferenceAccessor, -): RAGCapability { - return { - listDocuments: async (vectorStoreId?: string): Promise => { - return facade.listDocuments(vectorStoreId); - }, - - listVectorStores: async (): Promise => { - return facade.listVectorStores(); - }, - - getDefaultVectorStoreId: (): string | undefined => { - return facade.getDefaultVectorStoreId(); - }, - - getActiveVectorStoreIds: async (): Promise => { - return facade.getActiveVectorStoreIds(); - }, - - syncDocuments: async (): Promise => { - return facade.syncDocuments(); - }, - - uploadDocument: async ( - fileName: string, - content: Buffer, - vectorStoreId?: string, - ): Promise<{ fileId: string; fileName: string; status: string }> => { - return facade.uploadDocument(fileName, content, vectorStoreId); - }, - - deleteDocument: async ( - fileId: string, - vectorStoreId?: string, - ): Promise<{ success: boolean }> => { - return facade.deleteDocument(fileId, vectorStoreId); - }, - - searchVectorStore: async ( - query: string, - maxResults?: number, - vectorStoreId?: string, - vectorStoreIds?: string[], - ): Promise<{ - query: string; - chunks: Array<{ - text: string; - score?: number; - fileId?: string; - fileName?: string; - vectorStoreId?: string; - }>; - vectorStoreId: string; - totalResults: number; - }> => { - return facade.searchVectorStore( - query, - maxResults, - vectorStoreId, - vectorStoreIds, - ); - }, - - getVectorStoreConfig: async () => { - return facade.getVectorStoreConfig(); - }, - - createVectorStoreWithConfig: async (overrides: Record) => { - return facade.createVectorStoreWithConfig(overrides); - }, - - getVectorStoreStatus: async () => { - return facade.getVectorStoreStatus(); - }, - - addVectorStoreId: (id: string) => { - facade.addVectorStoreId(id); - }, - - removeVectorStoreId: (id: string) => { - facade.removeVectorStoreId(id); - }, - - deleteVectorStore: (vectorStoreId: string) => { - return facade.deleteVectorStore(vectorStoreId); - }, - - generateAnswer: inference - ? async ( - query: string, - maxResults?: number, - vectorStoreId?: string, - vectorStoreIds?: string[], - ) => { - const searchResult = await facade.searchVectorStore( - query, - maxResults, - vectorStoreId, - vectorStoreIds, - ); - - const chunksContext = searchResult.chunks - .map((c, i) => { - const fileNamePart = c.fileName ? ` — ${c.fileName}` : ''; - const scorePart = - c.score !== undefined - ? ` (score: ${(c.score * 100).toFixed(1)}%)` - : ''; - return `[Chunk ${i + 1}${fileNamePart}${scorePart}]\n${c.text}`; - }) - .join('\n\n'); - - const instructions = [ - 'You are a helpful assistant answering questions using ONLY the provided context chunks.', - 'If the context does not contain enough information to answer, say so clearly.', - 'Cite which chunk(s) you used when possible.', - 'Do not make up information beyond what the context provides.', - ].join(' '); - - const input = `Context:\n${chunksContext}\n\nQuestion: ${query}`; - const model = inference.getModel(); - const client = inference.getClient(); - - const response = await client.request<{ - output: Array<{ - type: string; - content?: Array<{ type: string; text?: string }>; - }>; - }>('/v1/responses', { - method: 'POST', - body: JSON.stringify({ - input, - instructions, - model, - store: false, - }), - }); - - let answer = ''; - for (const item of response.output) { - if (item.type === 'message' && item.content) { - for (const block of item.content) { - if (block.type === 'output_text' && block.text) { - answer = block.text.trim(); - } - } - } - } - answer = - answer || 'The model returned no answer. Try rephrasing the query.'; - - return { - query, - answer, - chunks: searchResult.chunks, - model, - totalResults: searchResult.totalResults, - }; - } - : undefined, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/EvaluationService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/EvaluationService.test.ts deleted file mode 100644 index 396f9fb5c57..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/EvaluationService.test.ts +++ /dev/null @@ -1,537 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { RootConfigService } from '@backstage/backend-plugin-api'; -import { EvaluationService } from './EvaluationService'; -import type { ResponsesApiClient } from '../ResponsesApiClient'; -import { createMockLogger } from '../../../test-utils/mocks'; - -function createMockClient(): jest.Mocked { - return { - request: jest.fn(), - streamRequest: jest.fn(), - testConnection: jest.fn(), - getConfig: jest.fn(), - } as unknown as jest.Mocked; -} - -function makeConfig(overrides: Record = {}) { - const evalValues: Record = { - enabled: overrides.enabled ?? false, - scoringFunctions: overrides.scoringFunctions, - minScoreThreshold: overrides.minScoreThreshold, - onError: overrides.onError, - }; - - const evalConfig = { - getOptionalBoolean: jest.fn((key: string) => - key === 'enabled' ? evalValues.enabled : undefined, - ), - getOptionalStringArray: jest.fn((key: string) => evalValues[key]), - getOptionalNumber: jest.fn((key: string) => evalValues[key]), - getOptionalString: jest.fn((key: string) => evalValues[key]), - }; - - const llamaStackConfig = { - getString: jest.fn((key: string) => { - if (key === 'baseUrl') return 'http://localhost:8321'; - return ''; - }), - getOptionalString: jest.fn(() => undefined), - getOptionalBoolean: jest.fn(() => false), - }; - - return { - getOptionalConfig: jest.fn((key: string) => { - if (key === 'augment.llamaStack') return llamaStackConfig; - if (key === 'augment.evaluation') return evalConfig; - return undefined; - }), - } as unknown as RootConfigService; -} - -describe('EvaluationService', () => { - let mockLogger: ReturnType; - let mockClient: jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - mockLogger = createMockLogger(); - mockClient = createMockClient(); - }); - - // --------------------------------------------------------------------------- - // Basic state - // --------------------------------------------------------------------------- - - describe('isEnabled', () => { - it('returns false before initialization', () => { - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({}), - }); - expect(service.isEnabled()).toBe(false); - }); - }); - - describe('getAvailableScoringFunctions', () => { - it('returns empty array before initialization', () => { - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({}), - }); - expect(service.getAvailableScoringFunctions()).toEqual([]); - }); - }); - - // --------------------------------------------------------------------------- - // Initialization - // --------------------------------------------------------------------------- - - describe('initialize', () => { - it('handles missing llamaStack config gracefully', async () => { - const service = new EvaluationService({ - logger: mockLogger, - config: { - getOptionalConfig: jest.fn(() => undefined), - } as unknown as RootConfigService, - }); - - await service.initialize(); - expect(service.isEnabled()).toBe(false); - }); - - it('is idempotent', async () => { - const config = makeConfig({ enabled: false }); - const service = new EvaluationService({ - logger: mockLogger, - config, - }); - - await service.initialize(); - await service.initialize(); - expect(config.getOptionalConfig).toHaveBeenCalledTimes(2); - }); - - it('warns when enabled but no client accessor provided', async () => { - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - }); - - await service.initialize(); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('no ResponsesApiClient accessor provided'), - ); - }); - - it('loads scoring functions on init when enabled', async () => { - const fns = [ - { - identifier: 'basic::subset_of', - provider_id: 'basic', - provider_resource_id: '', - type: 'scoring', - description: 'Subset match', - }, - ]; - mockClient.request.mockResolvedValueOnce(fns); - - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - - await service.initialize(); - expect(service.isEnabled()).toBe(true); - expect(service.getAvailableScoringFunctions()).toEqual(fns); - expect(mockClient.request).toHaveBeenCalledWith('/v1/scoring-functions', { - method: 'GET', - }); - }); - - it('handles {data: [...]} response format', async () => { - const fns = [ - { - identifier: 'fn1', - provider_id: 'p', - provider_resource_id: '', - type: 's', - }, - ]; - mockClient.request.mockResolvedValueOnce({ data: fns }); - - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - - await service.initialize(); - expect(service.getAvailableScoringFunctions()).toEqual(fns); - }); - }); - - // --------------------------------------------------------------------------- - // scoreResponse - field names and scoring - // --------------------------------------------------------------------------- - - describe('scoreResponse', () => { - async function createEnabledService( - configOverrides: Record = {}, - ) { - const fns = [ - { - identifier: 'basic::subset_of', - provider_id: 'basic', - provider_resource_id: '', - type: 'scoring', - }, - ]; - mockClient.request.mockResolvedValueOnce(fns); - - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true, ...configOverrides }), - getClient: () => mockClient, - }); - - await service.initialize(); - return service; - } - - it('returns undefined when evaluation is disabled', async () => { - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: false }), - }); - const result = await service.scoreResponse('q', 'a'); - expect(result).toBeUndefined(); - }); - - it('sends correct Llama Stack field names (input_query, generated_answer)', async () => { - const service = await createEnabledService(); - - mockClient.request.mockResolvedValueOnce({ - results: { - 'basic::subset_of': { - score_rows: [{ score: 1.0 }], - }, - }, - }); - - await service.scoreResponse('What is 2+2?', '4'); - - const scoreCall = mockClient.request.mock.calls.find( - c => c[0] === '/v1/scoring/score', - ); - expect(scoreCall).toBeDefined(); - - const body = (scoreCall![1] as { body: Record }).body; - expect((body.input_rows as Record[])[0]).toEqual({ - input_query: 'What is 2+2?', - generated_answer: '4', - }); - expect( - (body.input_rows as Record[])[0], - ).not.toHaveProperty('input'); - expect( - (body.input_rows as Record[])[0], - ).not.toHaveProperty('generated_output'); - }); - - it('includes expected_answer when provided', async () => { - const service = await createEnabledService(); - - mockClient.request.mockResolvedValueOnce({ - results: { 'basic::subset_of': { score_rows: [{ score: 1.0 }] } }, - }); - - await service.scoreResponse('What is 2+2?', '4', undefined, '4'); - - const scoreCall = mockClient.request.mock.calls.find( - c => c[0] === '/v1/scoring/score', - ); - const body = ( - scoreCall![1] as { - body: { input_rows: Array<{ expected_answer?: string }> }; - } - ).body; - expect(body.input_rows[0].expected_answer).toBe('4'); - }); - - it('includes context when provided', async () => { - const service = await createEnabledService(); - - mockClient.request.mockResolvedValueOnce({ - results: { 'basic::subset_of': { score_rows: [{ score: 0.8 }] } }, - }); - - await service.scoreResponse('q', 'a', ['doc1', 'doc2']); - - const scoreCall = mockClient.request.mock.calls.find( - c => c[0] === '/v1/scoring/score', - ); - const body = ( - scoreCall![1] as { body: { input_rows: Array<{ context?: string }> } } - ).body; - expect(body.input_rows[0].context).toBe('doc1\ndoc2'); - }); - - it('parses direct score format (basic::subset_of style)', async () => { - const service = await createEnabledService(); - - mockClient.request.mockResolvedValueOnce({ - results: { - 'basic::subset_of': { - score_rows: [{ score: 1.0 }], - aggregated_results: { - accuracy: { accuracy: 1.0, num_correct: 1, num_total: 1 }, - }, - }, - }, - }); - - const result = await service.scoreResponse('What is 2+2?', '4'); - - expect(result).toBeDefined(); - expect(result!.overallScore).toBe(1.0); - expect(result!.scores['basic::subset_of']).toBe(1.0); - expect(result!.qualityLevel).toBe('excellent'); - expect(result!.passedThreshold).toBe(true); - }); - - it('parses nested scores format', async () => { - const service = await createEnabledService(); - - mockClient.request.mockResolvedValueOnce({ - results: { - 'basic::subset_of': { - score_rows: [ - { - scores: { - accuracy: { score_type: 'float', value: 0.8 }, - }, - }, - ], - }, - }, - }); - - const result = await service.scoreResponse('q', 'a'); - - expect(result!.overallScore).toBe(0.8); - expect(result!.scores['basic::subset_of:accuracy']).toBe(0.8); - }); - - it('computes average score across multiple functions', async () => { - const fns = [ - { - identifier: 'fn1', - provider_id: 'p', - provider_resource_id: '', - type: 's', - }, - { - identifier: 'fn2', - provider_id: 'p', - provider_resource_id: '', - type: 's', - }, - ]; - mockClient.request.mockResolvedValueOnce(fns); - - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - await service.initialize(); - - mockClient.request.mockResolvedValueOnce({ - results: { - fn1: { score_rows: [{ score: 1.0 }] }, - fn2: { score_rows: [{ score: 0.5 }] }, - }, - }); - - const result = await service.scoreResponse('q', 'a'); - - expect(result!.overallScore).toBe(0.75); - expect(result!.qualityLevel).toBe('good'); - }); - - it('uses configured scoring functions instead of all available', async () => { - const service = await createEnabledService({ - scoringFunctions: ['specific::fn'], - }); - - mockClient.request.mockResolvedValueOnce({ - results: { 'specific::fn': { score_rows: [{ score: 0.9 }] } }, - }); - - await service.scoreResponse('q', 'a'); - - const scoreCall = mockClient.request.mock.calls.find( - c => c[0] === '/v1/scoring/score', - ); - const body = ( - scoreCall![1] as { - body: { scoring_functions: Record }; - } - ).body; - expect(Object.keys(body.scoring_functions)).toEqual(['specific::fn']); - }); - - it('uses null (not {}) as scoring function config values', async () => { - const service = await createEnabledService(); - - mockClient.request.mockResolvedValueOnce({ - results: { 'basic::subset_of': { score_rows: [{ score: 1.0 }] } }, - }); - - await service.scoreResponse('q', 'a'); - - const scoreCall = mockClient.request.mock.calls.find( - c => c[0] === '/v1/scoring/score', - ); - const body = ( - scoreCall![1] as { - body: { scoring_functions: Record }; - } - ).body; - expect(body.scoring_functions['basic::subset_of']).toBeNull(); - }); - - it('respects minScoreThreshold', async () => { - const service = await createEnabledService({ minScoreThreshold: 0.9 }); - - mockClient.request.mockResolvedValueOnce({ - results: { 'basic::subset_of': { score_rows: [{ score: 0.8 }] } }, - }); - - const result = await service.scoreResponse('q', 'a'); - - expect(result!.passedThreshold).toBe(false); - expect(result!.overallScore).toBe(0.8); - }); - }); - - // --------------------------------------------------------------------------- - // onError behavior - // --------------------------------------------------------------------------- - - describe('onError behavior', () => { - it('returns undefined on API failure when onError is "skip" (default)', async () => { - const fns = [ - { - identifier: 'fn1', - provider_id: 'p', - provider_resource_id: '', - type: 's', - }, - ]; - mockClient.request - .mockResolvedValueOnce(fns) - .mockRejectedValueOnce(new Error('Scoring API down')); - - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - await service.initialize(); - - const result = await service.scoreResponse('q', 'a'); - expect(result).toBeUndefined(); - }); - - it('returns error result on API failure when onError is "fail"', async () => { - const fns = [ - { - identifier: 'fn1', - provider_id: 'p', - provider_resource_id: '', - type: 's', - }, - ]; - mockClient.request - .mockResolvedValueOnce(fns) - .mockRejectedValueOnce(new Error('Scoring API down')); - - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true, onError: 'fail' }), - getClient: () => mockClient, - }); - await service.initialize(); - - const result = await service.scoreResponse('q', 'a'); - expect(result).toBeDefined(); - expect(result!.overallScore).toBe(0); - expect(result!.passedThreshold).toBe(false); - expect(result!.error).toContain('Scoring API down'); - }); - }); - - // --------------------------------------------------------------------------- - // Quality levels - // --------------------------------------------------------------------------- - - describe('quality levels', () => { - async function scoreWith(scoreValue: number) { - const fns = [ - { - identifier: 'fn1', - provider_id: 'p', - provider_resource_id: '', - type: 's', - }, - ]; - mockClient.request.mockResolvedValueOnce(fns).mockResolvedValueOnce({ - results: { fn1: { score_rows: [{ score: scoreValue }] } }, - }); - - const service = new EvaluationService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - await service.initialize(); - return service.scoreResponse('q', 'a'); - } - - it('maps 0.95 to excellent', async () => { - const result = await scoreWith(0.95); - expect(result!.qualityLevel).toBe('excellent'); - }); - - it('maps 0.75 to good', async () => { - const result = await scoreWith(0.75); - expect(result!.qualityLevel).toBe('good'); - }); - - it('maps 0.55 to fair', async () => { - const result = await scoreWith(0.55); - expect(result!.qualityLevel).toBe('fair'); - }); - - it('maps 0.3 to poor', async () => { - const result = await scoreWith(0.3); - expect(result!.qualityLevel).toBe('poor'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/EvaluationService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/EvaluationService.ts deleted file mode 100644 index 938b2b0aa35..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/EvaluationService.ts +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - LoggerService, - RootConfigService, -} from '@backstage/backend-plugin-api'; -import type { - EvaluationConfig, - ScoringFunctionInfo, - ScoringResponse, - EvaluationResult, -} from '../../../types'; -import { toErrorMessage } from '../../../services/utils'; -import { - loadEvaluationConfig, - loadAvailableScoringFunctions, - getQualityLevel, -} from './evaluationConfigLoader'; - -export type { EvalClientAccessor } from './evaluationConfigLoader'; -import type { EvalClientAccessor } from './evaluationConfigLoader'; - -export class EvaluationService { - private readonly logger: LoggerService; - private readonly config: RootConfigService; - private readonly getClient?: EvalClientAccessor; - private evaluationConfig: EvaluationConfig | null = null; - private availableScoringFunctions: ScoringFunctionInfo[] = []; - private initialized = false; - - constructor(options: { - logger: LoggerService; - config: RootConfigService; - getClient?: EvalClientAccessor; - }) { - this.logger = options.logger; - this.config = options.config; - this.getClient = options.getClient; - } - - async initialize(): Promise { - if (this.initialized) { - return; - } - - try { - const llamaStackConfig = - this.config.getOptionalConfig('augment.llamaStack'); - if (!llamaStackConfig) { - this.logger.info( - 'augment.llamaStack not configured, evaluation service disabled', - ); - this.initialized = true; - return; - } - - this.evaluationConfig = loadEvaluationConfig(this.config, this.logger); - - if (!this.evaluationConfig?.enabled) { - this.logger.info('Response evaluation is disabled'); - this.initialized = true; - return; - } - - if (!this.getClient) { - this.logger.warn( - 'Evaluation enabled but no ResponsesApiClient accessor provided, scoring API calls will fail', - ); - this.initialized = true; - return; - } - - this.availableScoringFunctions = await loadAvailableScoringFunctions( - this.getClient, - this.logger, - ); - - this.logger.info( - `Evaluation service initialized with ${this.availableScoringFunctions.length} scoring function(s) available`, - ); - this.initialized = true; - } catch (error) { - this.logger.warn( - `Failed to initialize evaluation service: ${toErrorMessage(error)}`, - ); - this.initialized = true; - } - } - - private dynamicOverrides?: { - enabled?: boolean; - scoringFunctions?: string[]; - minScoreThreshold?: number; - onError?: 'skip' | 'fail'; - }; - - applyDynamicOverrides(overrides: { - evaluationEnabled?: boolean; - scoringFunctions?: string[]; - minScoreThreshold?: number; - evaluationOnError?: 'skip' | 'fail'; - }): void { - if ( - overrides.evaluationEnabled !== undefined || - overrides.scoringFunctions !== undefined || - overrides.minScoreThreshold !== undefined || - overrides.evaluationOnError !== undefined - ) { - this.dynamicOverrides = { - enabled: overrides.evaluationEnabled, - scoringFunctions: overrides.scoringFunctions, - minScoreThreshold: overrides.minScoreThreshold, - onError: overrides.evaluationOnError, - }; - } - } - - private isEffectivelyEnabled(): boolean { - if (this.dynamicOverrides?.enabled !== undefined) { - return this.dynamicOverrides.enabled; - } - return this.evaluationConfig?.enabled ?? false; - } - - private getEffectiveScoringFunctions(): string[] | undefined { - return ( - this.dynamicOverrides?.scoringFunctions ?? - this.evaluationConfig?.scoringFunctions - ); - } - - private getEffectiveThreshold(): number { - return ( - this.dynamicOverrides?.minScoreThreshold ?? - this.evaluationConfig?.minScoreThreshold ?? - 0.7 - ); - } - - isEnabled(): boolean { - return this.isEffectivelyEnabled(); - } - - getAvailableScoringFunctions(): ScoringFunctionInfo[] { - return this.availableScoringFunctions; - } - - getMinScoreThreshold(): number { - return this.getEffectiveThreshold(); - } - - async scoreResponse( - userInput: string, - aiResponse: string, - context?: string[], - expectedAnswer?: string, - ): Promise { - if (!this.isEffectivelyEnabled()) { - return undefined; - } - - if (this.availableScoringFunctions.length === 0) { - this.logger.debug('No scoring functions available, skipping evaluation'); - return undefined; - } - - if (!this.getClient) { - this.logger.warn('Cannot score response: no ResponsesApiClient accessor'); - return undefined; - } - - try { - const effectiveFns = this.getEffectiveScoringFunctions(); - const functionsToUse = effectiveFns?.length - ? effectiveFns - : this.availableScoringFunctions.map(f => f.identifier); - - const inputRow: Record = { - input_query: userInput, - generated_answer: aiResponse, - }; - - if (expectedAnswer) { - inputRow.expected_answer = expectedAnswer; - } - - if (context && context.length > 0) { - inputRow.context = context.join('\n'); - } - - const scoringRequest = { - input_rows: [inputRow], - scoring_functions: Object.fromEntries( - functionsToUse.map(fn => [fn, null]), - ), - }; - - this.logger.debug( - `Scoring response with functions: ${functionsToUse.join(', ')}`, - ); - - const client = this.getClient(); - const response = await client.request( - '/v1/scoring/score', - { - method: 'POST', - body: scoringRequest, - }, - ); - - const scores: Record = {}; - let totalScore = 0; - let scoreCount = 0; - - for (const [functionId, result] of Object.entries( - response.results || {}, - )) { - if (result.score_rows && result.score_rows.length > 0) { - const row = result.score_rows[0]; - if (row.scores && Object.keys(row.scores).length > 0) { - for (const [scoreName, scoreData] of Object.entries(row.scores)) { - const value = scoreData.value; - if (typeof value === 'number') { - scores[`${functionId}:${scoreName}`] = value; - totalScore += value; - scoreCount++; - } - } - } else if (typeof row.score === 'number') { - scores[functionId] = row.score; - totalScore += row.score; - scoreCount++; - } - } - } - - const overallScore = scoreCount > 0 ? totalScore / scoreCount : 0; - const threshold = this.getEffectiveThreshold(); - - const evaluationResult: EvaluationResult = { - overallScore, - scores, - passedThreshold: overallScore >= threshold, - qualityLevel: getQualityLevel(overallScore), - evaluatedAt: new Date().toISOString(), - }; - - this.logger.info( - `Response scored: ${overallScore.toFixed(2)} (${ - evaluationResult.qualityLevel - })`, - ); - - return evaluationResult; - } catch (error) { - const errorMsg = toErrorMessage(error); - this.logger.warn(`Failed to score response: ${errorMsg}`); - - const onError = - this.dynamicOverrides?.onError ?? - this.evaluationConfig?.onError ?? - 'skip'; - - if (onError === 'fail') { - this.logger.warn( - 'Evaluation failed - returning error result (onError: fail)', - ); - return { - overallScore: 0, - scores: {}, - passedThreshold: false, - qualityLevel: 'poor', - evaluatedAt: new Date().toISOString(), - error: `Evaluation failed: ${errorMsg}`, - }; - } - - this.logger.debug( - 'Evaluation failed - skipping evaluation (onError: skip)', - ); - return undefined; - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/SafetyService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/SafetyService.test.ts deleted file mode 100644 index 23ae8d72983..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/SafetyService.test.ts +++ /dev/null @@ -1,596 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { RootConfigService } from '@backstage/backend-plugin-api'; -import { SafetyService } from './SafetyService'; -import type { ResponsesApiClient } from '../ResponsesApiClient'; -import { createMockLogger } from '../../../test-utils/mocks'; - -function createMockClient(): jest.Mocked { - return { - request: jest.fn(), - streamRequest: jest.fn(), - testConnection: jest.fn(), - getConfig: jest.fn(), - } as unknown as jest.Mocked; -} - -function makeConfig(overrides: Record = {}) { - const safetyValues: Record = { - enabled: overrides.enabled ?? false, - inputShields: overrides.inputShields, - outputShields: overrides.outputShields, - onError: overrides.onError, - }; - - const registerShieldsData = overrides.registerShields as - | Array<{ shieldId: string; providerId: string; providerShieldId: string }> - | undefined; - - const safetyConfig = { - getOptionalBoolean: jest.fn((key: string) => - key === 'enabled' ? safetyValues.enabled : undefined, - ), - getOptionalStringArray: jest.fn((key: string) => safetyValues[key]), - getOptionalConfigArray: jest.fn(() => - registerShieldsData?.map(s => ({ - getString: jest.fn((key: string) => { - if (key === 'shieldId') return s.shieldId; - if (key === 'providerId') return s.providerId; - if (key === 'providerShieldId') return s.providerShieldId; - return ''; - }), - })), - ), - getOptionalString: jest.fn((key: string) => safetyValues[key]), - }; - - const llamaStackConfig = { - getString: jest.fn((key: string) => { - if (key === 'baseUrl') return 'http://localhost:8321'; - return ''; - }), - getOptionalString: jest.fn(() => undefined), - getOptionalBoolean: jest.fn(() => false), - }; - - return { - getConfig: jest.fn(() => llamaStackConfig), - getOptionalConfig: jest.fn((key: string) => { - if (key === 'augment.llamaStack') return llamaStackConfig; - if (key === 'augment.safety') return safetyConfig; - return undefined; - }), - } as unknown as RootConfigService; -} - -describe('SafetyService', () => { - let mockLogger: ReturnType; - let mockClient: jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - mockLogger = createMockLogger(); - mockClient = createMockClient(); - }); - - // --------------------------------------------------------------------------- - // Basic state - // --------------------------------------------------------------------------- - - describe('isEnabled', () => { - it('returns false before initialization', () => { - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({}), - }); - expect(service.isEnabled()).toBe(false); - }); - }); - - describe('getAvailableShields', () => { - it('returns empty array before initialization', () => { - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({}), - }); - expect(service.getAvailableShields()).toEqual([]); - }); - }); - - // --------------------------------------------------------------------------- - // Initialization - // --------------------------------------------------------------------------- - - describe('initialize', () => { - it('handles missing llamaStack config gracefully', async () => { - const configMock = { - getOptionalConfig: jest.fn(() => undefined), - } as unknown as RootConfigService; - - const service = new SafetyService({ - logger: mockLogger, - config: configMock, - }); - - await service.initialize(); - expect(service.isEnabled()).toBe(false); - expect(mockLogger.info).toHaveBeenCalledWith( - 'augment.llamaStack not configured, safety service disabled', - ); - }); - - it('is idempotent — second call is a no-op', async () => { - const configMock = makeConfig({ enabled: false }); - const service = new SafetyService({ - logger: mockLogger, - config: configMock, - }); - - await service.initialize(); - await service.initialize(); - - expect(configMock.getOptionalConfig).toHaveBeenCalledTimes(2); - }); - - it('does not throw on config errors (safety is optional)', async () => { - const configMock = { - getOptionalConfig: jest.fn(() => undefined), - } as unknown as RootConfigService; - - const service = new SafetyService({ - logger: mockLogger, - config: configMock, - }); - - await expect(service.initialize()).resolves.not.toThrow(); - expect(service.isEnabled()).toBe(false); - }); - - it('warns when safety is enabled but no client accessor provided', async () => { - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - }); - - await service.initialize(); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('no ResponsesApiClient accessor provided'), - ); - }); - - it('loads shields from Llama Stack on init when enabled', async () => { - const shields = [ - { - identifier: 'content_safety', - provider_id: 'llama-guard', - provider_resource_id: 'meta-llama/Llama-Guard-3-8B', - type: 'shield', - }, - ]; - mockClient.request.mockResolvedValueOnce(shields); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - - await service.initialize(); - expect(service.isEnabled()).toBe(true); - expect(service.getAvailableShields()).toEqual(shields); - expect(mockClient.request).toHaveBeenCalledWith('/v1/shields', { - method: 'GET', - }); - }); - - it('handles {data: [...]} response format for shields', async () => { - const shields = [ - { - identifier: 'shield1', - provider_id: 'p1', - provider_resource_id: 'pr1', - type: 'shield', - }, - ]; - mockClient.request.mockResolvedValueOnce({ data: shields }); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - - await service.initialize(); - expect(service.getAvailableShields()).toEqual(shields); - }); - - it('registers configured shields when none found on server', async () => { - mockClient.request - .mockResolvedValueOnce([]) // first GET /v1/shields - empty - .mockResolvedValueOnce({}) // POST /v1/shields/register - .mockResolvedValueOnce([ - // reload GET /v1/shields - { - identifier: 'my_shield', - provider_id: 'llama-guard', - provider_resource_id: 'lg', - type: 'shield', - }, - ]); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - registerShields: [ - { - shieldId: 'my_shield', - providerId: 'llama-guard', - providerShieldId: 'lg', - }, - ], - }), - getClient: () => mockClient, - }); - - await service.initialize(); - expect(mockClient.request).toHaveBeenCalledWith('/v1/shields/register', { - method: 'POST', - body: { - shield_id: 'my_shield', - provider_id: 'llama-guard', - provider_shield_id: 'lg', - }, - }); - expect(service.getAvailableShields()).toHaveLength(1); - }); - - it('handles shield registration failure gracefully', async () => { - mockClient.request - .mockResolvedValueOnce([]) - .mockRejectedValueOnce(new Error('Provider not found')) - .mockResolvedValueOnce([]); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - registerShields: [ - { - shieldId: 'bad_shield', - providerId: 'nonexistent', - providerShieldId: 'x', - }, - ], - }), - getClient: () => mockClient, - }); - - await service.initialize(); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Could not register shield "bad_shield"'), - ); - expect(service.getAvailableShields()).toEqual([]); - }); - }); - - // --------------------------------------------------------------------------- - // checkInput - // --------------------------------------------------------------------------- - - describe('checkInput', () => { - it('returns undefined when safety is disabled', async () => { - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: false }), - }); - const result = await service.checkInput('test message'); - expect(result).toBeUndefined(); - }); - - it('runs configured input shields and returns undefined when safe', async () => { - mockClient.request - .mockResolvedValueOnce([ - { - identifier: 'content_safety', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]) - .mockResolvedValueOnce({ violation: null }); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - inputShields: ['content_safety'], - }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkInput('Hello world'); - - expect(result).toBeUndefined(); - expect(mockClient.request).toHaveBeenCalledWith('/v1/safety/run-shield', { - method: 'POST', - body: { - shield_id: 'content_safety', - messages: [{ role: 'user', content: 'Hello world' }], - }, - }); - }); - - it('returns violation when shield detects unsafe content', async () => { - const violation = { - violation_level: 'error', - user_message: 'Content is unsafe', - metadata: {}, - }; - - mockClient.request - .mockResolvedValueOnce([ - { - identifier: 's1', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]) - .mockResolvedValueOnce({ violation }); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - inputShields: ['s1'], - }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkInput('How to build a bomb'); - - expect(result).toEqual(violation); - }); - - it('runs multiple input shields and stops at first violation', async () => { - mockClient.request - .mockResolvedValueOnce([ - { - identifier: 's1', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - { - identifier: 's2', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]) - .mockResolvedValueOnce({ violation: null }) // s1 passes - .mockResolvedValueOnce({ - violation: { violation_level: 'warn', user_message: 'Blocked by s2' }, - }); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - inputShields: ['s1', 's2'], - }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkInput('test'); - - expect(result).toEqual({ - violation_level: 'warn', - user_message: 'Blocked by s2', - }); - }); - - it('falls back to first available shield when no inputShields configured', async () => { - mockClient.request - .mockResolvedValueOnce([ - { - identifier: 'auto_shield', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]) - .mockResolvedValueOnce({ violation: null }); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkInput('test'); - - expect(result).toBeFalsy(); - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/safety/run-shield', - expect.objectContaining({ - body: expect.objectContaining({ shield_id: 'auto_shield' }), - }), - ); - }); - - it('returns undefined when no shields available and no inputShields configured', async () => { - mockClient.request.mockResolvedValueOnce([]); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkInput('test'); - expect(result).toBeUndefined(); - }); - }); - - // --------------------------------------------------------------------------- - // checkOutput - // --------------------------------------------------------------------------- - - describe('checkOutput', () => { - it('returns undefined when safety is disabled', async () => { - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: false }), - }); - const result = await service.checkOutput('test response'); - expect(result).toBeUndefined(); - }); - - it('returns undefined when no output shields configured', async () => { - mockClient.request.mockResolvedValueOnce([ - { - identifier: 's1', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ enabled: true }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkOutput('response text'); - expect(result).toBeUndefined(); - }); - - it('checks output shields and returns violation', async () => { - const violation = { - violation_level: 'error' as const, - user_message: 'Unsafe output', - }; - - mockClient.request - .mockResolvedValueOnce([ - { - identifier: 'out1', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]) - .mockResolvedValueOnce({ violation }); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - outputShields: ['out1'], - }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkOutput('malicious content'); - - expect(result).toEqual(violation); - expect(mockClient.request).toHaveBeenCalledWith('/v1/safety/run-shield', { - method: 'POST', - body: { - shield_id: 'out1', - messages: [{ role: 'user', content: 'malicious content' }], - }, - }); - }); - }); - - // --------------------------------------------------------------------------- - // onError behavior (fail-closed / fail-open) - // --------------------------------------------------------------------------- - - describe('onError behavior', () => { - it('blocks message on shield API failure when onError is "block" (default)', async () => { - mockClient.request - .mockResolvedValueOnce([ - { - identifier: 's1', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]) - .mockRejectedValueOnce(new Error('Connection refused')); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - inputShields: ['s1'], - }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkInput('test'); - - expect(result).toBeDefined(); - expect(result!.violation_level).toBe('error'); - expect(result!.user_message).toContain('Safety check unavailable'); - expect(result!.metadata?.error).toContain('Connection refused'); - }); - - it('allows message on shield API failure when onError is "allow"', async () => { - mockClient.request - .mockResolvedValueOnce([ - { - identifier: 's1', - provider_id: 'p', - provider_resource_id: 'r', - type: 's', - }, - ]) - .mockRejectedValueOnce(new Error('Connection refused')); - - const service = new SafetyService({ - logger: mockLogger, - config: makeConfig({ - enabled: true, - inputShields: ['s1'], - onError: 'allow', - }), - getClient: () => mockClient, - }); - - await service.initialize(); - const result = await service.checkInput('test'); - - expect(result).toBeUndefined(); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('allowing message (onError: allow)'), - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/SafetyService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/SafetyService.ts deleted file mode 100644 index 11bf39c441e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/SafetyService.ts +++ /dev/null @@ -1,282 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - LoggerService, - RootConfigService, -} from '@backstage/backend-plugin-api'; -import type { - SafetyConfig, - ShieldInfo, - SafetyViolation, - RunShieldResponse, -} from '../../../types'; -import { toErrorMessage } from '../../../services/utils'; -import { loadSafetyConfig, loadAvailableShields } from './safetyConfigLoader'; - -export type { SafetyClientAccessor } from './safetyConfigLoader'; -import type { SafetyClientAccessor } from './safetyConfigLoader'; - -export class SafetyService { - private readonly logger: LoggerService; - private readonly config: RootConfigService; - private readonly getClient?: SafetyClientAccessor; - private safetyConfig: SafetyConfig | null = null; - private availableShields: ShieldInfo[] = []; - private initialized = false; - - constructor(options: { - logger: LoggerService; - config: RootConfigService; - getClient?: SafetyClientAccessor; - }) { - this.logger = options.logger; - this.config = options.config; - this.getClient = options.getClient; - } - - async initialize(): Promise { - if (this.initialized) { - return; - } - - try { - const llamaStackConfig = - this.config.getOptionalConfig('augment.llamaStack'); - if (!llamaStackConfig) { - this.logger.info( - 'augment.llamaStack not configured, safety service disabled', - ); - this.initialized = true; - return; - } - - this.safetyConfig = loadSafetyConfig(this.config, this.logger); - - if (!this.safetyConfig?.enabled) { - this.logger.info('Safety guardrails are disabled'); - this.initialized = true; - return; - } - - if (!this.getClient) { - this.logger.warn( - 'Safety enabled but no ResponsesApiClient accessor provided, shield API calls will fail', - ); - this.initialized = true; - return; - } - - this.availableShields = await loadAvailableShields( - this.getClient, - this.safetyConfig, - this.logger, - ); - - this.logger.info( - `Safety service initialized with ${this.availableShields.length} shield(s) available`, - ); - this.initialized = true; - } catch (error) { - this.logger.warn( - `Failed to initialize safety service: ${toErrorMessage( - error, - )}. Safety checks will be skipped.`, - ); - this.safetyConfig = null; - this.initialized = true; - } - } - - private dynamicOverrides?: { - enabled?: boolean; - inputShields?: string[]; - outputShields?: string[]; - onError?: 'allow' | 'block'; - }; - - applyDynamicOverrides(overrides: { - safetyEnabled?: boolean; - inputShields?: string[]; - outputShields?: string[]; - safetyOnError?: 'allow' | 'block'; - }): void { - if ( - overrides.safetyEnabled !== undefined || - overrides.inputShields !== undefined || - overrides.outputShields !== undefined || - overrides.safetyOnError !== undefined - ) { - this.dynamicOverrides = { - enabled: overrides.safetyEnabled, - inputShields: overrides.inputShields, - outputShields: overrides.outputShields, - onError: overrides.safetyOnError, - }; - } - } - - private isEffectivelyEnabled(): boolean { - if (this.dynamicOverrides?.enabled !== undefined) { - return this.dynamicOverrides.enabled; - } - return this.safetyConfig?.enabled ?? false; - } - - private getEffectiveInputShields(): string[] | undefined { - return ( - this.dynamicOverrides?.inputShields ?? this.safetyConfig?.inputShields - ); - } - - private getEffectiveOutputShields(): string[] | undefined { - return ( - this.dynamicOverrides?.outputShields ?? this.safetyConfig?.outputShields - ); - } - - isEnabled(): boolean { - return this.isEffectivelyEnabled(); - } - - getAvailableShields(): ShieldInfo[] { - return this.availableShields; - } - - async checkInput(userMessage: string): Promise { - if (!this.isEffectivelyEnabled()) { - return undefined; - } - - const inputShields = this.getEffectiveInputShields(); - if (!inputShields || inputShields.length === 0) { - if (this.availableShields.length === 0) { - return undefined; - } - return this.runShield(this.availableShields[0].identifier, userMessage); - } - - const results = await Promise.all( - inputShields.map(shieldId => this.runShield(shieldId, userMessage)), - ); - for (let i = 0; i < results.length; i++) { - if (results[i]) { - this.logger.info( - `Input blocked by shield ${inputShields[i]}: ${results[i]!.user_message}`, - ); - return results[i]; - } - } - - return undefined; - } - - async checkOutput(aiResponse: string): Promise { - if (!this.isEffectivelyEnabled()) { - return undefined; - } - - const outputShields = this.getEffectiveOutputShields(); - if (!outputShields || outputShields.length === 0) { - return undefined; - } - - const results = await Promise.all( - outputShields.map(shieldId => this.runShield(shieldId, aiResponse)), - ); - for (let i = 0; i < results.length; i++) { - if (results[i]) { - this.logger.info( - `Output blocked by shield ${outputShields[i]}: ${results[i]!.user_message}`, - ); - return results[i]; - } - } - - return undefined; - } - - private static readonly SHIELD_TIMEOUT_MS = 5_000; - - private async runShield( - shieldId: string, - content: string, - ): Promise { - if (!this.getClient) { - this.logger.warn( - `Cannot run shield ${shieldId}: no ResponsesApiClient accessor`, - ); - return this.handleShieldError( - shieldId, - 'No ResponsesApiClient accessor available', - ); - } - - try { - const client = this.getClient(); - const response = await Promise.race([ - client.request('/v1/safety/run-shield', { - method: 'POST', - body: { - shield_id: shieldId, - messages: [{ role: 'user', content }], - }, - }), - new Promise((_, reject) => - setTimeout( - () => reject(new Error(`Shield ${shieldId} timed out`)), - SafetyService.SHIELD_TIMEOUT_MS, - ), - ), - ]); - - return response.violation ?? undefined; - } catch (error) { - const errorMsg = toErrorMessage(error); - this.logger.warn(`Failed to run shield ${shieldId}: ${errorMsg}`); - return this.handleShieldError(shieldId, errorMsg); - } - } - - private getEffectiveOnError(): 'allow' | 'block' { - return ( - this.dynamicOverrides?.onError ?? this.safetyConfig?.onError ?? 'block' - ); - } - - private handleShieldError( - shieldId: string, - errorMsg: string, - ): SafetyViolation | undefined { - const onError = this.getEffectiveOnError(); - - if (onError === 'block') { - this.logger.warn( - `Safety check failed for shield ${shieldId} - blocking message (onError: block)`, - ); - return { - violation_level: 'error', - user_message: `Safety check unavailable. Message blocked for security. (Shield: ${shieldId})`, - metadata: { error: errorMsg, shield_id: shieldId }, - }; - } - - this.logger.warn( - `Safety check failed for shield ${shieldId} - allowing message (onError: allow)`, - ); - return undefined; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/evaluationConfigLoader.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/evaluationConfigLoader.ts deleted file mode 100644 index 0bc5f95a076..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/evaluationConfigLoader.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - LoggerService, - RootConfigService, -} from '@backstage/backend-plugin-api'; -import type { ResponsesApiClient } from '../ResponsesApiClient'; -import type { EvaluationConfig, ScoringFunctionInfo } from '../../../types'; -import { toErrorMessage } from '../../../services/utils'; - -export type EvalClientAccessor = () => ResponsesApiClient; - -export function loadEvaluationConfig( - config: RootConfigService, - logger: LoggerService, -): EvaluationConfig | null { - try { - const evalConfig = config.getOptionalConfig('augment.evaluation'); - if (!evalConfig) { - return { enabled: false }; - } - - const onErrorValue = evalConfig.getOptionalString('onError'); - const onError: 'skip' | 'fail' = onErrorValue === 'fail' ? 'fail' : 'skip'; - - return { - enabled: evalConfig.getOptionalBoolean('enabled') ?? false, - scoringFunctions: evalConfig.getOptionalStringArray('scoringFunctions'), - minScoreThreshold: - evalConfig.getOptionalNumber('minScoreThreshold') ?? 0.7, - onError, - }; - } catch (error) { - logger.debug('No evaluation configuration found'); - return { enabled: false }; - } -} - -export async function loadAvailableScoringFunctions( - getClient: EvalClientAccessor, - logger: LoggerService, -): Promise { - try { - const client = getClient(); - const response = await client.request< - { data?: ScoringFunctionInfo[] } | ScoringFunctionInfo[] - >('/v1/scoring-functions', { method: 'GET' }); - - const functions = Array.isArray(response) ? response : response.data || []; - - if (functions.length > 0) { - logger.info( - `Scoring functions available: ${functions - .map(s => s.identifier) - .join(', ')}`, - ); - } else { - logger.debug('No scoring functions available on this Llama Stack server'); - } - - return functions; - } catch (error) { - logger.warn(`Could not load scoring functions: ${toErrorMessage(error)}`); - return []; - } -} - -export function getQualityLevel( - score: number, -): 'excellent' | 'good' | 'fair' | 'poor' { - if (score >= 0.9) return 'excellent'; - if (score >= 0.7) return 'good'; - if (score >= 0.5) return 'fair'; - return 'poor'; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/safetyConfigLoader.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/safetyConfigLoader.ts deleted file mode 100644 index efc65876890..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/safety/safetyConfigLoader.ts +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - LoggerService, - RootConfigService, -} from '@backstage/backend-plugin-api'; -import type { ResponsesApiClient } from '../ResponsesApiClient'; -import type { - SafetyConfig, - ShieldInfo, - ShieldRegistration, -} from '../../../types'; -import { toErrorMessage } from '../../../services/utils'; - -export type SafetyClientAccessor = () => ResponsesApiClient; - -export function loadSafetyConfig( - config: RootConfigService, - logger: LoggerService, -): SafetyConfig | null { - try { - const safetyConfig = config.getOptionalConfig('augment.safety'); - if (!safetyConfig) { - return { enabled: false }; - } - - const registerShieldsConfig = - safetyConfig.getOptionalConfigArray('registerShields'); - const registerShields: ShieldRegistration[] = []; - - if (registerShieldsConfig) { - for (const shieldConfig of registerShieldsConfig) { - registerShields.push({ - shieldId: shieldConfig.getString('shieldId'), - providerId: shieldConfig.getString('providerId'), - providerShieldId: shieldConfig.getString('providerShieldId'), - }); - } - } - - const onErrorValue = safetyConfig.getOptionalString('onError'); - const onError: 'allow' | 'block' = - onErrorValue === 'allow' ? 'allow' : 'block'; - - return { - enabled: safetyConfig.getOptionalBoolean('enabled') ?? false, - inputShields: safetyConfig.getOptionalStringArray('inputShields'), - outputShields: safetyConfig.getOptionalStringArray('outputShields'), - registerShields: registerShields.length > 0 ? registerShields : undefined, - onError, - }; - } catch (error) { - logger.debug('No safety configuration found'); - return { enabled: false }; - } -} - -export async function loadAvailableShields( - getClient: SafetyClientAccessor, - safetyConfig: SafetyConfig | null, - logger: LoggerService, -): Promise { - try { - const client = getClient(); - const response = await client.request< - { data?: ShieldInfo[] } | ShieldInfo[] - >('/v1/shields', { method: 'GET' }); - - let shields = Array.isArray(response) ? response : response.data || []; - - logger.debug(`Found ${shields.length} existing shields`); - - if (shields.length === 0) { - logger.info( - 'No shields found, attempting to register configured shields...', - ); - await registerConfiguredShields(getClient, safetyConfig, logger); - - const reloadResponse = await client.request< - { data?: ShieldInfo[] } | ShieldInfo[] - >('/v1/shields', { method: 'GET' }); - shields = Array.isArray(reloadResponse) - ? reloadResponse - : reloadResponse.data || []; - } - - if (shields.length > 0) { - logger.info( - `Safety shields available: ${shields - .map(s => s.identifier) - .join(', ')}`, - ); - } - - return shields; - } catch (error) { - logger.warn(`Could not load shields: ${toErrorMessage(error)}`); - return []; - } -} - -export async function registerConfiguredShields( - getClient: SafetyClientAccessor, - safetyConfig: SafetyConfig | null, - logger: LoggerService, -): Promise { - const shieldsToRegister = safetyConfig?.registerShields; - - if (!shieldsToRegister || shieldsToRegister.length === 0) { - logger.info( - 'No shields configured for registration (augment.safety.registerShields). ' + - 'Safety checks will only work with pre-existing shields on the server.', - ); - return; - } - - logger.info( - `Attempting to register ${shieldsToRegister.length} configured shield(s)...`, - ); - - const client = getClient(); - - for (const shield of shieldsToRegister) { - try { - await client.request('/v1/shields/register', { - method: 'POST', - body: { - shield_id: shield.shieldId, - provider_id: shield.providerId, - provider_shield_id: shield.providerShieldId, - }, - }); - logger.info( - `Registered shield: ${shield.shieldId} (provider: ${shield.providerId})`, - ); - } catch (error) { - const errorMsg = toErrorMessage(error); - logger.warn( - `Could not register shield "${shield.shieldId}": ${errorMsg}`, - ); - logger.info( - ` Make sure the "${shield.providerId}" provider is configured on your Llama Stack server`, - ); - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusAggregator.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusAggregator.test.ts deleted file mode 100644 index 8beb2b57f56..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusAggregator.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { aggregateStatus, type StatusAggregatorDeps } from './StatusAggregator'; -import { createMockLogger } from '../../../test-utils'; - -function createBaseDeps( - overrides: Partial = {}, -): StatusAggregatorDeps { - const mockStatus = { - providerId: 'llamastack', - provider: { - id: 'llamastack', - model: 'test-model', - baseUrl: 'https://localhost:8321', - connected: true, - }, - vectorStore: { id: 'default', connected: true }, - mcpServers: [], - securityMode: 'plugin-only' as const, - timestamp: '2025-01-01T00:00:00Z', - ready: true, - configurationErrors: [], - }; - - return { - llamaStackConfig: { - baseUrl: 'https://localhost:8321', - model: 'test-model', - vectorStoreIds: ['vs-1'], - vectorStoreName: 'test-store', - embeddingModel: 'all-MiniLM-L6-v2', - embeddingDimension: 384, - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - }, - resolved: { - baseUrl: 'https://localhost:8321', - model: 'test-model', - vectorStoreIds: ['vs-1'], - vectorStoreName: 'test-store', - embeddingModel: 'all-MiniLM-L6-v2', - embeddingDimension: 384, - systemPrompt: '', - enableWebSearch: false, - enableCodeInterpreter: false, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - skipTlsVerify: false, - zdrMode: false, - verboseStreamLogging: false, - }, - clientManager: {} as StatusAggregatorDeps['clientManager'], - mcpAuth: null, - mcpServers: [], - yamlMcpServers: [], - securityConfig: { mode: 'plugin-only' as const }, - vectorStoreReady: true, - statusService: { - getStatus: jest.fn().mockResolvedValue(mockStatus), - } as unknown as StatusAggregatorDeps['statusService'], - logger: createMockLogger(), - ...overrides, - }; -} - -describe('StatusAggregator', () => { - describe('aggregateStatus', () => { - it('delegates to statusService when llamaStackConfig is null', async () => { - const deps = createBaseDeps({ llamaStackConfig: null }); - await aggregateStatus(deps); - - expect(deps.statusService.getStatus).toHaveBeenCalledWith( - expect.objectContaining({ - config: null, - mcpServers: deps.yamlMcpServers, - }), - ); - }); - - it('merges resolved config into statusConfig when llamaStackConfig is set', async () => { - const deps = createBaseDeps(); - await aggregateStatus(deps); - - expect(deps.statusService.getStatus).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - model: 'test-model', - baseUrl: 'https://localhost:8321', - vectorStoreIds: ['vs-1'], - vectorStoreName: 'test-store', - embeddingModel: 'all-MiniLM-L6-v2', - }), - }), - ); - }); - - it('uses resolved model/baseUrl over llamaStackConfig values', async () => { - const deps = createBaseDeps({ - llamaStackConfig: { - baseUrl: 'https://yaml-url:8321', - model: 'yaml-model', - vectorStoreIds: [], - vectorStoreName: 'yaml-store', - embeddingModel: 'yaml-embed', - embeddingDimension: 384, - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - }, - resolved: { - baseUrl: 'https://admin-override:8321', - model: 'admin-model', - vectorStoreIds: ['vs-admin'], - vectorStoreName: 'admin-store', - embeddingModel: 'admin-embed', - embeddingDimension: 768, - systemPrompt: '', - enableWebSearch: false, - enableCodeInterpreter: false, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - skipTlsVerify: false, - zdrMode: false, - verboseStreamLogging: false, - }, - }); - - await aggregateStatus(deps); - - const call = (deps.statusService.getStatus as jest.Mock).mock.calls[0][0]; - expect(call.config.model).toBe('admin-model'); - expect(call.config.baseUrl).toBe('https://admin-override:8321'); - expect(call.config.vectorStoreIds).toEqual(['vs-admin']); - expect(call.config.vectorStoreName).toBe('admin-store'); - expect(call.config.embeddingModel).toBe('admin-embed'); - }); - - it('passes yamlServerIds derived from yamlMcpServers', async () => { - const yamlServers = [ - { id: 'server-1', name: 'S1', url: 'https://s1' }, - { id: 'server-2', name: 'S2', url: 'https://s2' }, - ] as StatusAggregatorDeps['yamlMcpServers']; - - const deps = createBaseDeps({ yamlMcpServers: yamlServers }); - await aggregateStatus(deps); - - const call = (deps.statusService.getStatus as jest.Mock).mock.calls[0][0]; - expect(call.yamlServerIds).toEqual(new Set(['server-1', 'server-2'])); - }); - - it('passes vectorStoreReady and securityConfig through', async () => { - const deps = createBaseDeps({ - vectorStoreReady: false, - securityConfig: { mode: 'full' as const }, - }); - - await aggregateStatus(deps); - - const call = (deps.statusService.getStatus as jest.Mock).mock.calls[0][0]; - expect(call.vectorStoreReady).toBe(false); - expect(call.securityConfig.mode).toBe('full'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusAggregator.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusAggregator.ts deleted file mode 100644 index 16d1bfc5815..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusAggregator.ts +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - LlamaStackConfig, - EffectiveConfig, - AugmentStatus, - MCPServerConfig, - SecurityConfig, -} from '../../../types'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ClientManager } from '../ClientManager'; -import type { McpAuthService } from '../McpAuthService'; -import type { StatusService } from './StatusService'; - -/** - * Dependencies for aggregating status. - * The orchestrator resolves config and passes a snapshot; the aggregator - * builds StatusDeps and delegates to StatusService. - */ -export interface StatusAggregatorDeps { - /** YAML baseline config; null when Llama Stack is not configured */ - llamaStackConfig: LlamaStackConfig | null; - /** Resolved config (YAML + DB overrides) when llamaStackConfig is not null */ - resolved?: EffectiveConfig; - clientManager: ClientManager; - mcpAuth: McpAuthService | null; - /** Effective MCP servers (resolved.mcpServers ?? yamlMcpServers when config exists) */ - mcpServers: MCPServerConfig[]; - /** MCP servers from YAML, used to derive yamlServerIds */ - yamlMcpServers: MCPServerConfig[]; - securityConfig: SecurityConfig; - vectorStoreReady: boolean; - statusService: StatusService; - logger: LoggerService; - /** Tool execution mode from YAML config */ - toolExecutionMode?: 'direct' | 'backend'; - /** Error from last agent graph resolution, if any */ - agentGraphError?: string | null; -} - -function checkToolNameConflicts( - mcpServers: NonNullable, - logger: LoggerService, -): void { - const toolNameToServers = new Map(); - for (const server of mcpServers) { - if (!server.connected || !server.tools) continue; - for (const tool of server.tools) { - const list = toolNameToServers.get(tool.name) ?? []; - list.push(server.id); - toolNameToServers.set(tool.name, list); - } - } - const conflicts = [...toolNameToServers.entries()].filter( - ([, servers]) => servers.length > 1, - ); - if (conflicts.length > 0) { - const details = conflicts - .map(([name, servers]) => `${name} on [${servers.join(', ')}]`) - .join('; '); - logger.warn( - `Tool name conflict detected in direct mode: ${details}. ` + - `Set augment.toolExecutionMode: backend to resolve.`, - ); - } -} - -/** - * Aggregates status by resolving config, building StatusDeps, and delegating - * to StatusService. Extracted from ResponsesApiCoordinator.getStatus() to - * keep the orchestrator focused on orchestration. - * - * When llamaStackConfig is null, returns status for "not configured". - * When llamaStackConfig is set, resolved must be provided to merge - * admin overrides into the status config. - */ -export async function aggregateStatus( - deps: StatusAggregatorDeps, -): Promise { - const { - llamaStackConfig, - resolved, - clientManager, - mcpAuth, - mcpServers, - yamlMcpServers, - securityConfig, - vectorStoreReady, - statusService, - logger, - } = deps; - - if (!llamaStackConfig) { - return statusService.getStatus({ - config: null, - clientManager, - mcpAuth, - mcpServers: yamlMcpServers, - yamlServerIds: new Set(yamlMcpServers.map(s => s.id)), - securityConfig, - vectorStoreReady, - logger, - }); - } - - if (!resolved) { - throw new Error('resolved config is required when llamaStackConfig is set'); - } - - const statusConfig: LlamaStackConfig = { - ...llamaStackConfig, - model: resolved.model, - baseUrl: resolved.baseUrl, - vectorStoreIds: resolved.vectorStoreIds, - vectorStoreName: resolved.vectorStoreName, - embeddingModel: resolved.embeddingModel, - }; - - const status = await statusService.getStatus({ - config: statusConfig, - clientManager, - mcpAuth, - mcpServers, - yamlServerIds: new Set(yamlMcpServers.map(s => s.id)), - securityConfig, - vectorStoreReady, - logger, - }); - - if (resolved.agents && Object.keys(resolved.agents).length > 0) { - status.agents = Object.entries(resolved.agents).map(([key, agent]) => ({ - key, - name: agent.name, - isDefault: key === resolved.defaultAgent, - })); - } - - if (deps.toolExecutionMode) { - status.toolExecutionMode = deps.toolExecutionMode; - } - - if (deps.agentGraphError) { - status.agentGraphError = deps.agentGraphError; - } - - if (deps.toolExecutionMode === 'direct' && status.mcpServers) { - checkToolNameConflicts(status.mcpServers, logger); - } - - return status; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusService.test.ts deleted file mode 100644 index abfc248b308..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusService.test.ts +++ /dev/null @@ -1,396 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { StatusService, StatusDeps } from './StatusService'; -import type { ClientManager } from '../ClientManager'; -import type { McpAuthService } from '../McpAuthService'; -import type { LlamaStackConfig, MCPServerConfig } from '../../../types'; - -jest.mock('../../../services/utils/mcpClient', () => ({ - listMcpServerTools: jest.fn(), -})); - -import { listMcpServerTools } from '../../../services/utils/mcpClient'; - -const mockListMcpServerTools = listMcpServerTools as jest.MockedFunction< - typeof listMcpServerTools ->; - -function createMockClient(overrides?: { - healthOk?: boolean; - healthError?: Error; - vectorStoreOk?: boolean; - vectorStoreTotal?: number; - models?: string[]; -}) { - return { - request: jest.fn().mockImplementation((path: string) => { - if (path === '/v1/health') { - if (overrides?.healthError) throw overrides.healthError; - if (overrides?.healthOk === false) - throw new Error('Connection refused'); - return Promise.resolve({ status: 'ok' }); - } - if (path === '/v1/models') { - if (overrides?.healthError) throw overrides.healthError; - if (overrides?.healthOk === false) - throw new Error('Connection refused'); - const modelList = overrides?.models ?? ['test-model']; - return Promise.resolve({ - data: modelList.map(id => ({ id })), - }); - } - if (path.startsWith('/v1/vector_stores/')) { - if (overrides?.vectorStoreOk === false) throw new Error('Not found'); - return Promise.resolve({ - file_counts: { total: overrides?.vectorStoreTotal ?? 5 }, - }); - } - return Promise.resolve({}); - }), - }; -} - -function createMockClientManager( - client: ReturnType, - hasClient = true, -): ClientManager { - return { - hasClient: jest.fn().mockReturnValue(hasClient), - getExistingClient: jest.fn().mockReturnValue(client), - getClient: jest.fn().mockReturnValue(client), - invalidate: jest.fn(), - } as unknown as ClientManager; -} - -const mockLogger = { - child: () => mockLogger, - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -} as unknown as import('@backstage/backend-plugin-api').LoggerService; - -function baseDeps(overrides?: Partial): StatusDeps { - const client = createMockClient(); - return { - config: { - baseUrl: 'https://llama.test', - model: 'test-model', - vectorStoreIds: ['vs_1'], - vectorStoreName: 'test-db', - embeddingModel: 'all-MiniLM-L6-v2', - embeddingDimension: 384, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 200, - chunkOverlapTokens: 50, - } as LlamaStackConfig, - clientManager: createMockClientManager(client), - mcpAuth: null, - mcpServers: [], - yamlServerIds: new Set(), - securityConfig: { mode: 'plugin-only' }, - vectorStoreReady: true, - logger: mockLogger, - ...overrides, - }; -} - -describe('StatusService', () => { - let service: StatusService; - - beforeEach(() => { - service = new StatusService(); - mockListMcpServerTools.mockReset(); - }); - - // =========================================================================== - // Not-configured state - // =========================================================================== - - it('returns not-configured when config is null', async () => { - const status = await service.getStatus(baseDeps({ config: null })); - - expect(status.ready).toBe(false); - expect(status.provider.connected).toBe(false); - expect(status.provider.error).toContain('not configured'); - expect(status.configurationErrors).toHaveLength(1); - }); - - it('returns not-configured when client manager has no client', async () => { - const client = createMockClient(); - const deps = baseDeps({ - clientManager: createMockClientManager(client, false), - }); - const status = await service.getStatus(deps); - - expect(status.ready).toBe(false); - expect(status.provider.connected).toBe(false); - }); - - // =========================================================================== - // Healthy state - // =========================================================================== - - it('returns healthy status when provider and vector store are ok', async () => { - const status = await service.getStatus(baseDeps()); - - expect(status.ready).toBe(true); - expect(status.provider.connected).toBe(true); - expect(status.provider.model).toBe('test-model'); - expect(status.vectorStore.connected).toBe(true); - expect(status.vectorStore.totalDocuments).toBe(5); - expect(status.configurationErrors).toHaveLength(0); - }); - - it('includes capabilities in healthy status', async () => { - const status = await service.getStatus(baseDeps()); - - expect(status.capabilities?.chat).toBe(true); - expect(status.capabilities?.rag.available).toBe(true); - expect(status.capabilities?.mcpTools.available).toBe(false); - expect(status.capabilities?.mcpTools.reason).toBe( - 'No MCP servers configured', - ); - }); - - // =========================================================================== - // Provider health failures - // =========================================================================== - - it('reports provider disconnected on health check failure', async () => { - const client = createMockClient({ healthOk: false }); - const deps = baseDeps({ - clientManager: createMockClientManager(client), - }); - const status = await service.getStatus(deps); - - expect(status.provider.connected).toBe(false); - expect(status.provider.error).toContain('Connection refused'); - expect(status.ready).toBe(false); - expect(status.configurationErrors).toHaveLength(1); - }); - - // =========================================================================== - // Vector store health - // =========================================================================== - - it('reports vector store not ready when not initialized', async () => { - const status = await service.getStatus( - baseDeps({ vectorStoreReady: false }), - ); - - expect(status.vectorStore.connected).toBe(false); - expect(status.vectorStore.error).toContain('Not initialized'); - }); - - it('reports vector store error when no IDs configured', async () => { - const deps = baseDeps({ - config: { - ...baseDeps().config!, - vectorStoreIds: [], - } as LlamaStackConfig, - }); - const status = await service.getStatus(deps); - - expect(status.vectorStore.connected).toBe(false); - expect(status.vectorStore.error).toContain('No vector store IDs'); - }); - - it('reports vector store error on fetch failure', async () => { - const client = createMockClient({ vectorStoreOk: false }); - const deps = baseDeps({ - clientManager: createMockClientManager(client), - }); - const status = await service.getStatus(deps); - - expect(status.vectorStore.connected).toBe(false); - }); - - // =========================================================================== - // MCP server health (using SDK mock) - // =========================================================================== - - it('reports MCP server as connected when health check succeeds', async () => { - const mcpServers: MCPServerConfig[] = [ - { - id: 'mcp-1', - name: 'Test MCP', - type: 'streamable-http', - url: 'http://mcp.test/sse', - }, - ]; - - mockListMcpServerTools.mockResolvedValue([ - { name: 'test-tool', description: 'A test tool' }, - ]); - - const status = await service.getStatus(baseDeps({ mcpServers })); - - expect(status.mcpServers).toHaveLength(1); - expect(status.mcpServers[0].connected).toBe(true); - expect(status.mcpServers[0].toolCount).toBe(1); - expect(status.mcpServers[0].tools).toEqual([ - { name: 'test-tool', description: 'A test tool' }, - ]); - expect(status.capabilities?.mcpTools.available).toBe(true); - expect(mockListMcpServerTools).toHaveBeenCalledWith( - 'http://mcp.test/sse', - expect.objectContaining({ clientName: 'augment-status' }), - ); - }); - - it('reports MCP server as disconnected when health check fails', async () => { - const mcpServers: MCPServerConfig[] = [ - { - id: 'mcp-1', - name: 'Test MCP', - type: 'streamable-http', - url: 'http://mcp.test/sse', - }, - ]; - - mockListMcpServerTools.mockRejectedValue(new Error('Connection refused')); - - const status = await service.getStatus(baseDeps({ mcpServers })); - - expect(status.mcpServers[0].connected).toBe(false); - expect(status.mcpServers[0].error).toBe('Connection refused'); - expect(status.capabilities?.mcpTools.available).toBe(false); - expect(status.capabilities?.mcpTools.reason).toContain('not reachable'); - }); - - it('reports MCP server error on network failure', async () => { - const mcpServers: MCPServerConfig[] = [ - { - id: 'mcp-1', - name: 'Test MCP', - type: 'streamable-http', - url: 'http://mcp.test/sse', - }, - ]; - - mockListMcpServerTools.mockRejectedValue(new Error('ECONNREFUSED')); - - const status = await service.getStatus(baseDeps({ mcpServers })); - - expect(status.mcpServers[0].connected).toBe(false); - expect(status.mcpServers[0].error).toBe('ECONNREFUSED'); - }); - - it('includes MCP auth headers when mcpAuth is provided', async () => { - const mcpServers: MCPServerConfig[] = [ - { - id: 'mcp-1', - name: 'Test MCP', - type: 'streamable-http', - url: 'http://mcp.test/sse', - }, - ]; - - const mockMcpAuth = { - getServerHeaders: jest - .fn() - .mockResolvedValue({ Authorization: 'Bearer tok' }), - }; - - mockListMcpServerTools.mockResolvedValue([]); - - await service.getStatus( - baseDeps({ - mcpServers, - mcpAuth: mockMcpAuth as unknown as McpAuthService, - }), - ); - - expect(mockMcpAuth.getServerHeaders).toHaveBeenCalledWith(mcpServers[0]); - expect(mockListMcpServerTools).toHaveBeenCalledWith( - 'http://mcp.test/sse', - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: 'Bearer tok' }), - }), - ); - }); - - it('tags MCP servers with correct source', async () => { - const mcpServers: MCPServerConfig[] = [ - { - id: 'yaml-srv', - name: 'YAML', - type: 'streamable-http', - url: 'http://yaml.test', - }, - { - id: 'admin-srv', - name: 'Admin', - type: 'sse', - url: 'http://admin.test', - }, - ]; - - mockListMcpServerTools.mockResolvedValue([]); - - const status = await service.getStatus( - baseDeps({ - mcpServers, - yamlServerIds: new Set(['yaml-srv']), - }), - ); - - expect(status.mcpServers[0].source).toBe('yaml'); - expect(status.mcpServers[1].source).toBe('admin'); - }); - - // =========================================================================== - // Security mode - // =========================================================================== - - it('reflects the security mode in status', async () => { - const status = await service.getStatus( - baseDeps({ securityConfig: { mode: 'full' } }), - ); - expect(status.securityMode).toBe('full'); - }); - - // =========================================================================== - // Parallel health checks - // =========================================================================== - - it('runs all health checks in parallel', async () => { - const mcpServers: MCPServerConfig[] = [ - { - id: 'mcp-1', - name: 'A', - type: 'streamable-http', - url: 'http://a.test/sse', - }, - { - id: 'mcp-2', - name: 'B', - type: 'streamable-http', - url: 'http://b.test/sse', - }, - ]; - - mockListMcpServerTools.mockResolvedValue([]); - - const status = await service.getStatus(baseDeps({ mcpServers })); - - expect(status.provider.connected).toBe(true); - expect(status.vectorStore.connected).toBe(true); - expect(status.mcpServers).toHaveLength(2); - expect(status.mcpServers.every(s => s.connected)).toBe(true); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusService.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusService.ts deleted file mode 100644 index 19f28f93c42..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/status/StatusService.ts +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { toErrorMessage, listMcpServerTools } from '../../../services/utils'; -import type { - LlamaStackConfig, - AugmentStatus, - LlamaStackVectorStoreResponse, - MCPServerConfig, - MCPServerStatus, - SecurityConfig, -} from '../../../types'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ClientManager } from '../ClientManager'; -import type { McpAuthService } from '../McpAuthService'; - -/** - * Dependencies passed per getStatus() call so the service - * always operates on the latest orchestrator state. - */ -export interface StatusDeps { - config: LlamaStackConfig | null; - clientManager: ClientManager; - mcpAuth: McpAuthService | null; - mcpServers: MCPServerConfig[]; - /** IDs of servers that originate from YAML (vs admin-added) */ - yamlServerIds: Set; - securityConfig: SecurityConfig; - vectorStoreReady: boolean; - logger: LoggerService; -} - -/** - * Encapsulates all status and health-check logic that was previously - * in ResponsesApiCoordinator. Each getStatus() call receives a fresh - * StatusDeps snapshot so there are no stale references. - */ -export class StatusService { - async getStatus(deps: StatusDeps): Promise { - const { config, clientManager, securityConfig, mcpServers } = deps; - - if (!config || !clientManager.hasClient()) { - return { - providerId: 'llamastack', - provider: { - id: 'llamastack', - model: 'not configured', - baseUrl: 'not configured', - connected: false, - error: 'Llama Stack not configured', - }, - vectorStore: { - id: 'not configured', - connected: false, - error: 'Llama Stack not configured', - }, - mcpServers: [], - securityMode: securityConfig.mode, - timestamp: new Date().toISOString(), - ready: false, - configurationErrors: [ - 'Llama Stack not configured. Add augment.llamaStack.baseUrl to your app-config.yaml', - ], - }; - } - - const [providerResult, vectorStoreResult, mcpServerStatuses] = - await Promise.all([ - this.checkProviderHealth(deps), - this.checkVectorStoreHealth(deps), - this.checkMcpServerHealth(deps), - ]); - - const configurationErrors: string[] = []; - if (!providerResult.connected) { - configurationErrors.push( - `AI Provider (Llama Stack) not connected: ${ - providerResult.error || 'Unknown error' - }. Check augment.llamaStack.baseUrl in app-config.yaml`, - ); - } else if (!providerResult.modelAvailable) { - configurationErrors.push( - `Model "${config.model}" not found on the Llama Stack server. Chat may fail. Update the model in the Admin Panel.`, - ); - } - - const mcpAvailable = mcpServerStatuses.some(s => s.connected); - const mcpConfigured = mcpServers.length > 0; - - const providerError = - providerResult.error ?? - (!providerResult.modelAvailable && providerResult.connected - ? `Model "${config.model}" not found on server` - : undefined); - - return { - providerId: 'llamastack', - provider: { - id: 'llamastack', - model: config.model, - baseUrl: config.baseUrl, - connected: providerResult.connected, - error: providerError, - }, - vectorStore: { - id: config.vectorStoreIds.join(', ') || 'not configured', - connected: vectorStoreResult.connected, - totalDocuments: vectorStoreResult.totalDocuments, - error: vectorStoreResult.error, - }, - mcpServers: mcpServerStatuses, - securityMode: securityConfig.mode, - timestamp: new Date().toISOString(), - ready: providerResult.connected, - configurationErrors, - capabilities: { - chat: providerResult.connected, - rag: { - available: vectorStoreResult.connected, - reason: !vectorStoreResult.connected - ? vectorStoreResult.error || - 'Vector store not initialized — trigger a sync to set up' - : undefined, - }, - mcpTools: { - available: mcpAvailable, - reason: getMcpToolsReason( - mcpAvailable, - mcpConfigured, - mcpServerStatuses, - ), - }, - agentCatalog: true, - agentSelection: false, - agentCards: false, - }, - }; - } - - private async checkProviderHealth( - deps: StatusDeps, - ): Promise<{ connected: boolean; modelAvailable: boolean; error?: string }> { - try { - const response = await deps.clientManager - .getExistingClient() - .request<{ data: Array<{ id: string }> }>('/v1/models', { - method: 'GET', - }); - - const models = response.data || []; - const modelAvailable = deps.config - ? models.some(m => m.id === deps.config!.model) - : false; - - return { connected: true, modelAvailable }; - } catch (error) { - return { - connected: false, - modelAvailable: false, - error: toErrorMessage(error, 'Failed to connect'), - }; - } - } - - private async checkVectorStoreHealth( - deps: StatusDeps, - ): Promise<{ connected: boolean; totalDocuments?: number; error?: string }> { - if (!deps.vectorStoreReady) { - return { - connected: false, - error: 'Not initialized yet — will be created on first sync', - }; - } - - try { - const storeIds = deps.config!.vectorStoreIds; - if (!storeIds || storeIds.length === 0) { - return { connected: false, error: 'No vector store IDs configured' }; - } - const vectorStoreInfo = await deps.clientManager - .getExistingClient() - .request( - `/v1/vector_stores/${storeIds[0]}`, - { method: 'GET' }, - ); - return { - connected: true, - totalDocuments: vectorStoreInfo.file_counts?.total, - }; - } catch (error) { - return { - connected: false, - error: toErrorMessage(error, 'Failed to connect'), - }; - } - } - - private async checkMcpServerHealth( - deps: StatusDeps, - ): Promise { - return Promise.all( - deps.mcpServers.map(async server => { - const source: 'yaml' | 'admin' = deps.yamlServerIds.has(server.id) - ? 'yaml' - : 'admin'; - try { - const headers: Record = { - ...(server.headers || {}), - }; - - if (deps.mcpAuth) { - const authHeaders = await deps.mcpAuth.getServerHeaders(server); - Object.assign(headers, authHeaders); - } - - const skipTls = deps.config?.skipTlsVerify ?? false; - const tools = await listMcpServerTools(server.url, { - headers, - skipTlsVerify: skipTls, - clientName: 'augment-status', - }); - - return { - id: server.id, - name: server.name, - url: server.url, - connected: true, - tools: tools.map(t => ({ - name: t.name, - description: t.description, - })), - toolCount: tools.length, - source, - }; - } catch (error) { - return { - id: server.id, - name: server.name, - url: server.url, - connected: false, - error: toErrorMessage(error, 'Failed to connect'), - source, - }; - } - }), - ); - } -} - -function getMcpToolsReason( - mcpAvailable: boolean, - mcpConfigured: boolean, - mcpServerStatuses: { id: string; connected: boolean }[], -): string | undefined { - if (mcpAvailable) return undefined; - if (mcpConfigured) { - const unreachable = mcpServerStatuses - .filter(s => !s.connected) - .map(s => s.id) - .join(', '); - return `MCP servers configured but not reachable: ${unreachable}`; - } - return 'No MCP servers configured'; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/WorkflowExecutor.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/WorkflowExecutor.ts deleted file mode 100644 index f27703023f9..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/WorkflowExecutor.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Re-export from modular executor package. - * All implementation lives in ./executor/ directory. - */ -export { - WorkflowExecutor, - type ExecutionTraceRecord, - type WorkflowExecutionResult, - type NodeExecutionResult, - type StreamEventEmitter, -} from './executor'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/WorkflowHydrator.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/WorkflowHydrator.ts deleted file mode 100644 index 7b84ad39013..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/WorkflowHydrator.ts +++ /dev/null @@ -1,251 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { Agent, Runner } from '@openai/agents-core'; -import type { - FunctionTool as AgentsFunctionTool, - Tool, -} from '@openai/agents-core'; -import type { - WorkflowDefinition, - WorkflowNode, - WorkflowEdge, - AgentNodeData, - NodeExecutionRecord, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ModelProvider } from '@openai/agents-core'; -import { buildExecutionPlan } from './workflowExecutionPlan'; -import { resolveToolsForAgent } from './workflowToolResolver'; - -export type { - WorkflowExecutionPlan, - ExecutionStep, -} from './workflowExecutionPlan'; - -export interface HydratedWorkflow { - runner: Runner; - entryAgent: Agent; - agents: Map; - maxTurns: number; - executionPlan: import('./workflowExecutionPlan').WorkflowExecutionPlan; -} - -export class WorkflowHydrator { - constructor(private readonly logger: LoggerService) {} - - hydrate( - definition: WorkflowDefinition, - modelProvider: ModelProvider, - backendTools: AgentsFunctionTool[], - ): HydratedWorkflow { - const { nodes, edges, settings } = definition; - - const nodeMap = new Map(); - for (const node of nodes) { - nodeMap.set(node.id, node); - } - - const agentNodes = nodes.filter(n => n.type === 'agent'); - const classifyNodes = nodes.filter(n => n.type === 'classify'); - - if (agentNodes.length === 0 && classifyNodes.length === 0) { - throw new Error( - 'Workflow must contain at least one agent or classify node', - ); - } - - const agents = new Map(); - const agentNodeDataMap = new Map(); - - for (const node of agentNodes) { - const data = node.data as AgentNodeData; - agentNodeDataMap.set(node.id, data); - - const nodeTools = resolveToolsForAgent( - node.id, - edges, - nodeMap, - backendTools, - this.logger, - ); - - agents.set( - node.id, - new Agent({ - name: data.name || data.agentKey, - instructions: data.instructions || '', - model: data.model || settings.defaultModel, - tools: nodeTools, - modelSettings: { - temperature: data.temperature, - maxTokens: data.maxOutputTokens, - toolChoice: data.toolChoice as - | 'auto' - | 'required' - | 'none' - | undefined, - }, - handoffDescription: data.handoffDescription, - resetToolChoice: data.resetToolChoice, - toolUseBehavior: - data.toolUseBehavior === 'stop_on_first_tool' - ? 'stop_on_first_tool' - : undefined, - }), - ); - } - - for (const node of classifyNodes) { - const data = node.data as Record; - const classifications = - (data.classifications as Array<{ - label: string; - description: string; - }>) || []; - const model = (data.model as string) || settings.defaultModel; - - const classDescriptions = classifications - .map(c => `- "${c.label}": ${c.description}`) - .join('\n'); - - const instructions = - (data.instructions as string) || - `Classify the input into exactly one of these categories:\n${classDescriptions}\n\nRespond with only the classification label in JSON format.`; - - agents.set( - node.id, - new Agent({ - name: node.label || 'Classify', - instructions, - model, - modelSettings: { temperature: 0 }, - }), - ); - } - - for (const node of agentNodes) { - const handoffEdges = edges.filter( - e => - e.source === node.id && - (e.type === 'handoff' || e.type === 'sequence') && - agents.has(e.target), - ); - - if (handoffEdges.length === 0) continue; - - const handoffTargets: Agent[] = []; - const asToolTargets: Tool[] = []; - - for (const edge of handoffEdges) { - const target = agents.get(edge.target); - if (!target) continue; - - if (edge.type === 'sequence') { - asToolTargets.push( - target.asTool({ - toolName: `delegate_to_${edge.target.replace(/[^a-zA-Z0-9_]/g, '_')}`, - toolDescription: - target.handoffDescription || `Delegate to ${target.name}`, - }), - ); - } else { - handoffTargets.push(target); - } - } - - if (handoffTargets.length > 0 || asToolTargets.length > 0) { - const data = agentNodeDataMap.get(node.id)!; - const nodeTools = resolveToolsForAgent( - node.id, - edges, - nodeMap, - backendTools, - this.logger, - ); - - agents.set( - node.id, - new Agent({ - name: data.name || data.agentKey, - instructions: data.instructions || '', - model: data.model || settings.defaultModel, - tools: [...nodeTools, ...asToolTargets] as AgentsFunctionTool[], - handoffs: handoffTargets.length > 0 ? handoffTargets : undefined, - modelSettings: { - temperature: data.temperature, - maxTokens: data.maxOutputTokens, - toolChoice: data.toolChoice as - | 'auto' - | 'required' - | 'none' - | undefined, - }, - handoffDescription: data.handoffDescription, - resetToolChoice: data.resetToolChoice, - toolUseBehavior: - data.toolUseBehavior === 'stop_on_first_tool' - ? 'stop_on_first_tool' - : undefined, - }), - ); - } - } - - const executionPlan = buildExecutionPlan(nodes, edges); - const entryAgent = this.resolveEntryAgent(nodes, edges, agents); - const maxTurns = settings.maxTurns ?? 10; - const runner = new Runner({ modelProvider }); - - this.logger.info( - `Hydrated workflow "${definition.name}" with ${agents.size} agents, ` + - `entry="${entryAgent.name}", maxTurns=${maxTurns}, ` + - `plan has ${executionPlan.steps.length} steps`, - ); - - return { runner, entryAgent, agents, maxTurns, executionPlan }; - } - - private resolveEntryAgent( - nodes: WorkflowNode[], - edges: WorkflowEdge[], - agents: Map, - ): Agent { - const startNode = nodes.find(n => n.type === 'start'); - if (startNode) { - const startEdge = edges.find(e => e.source === startNode.id); - if (startEdge) { - const entry = agents.get(startEdge.target); - if (entry) return entry; - } - } - const first = agents.values().next().value; - if (!first) { - throw new Error('No agents found in workflow'); - } - return first; - } - - createExecutionRecord(node: WorkflowNode): NodeExecutionRecord { - return { - nodeId: node.id, - nodeType: node.type, - nodeName: node.label ?? node.id, - startedAt: new Date().toISOString(), - status: 'running', - }; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/EdgeResolver.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/EdgeResolver.ts deleted file mode 100644 index 99ddd3a5c14..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/EdgeResolver.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - WorkflowNode, - WorkflowEdge, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ExecutionTraceRecord } from './types'; - -/** - * Resolves the next node in the workflow graph based on the current - * node type and execution result. - * - * Uses explicit edge labels for branching: - * - Logic nodes: edges labeled "true" / "false" - * - Classify nodes: edges with `condition` matching classification output - * - All others: first non-binding sequence edge - */ -export function resolveNextNode( - node: WorkflowNode, - result: { output: unknown; trace: ExecutionTraceRecord }, - outEdges: WorkflowEdge[], -): string | null { - if (node.type === 'classify') { - return resolveClassifyBranch(result, outEdges); - } - - if (node.type === 'logic') { - return resolveLogicBranch(result, outEdges); - } - - const sequenceEdges = outEdges.filter( - e => e.type !== 'tool_binding' && e.type !== 'guardrail_binding', - ); - return sequenceEdges[0]?.target ?? null; -} - -function resolveClassifyBranch( - result: { output: unknown }, - outEdges: WorkflowEdge[], -): string | null { - const parsed = result.output as { classification?: string } | null; - const classification = parsed?.classification?.toLowerCase().trim(); - - if (classification) { - const matchingEdge = outEdges.find(e => { - const cond = (e.condition || e.label || '').toLowerCase().trim(); - return cond === classification; - }); - if (matchingEdge) return matchingEdge.target; - } - - const defaultEdge = - outEdges.find(e => !e.condition && !e.label) || outEdges[0]; - return defaultEdge?.target ?? null; -} - -function resolveLogicBranch( - result: { output: unknown }, - outEdges: WorkflowEdge[], -): string | null { - const conditionResult = Boolean(result.output); - const trueEdge = outEdges.find(e => e.label === 'true'); - const falseEdge = outEdges.find(e => e.label === 'false'); - - if (conditionResult) { - return trueEdge?.target ?? outEdges[0]?.target ?? null; - } - return falseEdge?.target ?? outEdges[1]?.target ?? null; -} - -export function getFirstSequenceTarget( - outEdges: WorkflowEdge[], -): string | null { - return outEdges[0]?.target ?? null; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/McpExecutor.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/McpExecutor.ts deleted file mode 100644 index 8f2b52c3a99..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/McpExecutor.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { - WorkflowNode, - McpNodeData, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { NodeExecutionResult } from './types'; - -/** - * Processes an MCP node by registering the MCP server info in workflow state. - * The actual tool discovery and execution is delegated to LlamaStack, - * which supports native `type: mcp` tools in the Responses API. - */ -export async function executeMcpNode( - node: WorkflowNode, - state: Record, - logger: LoggerService, -): Promise { - const data = node.data as McpNodeData; - const { serverUrl, serverLabel } = data; - const startedAt = new Date().toISOString(); - const startMs = Date.now(); - - if (!serverUrl) { - return { - output: undefined, - trace: { - nodeId: node.id, - nodeType: 'mcp', - nodeName: serverLabel || 'MCP', - input: '', - output: '', - startedAt, - completedAt: new Date().toISOString(), - status: 'failed', - error: 'MCP server URL is required', - durationMs: Date.now() - startMs, - }, - }; - } - - const existing = - (state._mcpServers as - | Array<{ serverUrl: string; serverLabel: string }> - | undefined) || []; - state._mcpServers = [ - ...existing, - { serverUrl, serverLabel: serverLabel || 'MCP' }, - ]; - - logger.info( - `MCP node ${node.id}: registered server ${serverLabel} at ${serverUrl}`, - ); - - return { - output: { serverUrl, serverLabel }, - trace: { - nodeId: node.id, - nodeType: 'mcp', - nodeName: serverLabel || 'MCP', - input: serverUrl, - output: `Registered MCP server: ${serverLabel} (${serverUrl})`, - startedAt, - completedAt: new Date().toISOString(), - status: 'completed', - durationMs: Date.now() - startMs, - }, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/NodeExecutors.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/NodeExecutors.ts deleted file mode 100644 index 926aec3ab4e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/NodeExecutors.ts +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { WorkflowNode } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { NodeExecutionResult } from './types'; -import { Parser } from 'expr-eval'; - -export { executeAgentNode, executeClassifyNode } from './llmNodeExecutors'; - -const exprParser = new Parser(); - -function normalizeJsExpression(expr: string): string { - return expr - .replace(/!==/g, '!=') - .replace(/===/g, '==') - .replace(/&&/g, ' and ') - .replace(/\|\|/g, ' or ') - .replace(/(?, -): NodeExecutionResult { - const data = node.data as Record; - const condition = (data.condition as string) || 'true'; - const startMs = Date.now(); - - let result: boolean; - try { - const parsed = exprParser.parse(normalizeJsExpression(condition)); - result = Boolean(parsed.evaluate(state as Record)); - } catch { - result = false; - } - - return { - output: result, - trace: { - nodeId: node.id, - nodeType: 'logic', - nodeName: node.label || 'If/Else', - input: condition, - output: String(result), - parsedOutput: result, - startedAt: new Date().toISOString(), - completedAt: new Date().toISOString(), - status: 'completed', - durationMs: Date.now() - startMs, - }, - }; -} - -export function executeTransformNode( - node: WorkflowNode, - state: Record, -): NodeExecutionResult { - const data = node.data as Record; - const expression = (data.expression as string) || ''; - const outputVariable = (data.outputVariable as string) || '_transformed'; - const startMs = Date.now(); - - let result: unknown; - try { - const parsed = exprParser.parse(normalizeJsExpression(expression)); - result = parsed.evaluate(state as Record); - } catch { - result = expression; - } - - state[outputVariable] = result; - - return { - output: result, - trace: { - nodeId: node.id, - nodeType: 'transform', - nodeName: node.label || 'Transform', - input: expression, - output: String(result), - parsedOutput: result, - startedAt: new Date().toISOString(), - completedAt: new Date().toISOString(), - status: 'completed', - durationMs: Date.now() - startMs, - }, - }; -} - -export function executeSetStateNode( - node: WorkflowNode, - state: Record, -): NodeExecutionResult { - const data = node.data as Record; - const assignments = (data.assignments as Record) || {}; - const startMs = Date.now(); - - for (const [key, expr] of Object.entries(assignments)) { - try { - const parsed = exprParser.parse(normalizeJsExpression(expr)); - state[key] = parsed.evaluate(state as Record); - } catch { - state[key] = expr; - } - } - - return { - output: assignments, - trace: { - nodeId: node.id, - nodeType: 'set_state', - nodeName: node.label || 'Set State', - input: JSON.stringify(assignments), - output: JSON.stringify(assignments), - startedAt: new Date().toISOString(), - completedAt: new Date().toISOString(), - status: 'completed', - durationMs: Date.now() - startMs, - }, - }; -} - -export function executeUserInteractionNode( - node: WorkflowNode, -): NodeExecutionResult { - const data = node.data as Record; - const prompt = (data.prompt as string) || 'Awaiting approval'; - - return { - output: { approved: true, prompt }, - trace: { - nodeId: node.id, - nodeType: 'user_interaction', - nodeName: node.label || 'Approval', - input: prompt, - output: 'Auto-approved (preview mode)', - startedAt: new Date().toISOString(), - completedAt: new Date().toISOString(), - status: 'completed', - durationMs: 0, - }, - }; -} - -export function makeSkippedTrace(node: WorkflowNode): NodeExecutionResult { - return { - output: undefined, - trace: { - nodeId: node.id, - nodeType: node.type, - nodeName: node.label || node.id, - input: '', - output: '', - startedAt: new Date().toISOString(), - completedAt: new Date().toISOString(), - status: 'skipped', - durationMs: 0, - }, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/ResponseParser.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/ResponseParser.ts deleted file mode 100644 index a0d1f61657c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/ResponseParser.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { WorkflowNode } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ResponsesApiResponse } from './types'; - -export function extractTextFromResponse( - response: ResponsesApiResponse, -): string { - if (!response.output || response.output.length === 0) return ''; - - for (const item of response.output) { - if (item.type === 'message' && item.content) { - for (const content of item.content) { - if (content.type === 'output_text' && content.text) { - return content.text; - } - } - } - } - return ''; -} - -export function buildInputForNode( - _node: WorkflowNode, - userInput: string, - state: Record, -): string { - const lastOutput = Object.keys(state) - .filter(k => k.endsWith('_output')) - .pop(); - - if (lastOutput && state[lastOutput]) { - const prev = - typeof state[lastOutput] === 'string' - ? (state[lastOutput] as string) - : JSON.stringify(state[lastOutput]); - return `Original request: ${userInput}\n\nPrevious step output: ${prev}`; - } - return userInput; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/StreamingExecutor.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/StreamingExecutor.ts deleted file mode 100644 index ded79c3c285..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/StreamingExecutor.ts +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { - WorkflowNode, - AgentNodeData, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ResponsesApiClient } from '../../../../services/ResponsesApiClient'; -import type { NodeExecutionResult, StreamEventEmitter } from './types'; -import { buildInputForNode } from './ResponseParser'; - -export async function executeAgentNodeStreaming( - node: WorkflowNode, - userInput: string, - state: Record, - previousResponseId: string | undefined, - defaultModel: string, - client: ResponsesApiClient, - logger: LoggerService, - onEvent: StreamEventEmitter, - signal?: AbortSignal, -): Promise { - const data = node.data as AgentNodeData; - const model = data.model || defaultModel; - const instructions = data.instructions || ''; - const inputText = buildInputForNode(node, userInput, state); - const startedAt = new Date().toISOString(); - const startMs = Date.now(); - - const stateMcpServers = - (state._mcpServers as - | Array<{ serverUrl: string; serverLabel: string }> - | undefined) || []; - const directMcpUrls = Array.isArray(data.mcpServers) - ? (data.mcpServers as string[]) - : []; - - const tools: Array> = [ - ...stateMcpServers.map(s => ({ - type: 'mcp', - server_label: s.serverLabel, - server_url: s.serverUrl.endsWith('/sse') - ? s.serverUrl - : `${s.serverUrl}/sse`, - require_approval: 'never', - })), - ...directMcpUrls.map((url, i) => ({ - type: 'mcp', - server_label: `mcp_${i}`, - server_url: url.endsWith('/sse') ? url : `${url}/sse`, - require_approval: 'never', - })), - ]; - - try { - const body: Record = { - model, - input: inputText, - instructions: instructions || undefined, - store: true, - stream: true, - }; - if (previousResponseId) body.previous_response_id = previousResponseId; - if (data.temperature !== undefined) body.temperature = data.temperature; - if (data.maxOutputTokens) body.max_output_tokens = data.maxOutputTokens; - if (tools.length > 0) body.tools = tools; - - let fullText = ''; - let responseId = ''; - - await client.streamRequest( - '/v1/responses', - body, - (eventData: string) => { - try { - const evt = JSON.parse(eventData); - - if (evt.type === 'response.output_text.delta' && evt.delta) { - fullText += evt.delta; - onEvent({ - type: 'node.delta', - data: { nodeId: node.id, delta: evt.delta }, - }); - } else if (evt.type === 'response.completed' && evt.response?.id) { - responseId = evt.response.id; - } else if ( - evt.type === 'response.mcp_call.in_progress' || - evt.type === 'response.mcp_call_in_progress' - ) { - onEvent({ - type: 'node.tool_call.started', - data: { - nodeId: node.id, - toolName: evt.name || evt.item?.name || 'tool', - serverLabel: evt.server_label || evt.item?.server_label, - }, - }); - } else if ( - evt.type === 'response.mcp_call.completed' || - evt.type === 'response.mcp_call_completed' - ) { - onEvent({ - type: 'node.tool_call.completed', - data: { - nodeId: node.id, - toolName: evt.name || evt.item?.name || 'tool', - output: evt.output || evt.item?.output, - }, - }); - } else if ( - evt.type === 'response.function_call_arguments.delta' && - evt.delta - ) { - onEvent({ - type: 'node.tool_call.arguments_delta', - data: { nodeId: node.id, delta: evt.delta }, - }); - } else if ( - evt.type === 'response.output_item.added' && - evt.item?.type === 'mcp_call' - ) { - onEvent({ - type: 'node.tool_call.started', - data: { - nodeId: node.id, - toolName: evt.item.name || 'mcp_tool', - serverLabel: evt.item.server_label, - }, - }); - } else if ( - evt.type === 'response.output_item.done' && - evt.item?.type === 'mcp_call' - ) { - onEvent({ - type: 'node.tool_call.completed', - data: { - nodeId: node.id, - toolName: evt.item.name || 'mcp_tool', - output: evt.item.output, - }, - }); - } - } catch { - /* skip unparseable SSE frames */ - } - }, - signal, - ); - - return { - output: fullText, - trace: { - nodeId: node.id, - nodeType: 'agent', - nodeName: data.name || node.label || node.id, - model, - input: inputText, - output: fullText, - responseId, - startedAt, - completedAt: new Date().toISOString(), - status: 'completed', - durationMs: Date.now() - startMs, - }, - }; - } catch (err: unknown) { - const errMsg = err instanceof Error ? err.message : String(err); - logger.error(`Streaming agent node ${node.id} failed: ${errMsg}`); - return { - output: undefined, - trace: { - nodeId: node.id, - nodeType: 'agent', - nodeName: data.name || node.label || node.id, - model, - input: inputText, - output: '', - startedAt, - completedAt: new Date().toISOString(), - status: 'failed', - error: errMsg, - durationMs: Date.now() - startMs, - }, - }; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/WorkflowExecutor.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/WorkflowExecutor.ts deleted file mode 100644 index ca1fe6ab525..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/WorkflowExecutor.ts +++ /dev/null @@ -1,314 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { - WorkflowDefinition, - WorkflowNode, - WorkflowEdge, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ResponsesApiClient } from '../../../../services/ResponsesApiClient'; -import type { - ExecutionTraceRecord, - WorkflowExecutionResult, - NodeExecutionResult, - StreamEventEmitter, -} from './types'; -import { - executeAgentNode, - executeClassifyNode, - executeLogicNode, - executeTransformNode, - executeSetStateNode, - executeUserInteractionNode, - makeSkippedTrace, -} from './NodeExecutors'; -import { executeMcpNode } from './McpExecutor'; -import { resolveNextNode, getFirstSequenceTarget } from './EdgeResolver'; -import { executeAgentNodeStreaming } from './StreamingExecutor'; - -const MAX_STEPS = 50; - -/** - * Graph-traversal engine that executes workflow definitions against - * the LlamaStack /v1/responses endpoint, node by node. - */ -export class WorkflowExecutor { - private readonly nodeMap = new Map(); - private readonly edgesBySource = new Map(); - - constructor( - private readonly logger: LoggerService, - private readonly client: ResponsesApiClient, - private readonly defaultModel: string, - ) {} - - async execute( - definition: WorkflowDefinition, - userInput: string, - ): Promise { - const startTime = Date.now(); - this.buildGraph(definition); - - const startNode = definition.nodes.find(n => n.type === 'start'); - if (!startNode) throw new Error('Workflow has no start node'); - - const trace: ExecutionTraceRecord[] = []; - const state: Record = { input: userInput }; - let previousResponseId: string | undefined; - let finalOutput = ''; - let currentNodeId = getFirstSequenceTarget( - this.edgesBySource.get(startNode.id) || [], - ); - let steps = 0; - - while (currentNodeId && steps < MAX_STEPS) { - steps++; - const node = this.nodeMap.get(currentNodeId); - if (!node) { - this.logger.warn(`Node ${currentNodeId} not found, stopping`); - break; - } - - if (node.type === 'end') { - trace.push(makeEndTrace(node, finalOutput)); - break; - } - - const result = await this.runNode( - node, - userInput, - state, - previousResponseId, - ); - trace.push(result.trace); - - if (result.trace.status === 'failed') { - finalOutput = result.trace.error || 'Execution failed'; - break; - } - - if (result.trace.responseId) previousResponseId = result.trace.responseId; - if (result.output !== undefined) { - state[`${node.id}_output`] = result.output; - finalOutput = - typeof result.output === 'string' - ? result.output - : JSON.stringify(result.output); - } - - currentNodeId = resolveNextNode( - node, - result, - this.edgesBySource.get(node.id) || [], - ); - } - - return { - finalOutput, - trace, - state, - totalDurationMs: Date.now() - startTime, - }; - } - - async executeStream( - definition: WorkflowDefinition, - userInput: string, - onEvent: StreamEventEmitter, - signal?: AbortSignal, - ): Promise { - const startTime = Date.now(); - this.buildGraph(definition); - - const startNode = definition.nodes.find(n => n.type === 'start'); - if (!startNode) throw new Error('Workflow has no start node'); - - onEvent({ - type: 'workflow.started', - data: { workflowId: definition.id, input: userInput }, - }); - - const trace: ExecutionTraceRecord[] = []; - const state: Record = { input: userInput }; - let previousResponseId: string | undefined; - let finalOutput = ''; - let currentNodeId = getFirstSequenceTarget( - this.edgesBySource.get(startNode.id) || [], - ); - let steps = 0; - - while (currentNodeId && steps < MAX_STEPS) { - if (signal?.aborted) break; - steps++; - - const node = this.nodeMap.get(currentNodeId); - if (!node) break; - - if (node.type === 'end') { - const endTrace = makeEndTrace(node, finalOutput); - trace.push(endTrace); - onEvent({ - type: 'node.completed', - data: { nodeId: node.id, trace: endTrace }, - }); - break; - } - - onEvent({ - type: 'node.started', - data: { - nodeId: node.id, - nodeType: node.type, - nodeName: node.label || node.id, - }, - }); - - let result: NodeExecutionResult; - if (node.type === 'agent') { - result = await executeAgentNodeStreaming( - node, - userInput, - state, - previousResponseId, - this.defaultModel, - this.client, - this.logger, - onEvent, - signal, - ); - } else { - result = await this.runNode(node, userInput, state, previousResponseId); - } - - trace.push(result.trace); - - if (result.trace.status === 'failed') { - onEvent({ - type: 'node.failed', - data: { nodeId: node.id, error: result.trace.error }, - }); - finalOutput = result.trace.error || 'Execution failed'; - break; - } - - onEvent({ - type: 'node.completed', - data: { nodeId: node.id, trace: result.trace }, - }); - - if (result.trace.responseId) previousResponseId = result.trace.responseId; - if (result.output !== undefined) { - state[`${node.id}_output`] = result.output; - finalOutput = - typeof result.output === 'string' - ? result.output - : JSON.stringify(result.output); - } - - currentNodeId = resolveNextNode( - node, - result, - this.edgesBySource.get(node.id) || [], - ); - } - - const totalDurationMs = Date.now() - startTime; - onEvent({ - type: 'workflow.completed', - data: { finalOutput, totalDurationMs }, - }); - return { finalOutput, trace, state, totalDurationMs }; - } - - private async runNode( - node: WorkflowNode, - userInput: string, - state: Record, - previousResponseId?: string, - ): Promise { - switch (node.type) { - case 'agent': - return executeAgentNode( - node, - userInput, - state, - previousResponseId, - this.defaultModel, - this.client, - this.logger, - ); - case 'classify': - return executeClassifyNode( - node, - userInput, - state, - previousResponseId, - this.defaultModel, - this.client, - this.logger, - ); - case 'logic': - return executeLogicNode(node, state); - case 'transform': - return executeTransformNode(node, state); - case 'set_state': - return executeSetStateNode(node, state); - case 'user_interaction': - return executeUserInteractionNode(node); - case 'mcp': - return executeMcpNode(node, state, this.logger); - case 'tool': - case 'guardrail': - case 'file_search': - case 'note': - return makeSkippedTrace(node); - default: - return makeSkippedTrace(node); - } - } - - private buildGraph(definition: WorkflowDefinition): void { - this.nodeMap.clear(); - this.edgesBySource.clear(); - for (const node of definition.nodes) { - this.nodeMap.set(node.id, node); - } - for (const edge of definition.edges) { - const existing = this.edgesBySource.get(edge.source) || []; - existing.push(edge); - this.edgesBySource.set(edge.source, existing); - } - } -} - -function makeEndTrace( - node: WorkflowNode, - finalOutput: string, -): ExecutionTraceRecord { - const now = new Date().toISOString(); - return { - nodeId: node.id, - nodeType: 'end', - nodeName: node.label || 'End', - input: '', - output: finalOutput, - startedAt: now, - completedAt: now, - status: 'completed', - durationMs: 0, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/index.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/index.ts deleted file mode 100644 index 063cd6241e1..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { WorkflowExecutor } from './WorkflowExecutor'; -export type { - ExecutionTraceRecord, - WorkflowExecutionResult, - NodeExecutionResult, - StreamEventEmitter, -} from './types'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/llmNodeExecutors.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/llmNodeExecutors.ts deleted file mode 100644 index 36d4bbd368b..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/llmNodeExecutors.ts +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { - WorkflowNode, - AgentNodeData, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ResponsesApiClient } from '../../../../services/ResponsesApiClient'; -import type { NodeExecutionResult, ResponsesApiResponse } from './types'; -import { extractTextFromResponse, buildInputForNode } from './ResponseParser'; - -export async function executeAgentNode( - node: WorkflowNode, - userInput: string, - state: Record, - previousResponseId: string | undefined, - defaultModel: string, - client: ResponsesApiClient, - logger: LoggerService, -): Promise { - const data = node.data as AgentNodeData; - const model = data.model || defaultModel; - const instructions = data.instructions || ''; - const inputText = buildInputForNode(node, userInput, state); - const startedAt = new Date().toISOString(); - const startMs = Date.now(); - - const stateMcpServers = - (state._mcpServers as - | Array<{ serverUrl: string; serverLabel: string }> - | undefined) || []; - const directMcpUrls = Array.isArray(data.mcpServers) - ? (data.mcpServers as string[]) - : []; - - const tools: Array> = [ - ...stateMcpServers.map(s => ({ - type: 'mcp', - server_label: s.serverLabel, - server_url: s.serverUrl.endsWith('/sse') - ? s.serverUrl - : `${s.serverUrl}/sse`, - require_approval: 'never', - })), - ...directMcpUrls.map((url, i) => ({ - type: 'mcp', - server_label: `mcp_${i}`, - server_url: url.endsWith('/sse') ? url : `${url}/sse`, - require_approval: 'never', - })), - ]; - - try { - const body: Record = { - model, - input: inputText, - instructions: instructions || undefined, - store: true, - }; - if (previousResponseId) body.previous_response_id = previousResponseId; - if (data.temperature !== undefined) body.temperature = data.temperature; - if (data.maxOutputTokens) body.max_output_tokens = data.maxOutputTokens; - if (tools.length > 0) body.tools = tools; - - const response = await client.request( - '/v1/responses', - { method: 'POST', body }, - ); - - const outputText = extractTextFromResponse(response); - return { - output: outputText, - trace: { - nodeId: node.id, - nodeType: 'agent', - nodeName: data.name || node.label || node.id, - model, - input: inputText, - output: outputText, - responseId: response.id, - startedAt, - completedAt: new Date().toISOString(), - status: 'completed', - durationMs: Date.now() - startMs, - }, - }; - } catch (err: unknown) { - const errMsg = err instanceof Error ? err.message : String(err); - logger.error(`Agent node ${node.id} failed: ${errMsg}`); - return { - output: undefined, - trace: { - nodeId: node.id, - nodeType: 'agent', - nodeName: data.name || node.label || node.id, - model, - input: inputText, - output: '', - startedAt, - completedAt: new Date().toISOString(), - status: 'failed', - error: errMsg, - durationMs: Date.now() - startMs, - }, - }; - } -} - -export async function executeClassifyNode( - node: WorkflowNode, - userInput: string, - state: Record, - previousResponseId: string | undefined, - defaultModel: string, - client: ResponsesApiClient, - logger: LoggerService, -): Promise { - const data = node.data as Record; - const classifications = - (data.classifications as Array<{ label: string; description: string }>) || - []; - const model = (data.model as string) || defaultModel; - - const enumValues = classifications.map(c => c.label); - const classDescriptions = classifications - .map(c => `- "${c.label}": ${c.description}`) - .join('\n'); - - const instructions = - (data.instructions as string) || - `Classify the input into exactly one of these categories:\n${classDescriptions}\n\nRespond with the classification in the required JSON format.`; - - const inputText = buildInputForNode(node, userInput, state); - const startedAt = new Date().toISOString(); - const startMs = Date.now(); - - try { - const body: Record = { - model, - input: inputText, - instructions, - store: true, - text: { - format: { - type: 'json_schema', - name: 'classification', - schema: { - type: 'object', - properties: { - classification: { - type: 'string', - enum: enumValues.length > 0 ? enumValues : undefined, - }, - }, - required: ['classification'], - }, - }, - }, - }; - if (previousResponseId) body.previous_response_id = previousResponseId; - - const response = await client.request( - '/v1/responses', - { method: 'POST', body }, - ); - - const rawText = extractTextFromResponse(response); - let parsed: { classification: string } | null = null; - try { - parsed = JSON.parse(rawText); - } catch { - parsed = { classification: rawText.trim().toLowerCase() }; - } - - return { - output: parsed, - trace: { - nodeId: node.id, - nodeType: 'classify', - nodeName: node.label || 'Classify', - model, - input: inputText, - output: rawText, - parsedOutput: parsed, - responseId: response.id, - startedAt, - completedAt: new Date().toISOString(), - status: 'completed', - durationMs: Date.now() - startMs, - }, - }; - } catch (err: unknown) { - const errMsg = err instanceof Error ? err.message : String(err); - logger.error(`Classify node ${node.id} failed: ${errMsg}`); - return { - output: undefined, - trace: { - nodeId: node.id, - nodeType: 'classify', - nodeName: node.label || 'Classify', - model, - input: inputText, - output: '', - startedAt, - completedAt: new Date().toISOString(), - status: 'failed', - error: errMsg, - durationMs: Date.now() - startMs, - }, - }; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/types.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/types.ts deleted file mode 100644 index 7c37e04901f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/executor/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export interface ExecutionTraceRecord { - nodeId: string; - nodeType: string; - nodeName: string; - model?: string; - input: string; - output: string; - parsedOutput?: unknown; - responseId?: string; - startedAt: string; - completedAt: string; - status: 'completed' | 'failed' | 'skipped'; - error?: string; - durationMs: number; -} - -export interface WorkflowExecutionResult { - finalOutput: string; - trace: ExecutionTraceRecord[]; - state: Record; - totalDurationMs: number; -} - -export interface NodeExecutionResult { - output: unknown; - trace: ExecutionTraceRecord; -} - -export interface ResponsesApiResponse { - id: string; - status: string; - output: Array<{ - type: string; - content?: Array<{ type: string; text: string }>; - role?: string; - call_id?: string; - name?: string; - arguments?: string; - }>; - usage?: { input_tokens: number; output_tokens: number; total_tokens: number }; -} - -export interface McpToolDefinition { - name: string; - description: string; - inputSchema: Record; - serverUrl: string; -} - -export interface StreamEventEmitter { - (event: { type: string; data: unknown }): void; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/index.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/index.ts deleted file mode 100644 index 0b8db9cf16a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { WorkflowHydrator } from './WorkflowHydrator'; -export type { HydratedWorkflow } from './WorkflowHydrator'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/workflowExecutionPlan.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/workflowExecutionPlan.ts deleted file mode 100644 index 8be7b887e3c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/workflowExecutionPlan.ts +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - WorkflowNode, - WorkflowEdge, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -export interface WorkflowExecutionPlan { - startNodeId: string; - steps: ExecutionStep[]; -} - -export type ExecutionStep = - | { type: 'agent'; nodeId: string; agentId: string } - | { - type: 'classify'; - nodeId: string; - agentId: string; - routes: Record; - } - | { - type: 'logic'; - nodeId: string; - condition: string; - trueTarget: string; - falseTarget?: string; - } - | { - type: 'transform'; - nodeId: string; - expression: string; - outputVariable: string; - } - | { type: 'set_state'; nodeId: string; assignments: Record } - | { type: 'user_interaction'; nodeId: string; prompt: string } - | { type: 'end'; nodeId: string }; - -export function buildExecutionPlan( - nodes: WorkflowNode[], - edges: WorkflowEdge[], -): WorkflowExecutionPlan { - const startNode = nodes.find(n => n.type === 'start'); - if (!startNode) { - return { startNodeId: '', steps: [] }; - } - - const steps: ExecutionStep[] = []; - const visited = new Set(); - - const traverse = (nodeId: string) => { - if (visited.has(nodeId)) return; - visited.add(nodeId); - - const node = nodes.find(n => n.id === nodeId); - if (!node) return; - - const outEdges = edges.filter(e => e.source === nodeId); - - switch (node.type) { - case 'start': - for (const edge of outEdges) traverse(edge.target); - break; - - case 'agent': - steps.push({ type: 'agent', nodeId: node.id, agentId: node.id }); - for (const edge of outEdges.filter( - e => e.type !== 'tool_binding' && e.type !== 'guardrail_binding', - )) { - traverse(edge.target); - } - break; - - case 'classify': { - const routes: Record = {}; - for (const edge of outEdges) { - if (edge.condition) { - routes[edge.condition] = edge.target; - } else { - routes.__default__ = edge.target; - } - } - steps.push({ - type: 'classify', - nodeId: node.id, - agentId: node.id, - routes, - }); - for (const edge of outEdges) traverse(edge.target); - break; - } - - case 'logic': { - const d = node.data as Record; - const condition = (d.condition as string) || 'true'; - const trueTarget = outEdges[0]?.target || ''; - const falseTarget = outEdges[1]?.target; - steps.push({ - type: 'logic', - nodeId: node.id, - condition, - trueTarget, - falseTarget, - }); - for (const edge of outEdges) traverse(edge.target); - break; - } - - case 'transform': { - const d = node.data as Record; - steps.push({ - type: 'transform', - nodeId: node.id, - expression: (d.expression as string) || '', - outputVariable: (d.outputVariable as string) || '_transformed', - }); - for (const edge of outEdges) traverse(edge.target); - break; - } - - case 'set_state': { - const d = node.data as Record; - steps.push({ - type: 'set_state', - nodeId: node.id, - assignments: (d.assignments as Record) || {}, - }); - for (const edge of outEdges) traverse(edge.target); - break; - } - - case 'user_interaction': { - const d = node.data as Record; - steps.push({ - type: 'user_interaction', - nodeId: node.id, - prompt: (d.prompt as string) || 'Please confirm.', - }); - for (const edge of outEdges) traverse(edge.target); - break; - } - - case 'end': - steps.push({ type: 'end', nodeId: node.id }); - break; - - default: - for (const edge of outEdges) traverse(edge.target); - break; - } - }; - - traverse(startNode.id); - return { startNodeId: startNode.id, steps }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/workflowToolResolver.ts b/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/workflowToolResolver.ts deleted file mode 100644 index 6bb5fefd36c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/llamastack/workflow/workflowToolResolver.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { FunctionTool as AgentsFunctionTool } from '@openai/agents-core'; -import type { - WorkflowNode, - WorkflowEdge, - ToolNodeData, - FileSearchNodeData, - McpNodeData, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -export function resolveToolsForAgent( - agentNodeId: string, - edges: WorkflowEdge[], - nodeMap: Map, - backendTools: AgentsFunctionTool[], - logger: LoggerService, -): AgentsFunctionTool[] { - const tools: AgentsFunctionTool[] = []; - - const toolEdges = edges.filter( - e => e.source === agentNodeId && e.type === 'tool_binding', - ); - - for (const edge of toolEdges) { - const toolNode = nodeMap.get(edge.target); - if (!toolNode) continue; - - if (toolNode.type === 'tool') { - const data = toolNode.data as ToolNodeData; - switch (data.kind) { - case 'mcp_server': { - if (data.mcpServerId) { - const filtered = data.mcpToolFilter; - for (const bt of backendTools) { - if ( - !filtered || - filtered.length === 0 || - filtered.includes(bt.name) - ) { - tools.push(bt); - } - } - } - break; - } - case 'custom_function': { - if (data.functionDef) { - tools.push({ - type: 'function', - name: data.functionDef.name, - description: data.functionDef.description, - parameters: data.functionDef.parameters, - strict: false, - execute: async () => - JSON.stringify({ result: 'custom function placeholder' }), - } as unknown as AgentsFunctionTool); - } - break; - } - case 'file_search': - case 'web_search': - case 'code_interpreter': - break; - default: - break; - } - } else if (toolNode.type === 'file_search') { - const data = toolNode.data as FileSearchNodeData; - const vectorStoreIds = data.vectorStoreIds || []; - const maxResults = data.maxResults || 10; - tools.push({ - type: 'function', - name: `file_search_${toolNode.id.replace(/[^a-zA-Z0-9_]/g, '_')}`, - description: `Search files in vector stores: ${vectorStoreIds.join(', ') || 'default'}`, - parameters: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query' }, - }, - required: ['query'], - }, - strict: false, - execute: async (args: Record) => { - logger.info( - `File search: query="${args.query}", stores=${vectorStoreIds}, max=${maxResults}`, - ); - return JSON.stringify({ - results: [], - message: 'File search executed via LlamaStack vector_stores API', - }); - }, - } as unknown as AgentsFunctionTool); - } else if (toolNode.type === 'mcp') { - const data = toolNode.data as McpNodeData; - const serverLabel = data.serverLabel || 'MCP Server'; - const serverUrl = data.serverUrl || ''; - const requireApproval = data.requireApproval || 'never'; - const allowedTools = data.allowedTools || []; - - tools.push({ - type: 'function', - name: `mcp_${toolNode.id.replace(/[^a-zA-Z0-9_]/g, '_')}`, - description: `MCP Server: ${serverLabel} (${serverUrl}). Approval: ${requireApproval}. Tools: ${allowedTools.length > 0 ? allowedTools.join(', ') : 'all'}`, - parameters: { - type: 'object', - properties: { - tool_name: { - type: 'string', - description: 'Name of the MCP tool to invoke', - }, - arguments: { - type: 'object', - description: 'Arguments to pass to the tool', - }, - }, - required: ['tool_name'], - }, - strict: false, - execute: async (args: Record) => { - logger.info( - `MCP call: server=${serverUrl}, tool=${args.tool_name}, approval=${requireApproval}`, - ); - if (requireApproval === 'always') { - return JSON.stringify({ - status: 'pending_approval', - tool: args.tool_name, - server: serverLabel, - }); - } - return JSON.stringify({ - status: 'executed', - tool: args.tool_name, - server: serverLabel, - result: 'MCP tool placeholder', - }); - }, - } as unknown as AgentsFunctionTool); - } - } - - if (tools.length === 0) { - return [...backendTools]; - } - return tools; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/providerInterface.ts b/workspaces/augment/plugins/augment-backend/src/providers/providerInterface.ts deleted file mode 100644 index 531d1df0b2f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/providerInterface.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - NormalizedStreamEvent, - ChatAgent, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ChatRequest, ChatResponse } from '../types'; -import type { - ConversationCapability, - RAGCapability, - SafetyCapability, - EvaluationCapability, - AgenticProviderStatus, -} from './capabilityTypes'; - -/** - * AgenticProvider is the abstraction boundary between the Backstage plugin - * and the underlying AI/agentic runtime (Llama Stack, ADK, LangGraph, etc.). - * - * The router and plugin lifecycle interact ONLY through this interface. - * Provider-specific code lives entirely within the provider implementation. - * - * @public - */ -export interface AgenticProvider { - /** Unique identifier for this provider type (e.g., 'llamastack', 'adk') */ - readonly id: string; - - /** Human-readable display name */ - readonly displayName: string; - - initialize(): Promise; - - postInitialize(): Promise; - - getStatus(): Promise; - - chat(request: ChatRequest): Promise; - - chatStream( - request: ChatRequest, - onEvent: (event: NormalizedStreamEvent) => void, - signal?: AbortSignal, - ): Promise; - - shutdown?(): Promise; - - invalidateRuntimeConfig?(): void; - - refreshDynamicConfig?(): Promise; - - getEffectiveConfig?(): Promise>; - - generateSystemPrompt?( - description: string, - model?: string, - capabilities?: import('@red-hat-developer-hub/backstage-plugin-augment-common').PromptCapabilities, - ): Promise; - - listAgents?(): Promise; - - listModels?(): Promise< - Array<{ id: string; owned_by?: string; model_type?: string }> - >; - - testModel?( - model?: string, - baseUrl?: string, - ): Promise<{ - connected: boolean; - modelFound: boolean; - canGenerate: boolean; - error?: string; - }>; - - // ---- Optional capabilities ---- - - conversations?: ConversationCapability; - - rag?: RAGCapability; - - safety?: SafetyCapability; - - evaluation?: EvaluationCapability; - - // ---- Lifecycle & context methods (capability-gated) ---- - - registerRoutes?(router: import('express').Router, deps: unknown): void; - - setUserContext?(userRef: string): void; - - getSessionContextId?(backstageSessionId: string): Promise; - - hydrateSessionContext?( - backstageSessionId: string, - contextId: string, - model?: string, - ): Promise; - - submitApproval?(approval: { - responseId: string; - callId: string; - approved: boolean; - toolName?: string; - toolArguments?: string; - reason?: string; - }): Promise<{ - content?: string; - responseId?: string; - toolExecuted?: boolean; - toolOutput?: string; - pendingApproval?: { - approvalRequestId: string; - toolName: string; - serverLabel?: string; - arguments?: string; - }; - handoff?: unknown; - }>; - - getAuthToken?(): Promise; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/registry.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/registry.test.ts deleted file mode 100644 index f49a2233b49..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/registry.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - PROVIDER_REGISTRY, - getProviderDescriptor, - getAllProviderDescriptors, - isValidProviderType, -} from './registry'; - -describe('provider registry', () => { - it('PROVIDER_REGISTRY contains googleadk and kagenti entries', () => { - expect(PROVIDER_REGISTRY.has('googleadk')).toBe(true); - expect(PROVIDER_REGISTRY.has('kagenti')).toBe(true); - }); - - it('PROVIDER_REGISTRY contains llamastack entry', () => { - expect(PROVIDER_REGISTRY.has('llamastack')).toBe(true); - }); - - it('getProviderDescriptor("kagenti") returns descriptor with implemented: true', () => { - const d = getProviderDescriptor('kagenti'); - expect(d).toBeDefined(); - expect(d && d.id).toBe('kagenti'); - expect(d && d.implemented).toBe(true); - }); - - it('getProviderDescriptor("googleadk") returns descriptor with implemented: false', () => { - const d = getProviderDescriptor('googleadk'); - expect(d).toBeDefined(); - expect(d && d.id).toBe('googleadk'); - expect(d && d.implemented).toBe(false); - }); - - it('getProviderDescriptor("unknown") returns undefined', () => { - expect(getProviderDescriptor('unknown')).toBeUndefined(); - }); - - it('getAllProviderDescriptors returns all providers sorted by displayName', () => { - const all = getAllProviderDescriptors(); - expect(all).toHaveLength(3); - expect(all[0].displayName).toBe('Google ADK'); - expect(all[1].displayName).toBe('Llama Stack'); - expect(all[2].displayName).toBe('Red Hat AI'); - }); - - it('isValidProviderType("kagenti") returns true', () => { - expect(isValidProviderType('kagenti')).toBe(true); - }); - - it('isValidProviderType("unknown") returns false', () => { - expect(isValidProviderType('unknown')).toBe(false); - }); - - it('GoogleADK descriptor has chat, conversations, mcpTools true and others false', () => { - const d = getProviderDescriptor('googleadk'); - expect(d).toBeDefined(); - expect(d?.capabilities.chat).toBe(true); - expect(d?.capabilities.conversations).toBe(true); - expect(d?.capabilities.mcpTools).toBe(true); - expect(d?.capabilities.rag).toBe(false); - expect(d?.capabilities.safety).toBe(false); - expect(d?.capabilities.evaluation).toBe(false); - }); - - it('Kagenti has configFields with baseUrl', () => { - const d = getProviderDescriptor('kagenti'); - expect(d).toBeDefined(); - const keys = d?.configFields.map(f => f.key); - expect(keys).toContain('baseUrl'); - }); - - it('Kagenti exposes all platform capabilities', () => { - const d = getProviderDescriptor('kagenti'); - expect(d).toBeDefined(); - expect(d?.capabilities.chat).toBe(true); - expect(d?.capabilities.rag).toBe(true); - expect(d?.capabilities.safety).toBe(true); - expect(d?.capabilities.evaluation).toBe(true); - expect(d?.capabilities.conversations).toBe(true); - expect(d?.capabilities.mcpTools).toBe(true); - expect(d?.capabilities.tools).toBe(true); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/registry.ts b/workspaces/augment/plugins/augment-backend/src/providers/registry.ts deleted file mode 100644 index 8fb800e780f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/registry.ts +++ /dev/null @@ -1,227 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - ProviderType, - ProviderDescriptor, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -/** - * Built-in provider descriptors shipped with the plugin. - * - * External providers are registered at runtime via the - * {@link augmentProviderExtensionPoint} and stored in - * `dynamicProviders` below. - * - * @internal - */ -const BUILT_IN_PROVIDERS: ReadonlyMap = new Map< - string, - ProviderDescriptor ->([ - [ - 'llamastack', - { - id: 'llamastack', - displayName: 'Llama Stack', - description: - 'Meta Llama Stack -- open source LLM inference and agent runtime', - implemented: true, - capabilities: { - chat: true, - rag: true, - safety: true, - evaluation: true, - conversations: true, - mcpTools: true, - tools: true, - toolLifecycle: false, - agentLifecycle: false, - devSpaces: false, - contextHydration: false, - providerRoutes: false, - }, - configFields: [], - }, - ], - [ - 'googleadk', - { - id: 'googleadk', - displayName: 'Google ADK', - description: - 'Google Agent Development Kit -- build agents with Gemini models', - implemented: false, - capabilities: { - chat: true, - rag: false, - safety: false, - evaluation: false, - conversations: true, - mcpTools: true, - tools: false, - toolLifecycle: false, - agentLifecycle: false, - devSpaces: false, - contextHydration: false, - providerRoutes: false, - }, - configFields: [], - }, - ], - [ - 'kagenti', - { - id: 'kagenti', - displayName: 'Red Hat AI', - description: - 'Red Hat AI -- Kubernetes-native AI agent operations platform', - implemented: true, - capabilities: { - chat: true, - rag: true, - safety: true, - evaluation: true, - conversations: true, - mcpTools: true, - tools: true, - toolLifecycle: true, - agentLifecycle: true, - devSpaces: true, - contextHydration: true, - providerRoutes: true, - }, - configFields: [ - { - key: 'baseUrl', - label: 'Kagenti API URL', - type: 'string', - required: true, - description: 'Base URL of the Kagenti API server', - placeholder: - 'https://kagenti-api-kagenti-system.apps.ocp.example.com', - }, - { - key: 'auth.tokenEndpoint', - label: 'Token endpoint', - type: 'string', - required: true, - description: - 'OAuth2 token endpoint (Keycloak) for service authentication', - placeholder: - 'https://keycloak.example.com/realms/kagenti/protocol/openid-connect/token', - }, - { - key: 'auth.clientId', - label: 'Client ID', - type: 'string', - required: true, - description: 'OAuth2 client ID for Kagenti API authentication', - placeholder: 'backstage-augment', - }, - { - key: 'auth.clientSecret', - label: 'Client secret', - type: 'string', - required: true, - sensitive: true, - description: 'OAuth2 client secret for Kagenti API authentication', - }, - { - key: 'agentName', - label: 'Default Agent', - type: 'string', - required: false, - description: 'Default agent name for chat', - placeholder: 'weather-service', - }, - ], - }, - ], -]); - -/** - * Dynamically registered provider descriptors from extension modules. - * Populated at startup via {@link registerProvider}. - */ -const dynamicProviders = new Map(); - -/** - * Backwards-compatible alias for the combined registry. - * Consumers that reference `PROVIDER_REGISTRY` continue to work. - * - * @internal - */ -export const PROVIDER_REGISTRY: ReadonlyMap = - BUILT_IN_PROVIDERS; - -/** - * Register an external provider descriptor. - * - * Called during plugin initialization by extension modules. - * Throws if the provider ID conflicts with a built-in provider. - * - * @param descriptor - The provider descriptor to register - * @internal - */ -export function registerProvider(descriptor: ProviderDescriptor): void { - if (BUILT_IN_PROVIDERS.has(descriptor.id)) { - throw new Error( - `Cannot register provider "${descriptor.id}": conflicts with a built-in provider`, - ); - } - dynamicProviders.set(descriptor.id, descriptor); -} - -/** - * Get a provider descriptor by ID. - * - * Searches built-in providers first, then dynamically registered ones. - * - * @param id - The provider type identifier - * @returns The descriptor, or `undefined` if not found - * @internal - */ -export function getProviderDescriptor( - id: ProviderType, -): ProviderDescriptor | undefined { - return BUILT_IN_PROVIDERS.get(id) ?? dynamicProviders.get(id); -} - -/** - * Get all provider descriptors as an array, sorted by display name. - * - * Includes both built-in and dynamically registered providers. - * - * @returns All known provider descriptors - * @internal - */ -export function getAllProviderDescriptors(): readonly ProviderDescriptor[] { - return [...BUILT_IN_PROVIDERS.values(), ...dynamicProviders.values()].sort( - (a, b) => a.displayName.localeCompare(b.displayName), - ); -} - -/** - * Check whether a provider type ID is valid (exists in the registry). - * - * @param id - The string to check - * @returns `true` if the ID maps to a known provider - * @internal - */ -export function isValidProviderType(id: string): id is ProviderType { - return BUILT_IN_PROVIDERS.has(id) || dynamicProviders.has(id); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/agents/agentGraph.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/agents/agentGraph.ts deleted file mode 100644 index 235eb7b4afd..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/agents/agentGraph.ts +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Agent graph types and resolution. - * - * These types were previously re-exported from @augment-adk/augment-adk. - * They are now defined locally to remove that dependency while maintaining - * the same interface for AgentGraphManager and ResponsesApiCoordinator. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ChatDeps } from '../chat/ResponsesApiService'; -import type { - AgentConfig, - ChatResponse, - ResponsesApiInputItem, - ResponsesApiResponse, -} from '../../../types'; - -export interface ResolvedAgent { - key: string; - functionName: string; - config: AgentConfig; - handoffTools: Array<{ - type: string; - name: string; - description?: string; - parameters?: unknown; - strict?: boolean; - }>; - agentAsToolTools: Array<{ - type: string; - name: string; - description?: string; - parameters?: unknown; - strict?: boolean; - }>; - handoffTargetKeys: Set; - asToolTargetKeys: Set; -} - -export interface AgentGraphSnapshot { - agents: Map; - defaultAgentKey: string; - maxTurns: number; -} - -export function sanitizeName(name: string): string { - return name.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); -} - -export const toFunctionToolName = sanitizeName; - -export type BuildDepsForAgent = (agent: ResolvedAgent) => Promise; - -export type MaxTurnsExceededHandler = (ctx: { - agentPath: string[]; - lastResponse?: ResponsesApiResponse; -}) => ChatResponse | undefined; - -export type InputFilterFn = ( - input: string | ResponsesApiInputItem[], - agentKey: string, - turn: number, -) => string | ResponsesApiInputItem[]; - -export type ToolErrorFormatterFn = (toolName: string, error: string) => string; - -/** - * Build and validate an AgentGraphSnapshot from raw AgentConfig records. - * - * This is a local implementation that replaces the ADK's resolveAgentGraph. - * It builds a map of agents with their handoff/delegation relationships. - */ -export function resolveAgentGraph( - configs: Record, - defaultAgent: string, - maxAgentTurns: number | undefined, - logger: LoggerService, -): AgentGraphSnapshot { - const agents = new Map(); - - for (const [key, config] of Object.entries(configs)) { - const handoffTargetKeys = new Set(config.handoffs ?? []); - const asToolTargetKeys = new Set(config.asTools ?? []); - - const handoffTools = [...handoffTargetKeys] - .filter(targetKey => configs[targetKey]) - .map(targetKey => ({ - type: 'function' as const, - name: `transfer_to_${sanitizeName(targetKey)}`, - description: - configs[targetKey]?.handoffDescription ?? - `Transfer to ${configs[targetKey]?.name ?? targetKey}`, - parameters: { type: 'object', properties: {} } as unknown, - strict: false, - })); - - const agentAsToolTools = [...asToolTargetKeys] - .filter(targetKey => configs[targetKey]) - .map(targetKey => ({ - type: 'function' as const, - name: `delegate_to_${sanitizeName(targetKey)}`, - description: - configs[targetKey]?.handoffDescription ?? - `Delegate task to ${configs[targetKey]?.name ?? targetKey}`, - parameters: { type: 'object', properties: {} } as unknown, - strict: false, - })); - - agents.set(key, { - key, - functionName: sanitizeName(key), - config, - handoffTools, - agentAsToolTools, - handoffTargetKeys, - asToolTargetKeys, - }); - } - - let defaultAgentKey = defaultAgent; - if (!agents.has(defaultAgentKey)) { - const firstKey = Object.keys(configs)[0]; - if (firstKey) { - logger.warn( - `Default agent "${defaultAgent}" not found in config, falling back to "${firstKey}"`, - ); - defaultAgentKey = firstKey; - } else { - logger.warn('No agents configured, creating empty graph'); - } - } - - return { - agents, - defaultAgentKey, - maxTurns: maxAgentTurns ?? 10, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/ResponsesApiService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/ResponsesApiService.test.ts deleted file mode 100644 index f1a7ec8aee8..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/ResponsesApiService.test.ts +++ /dev/null @@ -1,558 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import type { - EffectiveConfig, - ChatRequest, - ResponsesApiResponse, -} from '../../../types'; -import { ResponsesApiService } from './ResponsesApiService'; -import { createMockLogger, createMockClient } from '../../../test-utils/mocks'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function createBaseConfig( - overrides: Partial = {}, -): EffectiveConfig { - return { - model: 'test-model', - baseUrl: 'http://localhost:8321', - systemPrompt: 'You are a helpful assistant.', - enableWebSearch: false, - enableCodeInterpreter: false, - vectorStoreIds: [], - vectorStoreName: 'default', - embeddingModel: 'all-MiniLM-L6-v2', - embeddingDimension: 384, - chunkingStrategy: 'auto', - maxChunkSizeTokens: 800, - chunkOverlapTokens: 400, - skipTlsVerify: false, - zdrMode: false, - verboseStreamLogging: false, - ...overrides, - }; -} - -function simpleApiResponse(text = 'Hi there'): ResponsesApiResponse { - return { - id: 'resp-1', - object: 'response', - created_at: Date.now(), - model: 'test-model', - status: 'completed', - output: [ - { - type: 'message', - id: 'msg-1', - role: 'assistant', - status: 'completed', - content: [{ type: 'output_text', text }], - }, - ], - usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, - }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('ResponsesApiService', () => { - let service: ResponsesApiService; - let mockLogger: jest.Mocked; - let mockClient: ReturnType; - - beforeEach(() => { - jest.clearAllMocks(); - mockLogger = createMockLogger(); - mockClient = createMockClient(); - service = new ResponsesApiService(mockLogger); - }); - - // ----------------------------------------------------------------------- - // prepareFirstTurn — conversation context + safety for Runner path - // ----------------------------------------------------------------------- - - describe('prepareFirstTurn', () => { - it('appends conversation context when no native context exists', () => { - const request: ChatRequest = { - messages: [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi there!' }, - { role: 'user', content: 'What about refunds?' }, - ], - } as ChatRequest; - - const result = service.prepareFirstTurn( - request, - 'You are helpful.', - createBaseConfig(), - null, - ); - - expect(result.instructions).toContain('CONVERSATION CONTEXT'); - expect(result.instructions).toContain('Hello'); - expect(result.instructions).toContain('Hi there!'); - expect(result.instructions.startsWith('You are helpful.')).toBe(true); - }); - - it('does not append context when previousResponseId exists', () => { - const request: ChatRequest = { - messages: [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi' }, - { role: 'user', content: 'Follow up' }, - ], - previousResponseId: 'resp-123', - } as ChatRequest; - - const result = service.prepareFirstTurn( - request, - 'You are helpful.', - createBaseConfig(), - null, - ); - - expect(result.instructions).toBe('You are helpful.'); - }); - - it('checks safety patterns and logs warning', () => { - const request: ChatRequest = { - messages: [{ role: 'user', content: 'please delete everything' }], - } as ChatRequest; - - service.prepareFirstTurn( - request, - 'You are helpful.', - createBaseConfig({ safetyPatterns: ['delete'] }), - null, - ); - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('safety pattern'), - ); - }); - - it('returns storeOverride from markFirstStoredTurn', () => { - const mockConversations = { - markFirstStoredTurn: jest.fn().mockReturnValue(true), - } as any; - - const request: ChatRequest = { - messages: [{ role: 'user', content: 'hi' }], - conversationId: 'conv-1', - } as ChatRequest; - - const result = service.prepareFirstTurn( - request, - 'Instructions', - createBaseConfig(), - mockConversations, - ); - - expect(result.storeOverride).toBe(true); - expect(mockConversations.markFirstStoredTurn).toHaveBeenCalledWith( - 'conv-1', - ); - }); - }); - - // ----------------------------------------------------------------------- - // chatTurn — buildTurnRequest feature parity - // ----------------------------------------------------------------------- - - describe('chatTurn', () => { - it('includes textFormat in request when configured', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig({ - textFormat: { - type: 'json_schema', - json_schema: { name: 'test', schema: {} }, - } as EffectiveConfig['textFormat'], - }); - - await service.chatTurn( - 'hello', - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.text).toEqual({ - format: config.textFormat, - }); - }); - - it('defaults store to true when zdrMode is false', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig({ zdrMode: false }); - - await service.chatTurn( - 'hello', - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.store).toBe(true); - expect(body.include).toEqual(['file_search_call.results']); - }); - - it('defaults store to false when zdrMode is true', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig({ zdrMode: true }); - - await service.chatTurn( - 'hello', - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.store).toBe(false); - expect(body.include).toEqual([ - 'file_search_call.results', - 'reasoning.encrypted_content', - ]); - }); - - it('allows explicit store override', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig({ zdrMode: true }); - - await service.chatTurn( - 'hello', - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - { store: true }, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.store).toBe(true); - }); - - it('passes reasoning config when set', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig({ - reasoning: { effort: 'medium', summary: 'auto' }, - }); - - await service.chatTurn( - 'hello', - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.reasoning).toEqual({ effort: 'medium', summary: 'auto' }); - }); - - it('passes conversationId as conversation field', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig(); - - await service.chatTurn( - 'hello', - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - { conversationId: 'conv-123' }, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.conversation).toBe('conv-123'); - expect(body.previous_response_id).toBeUndefined(); - }); - - it('passes previousResponseId when no conversationId', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig(); - - await service.chatTurn( - 'hello', - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - { previousResponseId: 'resp-prev' }, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.previous_response_id).toBe('resp-prev'); - expect(body.conversation).toBeUndefined(); - }); - - it('accepts array input items', async () => { - mockClient.request.mockResolvedValue(simpleApiResponse()); - const config = createBaseConfig(); - - await service.chatTurn( - [{ type: 'function_call_output', call_id: 'c1', output: 'done' }], - 'Be helpful.', - [], - config, - mockClient as unknown as ResponsesApiClient, - ); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(Array.isArray(body.input)).toBe(true); - expect(body.input[0].type).toBe('function_call_output'); - }); - }); - - describe('CapabilityInfo integration', () => { - it('isResponsesParamSupported returns false when capability is disabled', () => { - service.setCapabilityProvider(() => ({ - functionTools: true, - strictField: false, - maxOutputTokens: false, - mcpTools: true, - parallelToolCalls: true, - truncation: true, - })); - expect(service.isResponsesParamSupported('max_output_tokens')).toBe( - false, - ); - expect(service.isResponsesParamSupported('strict')).toBe(false); - }); - - it('isResponsesParamSupported returns true when capability is enabled', () => { - service.setCapabilityProvider(() => ({ - functionTools: true, - strictField: true, - maxOutputTokens: true, - mcpTools: true, - parallelToolCalls: true, - truncation: true, - })); - expect(service.isResponsesParamSupported('max_output_tokens')).toBe(true); - expect(service.isResponsesParamSupported('strict')).toBe(true); - }); - - it('getCapabilities delegates to the injected provider', () => { - const caps = { - functionTools: false, - strictField: false, - maxOutputTokens: false, - mcpTools: false, - parallelToolCalls: false, - truncation: false, - }; - service.setCapabilityProvider(() => caps); - expect(service.getCapabilities()).toBe(caps); - }); - - it('setCapabilityProvider replaces the previous provider', () => { - service.setCapabilityProvider(() => ({ - functionTools: true, - strictField: false, - maxOutputTokens: false, - mcpTools: true, - parallelToolCalls: true, - truncation: true, - })); - expect(service.getCapabilities().strictField).toBe(false); - - service.setCapabilityProvider(() => ({ - functionTools: true, - strictField: true, - maxOutputTokens: true, - mcpTools: true, - parallelToolCalls: true, - truncation: true, - })); - expect(service.getCapabilities().strictField).toBe(true); - }); - }); - - // ----------------------------------------------------------------------- - // continueFunctionCallOutput - // ----------------------------------------------------------------------- - - describe('continueFunctionCallOutput', () => { - it('sends function_call_output with previous_response_id', async () => { - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [ - { - type: 'message', - content: [{ type: 'output_text', text: 'Done' }], - }, - ], - }); - - const result = await service.continueFunctionCallOutput({ - client: mockClient as unknown as ResponsesApiClient, - model: 'test-model', - callId: 'call-1', - output: 'namespace list output', - previousResponseId: 'resp-1', - }); - - expect(result).toEqual({ - responseId: 'resp-2', - text: 'Done', - functionCalls: [], - }); - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.model).toBe('test-model'); - expect(body.previous_response_id).toBe('resp-1'); - expect(body.store).toBe(true); - expect(body.input).toEqual([ - { - type: 'function_call_output', - call_id: 'call-1', - output: 'namespace list output', - }, - ]); - }); - - it('includes function_call input item when functionCall is provided', async () => { - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [ - { - type: 'message', - content: [{ type: 'output_text', text: 'Result' }], - }, - ], - }); - - await service.continueFunctionCallOutput({ - client: mockClient as unknown as ResponsesApiClient, - model: 'test-model', - callId: 'call-1', - output: 'tool result', - previousResponseId: 'resp-1', - functionCall: { name: 'namespaces_list', arguments: '{"all":true}' }, - }); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.input).toEqual([ - { - type: 'function_call', - call_id: 'call-1', - name: 'namespaces_list', - arguments: '{"all":true}', - }, - { - type: 'function_call_output', - call_id: 'call-1', - output: 'tool result', - }, - ]); - }); - - it('prefers previous_response_id over conversation for correct chained HITL linking', async () => { - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [], - }); - - await service.continueFunctionCallOutput({ - client: mockClient as unknown as ResponsesApiClient, - model: 'test-model', - callId: 'call-1', - output: 'result', - previousResponseId: 'resp-1', - conversationId: 'conv_abc123', - }); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.previous_response_id).toBe('resp-1'); - expect(body.conversation).toBeUndefined(); - }); - - it('falls back to conversation when previousResponseId is not provided', async () => { - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [], - }); - - await service.continueFunctionCallOutput({ - client: mockClient as unknown as ResponsesApiClient, - model: 'test-model', - callId: 'call-1', - output: 'result', - previousResponseId: '', - conversationId: 'conv_abc123', - }); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.conversation).toBe('conv_abc123'); - expect(body.previous_response_id).toBeUndefined(); - }); - - it('omits conversation field when conversationId is not provided', async () => { - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [], - }); - - await service.continueFunctionCallOutput({ - client: mockClient as unknown as ResponsesApiClient, - model: 'test-model', - callId: 'call-1', - output: 'result', - previousResponseId: 'resp-1', - }); - - const body = JSON.parse( - (mockClient.request.mock.calls[0]![1] as { body: string }).body, - ); - expect(body.conversation).toBeUndefined(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/ResponsesApiService.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/ResponsesApiService.ts deleted file mode 100644 index b0a4838167a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/ResponsesApiService.ts +++ /dev/null @@ -1,320 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import type { McpAuthService } from '../../llamastack/McpAuthService'; -import type { ConversationService } from '../conversations/ConversationService'; -import { MESSAGE_PREVIEW_MAX_LENGTH } from '../../../constants'; -import type { - ChatRequest, - EffectiveConfig, - MCPServerConfig, - ResponsesApiInputItem, - ResponsesApiResponse, - ResponsesApiTool, -} from '../../../types'; -import type { BackendToolExecutor } from '../tools/BackendToolExecutor'; -import { requireLastUserMessage } from './chatUtils'; -import type { CapabilityInfo } from '../types'; - -export type { ContinuationResult } from './chatTurnBuilder'; -import type { ContinuationResult } from './chatTurnBuilder'; -import { buildTurnRequest } from './chatTurnBuilder'; -import { executeContinuation } from './continuationHandler'; - -/** - * Dependencies injected into every ResponsesApiService call. - * These are resolved per-request by the orchestrator so that - * runtime config changes take effect immediately. - */ -export interface ChatDeps { - client: ResponsesApiClient; - config: EffectiveConfig; - mcpServers: MCPServerConfig[]; - mcpAuth: McpAuthService | null; - conversations: ConversationService | null; - /** When set, MCP tools are converted to function tools and executed by the backend. */ - backendToolExecutor?: BackendToolExecutor; -} - -export class ResponsesApiService { - private readonly logger: LoggerService; - private capabilityProvider: () => CapabilityInfo; - - constructor( - logger: LoggerService, - capabilityProvider?: () => CapabilityInfo, - ) { - this.logger = logger; - this.capabilityProvider = - capabilityProvider ?? - (() => ({ - functionTools: true, - strictField: true, - maxOutputTokens: true, - mcpTools: true, - parallelToolCalls: true, - truncation: false, - })); - } - - /** Replace the capability provider (e.g. after server version is known). */ - setCapabilityProvider(provider: () => CapabilityInfo): void { - this.capabilityProvider = provider; - } - - /** Get the current server capabilities from the injected provider. */ - getCapabilities(): CapabilityInfo { - return this.capabilityProvider(); - } - - prepareFirstTurn( - request: ChatRequest, - agentInstructions: string, - config: EffectiveConfig, - conversations: ConversationService | null, - ): { - instructions: string; - storeOverride: boolean | undefined; - } { - const userInput = this.extractUserInput(request); - const conversationContext = this.buildConversationContext( - request, - userInput, - ); - const instructions = agentInstructions + conversationContext; - - this.checkSafetyPatterns(userInput, config.safetyPatterns, '[Runner] '); - - let storeOverride: boolean | undefined; - if (request.conversationId && conversations) { - const isFirst = conversations.markFirstStoredTurn(request.conversationId); - storeOverride = isFirst; - } - - return { instructions, storeOverride }; - } - - async chatTurn( - input: string | ResponsesApiInputItem[], - instructions: string, - tools: ResponsesApiTool[], - config: EffectiveConfig, - client: ResponsesApiClient, - options?: { - previousResponseId?: string; - conversationId?: string; - store?: boolean; - }, - ): Promise { - const request = this.buildTurnRequestImpl( - input, - instructions, - tools, - config, - options, - ); - - this.logger.info('[MultiAgent] chatTurn request', { - model: config.model, - inputType: typeof input === 'string' ? 'string' : 'items', - toolCount: tools.length, - store: request.store as boolean, - hasPreviousResponse: !!options?.previousResponseId, - }); - - return client.requestWithRetry('/v1/responses', { - method: 'POST', - body: JSON.stringify(request), - }); - } - - async chatTurnStream( - input: string | ResponsesApiInputItem[], - instructions: string, - tools: ResponsesApiTool[], - config: EffectiveConfig, - client: ResponsesApiClient, - onEvent: (eventData: string) => void, - options?: { - previousResponseId?: string; - conversationId?: string; - store?: boolean; - }, - signal?: AbortSignal, - ): Promise { - const request = this.buildTurnRequestImpl( - input, - instructions, - tools, - config, - { ...options, stream: true }, - ); - - this.logger.info('[MultiAgent] chatTurnStream request', { - model: config.model, - inputType: typeof input === 'string' ? 'string' : 'items', - toolCount: tools.length, - store: request.store as boolean, - hasPreviousResponse: !!options?.previousResponseId, - }); - - await client.streamRequest('/v1/responses', request, onEvent, signal); - } - - private buildTurnRequestImpl( - input: string | ResponsesApiInputItem[], - instructions: string, - tools: ResponsesApiTool[], - config: EffectiveConfig, - options?: { - previousResponseId?: string; - conversationId?: string; - stream?: boolean; - store?: boolean; - }, - ): Record { - return buildTurnRequest( - input, - instructions, - tools, - config, - options, - p => this.isResponsesParamSupported(p), - (req, cfg) => this.applyProductionParams(req, cfg), - ); - } - - private checkSafetyPatterns( - userInput: string, - patterns: string[] | undefined, - logPrefix: string, - ): void { - if (!patterns || patterns.length === 0) return; - const lower = userInput.toLowerCase(); - const matched = patterns.filter(p => lower.includes(p.toLowerCase())); - if (matched.length > 0) { - this.logger.warn( - `${logPrefix}User input matched safety pattern(s): ${matched.join( - ', ', - )}`, - ); - } - } - - private extractUserInput(request: ChatRequest): string { - return requireLastUserMessage(request); - } - - private buildConversationContext( - request: ChatRequest, - userInput: string, - ): string { - const hasNativeContext = - !!request.previousResponseId || !!request.conversationId; - - if (hasNativeContext || request.messages.length <= 1) { - return ''; - } - - const historyMessages = request.messages - .slice(0, -1) - .filter(m => m.role !== 'system'); - - if (historyMessages.length === 0) { - return ''; - } - - const historyParts = historyMessages.map(m => { - const role = m.role === 'user' ? 'User' : 'Assistant'; - const content = m.content - .replace(/[\n\r]/g, ' ') - .substring(0, MESSAGE_PREVIEW_MAX_LENGTH); - return `${role} said: ${content}`; - }); - - const sanitizedInput = userInput.replace(/[\n\r]/g, ' '); - return ` CONVERSATION CONTEXT: This is a follow-up. Previous: ${historyParts.join( - ' | ', - )} | User now says: ${sanitizedInput}`; - } - - private applyProductionParams( - request: Record, - config: EffectiveConfig, - ): void { - if (config.guardrails && config.guardrails.length > 0) { - request.guardrails = config.guardrails; - } - if (config.maxToolCalls !== undefined && config.maxToolCalls > 0) { - request.max_tool_calls = config.maxToolCalls; - } - if (config.maxOutputTokens !== undefined && config.maxOutputTokens > 0) { - if (this.isResponsesParamSupported('max_output_tokens')) { - request.max_output_tokens = config.maxOutputTokens; - } - } - if (config.temperature !== undefined) { - request.temperature = config.temperature; - } - if (config.safetyIdentifier) { - request.safety_identifier = config.safetyIdentifier; - } - if (config.maxInferIters !== undefined && config.maxInferIters > 0) { - request.max_infer_iters = config.maxInferIters; - } - } - - isResponsesParamSupported(param: string): boolean { - const caps = this.getCapabilities(); - switch (param) { - case 'max_output_tokens': - return caps.maxOutputTokens; - case 'function_tools': - return caps.functionTools; - case 'strict': - return caps.strictField; - case 'truncation': - return caps.truncation; - default: - return true; - } - } - - async continueFunctionCallOutput(options: { - client: ResponsesApiClient; - model: string; - callId: string; - output: string; - previousResponseId: string; - guardrails?: string[]; - safetyIdentifier?: string; - functionCall?: { name: string; arguments: string }; - conversationId?: string; - maxOutputTokens?: number; - temperature?: number; - instructions?: string; - truncation?: 'auto' | 'disabled'; - tools?: ResponsesApiTool[]; - additionalToolOutputs?: Array<{ - callId: string; - output: string; - functionCall?: { name: string; arguments: string }; - }>; - }): Promise { - return executeContinuation(options, p => this.isResponsesParamSupported(p)); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/chatTurnBuilder.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/chatTurnBuilder.ts deleted file mode 100644 index 57f3a8734cc..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/chatTurnBuilder.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { DEFAULT_INCLUDE_FIELDS, ZDR_INCLUDE_FIELDS } from '../../../constants'; -import type { - EffectiveConfig, - ResponsesApiInputItem, - ResponsesApiTool, -} from '../../../types'; - -export interface ContinuationResult { - responseId: string; - text: string; - functionCalls: Array<{ callId: string; name: string; arguments: string }>; -} - -export function buildTurnRequest( - input: string | ResponsesApiInputItem[], - instructions: string, - tools: ResponsesApiTool[], - config: EffectiveConfig, - options: - | { - previousResponseId?: string; - conversationId?: string; - stream?: boolean; - store?: boolean; - } - | undefined, - isParamSupported: (param: string) => boolean, - applyProductionParams: ( - request: Record, - config: EffectiveConfig, - ) => void, -): Record { - const isZdrMode = config.zdrMode === true; - const storeValue = options?.store ?? !isZdrMode; - const includeFields = isZdrMode ? ZDR_INCLUDE_FIELDS : DEFAULT_INCLUDE_FIELDS; - - const sanitizedTools = - tools.length > 0 - ? tools.map(tool => { - if (tool.type !== 'function') return tool; - return { - type: 'function' as const, - name: tool.name, - description: tool.description, - parameters: tool.parameters, - ...(tool.strict !== undefined ? { strict: tool.strict } : {}), - }; - }) - : undefined; - - const request: Record = { - input, - model: config.model, - tools: sanitizedTools, - store: storeValue, - include: includeFields, - }; - - if (config.promptRef) { - const prompt: Record = { id: config.promptRef.id }; - if (config.promptRef.version !== undefined) - prompt.version = config.promptRef.version; - if (config.promptRef.variables) - prompt.variables = config.promptRef.variables; - request.prompt = prompt; - } else { - request.instructions = instructions; - } - - if (options?.stream) request.stream = true; - if (config.toolChoice) request.tool_choice = config.toolChoice; - request.parallel_tool_calls = config.parallelToolCalls ?? false; - if (config.textFormat) request.text = { format: config.textFormat }; - - if (config.reasoning) { - const reasoning: Record = {}; - if (config.reasoning.effort) reasoning.effort = config.reasoning.effort; - if (config.reasoning.summary) reasoning.summary = config.reasoning.summary; - if (Object.keys(reasoning).length > 0) request.reasoning = reasoning; - } - - if (options?.conversationId) { - request.conversation = options.conversationId; - } else if (options?.previousResponseId) { - request.previous_response_id = options.previousResponseId; - } - - applyProductionParams(request, config); - - if (config.truncation && isParamSupported('truncation')) { - request.truncation = config.truncation; - } - - return request; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/chatUtils.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/chatUtils.ts deleted file mode 100644 index acb971a1a96..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/chatUtils.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { ChatRequest } from '../../../types'; - -/** - * Extract the last user message content from a ChatRequest. - * Returns `undefined` if no user message is found. - */ -export function extractLastUserMessage( - request: ChatRequest, -): string | undefined { - for (let i = request.messages.length - 1; i >= 0; i--) { - const msg = request.messages[i]; - if (msg.role === 'user' && typeof msg.content === 'string') { - return msg.content; - } - } - return undefined; -} - -/** - * Extract the last user message content from a ChatRequest. - * Throws if no user message is found. - */ -export function requireLastUserMessage( - request: ChatRequest, - errorPrefix = '', -): string { - const content = extractLastUserMessage(request); - if (content === undefined) { - throw new Error(`${errorPrefix}No user message found in request`); - } - return content; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/continuationHandler.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/continuationHandler.ts deleted file mode 100644 index 7aabf7e18c4..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/continuationHandler.ts +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import type { ResponsesApiTool } from '../../../types'; -import type { ContinuationResult } from './chatTurnBuilder'; - -export function buildToolOutputInput(options: { - callId: string; - output: string; - functionCall?: { name: string; arguments: string }; - additionalToolOutputs?: Array<{ - callId: string; - output: string; - functionCall?: { name: string; arguments: string }; - }>; -}): Array> { - const input: Array> = []; - const allOutputs = [ - { - callId: options.callId, - output: options.output, - functionCall: options.functionCall, - }, - ...(options.additionalToolOutputs ?? []), - ]; - for (const toolOutput of allOutputs) { - if (toolOutput.functionCall) { - input.push({ - type: 'function_call', - call_id: toolOutput.callId, - name: toolOutput.functionCall.name, - arguments: toolOutput.functionCall.arguments, - }); - } - input.push({ - type: 'function_call_output', - call_id: toolOutput.callId, - output: toolOutput.output, - }); - } - return input; -} - -export function buildContinuationBody( - options: { - model: string; - conversationId?: string; - previousResponseId?: string; - tools?: ResponsesApiTool[]; - guardrails?: string[]; - safetyIdentifier?: string; - instructions?: string; - maxOutputTokens?: number; - temperature?: number; - truncation?: 'auto' | 'disabled'; - }, - input: Array>, - isParamSupported: (param: string) => boolean, -): Record { - const body: Record = { - model: options.model, - input, - store: true, - }; - - if (options.previousResponseId) { - body.previous_response_id = options.previousResponseId; - } else if (options.conversationId) { - body.conversation = options.conversationId; - } - if (options.tools && options.tools.length > 0) { - body.tools = options.tools.map(tool => { - if (tool.type !== 'function') return tool; - const { type: _discriminator, ...rest } = tool; - return rest; - }); - } - if (options.guardrails && options.guardrails.length > 0) - body.guardrails = options.guardrails; - if (options.safetyIdentifier) - body.safety_identifier = options.safetyIdentifier; - if (options.instructions) body.instructions = options.instructions; - if (options.maxOutputTokens !== undefined && options.maxOutputTokens > 0) { - if (isParamSupported('max_output_tokens')) - body.max_output_tokens = options.maxOutputTokens; - } - if (options.temperature !== undefined) body.temperature = options.temperature; - if (options.truncation && isParamSupported('truncation')) - body.truncation = options.truncation; - return body; -} - -export function parseContinuationResponse(response: { - id: string; - output: Array<{ - type: string; - content?: Array<{ type: string; text: string }>; - name?: string; - call_id?: string; - arguments?: string; - id?: string; - }>; -}): ContinuationResult { - let text = ''; - const functionCalls: ContinuationResult['functionCalls'] = []; - - for (const item of response.output ?? []) { - if (item.type === 'function_call') { - functionCalls.push({ - callId: item.call_id ?? item.id ?? '', - name: item.name ?? '', - arguments: item.arguments ?? '{}', - }); - continue; - } - if (item.type !== 'message') continue; - const msg = item as { content?: Array<{ type: string; text?: string }> }; - for (const c of msg.content ?? []) { - if (c.type === 'output_text' && c.text) text += c.text; - } - } - return { responseId: response.id, text, functionCalls }; -} - -export async function executeContinuation( - options: { - client: ResponsesApiClient; - model: string; - callId: string; - output: string; - previousResponseId: string; - guardrails?: string[]; - safetyIdentifier?: string; - functionCall?: { name: string; arguments: string }; - conversationId?: string; - maxOutputTokens?: number; - temperature?: number; - instructions?: string; - truncation?: 'auto' | 'disabled'; - tools?: ResponsesApiTool[]; - additionalToolOutputs?: Array<{ - callId: string; - output: string; - functionCall?: { name: string; arguments: string }; - }>; - }, - isParamSupported: (param: string) => boolean, -): Promise { - const input = buildToolOutputInput(options); - const body = buildContinuationBody(options, input, isParamSupported); - - const response = await options.client.request<{ - id: string; - output: Array<{ - type: string; - content?: Array<{ type: string; text: string }>; - name?: string; - call_id?: string; - server_label?: string; - arguments?: string; - id?: string; - }>; - }>('/v1/responses', { method: 'POST', body: JSON.stringify(body) }); - - return parseContinuationResponse(response); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/promptGeneration.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/promptGeneration.test.ts deleted file mode 100644 index 3c83c210d85..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/promptGeneration.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { EffectiveConfig } from '../../../types'; -import { - buildMetaPrompt, - extractToolContext, - PROMPT_ENGINEER_INSTRUCTIONS, - type PromptCapabilities, -} from './promptGeneration'; - -function makeConfig(overrides: Partial = {}): EffectiveConfig { - return { - model: 'test-model', - baseUrl: 'https://localhost:8321', - systemPrompt: '', - enableWebSearch: false, - enableCodeInterpreter: false, - vectorStoreIds: [], - vectorStoreName: 'vs', - embeddingModel: 'em', - embeddingDimension: 384, - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - skipTlsVerify: false, - zdrMode: false, - verboseStreamLogging: false, - ...overrides, - }; -} - -describe('promptGeneration', () => { - describe('extractToolContext', () => { - it('lists model and disabled tools by default', () => { - const ctx = extractToolContext(makeConfig()); - expect(ctx).toContain('Model: test-model'); - expect(ctx).toContain('Web Search: disabled'); - expect(ctx).toContain('Code Interpreter: disabled'); - expect(ctx).toContain('MCP Servers: none configured'); - expect(ctx).toContain('not configured'); - expect(ctx).toContain('Safety Shields: disabled'); - }); - - it('reflects enabled capabilities', () => { - const ctx = extractToolContext( - makeConfig({ - enableWebSearch: true, - enableCodeInterpreter: true, - mcpServers: [ - { id: 's1', name: 'GitHub', type: 'sse', url: 'https://gh' }, - { - id: 's2', - name: 'Jira', - type: 'streamable-http', - url: 'https://j', - }, - ], - vectorStoreIds: ['vs1', 'vs2'], - safetyEnabled: true, - inputShields: ['content-safety'], - outputShields: [], - evaluationEnabled: true, - }), - ); - - expect(ctx).toContain('Web Search: enabled'); - expect(ctx).toContain('Code Interpreter: enabled'); - expect(ctx).toContain('GitHub, Jira'); - expect(ctx).toContain('2 active vector store(s)'); - expect(ctx).toContain('Safety Shields: enabled (content-safety)'); - expect(ctx).toContain('Response Evaluation: enabled'); - }); - }); - - describe('buildMetaPrompt', () => { - it('returns { instructions, input } with separated concerns', () => { - const result = buildMetaPrompt( - 'Help with Kubernetes', - makeConfig({ model: 'llama-3' }), - ); - - expect(result).toHaveProperty('instructions'); - expect(result).toHaveProperty('input'); - expect(result.instructions).toBe(PROMPT_ENGINEER_INSTRUCTIONS); - expect(result.input).toContain('Help with Kubernetes'); - expect(result.input).toContain('Model: llama-3'); - expect(result.input).toContain('Generate the system prompt now'); - }); - - it('keeps prompt engineer role in instructions, not in input', () => { - const result = buildMetaPrompt('Help with Kubernetes', makeConfig()); - - expect(result.instructions).toContain('expert AI prompt engineer'); - expect(result.input).not.toContain('expert AI prompt engineer'); - }); - - it('keeps user description in input, not in instructions', () => { - const result = buildMetaPrompt('Help with Kubernetes', makeConfig()); - - expect(result.input).toContain('Help with Kubernetes'); - expect(result.instructions).not.toContain('Help with Kubernetes'); - }); - - it('includes requirement guidelines in instructions', () => { - const result = buildMetaPrompt('Do stuff', makeConfig()); - expect(result.instructions).toContain('clear, concise role definition'); - expect(result.instructions).toContain('150 and 500 words'); - expect(result.instructions).toContain('no meta-commentary'); - }); - - it('uses basic extractToolContext when no capabilities provided', () => { - const result = buildMetaPrompt('Help with K8s', makeConfig()); - expect(result.input).toContain('Web Search: disabled'); - expect(result.input).not.toContain('CONNECTED TOOLS'); - }); - - it('uses detailed capabilities section when capabilities are provided', () => { - const caps: PromptCapabilities = { - enableWebSearch: true, - enableCodeInterpreter: false, - tools: [ - { - name: 'get_pod_logs', - description: 'Retrieve logs from a Kubernetes pod', - serverLabel: 'k8s-mcp', - }, - { - name: 'list_deployments', - description: 'List all deployments', - serverLabel: 'k8s-mcp', - }, - { - name: 'create_ticket', - description: 'Create a JIRA ticket', - serverLabel: 'jira-mcp', - }, - ], - ragEnabled: true, - vectorStoreNames: ['docs-store'], - safetyEnabled: true, - safetyShields: ['content-safety'], - }; - - const result = buildMetaPrompt( - 'Help with Kubernetes', - makeConfig({ model: 'llama-3' }), - caps, - ); - - expect(result.input).toContain('CONNECTED TOOLS'); - expect(result.input).toContain('[k8s-mcp]'); - expect(result.input).toContain('get_pod_logs'); - expect(result.input).toContain('Retrieve logs from a Kubernetes pod'); - expect(result.input).toContain('[jira-mcp]'); - expect(result.input).toContain('create_ticket'); - expect(result.input).toContain('KNOWLEDGE BASE (RAG)'); - expect(result.input).toContain('docs-store'); - expect(result.input).toContain('SAFETY: enabled'); - expect(result.input).toContain('BUILT-IN TOOLS: Web Search'); - expect(result.input).not.toContain('Code Interpreter'); - }); - - it('falls back to extractToolContext when capabilities have no tools', () => { - const result = buildMetaPrompt( - 'Just chat', - makeConfig({ enableWebSearch: true }), - { enableWebSearch: false, enableCodeInterpreter: false }, - ); - expect(result.input).toContain('Web Search: enabled'); - expect(result.input).not.toContain('CONNECTED TOOLS'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/promptGeneration.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/promptGeneration.ts deleted file mode 100644 index 8fdc6eb10cc..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/chat/promptGeneration.ts +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { EffectiveConfig } from '../../../types'; -import type { - ToolCapabilityInfo, - PromptCapabilities, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -export type { ToolCapabilityInfo, PromptCapabilities }; - -export function extractToolContext(config: EffectiveConfig): string { - const lines: string[] = []; - - lines.push( - `- Model: ${config.model}`, - `- Web Search: ${config.enableWebSearch ? 'enabled' : 'disabled'}`, - `- Code Interpreter: ${ - config.enableCodeInterpreter ? 'enabled' : 'disabled' - }`, - ); - - if (config.mcpServers && config.mcpServers.length > 0) { - const names = config.mcpServers.map(s => s.name || s.id).join(', '); - lines.push(`- MCP Servers: ${names}`); - } else { - lines.push('- MCP Servers: none configured'); - } - - const storeCount = config.vectorStoreIds?.length ?? 0; - lines.push( - `- RAG / Knowledge Base: ${ - storeCount > 0 ? `${storeCount} active vector store(s)` : 'not configured' - }`, - ); - - if (config.safetyEnabled) { - const shields = [ - ...(config.inputShields ?? []), - ...(config.outputShields ?? []), - ]; - lines.push( - `- Safety Shields: enabled (${ - shields.length > 0 ? shields.join(', ') : 'default' - })`, - ); - } else { - lines.push('- Safety Shields: disabled'); - } - - if (config.evaluationEnabled) { - lines.push(`- Response Evaluation: enabled`); - } - - return lines.join('\n'); -} - -/** - * Build a rich capabilities section from the user's selected capabilities. - * Unlike extractToolContext, this includes actual tool descriptions so the - * LLM can generate specific, actionable instructions referencing each tool. - */ -function buildDetailedCapabilitiesSection( - capabilities: PromptCapabilities, - config: EffectiveConfig, -): string { - const sections: string[] = []; - - sections.push(`MODEL: ${config.model}`); - - // Built-in tools - const builtIn: string[] = []; - if (capabilities.enableWebSearch) builtIn.push('Web Search'); - if (capabilities.enableCodeInterpreter) builtIn.push('Code Interpreter'); - if (builtIn.length > 0) { - sections.push(`\nBUILT-IN TOOLS: ${builtIn.join(', ')}`); - } - - // MCP tools with descriptions - if (capabilities.tools && capabilities.tools.length > 0) { - const byServer = new Map(); - for (const t of capabilities.tools) { - const key = t.serverLabel || 'general'; - if (!byServer.has(key)) byServer.set(key, []); - byServer.get(key)!.push(t); - } - - const toolLines: string[] = []; - for (const [server, tools] of byServer) { - toolLines.push(` [${server}]`); - for (const t of tools) { - const desc = t.description ? ` — ${t.description}` : ''; - toolLines.push(` • ${t.name}${desc}`); - } - } - sections.push( - `\nCONNECTED TOOLS (the agent can call these):\n${toolLines.join('\n')}`, - ); - } - - // Knowledge base / RAG - if (capabilities.ragEnabled) { - const storeInfo = capabilities.vectorStoreNames?.length - ? `active stores: ${capabilities.vectorStoreNames.join(', ')}` - : 'enabled'; - sections.push(`\nKNOWLEDGE BASE (RAG): ${storeInfo}`); - sections.push( - ' The agent can search uploaded documents for relevant context before answering.', - ); - } - - // Safety - if (capabilities.safetyEnabled) { - const shields = capabilities.safetyShields?.length - ? capabilities.safetyShields.join(', ') - : 'default shields'; - sections.push(`\nSAFETY: enabled (${shields})`); - } - - return sections.join('\n'); -} - -/** - * Permanent system-level instructions for the prompt generation agent. - * Sent as the Responses API `instructions` field to clearly separate the - * agent's role from the user-provided context in `input`. - */ -export const PROMPT_ENGINEER_INSTRUCTIONS = `You are an expert AI prompt engineer specializing in creating production-quality system prompts (agent instructions) for AI assistants deployed on enterprise platforms. - -Your task: Given the user's natural-language description of what they want their agent to do, together with a list of the agent's actually available capabilities and tools, generate a complete, ready-to-use system prompt. - -REQUIREMENTS FOR THE GENERATED PROMPT: -1. Start with a clear, concise role definition ("You are...") -2. Reference EACH available tool by name and explain when the agent should use it — be specific about tool names and their purposes -3. If a knowledge base (RAG) is available, instruct the agent to search it before answering knowledge questions -4. Include behavioral guidelines: tone, safety boundaries, and response formatting -5. Be specific about what the agent should and should not do -6. If safety shields are enabled, include appropriate safety instructions -7. Keep the prompt between 150 and 500 words -8. Output ONLY the system prompt text — no meta-commentary, no markdown fencing, no explanations`; - -export interface MetaPromptResult { - instructions: string; - input: string; -} - -export function buildMetaPrompt( - description: string, - config: EffectiveConfig, - capabilities?: PromptCapabilities, -): MetaPromptResult { - const hasDetailedCaps = - capabilities && - ((capabilities.tools && capabilities.tools.length > 0) || - capabilities.enableWebSearch || - capabilities.enableCodeInterpreter || - capabilities.ragEnabled); - - const capabilitiesSection = hasDetailedCaps - ? buildDetailedCapabilitiesSection(capabilities, config) - : extractToolContext(config); - - const input = `AVAILABLE CAPABILITIES: -${capabilitiesSection} - -USER'S DESCRIPTION: -${description} - -Generate the system prompt now.`; - - return { - instructions: PROMPT_ENGINEER_INSTRUCTIONS, - input, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/ResponsesApiClient.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/ResponsesApiClient.ts deleted file mode 100644 index 71183ab7dfc..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/ResponsesApiClient.ts +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as http from 'http'; -import * as https from 'https'; -import FormDataNode from 'form-data'; -import type { ResponsesApiClientConfig } from '../types'; -import { - API_REQUEST_TIMEOUT_MS, - MAX_API_RETRIES, - RETRY_BASE_DELAY_MS, -} from '../../../constants'; -import { toErrorMessage } from '../../../services/utils'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { streamRequest } from './streamRequest'; - -export { ResponsesApiError, LlamaStackApiError } from './ResponsesApiError'; -export type { ResponsesApiRequestOptions } from './ResponsesApiError'; -import { ResponsesApiError } from './ResponsesApiError'; -import type { ResponsesApiRequestOptions } from './ResponsesApiError'; - -export class ResponsesApiClient { - private readonly config: ResponsesApiClientConfig; - private readonly agent: http.Agent | https.Agent; - private readonly logger?: LoggerService; - - constructor(config: ResponsesApiClientConfig, logger?: LoggerService) { - this.config = config; - this.logger = logger; - const isHttps = config.baseUrl.startsWith('https'); - this.agent = isHttps - ? new https.Agent({ - keepAlive: true, - maxSockets: 10, - rejectUnauthorized: !config.skipTlsVerify, - }) - : new http.Agent({ keepAlive: true, maxSockets: 10 }); - } - - getConfig(): ResponsesApiClientConfig { - return this.config; - } - - async request( - endpoint: string, - options: ResponsesApiRequestOptions = {}, - ): Promise { - const url = new URL(`${this.config.baseUrl}${endpoint}`); - const headers: Record = { - Accept: 'application/json', - ...(options.headers || {}), - }; - - if (this.config.token) { - headers.Authorization = `Bearer ${this.config.token}`; - } - - const isFormData = options.body instanceof FormDataNode; - - if (options.body && !isFormData) { - headers['Content-Type'] = 'application/json'; - } - - return new Promise((resolve, reject) => { - const formDataBody: FormDataNode | null = isFormData - ? (options.body as FormDataNode) - : null; - - const isHttps = url.protocol === 'https:'; - const mergedHeaders = formDataBody - ? { ...headers, ...formDataBody.getHeaders() } - : headers; - - if (formDataBody) { - try { - mergedHeaders['content-length'] = String( - formDataBody.getLengthSync(), - ); - } catch { - // fall back to chunked encoding - } - } - - const reqOptions: https.RequestOptions = { - hostname: url.hostname, - port: url.port || (isHttps ? 443 : 80), - path: url.pathname + url.search, - method: options.method || 'GET', - headers: mergedHeaders, - agent: this.agent, - }; - - const transport = isHttps ? https : http; - const req = transport.request(reqOptions, res => { - let data = ''; - res.on('data', chunk => { - data += chunk; - }); - res.on('end', () => { - if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { - try { - resolve(JSON.parse(data) as T); - } catch { - reject( - new Error( - `Failed to parse response: ${data.substring(0, 200)}`, - ), - ); - } - } else { - reject( - new ResponsesApiError( - res.statusCode!, - res.statusMessage || '', - data, - ), - ); - } - }); - }); - - req.setTimeout(API_REQUEST_TIMEOUT_MS, () => { - req.destroy(); - reject( - new Error( - `Responses API request timed out after ${ - API_REQUEST_TIMEOUT_MS / 1000 - } seconds`, - ), - ); - }); - - req.on('error', e => { - reject(new Error(`Responses API connection error: ${e.message}`)); - }); - - if (options.body) { - if (formDataBody) { - const buf = formDataBody.getBuffer(); - req.write(buf); - req.end(); - } else { - const bodyStr = - typeof options.body === 'string' - ? options.body - : JSON.stringify(options.body); - req.write(bodyStr); - req.end(); - } - } else { - req.end(); - } - }); - } - - async requestWithRetry( - endpoint: string, - options: ResponsesApiRequestOptions = {}, - maxRetries = MAX_API_RETRIES, - ): Promise { - let lastError: Error | undefined; - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - return await this.request(endpoint, options); - } catch (error) { - lastError = error as Error; - if (!this.isRetryable(error) || attempt === maxRetries) throw lastError; - const delay = Math.min(RETRY_BASE_DELAY_MS * 2 ** attempt, 4000); - this.logger?.info( - `[ResponsesApiClient] Retrying ${endpoint} after ${delay}ms (attempt ${attempt + 1}/${maxRetries})`, - ); - await new Promise(r => setTimeout(r, delay)); - } - } - throw lastError ?? new Error('Request failed after retries'); - } - - private isRetryable(error: unknown): boolean { - if (error instanceof ResponsesApiError) { - return [502, 503, 504].includes(error.statusCode); - } - if (error instanceof Error) { - return /ECONNRESET|ECONNREFUSED|ETIMEDOUT|socket hang up/i.test( - error.message, - ); - } - return false; - } - - async streamRequest( - endpoint: string, - body: unknown, - onData: (data: string) => void, - signal?: AbortSignal, - ): Promise { - return streamRequest( - this.config.baseUrl, - this.config.token, - this.agent, - endpoint, - body, - onData, - signal, - ); - } - - async testConnection(): Promise<{ connected: boolean; error?: string }> { - try { - await this.request<{ data: unknown[] }>('/v1/models', { - method: 'GET', - }); - return { connected: true }; - } catch (error) { - return { - connected: false, - error: toErrorMessage(error), - }; - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/ResponsesApiError.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/ResponsesApiError.test.ts deleted file mode 100644 index 9fbfdf1a318..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/ResponsesApiError.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ResponsesApiError } from './ResponsesApiClient'; - -describe('ResponsesApiError', () => { - it('extracts detail from JSON body', () => { - const err = new ResponsesApiError( - 400, - 'Bad Request', - JSON.stringify({ detail: 'unsupported tool type: function' }), - ); - expect(err.statusCode).toBe(400); - expect(err.detail).toBe('unsupported tool type: function'); - expect(err.message).toContain('unsupported tool type: function'); - }); - - it('falls back to raw body when not JSON', () => { - const err = new ResponsesApiError(500, 'Server Error', 'raw text error'); - expect(err.detail).toBe('raw text error'); - }); - - it('truncates long raw body', () => { - const longBody = 'x'.repeat(500); - const err = new ResponsesApiError(500, 'Error', longBody); - expect(err.detail.length).toBeLessThanOrEqual(303); - expect(err.detail).toContain('...'); - }); - - it('isValidationError returns true for 400 and 422', () => { - expect(new ResponsesApiError(400, '', '').isValidationError()).toBe(true); - expect(new ResponsesApiError(422, '', '').isValidationError()).toBe(true); - expect(new ResponsesApiError(500, '', '').isValidationError()).toBe(false); - }); - - it('mentionsToolType detects tool-related errors', () => { - const err = new ResponsesApiError( - 400, - '', - JSON.stringify({ detail: 'unsupported tool type: function' }), - ); - expect(err.mentionsToolType()).toBe(true); - }); - - it('mentionsToolType returns false for unrelated errors', () => { - const err = new ResponsesApiError( - 400, - '', - JSON.stringify({ detail: 'invalid model name' }), - ); - expect(err.mentionsToolType()).toBe(false); - }); - - it('mentionsStrictField detects strict-related errors with field context', () => { - const err = new ResponsesApiError( - 422, - '', - JSON.stringify({ detail: 'unexpected field strict in schema' }), - ); - expect(err.mentionsStrictField()).toBe(true); - }); - - it('has correct name property', () => { - const err = new ResponsesApiError(400, '', ''); - expect(err.name).toBe('ResponsesApiError'); - expect(err instanceof Error).toBe(true); - }); - - it('message uses Responses API prefix', () => { - const err = new ResponsesApiError(400, 'Bad Request', '{}'); - expect(err.message).toContain('Responses API error'); - }); - - it('instanceof works correctly after Object.setPrototypeOf', () => { - const err = new ResponsesApiError(422, 'Unprocessable Entity', '{}'); - expect(err instanceof ResponsesApiError).toBe(true); - expect(err instanceof Error).toBe(true); - expect(err.isValidationError()).toBe(true); - }); - - it('extracts error from different JSON field names', () => { - expect( - new ResponsesApiError(400, '', JSON.stringify({ error: 'err1' })).detail, - ).toBe('err1'); - expect( - new ResponsesApiError(400, '', JSON.stringify({ message: 'msg1' })) - .detail, - ).toBe('msg1'); - }); - - it('extracts detail from Pydantic array-style validation errors', () => { - const pydanticBody = JSON.stringify({ - detail: [ - { - msg: 'extra inputs are not permitted', - loc: ['body', 'tools', 0, 'strict'], - }, - { msg: 'value is not valid', loc: ['body', 'tools', 0, 'type'] }, - ], - }); - const err = new ResponsesApiError( - 422, - 'Unprocessable Entity', - pydanticBody, - ); - expect(err.detail).toContain('extra inputs are not permitted'); - expect(err.detail).toContain('strict'); - expect(err.detail).toContain('value is not valid'); - }); - - it('mentionsStrictField avoids false positives on unrelated use', () => { - const err = new ResponsesApiError( - 400, - '', - JSON.stringify({ detail: 'strict mode is enabled for safety' }), - ); - expect(err.mentionsStrictField()).toBe(false); - }); - - it('mentionsStrictField detects Pydantic-style strict field errors', () => { - const err = new ResponsesApiError( - 422, - '', - JSON.stringify({ - detail: 'extra inputs are not permitted: strict field', - }), - ); - expect(err.mentionsStrictField()).toBe(true); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/ResponsesApiError.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/ResponsesApiError.ts deleted file mode 100644 index 4050749fbb5..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/ResponsesApiError.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export class ResponsesApiError extends Error { - readonly statusCode: number; - readonly detail: string; - readonly rawBody: string; - - constructor(statusCode: number, statusMessage: string, rawBody: string) { - const detail = ResponsesApiError.extractDetail(rawBody); - super(`Responses API error: ${statusCode} ${statusMessage} - ${detail}`); - Object.setPrototypeOf(this, ResponsesApiError.prototype); - this.name = 'ResponsesApiError'; - this.statusCode = statusCode; - this.detail = detail; - this.rawBody = rawBody; - } - - isValidationError(): boolean { - return this.statusCode === 400 || this.statusCode === 422; - } - - mentionsToolType(): boolean { - const combined = `${this.detail} ${this.rawBody}`; - return /unsupported.*tool|tool.*type.*unsupported|function.*tool|unknown.*tool/i.test( - combined, - ); - } - - mentionsStrictField(): boolean { - const combined = `${this.detail} ${this.rawBody}`; - return ( - /\bstrict\b.*\b(field|param|key|property|schema|valid)/i.test(combined) || - /\b(field|param|key|property|unexpected|extra|unknown)\b.*\bstrict\b/i.test( - combined, - ) - ); - } - - isContextOverflowError(): boolean { - const combined = `${this.detail} ${this.rawBody}`; - return ( - this.statusCode === 400 && - /max_tokens\s+must\s+be\s+at\s+least\s+1,\s+got\s+-?\d+/i.test(combined) - ); - } - - private static extractDetail(rawBody: string): string { - try { - const parsed = JSON.parse(rawBody); - if (typeof parsed.detail === 'string') return parsed.detail; - if (Array.isArray(parsed.detail)) { - return parsed.detail - .map((d: { msg?: string; loc?: unknown[] }) => - d.msg - ? `${d.msg}${d.loc ? ` at ${JSON.stringify(d.loc)}` : ''}` - : JSON.stringify(d), - ) - .join('; '); - } - if (typeof parsed.error === 'string') return parsed.error; - if (typeof parsed.message === 'string') return parsed.message; - } catch { - // Not JSON - } - return rawBody.length > 300 ? `${rawBody.substring(0, 300)}...` : rawBody; - } -} - -export interface ResponsesApiRequestOptions { - method?: string; - headers?: Record; - body?: unknown; -} - -/** @deprecated Use ResponsesApiError instead */ -export const LlamaStackApiError = ResponsesApiError; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/streamRequest.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/streamRequest.ts deleted file mode 100644 index 7244b72a470..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/client/streamRequest.ts +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as http from 'http'; -import * as https from 'https'; -import { STREAM_REQUEST_TIMEOUT_MS } from '../../../constants'; -import { ResponsesApiError } from './ResponsesApiError'; - -export async function streamRequest( - baseUrl: string, - token: string | undefined, - agent: http.Agent | https.Agent, - endpoint: string, - body: unknown, - onData: (data: string) => void, - signal?: AbortSignal, -): Promise { - const url = new URL(`${baseUrl}${endpoint}`); - const headers: Record = { - 'Content-Type': 'application/json', - Accept: 'text/event-stream', - }; - - if (token) { - headers.Authorization = `Bearer ${token}`; - } - - const bodyStr = JSON.stringify(body); - - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new Error('Stream aborted before request started')); - return; - } - - const isHttps = url.protocol === 'https:'; - const reqOptions: https.RequestOptions = { - hostname: url.hostname, - port: url.port || (isHttps ? 443 : 80), - path: url.pathname + url.search, - method: 'POST', - headers: { - ...headers, - 'Content-Length': Buffer.byteLength(bodyStr), - }, - agent, - }; - - const transport = isHttps ? https : http; - const req = transport.request(reqOptions, res => { - if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) { - let errorData = ''; - res.on('data', chunk => { - errorData += chunk; - }); - res.on('end', () => { - reject( - new ResponsesApiError( - res.statusCode!, - `Streaming request failed`, - errorData, - ), - ); - }); - return; - } - - let buffer = ''; - - res.on('data', (chunk: Buffer) => { - buffer += chunk.toString(); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.slice(6).trim(); - if (data && data !== '[DONE]') { - onData(data); - } - } - } - }); - - res.on('end', () => { - if (buffer.startsWith('data: ')) { - const data = buffer.slice(6).trim(); - if (data && data !== '[DONE]') { - onData(data); - } - } - resolve(); - }); - - res.on('error', e => { - reject(new Error(`Streaming response error: ${e.message}`)); - }); - }); - - if (signal) { - const onAbort = () => { - req.destroy(); - reject(new Error('Stream aborted by client')); - }; - signal.addEventListener('abort', onAbort, { once: true }); - req.on('close', () => signal.removeEventListener('abort', onAbort)); - } - - req.setTimeout(STREAM_REQUEST_TIMEOUT_MS, () => { - req.destroy(); - reject( - new Error( - `Streaming request timed out after ${ - STREAM_REQUEST_TIMEOUT_MS / 1000 - } seconds`, - ), - ); - }); - - req.on('error', e => { - reject( - new Error(`Responses API streaming connection error: ${e.message}`), - ); - }); - - req.write(bodyStr); - req.end(); - }); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationFacade.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationFacade.ts deleted file mode 100644 index 3439c8a5fd6..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationFacade.ts +++ /dev/null @@ -1,251 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ConversationService } from './ConversationService'; -import type { - ConversationListResult, - ConversationDetails, - InputItemsResult, - ConversationItemsResult, - ProcessedMessage, - ApprovalResult, - ToolApproval, -} from './conversationTypes'; -import type { BackendApprovalStore } from '../tools/BackendApprovalStore'; - -/** - * Context provided by the orchestrator for conversation operations. - */ -export interface ConversationFacadeContext { - ensureInitialized: () => void; -} - -/** - * Dependencies for ConversationFacade. - */ -export interface ConversationFacadeDeps { - conversations: ConversationService | null; - context: ConversationFacadeContext; - logger?: LoggerService; -} - -/** - * Facade for all conversation-related operations. - * Extracted from ResponsesApiCoordinator to separate concerns. - */ -/** Callback that executes a backend tool approval and returns the result. */ -export type BackendToolApprovalHandler = ( - approval: ToolApproval, -) => Promise; - -export class ConversationFacade { - private conversations: ConversationService | null; - private readonly ctx: ConversationFacadeContext; - private readonly logger?: LoggerService; - private backendApprovalStore?: BackendApprovalStore; - private backendToolApprovalHandler?: BackendToolApprovalHandler; - - constructor(deps: ConversationFacadeDeps) { - this.conversations = deps.conversations; - this.ctx = deps.context; - this.logger = deps.logger; - } - - /** - * Update the underlying conversation service reference. - * Called by the orchestrator after initialization. - */ - setConversations(conversations: ConversationService | null): void { - this.conversations = conversations; - } - - /** - * Wire in backend tool approval support. - * Called by the orchestrator when backend tool execution mode is available. - */ - setBackendApprovalHandler( - store: BackendApprovalStore, - handler: BackendToolApprovalHandler, - ): void { - this.backendApprovalStore = store; - this.backendToolApprovalHandler = handler; - } - - /** - * List stored conversations - */ - async listConversations( - limit?: number, - order?: 'asc' | 'desc', - after?: string, - ): Promise { - this.ctx.ensureInitialized(); - if (!this.conversations) { - return { conversations: [], hasMore: false }; - } - return this.conversations.listConversations(limit, order, after); - } - - /** - * Get a specific conversation - */ - async getConversation( - responseId: string, - ): Promise { - this.ctx.ensureInitialized(); - if (!this.conversations) { - return null; - } - return this.conversations.getConversation(responseId); - } - - /** - * Get input items for a conversation - */ - async getConversationInputs(responseId: string): Promise { - this.ctx.ensureInitialized(); - if (!this.conversations) { - return { items: [], hasMore: false }; - } - return this.conversations.getConversationInputs(responseId); - } - - /** - * Delete a conversation (response + optional conversation container) - */ - async deleteConversation( - responseId: string, - conversationId?: string, - ): Promise { - this.ctx.ensureInitialized(); - if (!this.conversations) { - return false; - } - return this.conversations.deleteConversation(responseId, conversationId); - } - - /** - * Delete only the Llama Stack conversation container by ID. - * Used during session cleanup when we have a conversationId but no responseId. - */ - async deleteConversationContainer(conversationId: string): Promise { - this.ctx.ensureInitialized(); - if (!this.conversations) { - return false; - } - return this.conversations.deleteConversationContainer(conversationId); - } - - /** - * Create a new Llama Stack conversation container - */ - async createConversation(): Promise { - this.ctx.ensureInitialized(); - if (!this.conversations) { - throw new Error('Conversation service not initialized'); - } - return this.conversations.createConversation(); - } - - /** - * Get all items for a Llama Stack conversation - */ - async getConversationItems( - conversationId: string, - ): Promise { - this.ctx.ensureInitialized(); - if (!this.conversations) { - return { items: [] }; - } - return this.conversations.getConversationItems(conversationId); - } - - /** - * Get processed messages for a conversation, ready for frontend rendering. - */ - async getProcessedMessages( - conversationId: string, - ): Promise { - this.ctx.ensureInitialized(); - if (!this.conversations) { - this.logger?.warn( - `[ConversationFacade] getProcessedMessages called but ConversationService is null — returning empty for ${conversationId}`, - ); - return []; - } - return this.conversations.getProcessedMessages(conversationId); - } - - /** - * Walk the response chain to reconstruct full conversation history (legacy fallback) - */ - async walkResponseChain( - responseId: string, - ): Promise> { - this.ctx.ensureInitialized(); - if (!this.conversations) { - return []; - } - return this.conversations.walkResponseChain(responseId); - } - - /** - * Continue conversation after HITL approval. - * Checks for pending backend tool approvals first — if found, the - * tool is executed by the backend and the result is fed to LlamaStack. - * Otherwise falls through to the standard MCP approval flow. - */ - async continueAfterApproval( - responseId: string, - approvalRequestId: string, - approved: boolean, - toolName?: string, - toolArguments?: string, - conversationId?: string, - ): Promise { - this.ctx.ensureInitialized(); - - if (this.backendApprovalStore && this.backendToolApprovalHandler) { - const pending = this.backendApprovalStore.get( - responseId, - approvalRequestId, - ); - if (pending) { - const result = await this.backendToolApprovalHandler({ - responseId, - callId: approvalRequestId, - approved, - toolName: toolName ?? pending.originalToolName, - toolArguments: toolArguments ?? pending.argumentsJson, - }); - this.backendApprovalStore.remove(responseId, approvalRequestId); - return result; - } - } - - if (!this.conversations) { - throw new Error('Conversation service not initialized'); - } - return this.conversations.continueAfterApproval( - responseId, - approvalRequestId, - approved, - toolName, - toolArguments, - conversationId, - ); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationHelpers.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationHelpers.ts deleted file mode 100644 index f289fd18615..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationHelpers.ts +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { normalizeContent } from './InputItemsNormalizer'; -import type { - ConversationItem, - ConversationSummary, - InputItem, -} from './conversationTypes'; - -/** Raw item shape from Llama Stack /v1/conversations/{id}/items API */ -export type RawConversationItem = Record; - -/** Raw item shape from Llama Stack /v1/responses/{id}/input_items API */ -export type RawInputItem = Record; - -/** - * Map raw API items from /v1/conversations/{id}/items to ConversationItem[]. - * Pure transformation - no side effects. - */ -export function mapRawItemsToConversationItems( - rawItems: RawConversationItem[], -): ConversationItem[] { - return rawItems.map(item => { - const normalizedContent = normalizeContent(item.content); - - const result: ConversationItem = { - type: item.type as string, - id: item.id as string | undefined, - role: item.role as string | undefined, - content: normalizedContent, - status: item.status as string | undefined, - call_id: item.call_id as string | undefined, - output: item.output as string | undefined, - }; - - if (typeof item.name === 'string') result.name = item.name; - if (typeof item.server_label === 'string') - result.server_label = item.server_label; - if (typeof item.arguments === 'string') result.arguments = item.arguments; - if (typeof item.error === 'string') result.error = item.error; - if (Array.isArray(item.queries)) result.queries = item.queries as string[]; - if (Array.isArray(item.results)) { - result.results = (item.results as Array>).map( - r => ({ - filename: r.filename as string | undefined, - file_id: r.file_id as string | undefined, - text: r.text as string | undefined, - score: r.score as number | undefined, - attributes: r.attributes as Record | undefined, - }), - ); - } - if (Array.isArray(item.summary)) { - result.summary = (item.summary as Array>).map( - s => ({ - type: s.type as string, - text: s.text as string, - }), - ); - } - if (typeof item.created_at === 'number') { - result.created_at = item.created_at; - } - - return result; - }); -} - -/** - * Map raw API items from /v1/responses/{id}/input_items to InputItem[]. - * Pure transformation - normalizes content field for each item. - */ -export function mapRawInputItemsToNormalized( - rawItems: RawInputItem[], -): InputItem[] { - return rawItems.map(item => { - const normalizedContent = normalizeContent(item.content); - return { - type: item.type as string, - id: item.id as string | undefined, - role: item.role as string | undefined, - content: normalizedContent, - status: item.status as string | undefined, - call_id: item.call_id as string | undefined, - output: item.output as string | undefined, - }; - }); -} - -/** Raw response item from Llama Stack list responses API */ -export type RawListResponseItem = { - id?: string; - model?: string; - status?: string; - created_at?: number; - input?: unknown; - previous_response_id?: string; - conversation?: string; -}; - -/** - * Build a ConversationSummary from a raw list response item. - * Returns null if required fields (id, created_at, model) are missing. - */ -export function toConversationSummary( - r: RawListResponseItem, - preview: string, - conversationId: string | undefined, -): ConversationSummary | null { - if (!r.id || !r.created_at || !r.model) return null; - - return { - responseId: r.id, - preview, - createdAt: new Date(r.created_at * 1000), - model: r.model, - status: (r.status as 'completed' | 'failed' | 'in_progress') || 'completed', - conversationId: r.conversation || conversationId || undefined, - previousResponseId: r.previous_response_id, - }; -} - -/** Output item from Llama Stack approval continuation response */ -export type ApprovalOutputItem = { - type: string; - id?: string; - name?: string; - status?: string; - error?: string; - output?: string; - content?: Array<{ type: string; text: string }>; -}; - -/** - * Create a compact summary of output items for logging. - * Pure transformation - no side effects. - */ -export function createOutputSummaryForLogging( - output: ApprovalOutputItem[], -): Array> { - return (output || []).map(item => { - const summary: Record = { type: item.type }; - if (item.name) summary.name = item.name; - if (item.status) summary.status = item.status; - if (item.error) summary.error = item.error; - if (item.output !== undefined) - summary.hasOutput = item.output !== null && item.output !== ''; - if (item.content) summary.contentParts = item.content.length; - return summary; - }); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationRegistry.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationRegistry.ts deleted file mode 100644 index f51309cea01..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationRegistry.ts +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { LoggerService, DatabaseService } from '@backstage/backend-plugin-api'; -import { MAX_CONVERSATION_REGISTRY_SIZE } from '../../../constants'; -import { toErrorMessage } from '../../../services/utils'; - -/** - * ConversationRegistry - * - * Manages the mapping between Llama Stack response IDs and conversation IDs. - * Used to enrich the sidebar listing with conversationId so the frontend can - * load full history via the Conversations API. - * - * - In-memory cache (bounded) for fast lookups - * - Optional database persistence when DatabaseService is provided - * - Tracks first stored turn per conversation for store: true/false logic - */ -export class ConversationRegistry { - private responseToConversation = new Map(); - private storedConversationIds = new Set(); - - private static readonly MAX_STORED_IDS = MAX_CONVERSATION_REGISTRY_SIZE; - private static readonly TABLE_NAME = 'augment_conversation_registry'; - - /** Knex client — lazily initialized from DatabaseService */ - private db: Awaited> | null = null; - - constructor( - private readonly database: DatabaseService | undefined, - private readonly logger: LoggerService, - ) {} - - /** - * Set up the database table if DatabaseService is available. - * Falls back to in-memory if no database is configured. - */ - async initializeDatabase(): Promise { - if (!this.database) { - this.logger.info( - 'No DatabaseService provided — conversation registry will use in-memory storage', - ); - return; - } - - try { - this.db = await this.database.getClient(); - - const hasTable = await this.db.schema.hasTable( - ConversationRegistry.TABLE_NAME, - ); - if (!hasTable) { - try { - await this.db.schema.createTable( - ConversationRegistry.TABLE_NAME, - table => { - table.string('response_id').primary().notNullable(); - table.string('conversation_id').notNullable(); - table.timestamp('created_at').defaultTo(this.db!.fn.now()); - }, - ); - this.logger.info( - `Created ${ConversationRegistry.TABLE_NAME} table for persistent conversation registry`, - ); - } catch (createError) { - const existsNow = await this.db.schema.hasTable( - ConversationRegistry.TABLE_NAME, - ); - if (!existsNow) throw createError; - this.logger.info( - `${ConversationRegistry.TABLE_NAME} table was created by another instance`, - ); - } - } else { - this.logger.info( - `Using existing ${ConversationRegistry.TABLE_NAME} table for conversation registry`, - ); - } - } catch (error) { - const msg = toErrorMessage(error); - this.logger.warn( - `Failed to initialize database for conversation registry, falling back to in-memory: ${msg}`, - ); - this.db = null; - } - } - - /** - * Records a conversation's first stored turn. Returns true if this is - * the first call for this conversationId (i.e. it was not yet tracked). - * Side-effect: adds the conversationId to the tracked set so subsequent - * calls return false. - * - * This drives the `store` flag: first turn → store: true (appears - * in sidebar listing), subsequent turns → store: false (hidden - * from listing but still populates conversation items). - */ - markFirstStoredTurn(conversationId: string): boolean { - if (this.storedConversationIds.has(conversationId)) { - return false; - } - if ( - this.storedConversationIds.size >= ConversationRegistry.MAX_STORED_IDS - ) { - const firstKey = this.storedConversationIds.values().next().value; - if (firstKey !== undefined) { - this.storedConversationIds.delete(firstKey); - } - } - this.storedConversationIds.add(conversationId); - return true; - } - - /** - * Register a mapping from a response ID to its conversation ID. - * Called after a response completes so the sidebar listing can - * enrich entries with the correct conversationId. - * - * Persisted to the database when available; always cached in memory. - * In-memory cache is capped at 10 000 entries. - */ - async registerResponse( - conversationId: string, - responseId: string, - ): Promise { - // In-memory cache (bounded) - if (this.responseToConversation.size >= MAX_CONVERSATION_REGISTRY_SIZE) { - const firstKey = this.responseToConversation.keys().next().value; - if (firstKey) this.responseToConversation.delete(firstKey); - } - this.responseToConversation.set(responseId, conversationId); - - // Persist to database if available - if (this.db) { - try { - await this.db(ConversationRegistry.TABLE_NAME) - .insert({ response_id: responseId, conversation_id: conversationId }) - .onConflict('response_id') - .merge(); - } catch (error) { - const msg = toErrorMessage(error); - this.logger.warn( - `Failed to persist conversation registry entry: ${msg}`, - ); - } - } - - this.logger.debug( - `Registered response ${responseId} → conversation ${conversationId}`, - ); - } - - /** - * Look up the conversationId for a response (if known). - * Checks in-memory cache first, falls back to database. - */ - async getConversationForResponse( - responseId: string, - ): Promise { - // Fast path: in-memory cache hit - const cached = this.responseToConversation.get(responseId); - if (cached) return cached; - - // Slow path: database lookup (only if db is available) - if (this.db) { - try { - const row = await this.db(ConversationRegistry.TABLE_NAME) - .where('response_id', responseId) - .first<{ conversation_id: string } | undefined>(); - if (row) { - this.responseToConversation.set(responseId, row.conversation_id); - return row.conversation_id; - } - } catch (error) { - const msg = toErrorMessage(error); - this.logger.warn( - `Failed to look up conversation registry from database: ${msg}`, - ); - } - } - - return undefined; - } - - /** - * Remove all registry entries associated with a given conversationId. - * Called when a conversation is deleted to prevent stale lookups. - */ - async removeByConversationId(conversationId: string): Promise { - // Clean in-memory cache - const keysToDelete: string[] = []; - for (const [responseId, convId] of this.responseToConversation) { - if (convId === conversationId) keysToDelete.push(responseId); - } - for (const key of keysToDelete) { - this.responseToConversation.delete(key); - } - - this.storedConversationIds.delete(conversationId); - - // Clean database entries - if (this.db) { - try { - await this.db(ConversationRegistry.TABLE_NAME) - .where('conversation_id', conversationId) - .delete(); - } catch (error) { - const msg = toErrorMessage(error); - this.logger.warn( - `Failed to remove conversation registry entries for ${conversationId}: ${msg}`, - ); - } - } - - this.logger.debug( - `Removed ${keysToDelete.length} registry entries for conversation ${conversationId}`, - ); - } - - /** Returns the current size of the response→conversation cache (for logging). */ - getRegistrySize(): number { - return this.responseToConversation.size; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationService.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationService.ts deleted file mode 100644 index 32884a99ecb..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ConversationService.ts +++ /dev/null @@ -1,296 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { LoggerService, DatabaseService } from '@backstage/backend-plugin-api'; -import { toErrorMessage } from '../../../services/utils'; -import { MAX_AUTO_REAPPROVALS } from '../../../constants'; -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import { McpAuthService } from '../../llamastack/McpAuthService'; -import { MCPServerConfig } from '../../../types'; -import { ConversationRegistry } from './ConversationRegistry'; -import { - executeApprovalContinuation, - type ApprovalSafetyContext, -} from '../tools/ApprovalContinuationExecutor'; -import { - walkResponseChain as walkResponseChainFn, - fetchResponsesFromApi as fetchResponsesFromApiFn, -} from './ResponseChainWalker'; -import type { - ConversationClientAccessor, - ConversationListResult, - ConversationDetails, - InputItemsResult, - ConversationItemsResult, - ProcessedMessage, - ApprovalResult, -} from './conversationTypes'; -import { - fetchConversation, - fetchConversationInputs, - fetchConversationItems, - fetchProcessedMessages, - enrichResponsesWithConversations, - deleteConversation as doDeleteConversation, - createConversation as doCreateConversation, -} from './conversationFetcher'; - -/** - * Conversation Service - * - * Manages conversation history using the Llama Stack Responses API: - * - Listing conversations - * - Getting conversation details - * - Getting conversation input items - * - Deleting conversations - * - Human-in-the-Loop (HITL) approval handling - */ -export class ConversationService { - private readonly clientAccessor: ConversationClientAccessor; - private readonly mcpAuth: McpAuthService; - private readonly mcpServers: MCPServerConfig[]; - private readonly logger: LoggerService; - private readonly registry: ConversationRegistry; - private safetyContextProvider?: () => ApprovalSafetyContext | undefined; - - constructor( - clientAccessor: ConversationClientAccessor, - mcpAuth: McpAuthService, - mcpServers: MCPServerConfig[], - logger: LoggerService, - database?: DatabaseService, - ) { - this.clientAccessor = clientAccessor; - this.mcpAuth = mcpAuth; - this.mcpServers = mcpServers; - this.logger = logger; - this.registry = new ConversationRegistry(database, logger); - } - - setSafetyContextProvider( - provider: () => ApprovalSafetyContext | undefined, - ): void { - this.safetyContextProvider = provider; - } - - /** Convenience — returns the current ResponsesApiClient. */ - private get client(): ResponsesApiClient { - return this.clientAccessor.getClient(); - } - - /** - * Set up the database table if DatabaseService is available. - * Falls back to in-memory if no database is configured. - */ - async initializeDatabase(): Promise { - await this.registry.initializeDatabase(); - } - - /** - * Records a conversation's first stored turn. Returns true if this is - * the first call for this conversationId (i.e. it was not yet tracked). - */ - markFirstStoredTurn(conversationId: string): boolean { - return this.registry.markFirstStoredTurn(conversationId); - } - - /** - * Register a mapping from a response ID to its conversation ID. - */ - async registerResponse( - conversationId: string, - responseId: string, - ): Promise { - await this.registry.registerResponse(conversationId, responseId); - } - - /** - * Look up the conversationId for a response (if known). - */ - async getConversationForResponse( - responseId: string, - ): Promise { - return this.registry.getConversationForResponse(responseId); - } - - /** - * List stored conversations (responses) from Llama Stack - * Uses GET /v1/responses API - */ - async listConversations( - limit: number = 10, - order: 'asc' | 'desc' = 'desc', - after?: string, - ): Promise { - const result = await this.fetchConversationsWithLimit(limit, order, after); - - return { - conversations: result.conversations, - hasMore: result.hasMore, - lastId: result.lastId, - }; - } - - async getConversation( - responseId: string, - ): Promise { - return fetchConversation(this.client, responseId, this.logger); - } - - async getConversationInputs(responseId: string): Promise { - return fetchConversationInputs(this.client, responseId, this.logger); - } - - async deleteConversation( - responseId: string, - conversationId?: string, - ): Promise { - return doDeleteConversation( - this.client, - responseId, - conversationId, - this.logger, - ); - } - - async deleteConversationContainer(conversationId: string): Promise { - try { - await this.client.request(`/v1/conversations/${conversationId}`, { - method: 'DELETE', - }); - await this.registry.removeByConversationId(conversationId); - this.logger.info(`Deleted conversation container ${conversationId}`); - return true; - } catch (error) { - this.logger.warn( - `Failed to delete conversation container ${conversationId}: ${toErrorMessage(error)}`, - ); - return false; - } - } - - async createConversation(): Promise { - return doCreateConversation(this.client, this.logger); - } - - async getConversationItems( - conversationId: string, - ): Promise { - return fetchConversationItems(this.client, conversationId, this.logger); - } - - async getProcessedMessages( - conversationId: string, - ): Promise { - return fetchProcessedMessages(this.client, conversationId, this.logger); - } - - async walkResponseChain( - responseId: string, - ): Promise> { - return walkResponseChainFn( - responseId, - id => this.getConversation(id), - this.logger, - ); - } - - async continueAfterApproval( - responseId: string, - approvalRequestId: string, - approved: boolean, - toolName?: string, - toolArguments?: string, - conversationId?: string, - ): Promise { - return executeApprovalContinuation( - { - clientAccessor: this.clientAccessor, - mcpAuth: this.mcpAuth, - mcpServers: this.mcpServers, - getConversationForResponse: id => - this.registry.getConversationForResponse(id), - registerResponse: (convId, respId) => - this.registry.registerResponse(convId, respId), - logger: this.logger, - safetyContext: this.safetyContextProvider?.(), - }, - { - responseId, - approvalRequestId, - approved, - toolName, - toolArguments, - conversationId, - attempt: 0, - maxAttempts: MAX_AUTO_REAPPROVALS, - }, - ); - } - - private async fetchConversationsWithLimit( - limit: number, - order: 'asc' | 'desc', - after?: string, - ): Promise { - const apiResponse = await this.fetchResponsesFromApi(limit, order, after); - const conversations = await this.enrichResponsesWithConversations( - apiResponse.data || [], - ); - this.logger.info( - `Returning ${ - conversations.length - } conversations (${this.registry.getRegistrySize()} in registry)`, - ); - return { - conversations, - hasMore: apiResponse.has_more, - lastId: apiResponse.last_id, - }; - } - - /** HTTP call to list responses from Llama Stack GET /v1/responses */ - private async fetchResponsesFromApi( - limit: number, - order: 'asc' | 'desc', - after?: string, - ) { - return fetchResponsesFromApiFn( - this.clientAccessor, - limit, - order, - after, - this.logger, - ); - } - - private async enrichResponsesWithConversations( - items: Array<{ - id: string; - model: string; - status: string; - created_at: number; - input: Array<{ - type: string; - content?: string | Array<{ type: string; text?: string }>; - role?: string; - }>; - previous_response_id?: string; - conversation?: string; - }>, - ): Promise { - return enrichResponsesWithConversations(items, this.registry); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/InputItemsNormalizer.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/InputItemsNormalizer.ts deleted file mode 100644 index a7818916afc..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/InputItemsNormalizer.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Normalize message/response content into a consistent format. - * Handles string content (passed through) and array-of-parts content - * (normalized to { type, text } objects). - * - * Used by getConversationInputs and getConversationItems to ensure - * consistent content structure regardless of API response shape. - */ -export function normalizeContent( - content: unknown, -): string | Array<{ type: string; text?: string }> | undefined { - if (typeof content === 'string') { - return content; - } - if (Array.isArray(content)) { - return content.map((c: unknown) => { - if (typeof c === 'object' && c !== null) { - const part = c as Record; - return { - type: String(part.type || 'text'), - text: typeof part.text === 'string' ? part.text : undefined, - }; - } - return { type: 'text', text: String(c) }; - }); - } - return undefined; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/MessageProcessor.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/MessageProcessor.ts deleted file mode 100644 index 884d57d61bc..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/MessageProcessor.ts +++ /dev/null @@ -1,341 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { - ConversationItem, - ProcessedMessage, - ProcessedToolCall, - ProcessedRagSource, -} from './conversationTypes'; - -/** - * Extract plain text from a Responses API content field. - * Handles string, array-of-parts, and undefined. - * - * @param excludeInputText - When true, skips `input_text` parts. - * Use for assistant messages: Llama Stack stores the system prompt - * (instructions) as `input_text` in conversation items, which must - * not be shown to the user. - */ -export function extractContentFromItem( - content: string | Array<{ type: string; text?: string }> | undefined, - excludeInputText = false, -): string { - if (!content) return ''; - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - return content - .filter(c => { - if (typeof c.text !== 'string') return false; - if (c.type === 'output_text' || c.type === 'text') return true; - if (c.type === 'input_text') return !excludeInputText; - return false; - }) - .map(c => c.text || '') - .join(''); - } - return ''; -} - -/** - * Extract user input text from a raw response input field. - * - * Two-pass approach: first looks for explicit `{type:'message', role:'user'}` - * items, then falls back to bare `input_text` items. This prevents - * accidentally returning the system prompt (instructions) when Llama Stack - * stores them as `input_text` items before the user message. - */ -export function extractUserInputFromRaw(input: unknown): string { - if (typeof input === 'string') return input; - - if (Array.isArray(input)) { - // Pass 1: look for explicit user messages - for (const item of input) { - if (!item || typeof item !== 'object') continue; - const inputItem = item as Record; - - if (inputItem.type === 'message' && inputItem.role === 'user') { - if (typeof inputItem.content === 'string') return inputItem.content; - if (Array.isArray(inputItem.content)) { - return extractContentFromItem( - inputItem.content as Array<{ type: string; text?: string }>, - ); - } - } - } - - // Pass 2: fall back to bare input_text (only if no user message found) - for (const item of input) { - if (!item || typeof item !== 'object') continue; - const inputItem = item as Record; - - if ( - inputItem.type === 'input_text' && - typeof inputItem.text === 'string' - ) { - return inputItem.text; - } - } - } - - return ''; -} - -/** - * Extract a preview string from the response input. - * Prioritizes user messages to avoid showing the system prompt in - * conversation list previews. - */ -export function getInputText( - input: - | string - | Array<{ - type: string; - content?: string | Array<{ type: string; text?: string }>; - role?: string; - }>, -): string { - if (typeof input === 'string') { - return input.slice(0, 80) + (input.length > 80 ? '...' : ''); - } - if (!Array.isArray(input)) return ''; - - const userMessage = input.find( - item => item.type === 'message' && item.role === 'user', - ); - const target = - userMessage ?? - input.find( - item => - item.type === 'message' && - item.role !== 'developer' && - item.role !== 'system', - ); - if (target) { - const content = extractContentFromItem(target.content); - if (content) { - return content.slice(0, 80) + (content.length > 80 ? '...' : ''); - } - } - return ''; -} - -/** - * Process raw conversation items into frontend-ready messages. - * - * Groups tool calls (mcp_call, file_search_call, web_search_call) with - * the next assistant message, extracts RAG sources, and drops orphaned - * tool calls. - */ -const HANDOFF_PREFIX = 'transfer_to_'; - -function isHandoffFunctionCall(item: ConversationItem): boolean { - return ( - item.type === 'function_call' && - typeof item.name === 'string' && - item.name.startsWith(HANDOFF_PREFIX) - ); -} - -function extractReasoningText(item: ConversationItem): string { - if (Array.isArray(item.summary)) { - return item.summary - .filter(s => s.type === 'summary_text' && typeof s.text === 'string') - .map(s => s.text) - .join('\n\n'); - } - const text = extractContentFromItem( - item.content as string | Array<{ type: string; text?: string }> | undefined, - ); - return text || ''; -} - -function parseAgentNameFromHandoffOutput( - output: string | undefined, -): string | undefined { - if (!output) return undefined; - try { - const parsed = JSON.parse(output); - if ( - typeof parsed === 'object' && - parsed !== null && - typeof parsed.assistant === 'string' - ) { - return parsed.assistant; - } - } catch { - // not JSON — ignore - } - return undefined; -} - -export function processConversationItems( - items: ConversationItem[], - logger: LoggerService, -): ProcessedMessage[] { - const messages: ProcessedMessage[] = []; - let pendingToolCalls: ProcessedToolCall[] = []; - let pendingRagSources: ProcessedRagSource[] = []; - let pendingReasoning: string[] = []; - let pendingAgentName: string | undefined; - - for (const item of items) { - if (item.type === 'mcp_list_tools') continue; - - if (item.type === 'reasoning') { - const text = extractReasoningText(item); - if (text) pendingReasoning.push(text); - continue; - } - - if (item.type === 'function_call_output') { - const agentName = parseAgentNameFromHandoffOutput(item.output); - if (agentName) { - pendingAgentName = agentName; - } - continue; - } - - if ( - item.type === 'mcp_call' || - item.type === 'function_call' || - item.type === 'web_search_call' - ) { - if (isHandoffFunctionCall(item)) { - const rawName = item.name!.slice(HANDOFF_PREFIX.length); - pendingAgentName = rawName.replace(/_/g, ' '); - continue; - } - - pendingToolCalls.push({ - id: item.id || item.call_id || `tool-${pendingToolCalls.length}`, - name: item.name || item.type, - serverLabel: item.server_label || 'llamastack', - arguments: item.arguments, - output: item.output, - error: item.error, - status: item.error ? 'failed' : 'completed', - }); - continue; - } - - if (item.type === 'file_search_call') { - pendingToolCalls.push({ - id: item.id || item.call_id || `tool-${pendingToolCalls.length}`, - name: 'knowledge_search', - serverLabel: 'llamastack', - arguments: item.queries ? JSON.stringify(item.queries) : undefined, - output: item.results - ? `${item.results.length} result(s) found` - : 'No results found', - status: 'completed', - }); - if (item.results) { - for (const r of item.results) { - pendingRagSources.push({ - filename: r.filename || r.file_id || '', - fileId: r.file_id, - text: r.text, - score: r.score, - attributes: r.attributes, - }); - } - } - continue; - } - - if (item.type !== 'message') continue; - if (item.role !== 'user' && item.role !== 'assistant') continue; - - if (item.role === 'assistant' && Array.isArray(item.content)) { - const hasInputText = item.content.some( - c => c.type === 'input_text' && typeof c.text === 'string', - ); - if (hasInputText) { - logger.info( - '[Sanitization] Stripped input_text (instructions) from assistant message in conversation items', - ); - } - } - - const text = extractContentFromItem( - item.content as - | string - | Array<{ type: string; text?: string }> - | undefined, - item.role === 'assistant', - ); - if (!text.trim()) { - logger.debug( - `[MessageProcessor] Skipping ${item.role} message with empty text after content extraction`, - ); - continue; - } - - if (item.role === 'user' && pendingToolCalls.length > 0) { - logger.debug( - `Dropping ${pendingToolCalls.length} orphaned tool call(s) before user message`, - ); - pendingToolCalls = []; - pendingRagSources = []; - } - if (item.role === 'user') { - pendingReasoning = []; - pendingAgentName = undefined; - } - - const msg: ProcessedMessage = { role: item.role, text }; - - if (typeof item.created_at === 'number') { - msg.createdAt = new Date(item.created_at * 1000).toISOString(); - } - - if (item.role === 'assistant') { - if (pendingToolCalls.length > 0) { - msg.toolCalls = pendingToolCalls; - pendingToolCalls = []; - } - if (pendingRagSources.length > 0) { - msg.ragSources = pendingRagSources; - pendingRagSources = []; - } - if (pendingReasoning.length > 0) { - msg.reasoning = pendingReasoning.join('\n\n'); - pendingReasoning = []; - } - if (pendingAgentName) { - msg.agentName = pendingAgentName; - pendingAgentName = undefined; - } - } - - messages.push(msg); - } - - if (pendingToolCalls.length > 0) { - logger.debug( - `Dropping ${pendingToolCalls.length} orphaned tool call(s) at end of conversation`, - ); - } - - if (items.length > 0 && messages.length === 0) { - logger.warn( - `[MessageProcessor] Received ${items.length} conversation items but produced 0 messages — all items were filtered out`, - ); - } - - return messages; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ResponseChainWalker.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ResponseChainWalker.ts deleted file mode 100644 index 4f6103c464b..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/ResponseChainWalker.ts +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { - ConversationClientAccessor, - ConversationDetails, -} from './conversationTypes'; -import { toErrorMessage } from '../../../services/utils'; -import { - extractContentFromItem, - extractUserInputFromRaw, -} from './MessageProcessor'; -import { RESPONSE_CHAIN_TIMEOUT_MS } from '../../../constants'; - -/** Response shape from Llama Stack GET /v1/responses */ -export interface ResponseListApiResult { - data: Array<{ - id: string; - model: string; - status: string; - created_at: number; - input: Array<{ - type: string; - content?: string | Array<{ type: string; text?: string }>; - role?: string; - }>; - output: Array<{ - type: string; - role?: string; - content?: string | Array<{ type: string; text?: string }>; - }>; - previous_response_id?: string; - conversation?: string; - }>; - has_more: boolean; - last_id?: string; -} - -/** - * Walk the previous_response_id chain to reconstruct full conversation - * history. Used as a fallback for legacy responses that lack a - * conversationId. Returns messages in chronological order. - * - * Guards: - * - Per-request timeout of 15 s — if the chain is very long and the - * upstream server is slow, we return whatever we've collected so far - * rather than hanging indefinitely. - * - Visited-set to break cycles (defensive against bad data). - */ -export async function walkResponseChain( - responseId: string, - getConversation: (responseId: string) => Promise, - logger: LoggerService, -): Promise> { - const deadline = Date.now() + RESPONSE_CHAIN_TIMEOUT_MS; - - const chain: Array<{ role: 'user' | 'assistant'; text: string }> = []; - const visited = new Set(); - - let currentId: string | undefined = responseId; - let depth = 0; - - while (currentId) { - if (visited.has(currentId)) { - logger.warn(`Cycle detected in response chain at ${currentId}, stopping`); - break; - } - if (Date.now() > deadline) { - logger.warn( - `Chain walk timed out after ${RESPONSE_CHAIN_TIMEOUT_MS}ms at depth ${depth}, returning partial result`, - ); - break; - } - - visited.add(currentId); - depth++; - - try { - const response = await getConversation(currentId); - if (!response) break; - - // Extract assistant output - if (response.output) { - for (const output of response.output) { - if ( - output.type === 'message' && - output.role === 'assistant' && - output.content - ) { - const text = extractContentFromItem(output.content, true); - if (text.trim()) { - chain.push({ role: 'assistant', text }); - } - break; - } - } - } - - const userText = extractUserInputFromRaw(response.input); - if (userText.trim()) { - chain.push({ role: 'user', text: userText }); - } - - currentId = response.previousResponseId; - } catch { - logger.debug('Response chain walk stopped due to fetch error'); - break; - } - } - - chain.reverse(); - logger.info( - `Walked response chain for ${responseId}: ${chain.length} messages across ${depth} responses`, - ); - return chain; -} - -/** - * HTTP call to list responses from Llama Stack GET /v1/responses - */ -export async function fetchResponsesFromApi( - clientAccessor: ConversationClientAccessor, - limit: number, - order: 'asc' | 'desc', - after: string | undefined, - logger: LoggerService, -): Promise { - const params = new URLSearchParams({ - limit: limit.toString(), - order, - }); - if (after) { - params.set('after', after); - } - - logger.info(`Fetching conversations: /v1/responses?${params.toString()}`); - - try { - const client = clientAccessor.getClient(); - const response = await client.request( - `/v1/responses?${params.toString()}`, - { method: 'GET' }, - ); - - logger.info( - `Llama Stack returned ${ - (response.data || []).length - } responses, has_more: ${response.has_more}`, - ); - - return response; - } catch (error) { - logger.error(`Failed to fetch conversations: ${toErrorMessage(error)}`); - throw error; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/SessionStore.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/SessionStore.ts deleted file mode 100644 index 4afb62d681f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/SessionStore.ts +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import type { ResponsesApiInputItem } from '../../../types'; - -/** - * Conversation item stored on the Llama Stack server. - */ -export interface ConversationItem { - id: string; - type: string; - role?: string; - content?: unknown; - [key: string]: unknown; -} - -/** - * Session memory manager wrapping the Llama Stack Conversations API. - * - * Provides: - * - Session creation and lookup via `POST /v1/conversations` - * - Item retrieval via `GET /v1/conversations/{id}/items` - * - History compaction via `POST /v1/conversations/{id}/items` (inserting summary items) - * - Item count tracking for automatic compaction triggers - * - * Matches the OpenAI Agents SDK `RunState.session` pattern: - * the SDK maintains session history and supports compaction to avoid - * unbounded context growth. This implementation leverages Llama Stack's - * server-side persistence instead of maintaining client-side state. - */ -export class SessionStore { - private readonly compactionThreshold: number; - - constructor( - private readonly logger: LoggerService, - options?: { compactionThreshold?: number }, - ) { - this.compactionThreshold = options?.compactionThreshold ?? 50; - } - - async createSession(client: ResponsesApiClient): Promise { - const response = await client.requestWithRetry<{ id: string }>( - '/v1/conversations', - { method: 'POST', body: JSON.stringify({}) }, - ); - this.logger.info(`[SessionStore] Created session: ${response.id}`); - return response.id; - } - - async getItems( - client: ResponsesApiClient, - conversationId: string, - ): Promise { - const response = await client.requestWithRetry< - { data: ConversationItem[] } | ConversationItem[] - >(`/v1/conversations/${encodeURIComponent(conversationId)}/items`, { - method: 'GET', - }); - return Array.isArray(response) ? response : (response.data ?? []); - } - - /** - * Insert a summary item into the conversation to compact history. - * Older items remain on the server but the summary provides a condensed - * view for the model's context window. - */ - async insertCompactionSummary( - client: ResponsesApiClient, - conversationId: string, - summaryText: string, - ): Promise { - const item: ResponsesApiInputItem = { - type: 'message', - role: 'system', - content: `[Session Summary] ${summaryText}`, - }; - - await client.requestWithRetry( - `/v1/conversations/${encodeURIComponent(conversationId)}/items`, - { method: 'POST', body: JSON.stringify(item) }, - ); - - this.logger.info( - `[SessionStore] Inserted compaction summary into conversation ${conversationId}`, - ); - } - - /** - * Check if a conversation needs compaction based on item count. - */ - async shouldCompact( - client: ResponsesApiClient, - conversationId: string, - ): Promise { - const items = await this.getItems(client, conversationId); - return items.length >= this.compactionThreshold; - } - - /** - * Get the count of items in a conversation. - */ - async getItemCount( - client: ResponsesApiClient, - conversationId: string, - ): Promise { - const items = await this.getItems(client, conversationId); - return items.length; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/conversationFetcher.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/conversationFetcher.ts deleted file mode 100644 index 311258f18dd..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/conversationFetcher.ts +++ /dev/null @@ -1,299 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { toErrorMessage } from '../../../services/utils'; -import { - ResponsesApiError, - type ResponsesApiClient, -} from '../client/ResponsesApiClient'; -import { - mapRawItemsToConversationItems, - mapRawInputItemsToNormalized, -} from './ConversationHelpers'; -import { getInputText, processConversationItems } from './MessageProcessor'; -import type { - ConversationDetails, - ConversationSummary, - InputItemsResult, - ConversationItemsResult, - ProcessedMessage, -} from './conversationTypes'; -import { toConversationSummary } from './ConversationHelpers'; -import type { ConversationRegistry } from './ConversationRegistry'; - -function extractItemsArray( - response: unknown, - logger: LoggerService, -): Array> { - if (Array.isArray(response)) { - logger.debug('Conversation items returned as bare array — wrapping'); - return response as Array>; - } - if (response && typeof response === 'object') { - const obj = response as Record; - if (Array.isArray(obj.data)) - return obj.data as Array>; - if (Array.isArray(obj.items)) { - logger.debug( - 'Conversation items returned under "items" key instead of "data"', - ); - return obj.items as Array>; - } - } - logger.warn( - `Unexpected conversation items response shape: ${typeof response}`, - ); - return []; -} - -export async function fetchConversation( - client: ResponsesApiClient, - responseId: string, - logger: LoggerService, -): Promise { - logger.debug(`Getting conversation ${responseId}`); - - try { - const response = await client.request<{ - id: string; - model: string; - status: string; - created_at: number; - input: unknown; - output: Array<{ - type: string; - id?: string; - role?: string; - content?: Array<{ type: string; text: string }>; - }>; - previous_response_id?: string; - }>(`/v1/responses/${responseId}`, { method: 'GET' }); - - logger.info( - `Got conversation ${responseId}, input type: ${typeof response.input}, isArray: ${Array.isArray( - response.input, - )}, input length: ${ - Array.isArray(response.input) ? response.input.length : 'n/a' - }, output items: ${response.output?.length || 0}`, - ); - - return { - id: response.id, - model: response.model, - status: response.status, - createdAt: new Date(response.created_at * 1000), - input: response.input, - output: response.output || [], - previousResponseId: response.previous_response_id, - }; - } catch (error) { - const errorMsg = toErrorMessage(error); - if (errorMsg.includes('400') || errorMsg.includes('Bad Request')) { - logger.warn( - `Llama Stack schema validation error for ${responseId} - response may contain MCP tool calls that can't be serialized`, - ); - } else { - logger.warn(`Failed to get conversation ${responseId}: ${errorMsg}`); - } - return null; - } -} - -export async function fetchConversationInputs( - client: ResponsesApiClient, - responseId: string, - logger: LoggerService, -): Promise { - logger.debug(`Getting input items for response ${responseId}`); - - try { - const response = await client.request<{ - data: Array<{ - type: string; - id?: string; - role?: string; - content?: unknown; - status?: string; - call_id?: string; - output?: string; - }>; - has_more: boolean; - }>(`/v1/responses/${responseId}/input_items`, { method: 'GET' }); - - const normalizedItems = mapRawInputItemsToNormalized( - (response.data || []) as Array>, - ); - - logger.info( - `Got ${normalizedItems.length} input items for response ${responseId}`, - ); - - return { - items: normalizedItems, - hasMore: response.has_more || false, - }; - } catch (error) { - const errorMsg = toErrorMessage(error); - if (errorMsg.includes('400') || errorMsg.includes('Bad Request')) { - logger.warn( - `Llama Stack schema validation error for input_items ${responseId} - conversation may contain MCP tool calls`, - ); - } else { - logger.warn( - `Failed to get conversation inputs for ${responseId}: ${errorMsg}`, - ); - } - return { items: [], hasMore: false }; - } -} - -export async function fetchConversationItems( - client: ResponsesApiClient, - conversationId: string, - logger: LoggerService, -): Promise { - logger.info(`Getting items for conversation ${conversationId}`); - - try { - const response = await client.request( - `/v1/conversations/${conversationId}/items?order=asc`, - { method: 'GET' }, - ); - - const rawItems = extractItemsArray(response, logger); - const items = mapRawItemsToConversationItems(rawItems); - - logger.info(`Got ${items.length} items for conversation ${conversationId}`); - return { items }; - } catch (error) { - if (error instanceof ResponsesApiError && error.statusCode === 404) { - logger.warn( - `Conversation ${conversationId} not found (404) — may have been garbage-collected`, - ); - return { items: [] }; - } - const errorMsg = toErrorMessage(error); - logger.warn( - `Failed to get conversation items for ${conversationId}: ${errorMsg}`, - ); - throw error; - } -} - -export async function fetchProcessedMessages( - client: ResponsesApiClient, - conversationId: string, - logger: LoggerService, -): Promise { - logger.info(`Getting processed messages for conversation ${conversationId}`); - - const { items } = await fetchConversationItems( - client, - conversationId, - logger, - ); - const messages = processConversationItems(items, logger); - - logger.info( - `Processed ${messages.length} messages for conversation ${conversationId}`, - ); - return messages; -} - -export async function enrichResponsesWithConversations( - items: Array<{ - id: string; - model: string; - status: string; - created_at: number; - input: Array<{ - type: string; - content?: string | Array<{ type: string; text?: string }>; - role?: string; - }>; - previous_response_id?: string; - conversation?: string; - }>, - registry: ConversationRegistry, -): Promise { - const conversations: ConversationSummary[] = []; - - for (const r of items) { - if (!r.id) continue; - const preview = getInputText(r.input) || 'Conversation'; - const registryConvId = await registry.getConversationForResponse(r.id); - const summary = toConversationSummary( - r, - preview, - r.conversation || registryConvId, - ); - if (summary) conversations.push(summary); - } - - return conversations; -} - -export async function deleteConversation( - client: ResponsesApiClient, - responseId: string, - conversationId: string | undefined, - logger: LoggerService, -): Promise { - try { - await client.request(`/v1/responses/${responseId}`, { method: 'DELETE' }); - logger.info(`Deleted response ${responseId}`); - } catch (error) { - logger.warn( - `Failed to delete response ${responseId}: ${toErrorMessage(error)}`, - ); - return false; - } - - if (conversationId) { - try { - await client.request(`/v1/conversations/${conversationId}`, { - method: 'DELETE', - }); - logger.info(`Deleted conversation container ${conversationId}`); - } catch (error) { - logger.warn( - `Failed to delete conversation container ${conversationId} (non-fatal): ${toErrorMessage(error)}`, - ); - } - } - - return true; -} - -export async function createConversation( - client: ResponsesApiClient, - logger: LoggerService, -): Promise { - logger.info('Creating new Llama Stack conversation'); - try { - const response = await client.request<{ id: string }>('/v1/conversations', { - method: 'POST', - body: JSON.stringify({}), - }); - logger.info(`Created conversation: ${response.id}`); - return response.id; - } catch (error) { - const errorMsg = toErrorMessage(error); - logger.error(`Failed to create conversation: ${errorMsg}`); - throw error; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/conversationTypes.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/conversationTypes.ts deleted file mode 100644 index cdb09382999..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/conversations/conversationTypes.ts +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import type { ConversationSummary, ResponseUsage } from '../../../types'; - -/** - * Accessor functions injected into ConversationService so that - * runtime config changes (baseUrl, model) take effect without - * restarting the plugin. The orchestrator (or ClientManager in - * Step 3) provides concrete implementations. - */ -export interface ConversationClientAccessor { - /** Returns the current ResponsesApiClient (may be re-created on baseUrl change). */ - getClient: () => ResponsesApiClient; - /** Returns the current model identifier. */ - getModel: () => string; -} - -export type { ConversationSummary }; - -/** - * Conversation list result - * @public - */ -export interface ConversationListResult { - conversations: ConversationSummary[]; - hasMore: boolean; - lastId?: string; -} - -/** - * Conversation details from the Responses API. - * @public - */ -export interface ConversationDetails { - id: string; - model: string; - status: string; - createdAt: Date; - input: unknown; - output: Array<{ - type: string; - id?: string; - role?: string; - content?: Array<{ type: string; text: string }>; - status?: string; - name?: string; - call_id?: string; - arguments?: string; - output?: string; - error?: string; - server_label?: string; - results?: Array<{ - text: string; - filename?: string; - file_id?: string; - score?: number; - attributes?: Record; - }>; - }>; - usage?: ResponseUsage; - previousResponseId?: string; - conversationId?: string; -} - -/** - * Input item from conversation - * @public - */ -export interface InputItem { - type: string; - id?: string; - role?: string; - content?: unknown; - status?: string; - call_id?: string; - name?: string; - arguments?: string; - output?: string; -} - -/** - * Input items result - * @public - */ -export interface InputItemsResult { - items: InputItem[]; - hasMore: boolean; -} - -/** - * Item from the Conversations API (/v1/conversations/\{id\}/items) - * @public - */ -export interface ConversationItem { - type: string; - id?: string; - role?: string; - content?: unknown; - status?: string; - name?: string; - call_id?: string; - arguments?: string; - output?: string; - error?: string; - server_label?: string; - queries?: string[]; - results?: Array<{ - filename?: string; - file_id?: string; - text?: string; - score?: number; - attributes?: Record; - }>; - /** Reasoning summary parts (for type:'reasoning' items) */ - summary?: Array<{ type: string; text: string }>; - /** Unix timestamp (seconds) from LlamaStack, if available */ - created_at?: number; -} - -/** - * Result from getConversationItems - * @public - */ -export interface ConversationItemsResult { - items: ConversationItem[]; -} - -/** - * Processed tool call from conversation - * @public - */ -export interface ProcessedToolCall { - id: string; - name: string; - serverLabel: string; - arguments?: string; - output?: string; - error?: string; - status: string; -} - -/** - * Processed RAG source from conversation - * @public - */ -export interface ProcessedRagSource { - filename: string; - text?: string; - score?: number; - fileId?: string; - attributes?: Record; -} - -/** - * Processed message from conversation - * @public - */ -export interface ProcessedMessage { - role: 'user' | 'assistant' | 'system'; - text: string; - toolCalls?: ProcessedToolCall[]; - ragSources?: ProcessedRagSource[]; - /** ISO 8601 timestamp of when this message was created, if available */ - createdAt?: string; - /** Display name of the agent that produced this message (multi-agent only) */ - agentName?: string; - /** Model reasoning/thinking text extracted from reasoning items */ - reasoning?: string; -} - -/** - * Tool approval for HITL - * @public - */ -export interface ToolApproval { - responseId: string; - callId: string; - approved: boolean; - toolName?: string; - toolArguments?: string; -} - -/** - * Result of a tool approval submission - * @public - */ -export interface ApprovalResult { - content: string; - responseId: string; - toolExecuted: boolean; - toolOutput?: string; - /** Whether the tool output was truncated to fit context limits. */ - outputTruncated?: boolean; - /** Present when the model requests approval for a chained tool call. */ - pendingApproval?: { - approvalRequestId: string; - toolName: string; - serverLabel?: string; - arguments?: string; - }; - /** Present when the continuation triggered a handoff to another agent. */ - handoff?: { - fromAgent: string; - toAgent: string; - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentHashCache.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentHashCache.ts deleted file mode 100644 index d58938d3f84..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentHashCache.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentLister.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentLister.test.ts deleted file mode 100644 index 50024490c12..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentLister.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { fetchAllVectorStoreFiles, listDocuments } from './DocumentLister'; -import type { DocumentListerDeps } from './DocumentLister'; -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import type { LlamaStackConfig } from '../../../types'; -import { createMockLogger } from '../../../test-utils/mocks'; - -function createMockClient(): jest.Mocked> { - return { - request: jest.fn(), - }; -} - -function createDeps( - overrides?: Partial, -): DocumentListerDeps & { client: jest.Mocked } { - const client = createMockClient(); - const config: LlamaStackConfig = { - baseUrl: 'https://llama.example.com', - vectorStoreIds: ['vs_123'], - vectorStoreName: 'test-store', - embeddingModel: 'test-emb', - embeddingDimension: 384, - model: 'test-model', - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - }; - return { - client: client as unknown as jest.Mocked, - config, - logger: createMockLogger(), - ...overrides, - } as unknown as DocumentListerDeps & { - client: jest.Mocked; - }; -} - -describe('DocumentLister', () => { - describe('fetchAllVectorStoreFiles', () => { - it('fetches files when response is object with data array', async () => { - const deps = createDeps(); - deps.client.request.mockResolvedValue({ - data: [ - { - id: 'file_1', - object: 'vector_store.file', - status: 'completed', - created_at: 1234567890, - usage_bytes: 100, - vector_store_id: 'vs_123', - }, - ], - has_more: false, - }); - - const result = await fetchAllVectorStoreFiles(deps); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe('file_1'); - expect(result[0].status).toBe('completed'); - expect(deps.client.request).toHaveBeenCalledWith( - '/v1/vector_stores/vs_123/files?limit=100', - { method: 'GET' }, - ); - }); - - it('uses custom vectorStoreId when provided', async () => { - const deps = createDeps(); - deps.client.request.mockResolvedValue({ data: [], has_more: false }); - - await fetchAllVectorStoreFiles(deps, 'vs_custom'); - - expect(deps.client.request).toHaveBeenCalledWith( - '/v1/vector_stores/vs_custom/files?limit=100', - { method: 'GET' }, - ); - }); - - it('throws InputError when no vector store configured', async () => { - const deps = createDeps({ - config: { - baseUrl: 'https://x.com', - vectorStoreIds: [], - vectorStoreName: 'x', - embeddingModel: 'emb', - embeddingDimension: 384, - model: 'm', - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - } as LlamaStackConfig, - }); - - await expect(fetchAllVectorStoreFiles(deps)).rejects.toThrow( - 'No vector store configured', - ); - }); - - it('paginates when has_more is true', async () => { - const deps = createDeps(); - deps.client.request - .mockResolvedValueOnce({ - data: [ - { - id: 'file_1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_123', - }, - ], - has_more: true, - }) - .mockResolvedValueOnce({ - data: [ - { - id: 'file_2', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_123', - }, - ], - has_more: false, - }); - - const result = await fetchAllVectorStoreFiles(deps); - - expect(result).toHaveLength(2); - expect(result[0].id).toBe('file_1'); - expect(result[1].id).toBe('file_2'); - - expect(deps.client.request).toHaveBeenCalledTimes(2); - expect(deps.client.request).toHaveBeenNthCalledWith( - 1, - '/v1/vector_stores/vs_123/files?limit=100', - { method: 'GET' }, - ); - expect(deps.client.request).toHaveBeenNthCalledWith( - 2, - '/v1/vector_stores/vs_123/files?limit=100&after=file_1', - { method: 'GET' }, - ); - }); - - it('handles array response format', async () => { - const deps = createDeps(); - deps.client.request.mockResolvedValue([ - { - id: 'f1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_123', - }, - ]); - - const result = await fetchAllVectorStoreFiles(deps); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe('f1'); - }); - }); - - describe('listDocuments', () => { - it('returns documents from vector store files endpoint', async () => { - const deps = createDeps(); - deps.client.request - .mockResolvedValueOnce({ - data: [ - { - id: 'vsfile_1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 50, - vector_store_id: 'vs_123', - }, - ], - has_more: false, - }) - .mockResolvedValueOnce({ - id: 'vsfile_1', - object: 'file', - bytes: 50, - created_at: 0, - filename: 'doc.md', - purpose: 'assistants', - }); - - const result = await listDocuments(deps); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - id: 'vsfile_1', - fileName: 'doc.md', - fileSize: 50, - status: 'completed', - }); - expect(result[0].format).toBeDefined(); - expect(result[0].uploadedAt).toBeDefined(); - - expect(deps.client.request).toHaveBeenCalledWith('/v1/files/vsfile_1', { - method: 'GET', - }); - }); - - it('logs and uses basic info when file details request fails', async () => { - const deps = createDeps(); - deps.client.request - .mockResolvedValueOnce({ - data: [ - { - id: 'vsfile_1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 50, - vector_store_id: 'vs_123', - }, - ], - has_more: false, - }) - .mockRejectedValueOnce(new Error('File not found')); - - const result = await listDocuments(deps); - - expect(deps.logger.debug).toHaveBeenCalledWith( - 'Could not get details for file vsfile_1, using basic info', - ); - expect(result).toBeDefined(); - expect(Array.isArray(result)).toBe(true); - }); - - it('falls back to Files API when vector store endpoint fails', async () => { - const deps = createDeps(); - deps.client.request - .mockRejectedValueOnce(new Error('Vector store unavailable')) - .mockResolvedValueOnce({ - data: [ - { - id: 'file_1', - object: 'file', - bytes: 100, - created_at: 1234567890, - filename: 'fallback.txt', - purpose: 'assistants', - }, - ], - }); - - const result = await listDocuments(deps); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe('file_1'); - expect(result[0].fileName).toBe('fallback.txt'); - expect(result[0].status).toBe('completed'); - expect(deps.logger.debug).toHaveBeenCalledWith( - expect.stringContaining('Vector store files endpoint not available'), - ); - }); - - it('filters Files API fallback to purpose=assistants only', async () => { - const deps = createDeps(); - deps.client.request - .mockRejectedValueOnce(new Error('VS unavailable')) - .mockResolvedValueOnce({ - data: [ - { - id: 'f1', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'assistants.txt', - purpose: 'assistants', - }, - { - id: 'f2', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'other.txt', - purpose: 'fine-tune', - }, - ], - }); - - const result = await listDocuments(deps); - - expect(result).toHaveLength(1); - expect(result[0].fileName).toBe('assistants.txt'); - }); - - it('throws when both endpoints fail', async () => { - const deps = createDeps(); - deps.client.request - .mockRejectedValueOnce(new Error('Vector store failed')) - .mockRejectedValueOnce(new Error('Files API failed')); - - await expect(listDocuments(deps)).rejects.toThrow( - 'Could not load documents: Vector store failed', - ); - expect(deps.logger.warn).toHaveBeenCalledWith( - expect.stringContaining('Could not list documents'), - ); - }); - - it('deduplicates files in Files API fallback', async () => { - const deps = createDeps(); - deps.client.request - .mockRejectedValueOnce(new Error('VS failed')) - .mockResolvedValueOnce({ - data: [ - { - id: 'dup', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'a.txt', - purpose: 'assistants', - }, - { - id: 'dup', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'a.txt', - purpose: 'assistants', - }, - ], - }); - - const result = await listDocuments(deps); - - expect(result).toHaveLength(1); - }); - - it('processes files in batches of 10 for detail fetching', async () => { - const deps = createDeps(); - const vsFiles = Array.from({ length: 15 }, (_, i) => ({ - id: `vsfile_${i}`, - object: 'vector_store.file', - status: 'completed' as const, - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_123', - })); - deps.client.request.mockResolvedValueOnce({ - data: vsFiles, - has_more: false, - }); - - for (let i = 0; i < 15; i++) { - deps.client.request.mockResolvedValueOnce({ - id: `vsfile_${i}`, - object: 'file', - bytes: 10, - created_at: 0, - filename: `f${i}.txt`, - purpose: 'assistants', - }); - } - - const result = await listDocuments(deps); - - expect(result).toHaveLength(15); - expect(deps.client.request).toHaveBeenCalledTimes(16); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentLister.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentLister.ts deleted file mode 100644 index 987d8e40f66..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentLister.ts +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { InputError } from '@backstage/errors'; -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import { - FileFormat, - type DocumentInfo, - type LlamaStackConfig, - type LlamaStackFileResponse, - type LlamaStackVectorStoreFileResponse, -} from '../../../types'; -import { VECTOR_STORE_PAGE_SIZE } from '../../../constants'; -import { toErrorMessage, detectFileFormat } from '../../../services/utils'; - -export interface DocumentListerDeps { - client: ResponsesApiClient; - config: LlamaStackConfig; - logger: LoggerService; -} - -/** - * Fetch all files from vector store with pagination. - * Handles pagination automatically to retrieve all files. - * - * @param deps - Client, config, and logger - * @param vectorStoreId - Optional vector store ID (defaults to config.vectorStoreIds[0]) - */ -export async function fetchAllVectorStoreFiles( - deps: DocumentListerDeps, - vectorStoreId?: string, -): Promise { - const { client, config } = deps; - const targetId = vectorStoreId ?? config.vectorStoreIds[0]; - if (!targetId) { - throw new InputError('No vector store configured — cannot list files'); - } - const allFiles: LlamaStackVectorStoreFileResponse[] = []; - let after: string | undefined; - let hasMore = true; - - while (hasMore) { - const url = after - ? `/v1/vector_stores/${targetId}/files?limit=${VECTOR_STORE_PAGE_SIZE}&after=${after}` - : `/v1/vector_stores/${targetId}/files?limit=${VECTOR_STORE_PAGE_SIZE}`; - - const response = await client.request< - | { - data?: LlamaStackVectorStoreFileResponse[]; - has_more?: boolean; - } - | LlamaStackVectorStoreFileResponse[] - >(url, { method: 'GET' }); - - const files = Array.isArray(response) ? response : response.data || []; - allFiles.push(...files); - - if (Array.isArray(response)) { - hasMore = files.length === VECTOR_STORE_PAGE_SIZE; - } else { - hasMore = response.has_more ?? false; - } - - if (hasMore && files.length > 0) { - after = files[files.length - 1].id; - } - } - - return allFiles; -} - -/** - * List all documents in the vector store. - * Uses vector store files endpoint first (source of truth), falls back to Files API. - * Implements pagination to fetch all documents when count exceeds page size. - * - * @param deps - Client, config, and logger - * @param vectorStoreId - Optional vector store ID (defaults to config.vectorStoreIds[0]) - */ -export async function listDocuments( - deps: DocumentListerDeps, - vectorStoreId?: string, -): Promise { - const { client, logger } = deps; - - try { - const vsFiles = await fetchAllVectorStoreFiles(deps, vectorStoreId); - - logger.info(`Vector store files endpoint returned ${vsFiles.length} files`); - - const BATCH_SIZE = 10; - const documents: DocumentInfo[] = []; - for (let i = 0; i < vsFiles.length; i += BATCH_SIZE) { - const batch = vsFiles.slice(i, i + BATCH_SIZE); - const results = await Promise.all( - batch.map(async vsFile => { - try { - const fileDetails = await client.request( - `/v1/files/${vsFile.id}`, - { method: 'GET' }, - ); - return { - id: vsFile.id, - fileName: fileDetails.filename, - format: detectFileFormat(fileDetails.filename), - fileSize: fileDetails.bytes, - uploadedAt: new Date(vsFile.created_at * 1000).toISOString(), - status: vsFile.status, - } as DocumentInfo; - } catch { - logger.debug( - `Could not get details for file ${vsFile.id}, using basic info`, - ); - return { - id: vsFile.id, - fileName: vsFile.id, - format: FileFormat.TEXT, - fileSize: 0, - uploadedAt: new Date(vsFile.created_at * 1000).toISOString(), - status: vsFile.status, - } as DocumentInfo; - } - }), - ); - documents.push(...results); - } - - logger.info(`Returning ${documents.length} documents from vector store`); - return documents; - } catch (error) { - const errorMessage = toErrorMessage(error); - - logger.debug(`Vector store files endpoint not available: ${errorMessage}`); - - try { - const response = await client.request< - | { - data?: LlamaStackFileResponse[]; - } - | LlamaStackFileResponse[] - >(`/v1/files`, { method: 'GET' }); - - const files: LlamaStackFileResponse[] = Array.isArray(response) - ? response - : response.data || []; - - logger.debug(`Files API returned ${files.length} files`); - - const documents: DocumentInfo[] = []; - const seenFileIds = new Set(); - - for (const file of files) { - if (file.purpose !== 'assistants') { - continue; - } - - if (seenFileIds.has(file.id)) { - continue; - } - seenFileIds.add(file.id); - - documents.push({ - id: file.id, - fileName: file.filename, - format: detectFileFormat(file.filename), - fileSize: file.bytes, - uploadedAt: file.created_at - ? new Date(file.created_at * 1000).toISOString() - : new Date().toISOString(), - status: 'completed', - }); - } - - logger.info(`Returning ${documents.length} documents`); - return documents; - } catch (fallbackError) { - const fallbackMsg = toErrorMessage(fallbackError); - logger.warn( - `Could not list documents: vector store endpoint failed (${errorMessage}), files API also failed (${fallbackMsg})`, - ); - throw new Error(`Could not load documents: ${errorMessage}`); - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentSyncService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentSyncService.test.ts deleted file mode 100644 index 877cc4deca7..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentSyncService.test.ts +++ /dev/null @@ -1,661 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import os from 'os'; -import path from 'path'; -import type { DatabaseService } from '@backstage/backend-plugin-api'; -import { FileFormat } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { DocumentInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { VectorStoreService } from './VectorStoreService'; -import type { DocumentIngestionService } from '../../../services/DocumentIngestionService'; -import type { DocumentsConfig, FetchedDocument } from '../../../types'; -import { createMockLogger } from '../../../test-utils'; -import { DocumentSyncService } from './DocumentSyncService'; - -const HASH_TABLE = 'augment_content_hashes'; - -function createMockVectorStore(): jest.Mocked { - return { - listDocuments: jest.fn().mockResolvedValue([]), - uploadDocuments: jest.fn().mockResolvedValue({ uploaded: [], failed: [] }), - deleteDocument: jest.fn().mockResolvedValue(undefined), - } as unknown as jest.Mocked; -} - -function createMockIngestion(): jest.Mocked< - Pick -> { - return { - fetchFromSources: jest.fn().mockResolvedValue([]), - } as unknown as jest.Mocked< - Pick - >; -} - -function createMockDatabase( - rows: Array<{ - file_name: string; - content_hash: string; - source_id: string | null; - }> = [], -): { - getClient: jest.Mock; - tableChain: { - select: jest.Mock; - insert: jest.Mock; - onConflict: jest.Mock; - merge: jest.Mock; - where: jest.Mock; - delete: jest.Mock; - }; - schema: { hasTable: jest.Mock; createTable: jest.Mock }; -} { - const tableChain = { - select: jest.fn().mockResolvedValue(rows), - insert: jest.fn().mockReturnValue({ - onConflict: jest.fn().mockReturnValue({ - merge: jest.fn().mockResolvedValue(undefined), - }), - }), - onConflict: jest.fn().mockReturnThis(), - merge: jest.fn().mockResolvedValue(undefined), - where: jest.fn().mockReturnValue({ - delete: jest.fn().mockResolvedValue(undefined), - }), - delete: jest.fn().mockResolvedValue(undefined), - }; - - const schema = { - hasTable: jest.fn().mockResolvedValue(false), - createTable: jest - .fn() - .mockImplementation( - (_name: string, callback: (table: unknown) => void) => { - const table = { - string: jest.fn().mockReturnThis(), - primary: jest.fn().mockReturnThis(), - notNullable: jest.fn().mockReturnThis(), - nullable: jest.fn().mockReturnThis(), - timestamp: jest.fn().mockReturnThis(), - defaultTo: jest.fn().mockReturnThis(), - }; - callback(table); - return Promise.resolve(); - }, - ), - }; - const fn = { now: jest.fn().mockReturnValue('now()') }; - - const callableDb = jest.fn((_table: string) => tableChain) as jest.Mock & { - schema: typeof schema; - fn: typeof fn; - }; - callableDb.schema = schema; - callableDb.fn = fn; - - const getClient = jest.fn().mockResolvedValue(callableDb); - - return { - getClient, - tableChain, - schema, - }; -} - -function doc( - fileName: string, - contentHash?: string, - sourceId = `source://${fileName}`, -): FetchedDocument { - return { - fileName, - content: `content of ${fileName}`, - sourceId, - sourceType: 'directory', - contentHash: contentHash ?? `hash-${fileName}`, - }; -} - -function existingDoc(fileName: string, id: string): DocumentInfo { - return { - id, - fileName, - format: FileFormat.TEXT, - fileSize: 0, - uploadedAt: new Date().toISOString(), - status: 'completed', - }; -} - -function createDocumentsConfig( - overrides: Partial = {}, -): DocumentsConfig { - return { - syncMode: 'append', - sources: [ - { type: 'directory', path: path.join(os.tmpdir(), 'augment-test-docs') }, - ], - ...overrides, - }; -} - -describe('DocumentSyncService', () => { - let mockVectorStore: jest.Mocked; - let mockIngestion: jest.Mocked< - Pick - >; - let mockLogger: ReturnType; - - beforeEach(() => { - jest.clearAllMocks(); - mockVectorStore = createMockVectorStore(); - mockIngestion = createMockIngestion(); - mockLogger = createMockLogger(); - }); - - describe('constructor', () => { - it('sets up dependencies correctly', () => { - const config = createDocumentsConfig(); - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - ); - - expect(service.getDocumentsConfig()).toEqual(config); - expect(service.getSyncSchedule()).toBeUndefined(); - }); - }); - - describe('initialize', () => { - it('calls debug when no database provided', async () => { - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - null, - mockLogger, - ); - - await service.initialize(); - - expect(mockLogger.debug).toHaveBeenCalledWith( - 'No database provided — content hashes will not persist across restarts', - ); - }); - - it('creates DB table when needed', async () => { - const { getClient, schema } = createMockDatabase(); - schema.hasTable.mockResolvedValue(false); - - const database = { getClient } as unknown as DatabaseService; - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - null, - mockLogger, - database, - ); - - await service.initialize(); - - expect(getClient).toHaveBeenCalled(); - expect(schema.hasTable).toHaveBeenCalledWith(HASH_TABLE); - expect(schema.createTable).toHaveBeenCalledWith( - HASH_TABLE, - expect.any(Function), - ); - expect(mockLogger.info).toHaveBeenCalledWith( - `Created ${HASH_TABLE} table`, - ); - }); - - it('handles concurrent table creation - another instance created it', async () => { - const { getClient, schema } = createMockDatabase(); - schema.hasTable.mockResolvedValue(false); - schema.createTable.mockRejectedValueOnce(new Error('already exists')); - schema.hasTable.mockResolvedValueOnce(false).mockResolvedValueOnce(true); - - const database = { getClient } as unknown as DatabaseService; - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - null, - mockLogger, - database, - ); - - await service.initialize(); - - expect(mockLogger.info).toHaveBeenCalledWith( - `${HASH_TABLE} table was created by another instance`, - ); - }); - - it('falls back to in-memory when DB fails', async () => { - const { getClient, schema } = createMockDatabase(); - schema.hasTable.mockRejectedValue(new Error('DB connection failed')); - - const database = { getClient } as unknown as DatabaseService; - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - null, - mockLogger, - database, - ); - - await service.initialize(); - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to initialize hash cache table'), - ); - }); - }); - - describe('setDocumentsConfig and getDocumentsConfig', () => { - it('set and get documents config', () => { - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - null, - mockLogger, - ); - - expect(service.getDocumentsConfig()).toBeNull(); - - const config = createDocumentsConfig({ syncSchedule: '0 * * * *' }); - service.setDocumentsConfig(config); - - expect(service.getDocumentsConfig()).toEqual(config); - expect(service.getSyncSchedule()).toBe('0 * * * *'); - - service.setDocumentsConfig(null); - expect(service.getDocumentsConfig()).toBeNull(); - }); - }); - - describe('isSyncInProgress and getSyncSchedule', () => { - it('isSyncInProgress returns false when no sync', () => { - const config = createDocumentsConfig(); - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - ); - - expect(service.isSyncInProgress()).toBe(false); - }); - - it('getSyncSchedule returns config.syncSchedule', () => { - const config = createDocumentsConfig({ syncSchedule: '*/5 * * * *' }); - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - ); - - expect(service.getSyncSchedule()).toBe('*/5 * * * *'); - }); - }); - - describe('sync mutex', () => { - it('returns early when sync already in progress', async () => { - const config = createDocumentsConfig(); - let resolveFetch: (docs: FetchedDocument[]) => void; - const fetchPromise = new Promise(resolve => { - resolveFetch = resolve; - }); - mockIngestion.fetchFromSources.mockReturnValue(fetchPromise); - mockVectorStore.uploadDocuments.mockResolvedValue({ - uploaded: [{ id: 'id-a', fileName: 'a.md', status: 'completed' }], - failed: [], - }); - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - ); - - const sync1Promise = service.sync(); - const sync2Promise = service.sync(); - - resolveFetch!([doc('a.md')]); - const [result1, result2] = await Promise.all([ - sync1Promise, - sync2Promise, - ]); - - expect(mockLogger.info).toHaveBeenCalledWith( - 'Document sync already in progress, skipping', - ); - const fullResult = result1.added > 0 ? result1 : result2; - const earlyResult = result1.added > 0 ? result2 : result1; - expect(fullResult.added).toBe(1); - expect(earlyResult).toEqual({ - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [], - }); - }); - }); - - describe('sync skips when no sources', () => { - it('returns empty result when documentsConfig is null', async () => { - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - null, - mockLogger, - ); - - const result = await service.sync(); - - expect(result).toEqual({ - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [], - }); - expect(mockIngestion.fetchFromSources).not.toHaveBeenCalled(); - }); - - it('returns empty result when sources array is empty', async () => { - const config = createDocumentsConfig({ sources: [] }); - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - ); - - const result = await service.sync(); - - expect(result).toEqual({ - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [], - }); - expect(mockIngestion.fetchFromSources).not.toHaveBeenCalled(); - }); - }); - - describe("sync in 'append' mode", () => { - it('adds new docs, skips existing', async () => { - const config = createDocumentsConfig({ syncMode: 'append' }); - mockIngestion.fetchFromSources.mockResolvedValue([ - doc('new.md', 'hash-new'), - doc('existing.md', 'hash-existing'), - ]); - mockVectorStore.listDocuments.mockResolvedValue([ - existingDoc('existing.md', 'id-existing'), - ]); - mockVectorStore.uploadDocuments.mockResolvedValue({ - uploaded: [{ id: 'id-new', fileName: 'new.md', status: 'completed' }], - failed: [], - }); - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - ); - - const result = await service.sync(); - - expect(result.added).toBe(1); - expect(result.updated).toBe(0); - expect(result.unchanged).toBe(1); - expect(mockVectorStore.uploadDocuments).toHaveBeenCalledTimes(1); - expect(mockVectorStore.uploadDocuments).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - fileName: 'new.md', - content: 'content of new.md', - }), - ]), - ); - }); - }); - - describe("sync in 'full' mode", () => { - it('adds new, updates changed, removes deleted', async () => { - const config = createDocumentsConfig({ syncMode: 'full' }); - mockIngestion.fetchFromSources.mockResolvedValue([ - doc('new.md', 'hash-new'), - doc('changed.md', 'hash-changed'), - ]); - mockVectorStore.listDocuments.mockResolvedValue([ - existingDoc('changed.md', 'id-changed'), - existingDoc('deleted.md', 'id-deleted'), - ]); - mockVectorStore.uploadDocuments - .mockResolvedValueOnce({ - uploaded: [{ id: 'id-new', fileName: 'new.md', status: 'completed' }], - failed: [], - }) - .mockResolvedValueOnce({ - uploaded: [ - { id: 'id-changed-2', fileName: 'changed.md', status: 'completed' }, - ], - failed: [], - }); - - const rows: Array<{ - file_name: string; - content_hash: string; - source_id: string | null; - }> = [ - { - file_name: 'changed.md', - content_hash: 'hash-old', - source_id: 'source://changed.md', - }, - ]; - const { getClient, schema } = createMockDatabase(rows); - schema.hasTable.mockResolvedValue(true); - - const database = { getClient } as unknown as DatabaseService; - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - database, - ); - - await service.initialize(); - const result = await service.sync(); - - expect(result.added).toBe(1); - expect(result.updated).toBe(1); - expect(result.removed).toBe(1); - expect(mockVectorStore.deleteDocument).toHaveBeenCalledWith('id-changed'); - expect(mockVectorStore.deleteDocument).toHaveBeenCalledWith('id-deleted'); - }); - }); - - describe('content hash cache', () => { - it('loads from DB when available', async () => { - const config = createDocumentsConfig({ syncMode: 'append' }); - const rows = [ - { - file_name: 'cached.md', - content_hash: 'cached-hash', - source_id: 'source://cached.md', - }, - ]; - - const { getClient, schema } = createMockDatabase(rows); - schema.hasTable.mockResolvedValue(true); - - const database = { getClient } as unknown as DatabaseService; - - mockIngestion.fetchFromSources.mockResolvedValue([ - doc('cached.md', 'cached-hash'), - ]); - mockVectorStore.listDocuments.mockResolvedValue([ - existingDoc('cached.md', 'id-cached'), - ]); - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - database, - ); - - await service.initialize(); - const result = await service.sync(); - - expect(result.unchanged).toBe(1); - expect(mockVectorStore.uploadDocuments).not.toHaveBeenCalled(); - }); - - it('persists new hashes when adding documents', async () => { - const config = createDocumentsConfig({ syncMode: 'append' }); - mockIngestion.fetchFromSources.mockResolvedValue([ - doc('new.md', 'hash-new'), - ]); - mockVectorStore.listDocuments.mockResolvedValue([]); - mockVectorStore.uploadDocuments.mockResolvedValue({ - uploaded: [{ id: 'id-new', fileName: 'new.md', status: 'completed' }], - failed: [], - }); - - const { getClient, tableChain, schema } = createMockDatabase([]); - schema.hasTable.mockResolvedValue(true); - const database = { getClient } as unknown as DatabaseService; - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - database, - ); - - await service.initialize(); - await service.sync(); - - expect(tableChain.insert).toHaveBeenCalledWith( - expect.objectContaining({ - file_name: 'new.md', - content_hash: 'hash-new', - }), - ); - }); - - it('evicts cache when at capacity', async () => { - const config = createDocumentsConfig({ syncMode: 'append' }); - mockIngestion.fetchFromSources.mockResolvedValue([ - doc('a.md', 'hash-a'), - doc('b.md', 'hash-b'), - doc('c.md', 'hash-c'), - ]); - mockVectorStore.listDocuments.mockResolvedValue([]); - mockVectorStore.uploadDocuments.mockResolvedValue({ - uploaded: [ - { id: 'id-a', fileName: 'a.md', status: 'completed' }, - { id: 'id-b', fileName: 'b.md', status: 'completed' }, - { id: 'id-c', fileName: 'c.md', status: 'completed' }, - ], - failed: [], - }); - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - ); - - const result = await service.sync(); - - expect(result.added).toBe(3); - expect(result.failed).toBe(0); - }); - }); - - describe('change detection via content hashing', () => { - it('detects changed documents and re-uploads', async () => { - const config = createDocumentsConfig({ syncMode: 'append' }); - mockIngestion.fetchFromSources.mockResolvedValue([ - doc('changed.md', 'hash-new'), - ]); - mockVectorStore.listDocuments.mockResolvedValue([ - existingDoc('changed.md', 'id-changed'), - ]); - mockVectorStore.uploadDocuments.mockResolvedValue({ - uploaded: [ - { id: 'id-changed-2', fileName: 'changed.md', status: 'completed' }, - ], - failed: [], - }); - - const rows = [ - { - file_name: 'changed.md', - content_hash: 'hash-old', - source_id: 'source://changed.md', - }, - ]; - const { getClient, schema } = createMockDatabase(rows); - schema.hasTable.mockResolvedValue(true); - const database = { getClient } as unknown as DatabaseService; - - const service = new DocumentSyncService( - mockVectorStore, - mockIngestion as unknown as DocumentIngestionService, - config, - mockLogger, - database, - ); - - await service.initialize(); - const result = await service.sync(); - - expect(result.updated).toBe(1); - expect(mockVectorStore.deleteDocument).toHaveBeenCalledWith('id-changed'); - expect(mockVectorStore.uploadDocuments).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - fileName: 'changed.md', - content: 'content of changed.md', - }), - ]), - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentSyncService.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentSyncService.ts deleted file mode 100644 index bf192bd1d1f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentSyncService.ts +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - LoggerService, - DatabaseService, -} from '@backstage/backend-plugin-api'; -import type { SyncResult } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { toErrorMessage } from '../../../services/utils'; -import { MAX_CONTENT_HASH_CACHE_SIZE } from '../../../constants'; -import { VectorStoreService } from './VectorStoreService'; -import { DocumentIngestionService } from '../../../services/DocumentIngestionService'; -import type { DocumentsConfig } from '../../../types'; -import { performSync } from './documentSyncOps'; - -export type { SyncResult }; - -const HASH_TABLE = 'augment_content_hashes'; - -export class DocumentSyncService { - private readonly vectorStore: VectorStoreService; - private readonly ingestion: DocumentIngestionService; - private readonly logger: LoggerService; - private readonly database?: DatabaseService; - private documentsConfig: DocumentsConfig | null; - - private contentHashCache: Map = new Map(); - private hashCacheLoaded = false; - private db: Awaited> | null = null; - private syncLock: Promise | null = null; - - constructor( - vectorStore: VectorStoreService, - ingestion: DocumentIngestionService, - documentsConfig: DocumentsConfig | null, - logger: LoggerService, - database?: DatabaseService, - ) { - this.vectorStore = vectorStore; - this.ingestion = ingestion; - this.documentsConfig = documentsConfig; - this.logger = logger; - this.database = database; - } - - async initialize(): Promise { - if (!this.database) { - this.logger.debug( - 'No database provided — content hashes will not persist across restarts', - ); - return; - } - try { - this.db = await this.database.getClient(); - const hasTable = await this.db.schema.hasTable(HASH_TABLE); - if (!hasTable) { - try { - await this.db.schema.createTable(HASH_TABLE, table => { - table.string('file_name', 512).primary().notNullable(); - table.string('content_hash', 64).notNullable(); - table.string('source_id', 1024).nullable(); - table - .timestamp('updated_at') - .notNullable() - .defaultTo(this.db!.fn.now()); - }); - this.logger.info(`Created ${HASH_TABLE} table`); - } catch (createError) { - const existsNow = await this.db.schema.hasTable(HASH_TABLE); - if (!existsNow) throw createError; - this.logger.info( - `${HASH_TABLE} table was created by another instance`, - ); - } - } - } catch (error) { - this.logger.warn( - `Failed to initialize hash cache table: ${toErrorMessage(error)}. Falling back to in-memory cache.`, - ); - this.db = null; - } - } - - private evictCacheIfNeeded(): void { - if (this.contentHashCache.size >= MAX_CONTENT_HASH_CACHE_SIZE) { - const firstKey = this.contentHashCache.keys().next().value; - if (firstKey !== undefined) this.contentHashCache.delete(firstKey); - } - } - - private async loadHashCache(): Promise { - if (this.hashCacheLoaded || !this.db) return; - try { - const rows = await this.db<{ - file_name: string; - content_hash: string; - source_id: string | null; - }>(HASH_TABLE).select('file_name', 'content_hash', 'source_id'); - for (const row of rows) { - this.evictCacheIfNeeded(); - this.contentHashCache.set( - row.source_id || row.file_name, - row.content_hash, - ); - } - this.hashCacheLoaded = true; - this.logger.debug(`Loaded ${rows.length} content hash(es) from database`); - } catch (error) { - this.logger.warn( - `Failed to load hash cache from database: ${toErrorMessage(error)}`, - ); - } - } - - private async persistHash( - fileName: string, - contentHash: string, - sourceId?: string, - ): Promise { - if (!this.db) return; - try { - await this.db(HASH_TABLE) - .insert({ - file_name: fileName, - content_hash: contentHash, - source_id: sourceId ?? null, - updated_at: this.db.fn.now(), - }) - .onConflict('file_name') - .merge({ - content_hash: contentHash, - source_id: sourceId ?? null, - updated_at: this.db.fn.now(), - }); - } catch (error) { - this.logger.debug( - `Failed to persist hash for ${fileName}: ${toErrorMessage(error)}`, - ); - } - } - - private evictCacheByFileName(fileName: string): void { - for (const key of this.contentHashCache.keys()) { - if (key === fileName || key.endsWith(`:${fileName}`)) - this.contentHashCache.delete(key); - } - } - - private async removePersistedHash(fileName: string): Promise { - if (!this.db) return; - try { - await this.db(HASH_TABLE).where('file_name', fileName).delete(); - } catch (error) { - this.logger.debug( - `Failed to remove hash for ${fileName}: ${toErrorMessage(error)}`, - ); - } - } - - setDocumentsConfig(config: DocumentsConfig | null): void { - this.documentsConfig = config; - } - getDocumentsConfig(): DocumentsConfig | null { - return this.documentsConfig; - } - isSyncInProgress(): boolean { - return this.syncLock !== null; - } - getSyncSchedule(): string | undefined { - return this.documentsConfig?.syncSchedule; - } - - async sync(): Promise { - if (this.syncLock !== null) { - this.logger.info('Document sync already in progress, skipping'); - return { - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [], - }; - } - if (!this.documentsConfig || this.documentsConfig.sources.length === 0) { - this.logger.debug('No document sources configured, skipping sync'); - return { - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [], - }; - } - - const syncPromise = performSync( - this.documentsConfig, - this.ingestion, - this.vectorStore, - { - evictCacheIfNeeded: () => this.evictCacheIfNeeded(), - contentHashCache: this.contentHashCache, - persistHash: (fn, h, s) => this.persistHash(fn, h, s), - evictCacheByFileName: fn => this.evictCacheByFileName(fn), - removePersistedHash: fn => this.removePersistedHash(fn), - }, - () => this.loadHashCache(), - this.logger, - ); - this.syncLock = syncPromise; - try { - return await syncPromise; - } finally { - this.syncLock = null; - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentUploader.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentUploader.test.ts deleted file mode 100644 index e3b38b4676c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentUploader.test.ts +++ /dev/null @@ -1,341 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { uploadDocuments } from './DocumentUploader'; -import type { DocumentUploaderDeps, UploadFile } from './DocumentUploader'; -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import type { LlamaStackConfig } from '../../../types'; -import { createMockLogger } from '../../../test-utils/mocks'; - -function createMockClient(): jest.Mocked> { - return { - request: jest.fn(), - }; -} - -function createDeps( - overrides?: Partial, -): DocumentUploaderDeps & { client: jest.Mocked } { - const client = createMockClient(); - const config: LlamaStackConfig = { - baseUrl: 'https://llama.example.com', - vectorStoreIds: ['vs_123'], - vectorStoreName: 'test-store', - embeddingModel: 'test-emb', - embeddingDimension: 384, - model: 'test-model', - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - }; - return { - client: client as unknown as jest.Mocked, - config, - logger: createMockLogger(), - ...overrides, - } as unknown as DocumentUploaderDeps & { - client: jest.Mocked; - }; -} - -describe('DocumentUploader', () => { - describe('uploadDocuments', () => { - it('uploads a single document successfully', async () => { - const deps = createDeps(); - deps.client.request - .mockResolvedValueOnce({ - id: 'file_abc', - object: 'file', - bytes: 100, - created_at: 1234567890, - filename: 'doc.md', - purpose: 'assistants', - }) - .mockResolvedValueOnce({ - id: 'vsfile_xyz', - object: 'vector_store.file', - status: 'completed', - created_at: 1234567890, - usage_bytes: 100, - vector_store_id: 'vs_123', - }); - - const files: UploadFile[] = [{ fileName: 'doc.md', content: '# Hello' }]; - const result = await uploadDocuments(files, deps); - - expect(result.uploaded).toHaveLength(1); - expect(result.uploaded[0]).toEqual({ - id: 'file_abc', - fileName: 'doc.md', - status: 'completed', - }); - expect(result.failed).toHaveLength(0); - - expect(deps.client.request).toHaveBeenCalledTimes(2); - expect(deps.client.request).toHaveBeenNthCalledWith( - 1, - '/v1/files', - expect.objectContaining({ method: 'POST' }), - ); - expect(deps.client.request).toHaveBeenNthCalledWith( - 2, - '/v1/vector_stores/vs_123/files', - expect.objectContaining({ - method: 'POST', - body: expect.objectContaining({ - file_id: 'file_abc', - chunking_strategy: { type: 'auto' }, - }), - }), - ); - }); - - it('uses custom vectorStoreId when provided', async () => { - const deps = createDeps(); - deps.client.request - .mockResolvedValueOnce({ - id: 'file_1', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'a.txt', - purpose: 'assistants', - }) - .mockResolvedValueOnce({ - id: 'vsf_1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_custom', - }); - - const result = await uploadDocuments( - [{ fileName: 'a.txt', content: 'x' }], - deps, - 'vs_custom', - ); - - expect(result.uploaded).toHaveLength(1); - expect(deps.client.request).toHaveBeenNthCalledWith( - 2, - '/v1/vector_stores/vs_custom/files', - expect.any(Object), - ); - }); - - it('includes attributes in attach request when provided', async () => { - const deps = createDeps(); - deps.client.request - .mockResolvedValueOnce({ - id: 'file_1', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'doc.md', - purpose: 'assistants', - }) - .mockResolvedValueOnce({ - id: 'vsf_1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_123', - }); - - const files: UploadFile[] = [ - { - fileName: 'doc.md', - content: 'content', - attributes: { title: 'My Doc', source_url: 'https://example.com' }, - }, - ]; - await uploadDocuments(files, deps); - - expect(deps.client.request).toHaveBeenNthCalledWith( - 2, - '/v1/vector_stores/vs_123/files', - expect.objectContaining({ - body: expect.objectContaining({ - file_id: 'file_1', - attributes: { title: 'My Doc', source_url: 'https://example.com' }, - }), - }), - ); - }); - - it('uses static chunking when config specifies it', async () => { - const deps = createDeps({ - config: { - baseUrl: 'https://x.com', - vectorStoreIds: ['vs_1'], - vectorStoreName: 'x', - embeddingModel: 'emb', - embeddingDimension: 384, - model: 'm', - chunkingStrategy: 'static', - maxChunkSizeTokens: 256, - chunkOverlapTokens: 25, - } as LlamaStackConfig, - }); - deps.client.request - .mockResolvedValueOnce({ - id: 'f1', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'a.txt', - purpose: 'assistants', - }) - .mockResolvedValueOnce({ - id: 'vsf1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_1', - }); - - await uploadDocuments([{ fileName: 'a.txt', content: 'x' }], deps); - - expect(deps.client.request).toHaveBeenNthCalledWith( - 2, - expect.any(String), - expect.objectContaining({ - body: expect.objectContaining({ - chunking_strategy: { - type: 'static', - static: { - max_chunk_size_tokens: 256, - chunk_overlap_tokens: 25, - }, - }, - }), - }), - ); - }); - - it('batches multiple files and reports individual failures', async () => { - const deps = createDeps(); - deps.client.request - .mockResolvedValueOnce({ - id: 'file_1', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'ok.txt', - purpose: 'assistants', - }) - .mockResolvedValueOnce({ - id: 'vsf_1', - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_123', - }) - .mockRejectedValueOnce(new Error('Files API error')) - .mockResolvedValueOnce({ - id: 'file_3', - object: 'file', - bytes: 10, - created_at: 0, - filename: 'third.txt', - purpose: 'assistants', - }) - .mockResolvedValueOnce({ - id: 'vsf_3', - object: 'vector_store.file', - status: 'failed', - created_at: 0, - usage_bytes: 0, - vector_store_id: 'vs_123', - last_error: { code: 'err', message: 'Attachment failed' }, - }); - - const files: UploadFile[] = [ - { fileName: 'ok.txt', content: 'a' }, - { fileName: 'fail.txt', content: 'b' }, - { fileName: 'third.txt', content: 'c' }, - ]; - const result = await uploadDocuments(files, deps); - - expect(result.uploaded).toHaveLength(1); - expect(result.uploaded[0].fileName).toBe('ok.txt'); - - expect(result.failed).toHaveLength(2); - const failedFileNames = result.failed - .map(f => f.fileName) - .sort((a, b) => a!.localeCompare(b!)); - expect(failedFileNames).toEqual(['fail.txt', 'third.txt']); - const errors = result.failed.map(f => f.error); - expect(errors.some(e => e.includes('Files API error'))).toBe(true); - expect(errors.some(e => e.includes('Attachment failed'))).toBe(true); - }); - - it('throws InputError when no vector store configured', async () => { - const deps = createDeps({ - config: { - baseUrl: 'https://x.com', - vectorStoreIds: [], - vectorStoreName: 'x', - embeddingModel: 'emb', - embeddingDimension: 384, - model: 'm', - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - } as LlamaStackConfig, - }); - - await expect( - uploadDocuments([{ fileName: 'a.txt', content: 'x' }], deps), - ).rejects.toThrow('No vector store configured'); - }); - - it('processes files in batches of 5', async () => { - const deps = createDeps(); - for (let i = 0; i < 7; i++) { - deps.client.request - .mockResolvedValueOnce({ - id: `file_${i}`, - object: 'file', - bytes: 10, - created_at: 0, - filename: `f${i}.txt`, - purpose: 'assistants', - }) - .mockResolvedValueOnce({ - id: `vsf_${i}`, - object: 'vector_store.file', - status: 'completed', - created_at: 0, - usage_bytes: 10, - vector_store_id: 'vs_123', - }); - } - - const files: UploadFile[] = Array.from({ length: 7 }, (_, i) => ({ - fileName: `f${i}.txt`, - content: `content${i}`, - })); - const result = await uploadDocuments(files, deps); - - expect(result.uploaded).toHaveLength(7); - expect(deps.client.request).toHaveBeenCalledTimes(14); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentUploader.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentUploader.ts deleted file mode 100644 index 7d6a1b1f6d3..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/DocumentUploader.ts +++ /dev/null @@ -1,290 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { InputError } from '@backstage/errors'; -import FormDataNode from 'form-data'; -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import type { - LlamaStackConfig, - LlamaStackFileResponse, - LlamaStackVectorStoreFileResponse, - FileAttributes, -} from '../../../types'; -import { toErrorMessage } from '../../../services/utils'; - -/** Batch size for concurrent uploads to avoid overwhelming the API */ -const UPLOAD_BATCH_SIZE = 5; - -/** How long to wait between polls when checking file attach status */ -const POLL_INTERVAL_MS = 3_000; -/** Maximum number of polls before giving up */ -const MAX_POLL_ATTEMPTS = 40; // ~2 minutes - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -function isTimeoutOrHangup(error: unknown): boolean { - const msg = toErrorMessage(error); - return msg.includes('socket hang up') || msg.includes('timed out'); -} - -async function deleteOrphanedFile( - client: ResponsesApiClient, - fileId: string, - fileName: string, - logger: LoggerService, -): Promise { - try { - await client.request(`/v1/files/${fileId}`, { method: 'DELETE' }); - logger.info(`Cleaned up orphaned file ${fileId} (${fileName})`); - } catch (cleanupErr) { - logger.warn( - `Failed to clean up orphaned file ${fileId} (${fileName}): ${toErrorMessage( - cleanupErr, - )}`, - ); - } -} - -export interface DocumentUploaderDeps { - client: ResponsesApiClient; - config: LlamaStackConfig; - logger: LoggerService; -} - -/** - * File to upload to the vector store. - */ -export interface UploadFile { - fileName: string; - content: string; - attributes?: FileAttributes; -} - -/** - * Result of an upload operation. - */ -export interface UploadResult { - uploaded: Array<{ id: string; fileName: string; status: string }>; - failed: Array<{ fileName?: string; error: string }>; -} - -/** - * Poll the vector store file endpoint until the file reaches a terminal - * state (completed/failed/cancelled) or we exceed MAX_POLL_ATTEMPTS. - */ -async function pollFileStatus( - client: ResponsesApiClient, - vectorStoreId: string, - fileId: string, - logger: LoggerService, -): Promise { - for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) { - await sleep(POLL_INTERVAL_MS); - try { - const files = await client.request<{ - data?: LlamaStackVectorStoreFileResponse[]; - }>(`/v1/vector_stores/${vectorStoreId}/files`, { method: 'GET' }); - - const list = Array.isArray(files) ? files : (files.data ?? []); - const match = list.find( - (f: LlamaStackVectorStoreFileResponse) => f.id === fileId, - ); - - if (!match) { - logger.debug(`[upload] Poll ${i + 1}: file ${fileId} not in store yet`); - continue; - } - - if ( - match.status === 'completed' || - match.status === 'failed' || - match.status === 'cancelled' - ) { - logger.info( - `[upload] Poll ${i + 1}: file ${fileId} status=${match.status}`, - ); - return match; - } - - logger.debug( - `[upload] Poll ${i + 1}: file ${fileId} status=${match.status}`, - ); - } catch (pollErr) { - logger.debug(`[upload] Poll ${i + 1} error: ${toErrorMessage(pollErr)}`); - } - } - return null; -} - -/** - * Upload a single file to the Files API and attach it to the vector store. - * - * The attach step (POST /v1/vector_stores/{id}/files) can be slow because - * LlamaStack processes embeddings synchronously. If a reverse proxy - * (e.g. OpenShift route) times out before the operation completes, we - * fall back to polling the file list to check whether the file was - * actually ingested. - */ -async function uploadSingleFile( - file: UploadFile, - deps: DocumentUploaderDeps, - vectorStoreId: string, -): Promise< - | { id: string; fileName: string; status: string } - | { fileName: string; error: string } -> { - const { client, config, logger } = deps; - - try { - const form = new FormDataNode(); - form.append('file', Buffer.from(file.content, 'utf-8'), file.fileName); - form.append('purpose', 'assistants'); - - logger.info( - `[upload] POST /v1/files (${file.content.length} bytes, ${file.fileName})`, - ); - const uploadResponse = await client.request( - '/v1/files', - { method: 'POST', body: form }, - ); - logger.info(`[upload] File created: ${uploadResponse.id}`); - - const chunkingStrategy = - config.chunkingStrategy === 'auto' - ? { type: 'auto' as const } - : { - type: 'static' as const, - static: { - max_chunk_size_tokens: config.maxChunkSizeTokens ?? 512, - chunk_overlap_tokens: config.chunkOverlapTokens ?? 50, - }, - }; - - const attachBody: Record = { - file_id: uploadResponse.id, - chunking_strategy: chunkingStrategy, - }; - if (file.attributes && Object.keys(file.attributes).length > 0) { - attachBody.attributes = file.attributes; - } - - let attachResponse: LlamaStackVectorStoreFileResponse | null = null; - - try { - attachResponse = await client.request( - `/v1/vector_stores/${vectorStoreId}/files`, - { method: 'POST', body: attachBody }, - ); - } catch (attachError) { - if (isTimeoutOrHangup(attachError)) { - logger.info( - `[upload] Attach request timed out for ${file.fileName} — polling for completion`, - ); - attachResponse = await pollFileStatus( - client, - vectorStoreId, - uploadResponse.id, - logger, - ); - if (!attachResponse) { - return { - fileName: file.fileName, - error: - 'File ingestion timed out. The file may still be processing on the server. ' + - 'Check the OpenShift route timeout (annotation haproxy.router.openshift.io/timeout).', - }; - } - } else { - await deleteOrphanedFile( - client, - uploadResponse.id, - file.fileName, - logger, - ); - throw attachError; - } - } - - if (attachResponse.status === 'failed') { - const errMsg = attachResponse.last_error?.message ?? 'Attachment failed'; - logger.warn(`File attachment failed for ${file.fileName}: ${errMsg}`); - await deleteOrphanedFile( - client, - uploadResponse.id, - file.fileName, - logger, - ); - return { fileName: file.fileName, error: errMsg }; - } - - return { - id: uploadResponse.id, - fileName: file.fileName, - status: attachResponse.status, - }; - } catch (error) { - const msg = toErrorMessage(error); - logger.warn(`Upload failed for ${file.fileName}: ${msg}`); - return { fileName: file.fileName, error: msg }; - } -} - -/** - * Upload documents to the vector store in batches. - * Each file is uploaded to the Files API, then attached to the vector store. - * - * @param files - Files to upload - * @param deps - Client, config, and logger - * @param vectorStoreId - Optional vector store ID (defaults to config.vectorStoreIds[0]) - */ -export async function uploadDocuments( - files: UploadFile[], - deps: DocumentUploaderDeps, - vectorStoreId?: string, -): Promise { - const { config, logger } = deps; - const targetId = vectorStoreId ?? config.vectorStoreIds[0]; - - if (!targetId) { - throw new InputError( - 'No vector store configured — cannot upload documents', - ); - } - - const uploaded: UploadResult['uploaded'] = []; - const failed: UploadResult['failed'] = []; - - for (let i = 0; i < files.length; i += UPLOAD_BATCH_SIZE) { - const batch = files.slice(i, i + UPLOAD_BATCH_SIZE); - const results = await Promise.all( - batch.map(file => uploadSingleFile(file, deps, targetId)), - ); - - for (const result of results) { - if ('id' in result) { - uploaded.push(result); - } else { - failed.push(result); - } - } - } - - logger.info(`Uploaded ${uploaded.length} documents, ${failed.length} failed`); - - return { uploaded, failed }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/SyncCategorizer.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/SyncCategorizer.test.ts deleted file mode 100644 index 3929e00ab82..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/SyncCategorizer.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { categorizeDocuments } from './SyncCategorizer'; -import type { FetchedDocument } from '../../../types'; -import { FileFormat } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { DocumentInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -function doc( - fileName: string, - contentHash?: string, - sourceId = `source://${fileName}`, -): FetchedDocument { - return { - fileName, - content: `content of ${fileName}`, - sourceId, - sourceType: 'directory', - contentHash, - }; -} - -function existing(fileName: string, id: string): DocumentInfo { - return { - id, - fileName, - format: FileFormat.TEXT, - fileSize: 0, - uploadedAt: new Date().toISOString(), - status: 'completed', - }; -} - -describe('categorizeDocuments', () => { - it('puts new documents in toAdd', () => { - const fetched = [doc('new.md', 'hash1')]; - const existingMap = new Map(); - const cache = new Map(); - - const result = categorizeDocuments(fetched, existingMap, cache); - - expect(result.toAdd).toHaveLength(1); - expect(result.toAdd[0].fileName).toBe('new.md'); - expect(result.toUpdate).toHaveLength(0); - expect(result.unchanged).toHaveLength(0); - }); - - it('puts changed documents in toUpdate', () => { - const fetched = [doc('existing.md', 'newHash')]; - const existingMap = new Map([ - ['existing.md', existing('existing.md', 'id1')], - ]); - const cache = new Map([['source://existing.md', 'oldHash']]); - - const result = categorizeDocuments(fetched, existingMap, cache); - - expect(result.toAdd).toHaveLength(0); - expect(result.toUpdate).toHaveLength(1); - expect(result.toUpdate[0].fileName).toBe('existing.md'); - expect(result.unchanged).toHaveLength(0); - }); - - it('puts unchanged documents (matching hash) in unchanged', () => { - const fetched = [doc('existing.md', 'sameHash')]; - const existingMap = new Map([ - ['existing.md', existing('existing.md', 'id1')], - ]); - const cache = new Map([['source://existing.md', 'sameHash']]); - - const result = categorizeDocuments(fetched, existingMap, cache); - - expect(result.toAdd).toHaveLength(0); - expect(result.toUpdate).toHaveLength(0); - expect(result.unchanged).toEqual(['existing.md']); - }); - - it('puts documents without contentHash in unchanged', () => { - const fetched = [{ ...doc('existing.md'), contentHash: undefined }]; - const existingMap = new Map([ - ['existing.md', existing('existing.md', 'id1')], - ]); - const cache = new Map(); - - const result = categorizeDocuments(fetched, existingMap, cache); - - expect(result.toAdd).toHaveLength(0); - expect(result.toUpdate).toHaveLength(0); - expect(result.unchanged).toEqual(['existing.md']); - }); - - it('puts documents with no cached hash in unchanged', () => { - const fetched = [doc('existing.md', 'currentHash')]; - const existingMap = new Map([ - ['existing.md', existing('existing.md', 'id1')], - ]); - const cache = new Map(); - - const result = categorizeDocuments(fetched, existingMap, cache); - - expect(result.toAdd).toHaveLength(0); - expect(result.toUpdate).toHaveLength(0); - expect(result.unchanged).toEqual(['existing.md']); - }); - - it('categorizes mixed documents correctly', () => { - const fetched = [ - doc('new.md', 'hash1'), - doc('changed.md', 'newHash'), - doc('unchanged.md', 'sameHash'), - ]; - const existingMap = new Map([ - ['changed.md', existing('changed.md', 'id2')], - ['unchanged.md', existing('unchanged.md', 'id3')], - ]); - const cache = new Map([ - ['source://changed.md', 'oldHash'], - ['source://unchanged.md', 'sameHash'], - ]); - - const result = categorizeDocuments(fetched, existingMap, cache); - - expect(result.toAdd).toHaveLength(1); - expect(result.toAdd[0].fileName).toBe('new.md'); - expect(result.toUpdate).toHaveLength(1); - expect(result.toUpdate[0].fileName).toBe('changed.md'); - expect(result.unchanged).toEqual(['unchanged.md']); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/SyncCategorizer.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/SyncCategorizer.ts deleted file mode 100644 index a191e3576d8..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/SyncCategorizer.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { DocumentInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { FetchedDocument } from '../../../types'; - -/** - * Result of categorizing fetched documents against existing documents. - * Used to determine which documents need to be added, updated, or left unchanged. - */ -export interface SyncCategorization { - /** New documents to add to the vector store */ - toAdd: FetchedDocument[]; - /** Existing documents whose content has changed and need re-upload */ - toUpdate: FetchedDocument[]; - /** File names of documents that are unchanged (no action needed) */ - unchanged: string[]; -} - -/** - * Categorizes fetched documents into add, update, and unchanged buckets. - * Pure function: no side effects, deterministic output for given inputs. - * - * Change detection uses content hashing: - * - Documents not in existingDocsMap → toAdd - * - Documents with contentHash that differs from cached hash → toUpdate - * - Documents with matching hash or no hash → unchanged - * - * @param fetchedDocs - Documents fetched from configured sources - * @param existingDocsMap - Map of fileName → DocumentInfo for current vector store contents - * @param contentHashCache - Map of sourceId → content hash for change detection - */ -export function categorizeDocuments( - fetchedDocs: FetchedDocument[], - existingDocsMap: Map, - contentHashCache: ReadonlyMap, -): SyncCategorization { - const toAdd: FetchedDocument[] = []; - const toUpdate: FetchedDocument[] = []; - const unchanged: string[] = []; - - for (const doc of fetchedDocs) { - const cacheKey = doc.sourceId || doc.fileName; - const existingDoc = existingDocsMap.get(doc.fileName); - - if (!existingDoc) { - toAdd.push(doc); - continue; - } - - if (doc.contentHash) { - const cachedHash = contentHashCache.get(cacheKey); - if (cachedHash && cachedHash !== doc.contentHash) { - toUpdate.push(doc); - } else { - unchanged.push(doc.fileName); - } - } else { - unchanged.push(doc.fileName); - } - } - - return { toAdd, toUpdate, unchanged }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/VectorStoreService.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/VectorStoreService.test.ts deleted file mode 100644 index 3e7055f8453..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/VectorStoreService.test.ts +++ /dev/null @@ -1,371 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { VectorStoreService } from './VectorStoreService'; -import { ResponsesApiClient } from '../client/ResponsesApiClient'; -import { LlamaStackConfig } from '../../../types'; -import { LoggerService } from '@backstage/backend-plugin-api'; - -// Mock the ResponsesApiClient -jest.mock('../client/ResponsesApiClient'); - -describe('VectorStoreService', () => { - let mockClient: jest.Mocked; - let mockLogger: jest.Mocked; - let baseConfig: LlamaStackConfig; - - beforeEach(() => { - mockClient = { - request: jest.fn(), - } as unknown as jest.Mocked; - - mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - child: jest.fn().mockReturnThis(), - } as unknown as jest.Mocked; - - baseConfig = { - baseUrl: 'https://llama-stack.example.com', - vectorStoreIds: [], - vectorStoreName: 'test-vector-store', - embeddingModel: 'sentence-transformers/all-MiniLM-L6-v2', - embeddingDimension: 384, - model: 'gemini/gemini-2.5-flash', - chunkingStrategy: 'auto', - maxChunkSizeTokens: 200, - chunkOverlapTokens: 50, - }; - }); - - describe('ensureExists', () => { - it('should throw error when vectorStoreName is not configured', async () => { - const configWithoutName = { - ...baseConfig, - vectorStoreName: '', - }; - const service = new VectorStoreService( - mockClient, - configWithoutName, - mockLogger, - ); - - await expect(service.ensureExists()).rejects.toThrow( - 'CONFIGURATION ERROR: augment.llamaStack.vectorStoreName is required', - ); - }); - - it('should use existing vectorStoreId if it validates successfully', async () => { - const configWithId = { - ...baseConfig, - vectorStoreIds: ['vs_existing_123'], - }; - const service = new VectorStoreService( - mockClient, - configWithId, - mockLogger, - ); - - // Mock successful validation - mockClient.request.mockResolvedValueOnce({ - id: 'vs_existing_123', - name: 'test-vector-store', - status: 'completed', - file_counts: { total: 5 }, - }); - - await service.ensureExists(); - - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/vector_stores/vs_existing_123', - { method: 'GET' }, - ); - expect(service.getConfig().vectorStoreIds).toEqual(['vs_existing_123']); - }); - - it('should fall back to name-based lookup when configured vectorStoreId is invalid', async () => { - const configWithInvalidId = { - ...baseConfig, - vectorStoreIds: ['vs_invalid_id'], - }; - const service = new VectorStoreService( - mockClient, - configWithInvalidId, - mockLogger, - ); - - // Mock failed validation (vector store not found) - mockClient.request - .mockRejectedValueOnce(new Error('Not found')) - // Mock listing vector stores - find by name - .mockResolvedValueOnce({ - data: [ - { - id: 'vs_found_by_name', - name: 'test-vector-store', - status: 'completed', - file_counts: { total: 3 }, - }, - ], - }); - - await service.ensureExists(); - - // Should have tried validation first, then fell back to name lookup - expect(mockClient.request).toHaveBeenCalledTimes(2); - expect(service.getConfig().vectorStoreIds).toEqual(['vs_found_by_name']); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('vs_invalid_id'), - ); - }); - - it('should find existing vector store by name when no vectorStoreIds configured', async () => { - const service = new VectorStoreService( - mockClient, - baseConfig, - mockLogger, - ); - - // Mock listing vector stores - returns matching name - mockClient.request.mockResolvedValueOnce({ - data: [ - { - id: 'vs_other', - name: 'other-store', - status: 'completed', - file_counts: { total: 0 }, - }, - { - id: 'vs_matched', - name: 'test-vector-store', - status: 'completed', - file_counts: { total: 10 }, - }, - ], - }); - - await service.ensureExists(); - - expect(service.getConfig().vectorStoreIds).toEqual(['vs_matched']); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Found existing vector store'), - ); - }); - - it('should create new vector store when not found by name', async () => { - const service = new VectorStoreService( - mockClient, - baseConfig, - mockLogger, - ); - - // Mock listing vector stores - no match - mockClient.request - .mockResolvedValueOnce({ data: [] }) - // Mock resolveFromExistingStores → listVectorStores - .mockResolvedValueOnce({ data: [] }) - // Mock creation - .mockResolvedValueOnce({ - id: 'vs_newly_created', - name: 'test-vector-store', - status: 'completed', - }); - - await service.ensureExists(); - - expect(service.getConfig().vectorStoreIds).toEqual(['vs_newly_created']); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Created new vector store'), - ); - }); - - it('should include hybrid search config when creating vector store', async () => { - const hybridConfig = { - ...baseConfig, - searchMode: 'hybrid' as const, - bm25Weight: 0.3, - semanticWeight: 0.7, - }; - const service = new VectorStoreService( - mockClient, - hybridConfig, - mockLogger, - ); - - // Mock listing - no match - mockClient.request - .mockResolvedValueOnce({ data: [] }) - // Mock resolveFromExistingStores → listVectorStores - .mockResolvedValueOnce({ data: [] }) - // Mock creation - .mockResolvedValueOnce({ - id: 'vs_hybrid', - name: 'test-vector-store', - status: 'completed', - }); - - await service.ensureExists(); - - // Verify create was called with hybrid search params - expect(mockClient.request).toHaveBeenLastCalledWith('/v1/vector_stores', { - method: 'POST', - body: { - name: 'test-vector-store', - embedding_model: 'sentence-transformers/all-MiniLM-L6-v2', - embedding_dimension: 384, - search_mode: 'hybrid', - bm25_weight: 0.3, - semantic_weight: 0.7, - }, - }); - }); - - it('should handle API errors gracefully during creation', async () => { - const service = new VectorStoreService( - mockClient, - baseConfig, - mockLogger, - ); - - // Mock listing - no match - mockClient.request - .mockResolvedValueOnce({ data: [] }) - // Mock resolveFromExistingStores → listVectorStores - .mockResolvedValueOnce({ data: [] }) - // Mock creation failure - .mockRejectedValueOnce(new Error('API rate limit exceeded')); - - await expect(service.ensureExists()).rejects.toThrow( - 'Failed to create vector store "test-vector-store": API rate limit exceeded', - ); - }); - - it('should handle deleted and recreated vector store scenario', async () => { - // Scenario: User has old ID in config, but that store was deleted - // Plugin should fall back to name and find/create the new one - const configWithOldId = { - ...baseConfig, - vectorStoreIds: ['vs_old_deleted'], - }; - const service = new VectorStoreService( - mockClient, - configWithOldId, - mockLogger, - ); - - // Mock: old ID validation fails - mockClient.request - .mockRejectedValueOnce(new Error('Vector store not found')) - // Mock: list shows the recreated store with same name but new ID - .mockResolvedValueOnce({ - data: [ - { - id: 'vs_new_recreated', - name: 'test-vector-store', - status: 'completed', - file_counts: { total: 0 }, - }, - ], - }); - - await service.ensureExists(); - - // Should now use the new ID - expect(service.getConfig().vectorStoreIds).toEqual(['vs_new_recreated']); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('vs_old_deleted'), - ); - }); - }); - - describe('deleteVectorStore', () => { - let service: VectorStoreService; - - beforeEach(() => { - service = new VectorStoreService(mockClient, baseConfig, mockLogger); - }); - - it('should delete all files then the store itself', async () => { - mockClient.request - .mockResolvedValueOnce({ - data: [ - { id: 'file-1', status: 'completed' }, - { id: 'file-2', status: 'completed' }, - ], - has_more: false, - }) - .mockResolvedValueOnce({ success: true }) - .mockResolvedValueOnce({ success: true }) - .mockResolvedValueOnce({ success: true }) - .mockResolvedValueOnce({ success: true }) - .mockResolvedValueOnce({ id: 'vs_123', deleted: true }); - - const result = await service.deleteVectorStore('vs_123'); - - expect(result).toEqual({ success: true, filesDeleted: 2 }); - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/vector_stores/vs_123', - { method: 'DELETE' }, - ); - }); - - it('should treat store-not-found as successful deletion', async () => { - // Mock 1: fetchAllVectorStoreFiles → not found (caught, warned) - // Mock 2: DELETE /v1/vector_stores/{id} → not found (treated as success) - mockClient.request - .mockRejectedValueOnce( - new Error('Llama Stack API error: 400 - Vector_store not found'), - ) - .mockRejectedValueOnce( - new Error( - "Llama Stack API error: 400 - Vector_store 'vs_gone' not found", - ), - ); - - const result = await service.deleteVectorStore('vs_gone'); - - expect(result).toEqual({ success: true, filesDeleted: 0 }); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('not found on server'), - ); - }); - - it('should treat 404 as successful deletion', async () => { - mockClient.request - .mockRejectedValueOnce(new Error('404 Not Found')) - .mockRejectedValueOnce(new Error('404 Not Found')); - - const result = await service.deleteVectorStore('vs_404'); - - expect(result).toEqual({ success: true, filesDeleted: 0 }); - }); - - it('should throw on real server errors', async () => { - mockClient.request - .mockRejectedValueOnce(new Error('connection refused')) - .mockRejectedValueOnce( - new Error('Llama Stack API error: 500 Internal Server Error'), - ); - - await expect(service.deleteVectorStore('vs_err')).rejects.toThrow( - 'Failed to delete vector store vs_err', - ); - expect(mockLogger.error).toHaveBeenCalled(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/VectorStoreService.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/VectorStoreService.ts deleted file mode 100644 index 1e3f3765878..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/VectorStoreService.ts +++ /dev/null @@ -1,322 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { LoggerService } from '@backstage/backend-plugin-api'; -import { ResponsesApiClient } from '../client/ResponsesApiClient'; -import type { - LlamaStackConfig, - LlamaStackVectorStoreResponse, - VectorStoreInfo, -} from '../../../types'; -import { - uploadDocuments as uploadDocumentsImpl, - type UploadFile, - type UploadResult, -} from './DocumentUploader'; -import { - listDocuments as listDocumentsImpl, - fetchAllVectorStoreFiles, -} from './DocumentLister'; -import { - validateVectorStore, - findOrCreateVectorStore, -} from './vectorStoreLifecycle'; -import { - deleteDocument as doDeleteDocument, - deleteVectorStore as doDeleteVectorStore, - updateVectorStore as doUpdateVectorStore, -} from './vectorStoreMutations'; - -export type { UploadFile, UploadResult } from './DocumentUploader'; - -function applyVectorStoreOverrides( - baseConfig: LlamaStackConfig, - overrides: Record, -): LlamaStackConfig { - const cfg = { ...baseConfig } as LlamaStackConfig; - if (typeof overrides.vectorStoreName === 'string') - cfg.vectorStoreName = overrides.vectorStoreName; - if (typeof overrides.embeddingModel === 'string') - cfg.embeddingModel = overrides.embeddingModel; - if (typeof overrides.embeddingDimension === 'number') - cfg.embeddingDimension = overrides.embeddingDimension; - if ( - overrides.searchMode === 'semantic' || - overrides.searchMode === 'keyword' || - overrides.searchMode === 'hybrid' - ) - cfg.searchMode = overrides.searchMode; - if (typeof overrides.bm25Weight === 'number') - cfg.bm25Weight = overrides.bm25Weight; - if (typeof overrides.semanticWeight === 'number') - cfg.semanticWeight = overrides.semanticWeight; - if ( - overrides.chunkingStrategy === 'auto' || - overrides.chunkingStrategy === 'static' - ) - cfg.chunkingStrategy = overrides.chunkingStrategy; - if (typeof overrides.maxChunkSizeTokens === 'number') - cfg.maxChunkSizeTokens = overrides.maxChunkSizeTokens; - if (typeof overrides.chunkOverlapTokens === 'number') - cfg.chunkOverlapTokens = overrides.chunkOverlapTokens; - if (typeof overrides.fileSearchMaxResults === 'number') - cfg.fileSearchMaxResults = overrides.fileSearchMaxResults; - if (typeof overrides.fileSearchScoreThreshold === 'number') - cfg.fileSearchScoreThreshold = overrides.fileSearchScoreThreshold; - return cfg; -} - -export class DuplicateVectorStoreError extends Error { - constructor(name: string, existingId: string) { - super( - `A vector store named "${name}" already exists (ID: ${existingId}). Use a different name or connect the existing store.`, - ); - this.name = 'DuplicateVectorStoreError'; - } -} - -export class VectorStoreService { - private readonly client: ResponsesApiClient; - private readonly logger: LoggerService; - private config: LlamaStackConfig; - - constructor( - client: ResponsesApiClient, - config: LlamaStackConfig, - logger: LoggerService, - ) { - this.client = client; - this.config = config; - this.logger = logger; - } - - getConfig(): LlamaStackConfig { - return this.config; - } - updateConfig(config: LlamaStackConfig): void { - this.config = config; - } - getDefaultVectorStoreId(): string | undefined { - return this.config.vectorStoreIds[0]; - } - - private get ctx() { - return { client: this.client, config: this.config, logger: this.logger }; - } - - async ensureExists(): Promise { - const vectorStoreName = this.config.vectorStoreName; - if (!vectorStoreName) - throw new Error( - 'CONFIGURATION ERROR: augment.llamaStack.vectorStoreName is required.', - ); - this.logger.info( - `Initializing RAG with vector store: "${vectorStoreName}"`, - ); - if (this.config.vectorStoreIds.length > 0) { - const configuredId = this.config.vectorStoreIds[0]; - const validated = await validateVectorStore(this.ctx, configuredId); - if (validated) { - this.logger.info(`✓ Using configured vector store: ${configuredId}`); - return; - } - this.logger.warn( - `Configured vectorStoreId "${configuredId}" not found on server. Will find or create by name.`, - ); - } - const id = await findOrCreateVectorStore(this.ctx, vectorStoreName, () => - this.listVectorStores(), - ); - this.config.vectorStoreIds = [id]; - } - - async uploadDocuments( - files: UploadFile[], - vectorStoreId?: string, - ): Promise { - return uploadDocumentsImpl(files, this.ctx, vectorStoreId); - } - - async listDocuments(vectorStoreId?: string) { - return listDocumentsImpl(this.ctx, vectorStoreId); - } - - async searchSingle( - storeId: string, - query: string, - maxResults: number, - ): Promise< - Array<{ - text: string; - score?: number; - fileId?: string; - fileName?: string; - vectorStoreId?: string; - }> - > { - const response = await this.client.request<{ - data?: Array<{ - content?: Array<{ text?: string; type?: string }>; - score?: number; - file_id?: string; - filename?: string; - }>; - }>(`/v1/vector_stores/${storeId}/search`, { - method: 'POST', - body: { query, max_num_results: maxResults }, - }); - return (response.data ?? []).map(item => ({ - text: item.content?.map(c => c.text ?? '').join('') ?? '', - score: item.score, - fileId: item.file_id, - fileName: item.filename, - vectorStoreId: storeId, - })); - } - - async createWithConfig( - baseConfig: LlamaStackConfig, - overrides: Record, - ): Promise<{ - vectorStoreId: string; - vectorStoreName: string; - created: boolean; - embeddingModel: string; - embeddingDimension?: number; - }> { - const cfg = applyVectorStoreOverrides(baseConfig, overrides); - try { - const serverStores = await this.listVectorStores(); - const duplicate = serverStores.find(s => s.name === cfg.vectorStoreName); - if (duplicate) - throw new DuplicateVectorStoreError(cfg.vectorStoreName, duplicate.id); - } catch (error) { - if (error instanceof DuplicateVectorStoreError) throw error; - this.logger.warn( - `Could not check for duplicate vector stores. Proceeding with creation.`, - ); - } - cfg.vectorStoreIds = []; - this.updateConfig(cfg); - await this.ensureExists(); - const updatedCfg = this.getConfig(); - const newId = updatedCfg.vectorStoreIds[0]; - if (!newId) - throw new Error( - `Vector store "${cfg.vectorStoreName}" was created but no ID was returned.`, - ); - return { - vectorStoreId: newId, - vectorStoreName: cfg.vectorStoreName, - created: true, - embeddingModel: cfg.embeddingModel, - embeddingDimension: cfg.embeddingDimension, - }; - } - - private mapStoreToInfo( - store: LlamaStackVectorStoreResponse, - ): VectorStoreInfo { - return { - id: store.id, - name: store.name || store.id, - status: store.status || 'unknown', - fileCount: store.file_counts?.total || 0, - createdAt: store.created_at ?? 0, - embeddingModel: store.metadata?.embedding_model, - embeddingDimension: (() => { - if (!store.metadata?.embedding_dimension) return undefined; - const p = parseInt(store.metadata.embedding_dimension, 10); - return Number.isFinite(p) ? p : undefined; - })(), - providerType: store.metadata?.provider_id, - usageBytes: store.usage_bytes, - lastActiveAt: store.last_active_at, - fileCounts: store.file_counts - ? { - completed: store.file_counts.completed, - inProgress: store.file_counts.in_progress, - failed: store.file_counts.failed, - cancelled: store.file_counts.cancelled, - total: store.file_counts.total, - } - : undefined, - }; - } - - async listVectorStores(): Promise { - const PAGE_LIMIT = 100; - const MAX_PAGES = 20; - const allStores: LlamaStackVectorStoreResponse[] = []; - let after: string | undefined; - for (let page = 0; page < MAX_PAGES; page++) { - const qs = new URLSearchParams(); - qs.set('limit', String(PAGE_LIMIT)); - qs.set('order', 'desc'); - if (after) qs.set('after', after); - const response = await this.client.request< - | { - data?: LlamaStackVectorStoreResponse[]; - has_more?: boolean; - last_id?: string; - } - | LlamaStackVectorStoreResponse[] - >(`/v1/vector_stores?${qs.toString()}`, { method: 'GET' }); - let pageStores: LlamaStackVectorStoreResponse[]; - let hasMore = false; - let lastId: string | undefined; - if (Array.isArray(response)) { - pageStores = response; - } else { - pageStores = response.data || []; - hasMore = response.has_more ?? false; - lastId = response.last_id ?? undefined; - } - allStores.push(...pageStores); - if (!hasMore || pageStores.length === 0) break; - after = lastId ?? pageStores[pageStores.length - 1]?.id; - if (!after) break; - } - this.logger.info(`Found ${allStores.length} vector stores`); - return allStores.map(store => this.mapStoreToInfo(store)); - } - - async deleteDocument( - fileId: string, - vectorStoreId?: string, - ): Promise<{ success: boolean }> { - return doDeleteDocument(this.ctx, fileId, vectorStoreId); - } - - async deleteVectorStore( - vectorStoreId: string, - ): Promise<{ success: boolean; filesDeleted: number }> { - return doDeleteVectorStore( - this.ctx, - vectorStoreId, - vsId => fetchAllVectorStoreFiles(this.ctx, vsId), - (fId, vsId) => this.deleteDocument(fId, vsId), - ); - } - - async updateVectorStore( - vectorStoreId: string, - updates: { name?: string; metadata?: Record }, - ): Promise { - return doUpdateVectorStore(this.ctx, vectorStoreId, updates, s => - this.mapStoreToInfo(s), - ); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/documentSyncOps.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/documentSyncOps.ts deleted file mode 100644 index 2bcee6d717c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/documentSyncOps.ts +++ /dev/null @@ -1,315 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { - DocumentInfo, - SyncResult, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { toErrorMessage } from '../../../services/utils'; -import type { VectorStoreService } from './VectorStoreService'; -import type { DocumentIngestionService } from '../../../services/DocumentIngestionService'; -import type { DocumentsConfig, FetchedDocument } from '../../../types'; -import { generateDefaultAttributes } from '../../../services/utils/documentAttributes'; -import { categorizeDocuments } from './SyncCategorizer'; - -export interface HashCacheOps { - evictCacheIfNeeded: () => void; - contentHashCache: Map; - persistHash: ( - fileName: string, - contentHash: string, - sourceId?: string, - ) => Promise; - evictCacheByFileName: (fileName: string) => void; - removePersistedHash: (fileName: string) => Promise; -} - -export async function uploadNewDocuments( - docsToAdd: FetchedDocument[], - vectorStore: VectorStoreService, - hashOps: HashCacheOps, - logger: LoggerService, -): Promise<{ addedCount: number; failedCount: number }> { - if (docsToAdd.length === 0) { - return { addedCount: 0, failedCount: 0 }; - } - - logger.info(`Uploading ${docsToAdd.length} new documents`); - const uploadResult = await vectorStore.uploadDocuments( - docsToAdd.map(d => ({ - fileName: d.fileName, - content: d.content, - attributes: d.attributes || generateDefaultAttributes(d), - })), - ); - - const uploadedNames = new Set(uploadResult.uploaded.map(u => u.fileName)); - for (const doc of docsToAdd) { - if (doc.contentHash && uploadedNames.has(doc.fileName)) { - const cacheKey = doc.sourceId || doc.fileName; - hashOps.evictCacheIfNeeded(); - hashOps.contentHashCache.set(cacheKey, doc.contentHash); - await hashOps.persistHash(doc.fileName, doc.contentHash, doc.sourceId); - } - } - - return { - addedCount: uploadResult.uploaded.length, - failedCount: uploadResult.failed.length, - }; -} - -export async function updateChangedDocuments( - docsToUpdate: FetchedDocument[], - existingDocsMap: Map, - vectorStore: VectorStoreService, - hashOps: HashCacheOps, - logger: LoggerService, -): Promise<{ updatedCount: number; failedCount: number }> { - if (docsToUpdate.length === 0) { - return { updatedCount: 0, failedCount: 0 }; - } - - logger.info(`Updating ${docsToUpdate.length} changed documents`); - - let updatedCount = 0; - let failedCount = 0; - - for (const doc of docsToUpdate) { - const existingDoc = existingDocsMap.get(doc.fileName); - if (!existingDoc) continue; - - try { - await vectorStore.deleteDocument(existingDoc.id); - - const uploadResult = await vectorStore.uploadDocuments([ - { - fileName: doc.fileName, - content: doc.content, - attributes: doc.attributes || generateDefaultAttributes(doc), - }, - ]); - - if (uploadResult.uploaded.length > 0) { - updatedCount++; - if (doc.contentHash) { - const cacheKey = doc.sourceId || doc.fileName; - hashOps.evictCacheIfNeeded(); - hashOps.contentHashCache.set(cacheKey, doc.contentHash); - await hashOps.persistHash( - doc.fileName, - doc.contentHash, - doc.sourceId, - ); - } - } else { - failedCount++; - } - } catch (error) { - logger.warn( - `Failed to update document ${doc.fileName}: ${toErrorMessage(error)}`, - ); - failedCount++; - } - } - - return { updatedCount, failedCount }; -} - -export async function removeDeletedDocuments( - config: DocumentsConfig, - existingDocs: DocumentInfo[], - fetchedDocs: FetchedDocument[], - vectorStore: VectorStoreService, - hashOps: HashCacheOps, - logger: LoggerService, -): Promise<{ removedCount: number; failedCount: number }> { - if (config.syncMode !== 'full') { - return { removedCount: 0, failedCount: 0 }; - } - - const fetchedFileNames = new Set(fetchedDocs.map(d => d.fileName)); - const docsToRemove = existingDocs.filter( - d => !fetchedFileNames.has(d.fileName), - ); - - if (docsToRemove.length === 0) { - return { removedCount: 0, failedCount: 0 }; - } - - logger.info(`Removing ${docsToRemove.length} documents no longer in sources`); - - let removedCount = 0; - let failedCount = 0; - - for (const doc of docsToRemove) { - try { - await vectorStore.deleteDocument(doc.id); - removedCount++; - hashOps.evictCacheByFileName(doc.fileName); - await hashOps.removePersistedHash(doc.fileName); - } catch (error) { - logger.warn(`Failed to remove document ${doc.fileName}`); - failedCount++; - } - } - - return { removedCount, failedCount }; -} - -export async function fetchAndCategorize( - config: DocumentsConfig, - ingestion: DocumentIngestionService, - vectorStore: VectorStoreService, - contentHashCache: Map, - loadHashCache: () => Promise, - logger: LoggerService, -): Promise<{ - fetchedDocs: FetchedDocument[]; - existingDocs: DocumentInfo[]; - existingDocsMap: Map; - docsToAdd: FetchedDocument[]; - docsToUpdate: FetchedDocument[]; - docsUnchanged: string[]; -}> { - await loadHashCache(); - - const fetchedDocs = await ingestion.fetchFromSources(config.sources); - logger.info(`Fetched ${fetchedDocs.length} documents from sources`); - - const existingDocs = await vectorStore.listDocuments(); - const existingDocsMap = new Map(existingDocs.map(d => [d.fileName, d])); - - const { - toAdd: docsToAdd, - toUpdate: docsToUpdate, - unchanged: docsUnchanged, - } = categorizeDocuments(fetchedDocs, existingDocsMap, contentHashCache); - - for (const doc of docsToUpdate) { - const cacheKey = doc.sourceId || doc.fileName; - logger.debug( - `Document ${doc.fileName} content changed (hash: ${contentHashCache.get(cacheKey)} -> ${doc.contentHash})`, - ); - } - - return { - fetchedDocs, - existingDocs, - existingDocsMap, - docsToAdd, - docsToUpdate, - docsUnchanged, - }; -} - -export async function performSync( - config: DocumentsConfig, - ingestion: DocumentIngestionService, - vectorStore: VectorStoreService, - hashOps: HashCacheOps, - loadHashCache: () => Promise, - logger: LoggerService, -): Promise { - const startTime = Date.now(); - - try { - logger.info(`Starting document sync (mode: ${config.syncMode})`); - - const { - fetchedDocs, - existingDocs, - existingDocsMap, - docsToAdd, - docsToUpdate, - docsUnchanged, - } = await fetchAndCategorize( - config, - ingestion, - vectorStore, - hashOps.contentHashCache, - loadHashCache, - logger, - ); - - const fetchedByFileName = new Map(fetchedDocs.map(d => [d.fileName, d])); - for (const fileName of docsUnchanged) { - const doc = fetchedByFileName.get(fileName); - if (doc?.contentHash) { - const cacheKey = doc.sourceId || doc.fileName; - if (!hashOps.contentHashCache.has(cacheKey)) { - hashOps.evictCacheIfNeeded(); - hashOps.contentHashCache.set(cacheKey, doc.contentHash); - await hashOps.persistHash(fileName, doc.contentHash, doc.sourceId); - } - } - } - - let addedCount = 0; - let updatedCount = 0; - let failedCount = 0; - - const uploadResult = await uploadNewDocuments( - docsToAdd, - vectorStore, - hashOps, - logger, - ); - addedCount = uploadResult.addedCount; - failedCount += uploadResult.failedCount; - - const updateResult = await updateChangedDocuments( - docsToUpdate, - existingDocsMap, - vectorStore, - hashOps, - logger, - ); - updatedCount = updateResult.updatedCount; - failedCount += updateResult.failedCount; - - const removeResult = await removeDeletedDocuments( - config, - existingDocs, - fetchedDocs, - vectorStore, - hashOps, - logger, - ); - failedCount += removeResult.failedCount; - - const duration = Date.now() - startTime; - logger.info( - `Document sync completed in ${duration}ms: added=${addedCount}, updated=${updatedCount}, removed=${removeResult.removedCount}, failed=${failedCount}, unchanged=${docsUnchanged.length}`, - ); - - const errors: string[] = []; - if (failedCount > 0) - errors.push(`${failedCount} document(s) failed to sync`); - return { - added: addedCount, - updated: updatedCount, - removed: removeResult.removedCount, - failed: failedCount, - unchanged: docsUnchanged.length, - errors, - }; - } catch (error) { - logger.error(`Document sync failed: ${toErrorMessage(error)}`); - throw error; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/vectorStoreLifecycle.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/vectorStoreLifecycle.ts deleted file mode 100644 index b262469f975..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/vectorStoreLifecycle.ts +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import { toErrorMessage } from '../../../services/utils'; -import type { - LlamaStackConfig, - LlamaStackVectorStoreResponse, - VectorStoreInfo, -} from '../../../types'; - -export interface VsContext { - client: ResponsesApiClient; - config: LlamaStackConfig; - logger: LoggerService; -} - -export async function validateVectorStore( - ctx: VsContext, - vectorStoreId: string, -): Promise { - try { - const response = await ctx.client.request( - `/v1/vector_stores/${vectorStoreId}`, - { method: 'GET' }, - ); - ctx.logger.debug( - `Vector store ${vectorStoreId} validated: status=${response.status}, files=${response.file_counts?.total || 0}`, - ); - return true; - } catch (error) { - ctx.logger.debug( - `Vector store ${vectorStoreId} validation failed: ${toErrorMessage(error)}`, - ); - return false; - } -} - -export async function findVectorStoreByName( - ctx: VsContext, - vectorStoreName: string, -): Promise { - const listResponse = await ctx.client.request< - { data?: LlamaStackVectorStoreResponse[] } | LlamaStackVectorStoreResponse[] - >('/v1/vector_stores', { method: 'GET' }); - const existingStores = Array.isArray(listResponse) - ? listResponse - : listResponse.data || []; - ctx.logger.debug( - `Found ${existingStores.length} existing vector store(s) on server`, - ); - return existingStores.find( - (vs: LlamaStackVectorStoreResponse) => vs.name === vectorStoreName, - ); -} - -export async function resolveFromExistingStores( - ctx: VsContext, - listVectorStores: () => Promise, -): Promise<{ dimension: number | undefined; providerId: string | undefined }> { - const configuredDim = ctx.config.embeddingDimension; - const model = ctx.config.embeddingModel; - if (!model) return { dimension: configuredDim, providerId: undefined }; - try { - const stores = await listVectorStores(); - const sameModelStore = stores.find( - s => s.embeddingModel === model && s.embeddingDimension, - ); - if (sameModelStore) { - const detectedDim = sameModelStore.embeddingDimension; - if (detectedDim && configuredDim && configuredDim !== detectedDim) - ctx.logger.info( - `Correcting embeddingDimension from ${configuredDim} to ${detectedDim} (detected from existing store using "${model}")`, - ); - return { - dimension: detectedDim ?? configuredDim, - providerId: sameModelStore.providerType, - }; - } - if (stores.length > 0 && stores[0].providerType) - return { dimension: configuredDim, providerId: stores[0].providerType }; - } catch { - ctx.logger.debug('Could not auto-detect settings from existing stores'); - } - return { dimension: configuredDim, providerId: undefined }; -} - -export async function createVectorStoreOnServer( - ctx: VsContext, - vectorStoreName: string, - listVectorStores: () => Promise, -): Promise { - const createBody: Record = { name: vectorStoreName }; - if (ctx.config.embeddingModel) - createBody.embedding_model = ctx.config.embeddingModel; - const { dimension, providerId } = await resolveFromExistingStores( - ctx, - listVectorStores, - ); - if (dimension) createBody.embedding_dimension = dimension; - if (providerId) createBody.provider_id = providerId; - if (ctx.config.searchMode) createBody.search_mode = ctx.config.searchMode; - if (ctx.config.bm25Weight !== undefined) - createBody.bm25_weight = ctx.config.bm25Weight; - if (ctx.config.semanticWeight !== undefined) - createBody.semantic_weight = ctx.config.semanticWeight; - logVectorStoreConfig(ctx, vectorStoreName); - try { - return await ctx.client.request( - '/v1/vector_stores', - { method: 'POST', body: createBody }, - ); - } catch (error) { - const message = toErrorMessage(error); - ctx.logger.error( - `Failed to create vector store "${vectorStoreName}": ${message}`, - ); - throw new Error( - `Failed to create vector store "${vectorStoreName}": ${message}`, - ); - } -} - -function logVectorStoreConfig(ctx: VsContext, vectorStoreName: string): void { - const lines = [ - ` name: "${vectorStoreName}"`, - ` embedding_model: ${ctx.config.embeddingModel || '(default)'}`, - ` embedding_dimension: ${ctx.config.embeddingDimension || '(default)'}`, - ]; - if (ctx.config.searchMode) { - lines.push(` search_mode: ${ctx.config.searchMode}`); - if (ctx.config.searchMode === 'hybrid') { - lines.push( - ` bm25_weight: ${ctx.config.bm25Weight ?? 0.5}`, - ` semantic_weight: ${ctx.config.semanticWeight ?? 0.5}`, - ); - } - } - ctx.logger.info(`Vector store configuration:\n${lines.join('\n')}`); -} - -export async function findOrCreateVectorStore( - ctx: VsContext, - vectorStoreName: string, - listVectorStores: () => Promise, -): Promise { - try { - const existingStore = await findVectorStoreByName(ctx, vectorStoreName); - if (existingStore) { - ctx.logger.info( - `✓ Found existing vector store: ${existingStore.id} ("${vectorStoreName}") with ${existingStore.file_counts?.total || 0} files`, - ); - return existingStore.id; - } - } catch (error) { - ctx.logger.warn( - `Could not list vector stores: ${toErrorMessage(error)}. Will attempt to create new vector store.`, - ); - } - ctx.logger.info(`Creating new vector store: "${vectorStoreName}"`); - const createdStore = await createVectorStoreOnServer( - ctx, - vectorStoreName, - listVectorStores, - ); - ctx.logger.info( - `✓ Created new vector store: ${createdStore.id} ("${vectorStoreName}")`, - ); - return createdStore.id; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/vectorStoreMutations.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/vectorStoreMutations.ts deleted file mode 100644 index 3c80acefb5f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/documents/vectorStoreMutations.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { InputError } from '@backstage/errors'; -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import { toErrorMessage } from '../../../services/utils'; -import type { - LlamaStackConfig, - LlamaStackVectorStoreResponse, - LlamaStackVectorStoreFileResponse, - VectorStoreInfo, -} from '../../../types'; - -export interface MutationContext { - client: ResponsesApiClient; - config: LlamaStackConfig; - logger: LoggerService; -} - -export async function deleteDocument( - ctx: MutationContext, - fileId: string, - vectorStoreId?: string, -): Promise<{ success: boolean }> { - const targetId = vectorStoreId ?? ctx.config.vectorStoreIds[0]; - if (!targetId) - throw new InputError('No vector store configured — cannot delete document'); - let deletedFromVectorStore = false; - let deletedFromFiles = false; - try { - await ctx.client.request(`/v1/vector_stores/${targetId}/files/${fileId}`, { - method: 'DELETE', - }); - deletedFromVectorStore = true; - ctx.logger.debug(`Deleted from vector store: ${fileId}`); - } catch (error) { - const errorMsg = toErrorMessage(error, ''); - if (!errorMsg.includes('400') && !errorMsg.includes('not found')) - ctx.logger.warn(`Vector store delete failed for ${fileId}: ${errorMsg}`); - } - try { - await ctx.client.request(`/v1/files/${fileId}`, { method: 'DELETE' }); - deletedFromFiles = true; - ctx.logger.debug(`Deleted from files API: ${fileId}`); - } catch (error) { - const errorMsg = toErrorMessage(error, ''); - if (!errorMsg.includes('404') && !errorMsg.includes('not found')) - ctx.logger.debug(`Files API delete failed for ${fileId}: ${errorMsg}`); - } - if (deletedFromVectorStore || deletedFromFiles) { - ctx.logger.info(`Deleted document: ${fileId}`); - return { success: true }; - } - ctx.logger.warn(`Could not delete ${fileId} from either endpoint`); - return { success: false }; -} - -export async function deleteVectorStore( - ctx: MutationContext, - vectorStoreId: string, - fetchFiles: (vsId: string) => Promise, - deleteDoc: (fileId: string, vsId?: string) => Promise<{ success: boolean }>, -): Promise<{ success: boolean; filesDeleted: number }> { - let filesDeleted = 0; - try { - const files = await fetchFiles(vectorStoreId); - for (const file of files) { - try { - await deleteDoc(file.id, vectorStoreId); - filesDeleted++; - } catch (err) { - ctx.logger.warn( - `Failed to delete file ${file.id}: ${toErrorMessage(err, 'Unknown')}`, - ); - } - } - ctx.logger.info( - `Deleted ${filesDeleted}/${files.length} files from store ${vectorStoreId}`, - ); - } catch (err) { - ctx.logger.warn( - `Could not list files for store ${vectorStoreId}: ${toErrorMessage(err, 'Unknown')}`, - ); - } - try { - await ctx.client.request(`/v1/vector_stores/${vectorStoreId}`, { - method: 'DELETE', - }); - ctx.logger.info(`Permanently deleted vector store: ${vectorStoreId}`); - } catch (error) { - const msg = toErrorMessage(error); - if (msg.includes('not found') || msg.includes('404')) { - ctx.logger.info( - `Vector store ${vectorStoreId} not found on server (already deleted)`, - ); - } else { - ctx.logger.error( - `Failed to delete vector store ${vectorStoreId}: ${msg}`, - ); - throw new Error(`Failed to delete vector store ${vectorStoreId}: ${msg}`); - } - } - return { success: true, filesDeleted }; -} - -export async function updateVectorStore( - ctx: MutationContext, - vectorStoreId: string, - updates: { name?: string; metadata?: Record }, - mapStoreToInfo: (s: LlamaStackVectorStoreResponse) => VectorStoreInfo, -): Promise { - const body: Record = {}; - if (updates.name !== undefined) body.name = updates.name; - if (updates.metadata !== undefined) body.metadata = updates.metadata; - const response = await ctx.client.request( - `/v1/vector_stores/${vectorStoreId}`, - { method: 'POST', body }, - ); - return mapStoreToInfo(response); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/index.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/index.ts deleted file mode 100644 index 5511280a0e4..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/index.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export type { - ResponsesApiClientConfig, - McpAuthProvider, - ClientProvider, - ConfigProvider, - EffectiveConfigSnapshot, - CapabilityInfo, -} from './types'; - -export { - ResponsesApiClient, - ResponsesApiError, - type ResponsesApiRequestOptions, -} from './client/ResponsesApiClient'; - -export { - normalizeLlamaStackEvent, - LS_EVENT, -} from './stream/StreamEventNormalizer'; -export { hasResponse, hasItem, hasPart, hasError } from './stream/eventTypes'; - -export { ResponsesApiService, type ChatDeps } from './chat/ResponsesApiService'; -export { requireLastUserMessage } from './chat/chatUtils'; - -export { - resolveAgentGraph, - type ResolvedAgent, - type AgentGraphSnapshot, - type BuildDepsForAgent, - toFunctionToolName, -} from './agents/agentGraph'; - -export { - buildTools, - sanitizeToolsForServer, - type ToolsBuilderDeps, -} from './tools/ToolsBuilder'; -export { BackendToolExecutor } from './tools/BackendToolExecutor'; -export { BackendApprovalStore } from './tools/BackendApprovalStore'; - -export { ConversationService } from './conversations/ConversationService'; -export { - ConversationFacade, - type ConversationFacadeContext, -} from './conversations/ConversationFacade'; - -export { VectorStoreService } from './documents/VectorStoreService'; -export { DocumentSyncService } from './documents/DocumentSyncService'; diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/StreamEventNormalizer.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/StreamEventNormalizer.test.ts deleted file mode 100644 index 8c052ae2af9..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/StreamEventNormalizer.test.ts +++ /dev/null @@ -1,560 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { normalizeLlamaStackEvent } from './StreamEventNormalizer'; - -describe('normalizeLlamaStackEvent', () => { - describe('malformed input', () => { - it('returns empty array for invalid JSON', () => { - expect(normalizeLlamaStackEvent('not json')).toEqual([]); - }); - - it('returns empty array for empty object', () => { - expect(normalizeLlamaStackEvent('{}')).toEqual([]); - }); - - it('handles error object without type field', () => { - const raw = JSON.stringify({ error: { message: 'server crashed' } }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.error', error: 'server crashed' }, - ]); - }); - - it('handles error string without type field', () => { - const raw = JSON.stringify({ error: 'boom' }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.error', error: 'boom' }, - ]); - }); - }); - - describe('response lifecycle', () => { - it('normalizes response.created', () => { - const raw = JSON.stringify({ - type: 'response.created', - response: { id: 'resp-1', model: 'llama-3' }, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.started', responseId: 'resp-1', model: 'llama-3' }, - ]); - }); - - it('normalizes response.in_progress to empty', () => { - const raw = JSON.stringify({ type: 'response.in_progress' }); - expect(normalizeLlamaStackEvent(raw)).toEqual([]); - }); - - it('normalizes response.completed with usage', () => { - const raw = JSON.stringify({ - type: 'response.completed', - response: { - id: 'resp-1', - usage: { - input_tokens: 100, - output_tokens: 50, - total_tokens: 150, - }, - }, - }); - const result = normalizeLlamaStackEvent(raw); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: 'stream.completed', - responseId: 'resp-1', - usage: { - input_tokens: 100, - output_tokens: 50, - total_tokens: 150, - }, - }); - }); - - it('normalizes response.completed without usage', () => { - const raw = JSON.stringify({ - type: 'response.completed', - response: { id: 'resp-1' }, - }); - const result = normalizeLlamaStackEvent(raw); - expect(result[0]).toMatchObject({ - type: 'stream.completed', - responseId: 'resp-1', - usage: undefined, - }); - }); - - it('normalizes response.failed with no details', () => { - const raw = JSON.stringify({ type: 'response.failed' }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.error', error: 'Response generation failed' }, - ]); - }); - - it('normalizes response.failed with error details', () => { - const raw = JSON.stringify({ - type: 'response.failed', - response: { - error: { - type: 'server_error', - message: 'Model returned 0 output tokens', - }, - }, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.error', error: 'Model returned 0 output tokens' }, - ]); - }); - - it('normalizes error event', () => { - const raw = JSON.stringify({ type: 'error', message: 'rate limited' }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.error', error: 'rate limited' }, - ]); - }); - }); - - describe('text streaming', () => { - it('normalizes text delta', () => { - const raw = JSON.stringify({ - type: 'response.output_text.delta', - delta: 'Hello', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.text.delta', delta: 'Hello' }, - ]); - }); - - it('ignores text delta with no delta', () => { - const raw = JSON.stringify({ type: 'response.output_text.delta' }); - expect(normalizeLlamaStackEvent(raw)).toEqual([]); - }); - - it('normalizes text done with part.text', () => { - const raw = JSON.stringify({ - type: 'response.output_text.done', - part: { text: 'full response' }, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.text.done', text: 'full response' }, - ]); - }); - - it('normalizes text done with text field', () => { - const raw = JSON.stringify({ - type: 'response.output_text.done', - text: 'full response', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.text.done', text: 'full response' }, - ]); - }); - }); - - describe('reasoning/thinking', () => { - it('normalizes reasoning delta', () => { - const raw = JSON.stringify({ - type: 'response.reasoning_text.delta', - delta: 'thinking...', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.reasoning.delta', delta: 'thinking...' }, - ]); - }); - - it('normalizes reasoning done', () => { - const raw = JSON.stringify({ - type: 'response.reasoning_text.done', - text: 'I concluded that...', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.reasoning.done', text: 'I concluded that...' }, - ]); - }); - - it('normalizes reasoning summary delta the same as reasoning delta', () => { - const raw = JSON.stringify({ - type: 'response.reasoning_summary_text.delta', - delta: 'summarizing...', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.reasoning.delta', delta: 'summarizing...' }, - ]); - }); - - it('normalizes reasoning summary done the same as reasoning done', () => { - const raw = JSON.stringify({ - type: 'response.reasoning_summary_text.done', - text: 'Summary conclusion.', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.reasoning.done', text: 'Summary conclusion.' }, - ]); - }); - - it('handles reasoning_summary_part.added/done as no-ops', () => { - expect( - normalizeLlamaStackEvent( - JSON.stringify({ type: 'response.reasoning_summary_part.added' }), - ), - ).toEqual([]); - expect( - normalizeLlamaStackEvent( - JSON.stringify({ type: 'response.reasoning_summary_part.done' }), - ), - ).toEqual([]); - }); - }); - - describe('tool calls', () => { - it('normalizes MCP tool started via output_item.added', () => { - const raw = JSON.stringify({ - type: 'response.output_item.added', - item: { - id: 'call-1', - type: 'mcp_call', - name: 'search_docs', - server_label: 'my-server', - }, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.started', - callId: 'call-1', - name: 'search_docs', - serverLabel: 'my-server', - }, - ]); - }); - - it('normalizes function call started via output_item.added', () => { - const raw = JSON.stringify({ - type: 'response.output_item.added', - item: { id: 'call-2', type: 'function_call', name: 'get_weather' }, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.started', - callId: 'call-2', - name: 'get_weather', - serverLabel: undefined, - }, - ]); - }); - - it('normalizes tool arguments delta', () => { - const raw = JSON.stringify({ - type: 'response.function_call_arguments.delta', - item_id: 'call-1', - delta: '{"query":', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.tool.delta', callId: 'call-1', delta: '{"query":' }, - ]); - }); - - it('normalizes MCP call arguments delta', () => { - const raw = JSON.stringify({ - type: 'response.mcp_call.arguments.delta', - item_id: 'call-1', - delta: '"test"}', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { type: 'stream.tool.delta', callId: 'call-1', delta: '"test"}' }, - ]); - }); - - it('normalizes MCP call completed', () => { - const raw = JSON.stringify({ - type: 'response.mcp_call.completed', - item_id: 'call-1', - name: 'search_docs', - server_label: 'my-server', - output: '{"results": []}', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.completed', - callId: 'call-1', - name: 'search_docs', - serverLabel: 'my-server', - output: '{"results": []}', - }, - ]); - }); - - it('normalizes MCP call failed', () => { - const raw = JSON.stringify({ - type: 'response.mcp_call.failed', - item_id: 'call-1', - name: 'search_docs', - error: 'timeout', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.failed', - callId: 'call-1', - name: 'search_docs', - serverLabel: undefined, - error: 'timeout', - }, - ]); - }); - - it('normalizes MCP call requires approval', () => { - const raw = JSON.stringify({ - type: 'response.mcp_call.requires_approval', - item_id: 'call-1', - name: 'delete_file', - server_label: 'my-server', - arguments: '{"path": "/tmp/x"}', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.approval', - callId: 'call-1', - name: 'delete_file', - serverLabel: 'my-server', - arguments: '{"path": "/tmp/x"}', - }, - ]); - }); - - it('normalizes tool completed via output_item.done', () => { - const raw = JSON.stringify({ - type: 'response.output_item.done', - item: { - id: 'call-1', - type: 'mcp_call', - name: 'tool', - output: 'result', - }, - }); - const result = normalizeLlamaStackEvent(raw); - expect(result).toEqual([ - { - type: 'stream.tool.completed', - callId: 'call-1', - name: 'tool', - serverLabel: undefined, - output: 'result', - }, - ]); - }); - - it('normalizes tool failed via output_item.done', () => { - const raw = JSON.stringify({ - type: 'response.output_item.done', - item: { - id: 'call-1', - type: 'function_call', - name: 'tool', - error: 'crashed', - }, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.failed', - callId: 'call-1', - name: 'tool', - serverLabel: undefined, - error: 'crashed', - }, - ]); - }); - }); - - describe('RAG / file search', () => { - it('normalizes file search results from output_item.done', () => { - const raw = JSON.stringify({ - type: 'response.output_item.done', - item: { - type: 'file_search_call', - results: [ - { - filename: 'doc.md', - file_id: 'f-1', - text: 'relevant text', - score: 0.95, - attributes: { - title: 'My Doc', - source_url: 'https://example.com', - }, - }, - ], - }, - }); - const result = normalizeLlamaStackEvent(raw); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: 'stream.rag.results', - sources: [ - { - filename: 'doc.md', - fileId: 'f-1', - text: 'relevant text', - score: 0.95, - title: 'My Doc', - sourceUrl: 'https://example.com', - }, - ], - filesSearched: ['doc.md'], - }); - }); - }); - - describe('MCP tool discovery', () => { - it('normalizes mcp_list_tools in_progress', () => { - const raw = JSON.stringify({ - type: 'response.mcp_list_tools.in_progress', - server_label: 'my-server', - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.discovery', - serverLabel: 'my-server', - status: 'in_progress', - }, - ]); - }); - - it('normalizes mcp_list_tools completed', () => { - const raw = JSON.stringify({ - type: 'response.mcp_list_tools.completed', - server_label: 'my-server', - tool_count: 5, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.discovery', - serverLabel: 'my-server', - status: 'completed', - toolCount: 5, - }, - ]); - }); - }); - - describe('unknown events', () => { - it('returns empty for unrecognized event types', () => { - const raw = JSON.stringify({ type: 'response.some_future_event' }); - expect(normalizeLlamaStackEvent(raw)).toEqual([]); - }); - }); - - describe('approval via output_item.added', () => { - it('normalizes mcp_approval_request item', () => { - const raw = JSON.stringify({ - type: 'response.output_item.added', - item: { - id: 'approve-1', - type: 'mcp_approval_request', - name: 'delete_db', - server_label: 'my-server', - arguments: '{"table": "users"}', - }, - }); - expect(normalizeLlamaStackEvent(raw)).toEqual([ - { - type: 'stream.tool.approval', - callId: 'approve-1', - name: 'delete_db', - serverLabel: 'my-server', - arguments: '{"table": "users"}', - }, - ]); - }); - }); - - describe('error sanitization', () => { - it('sanitizes internal Kubernetes service URL', () => { - const raw = JSON.stringify({ - type: 'response.mcp_call.failed', - item_id: 'call-123', - name: 'tool_name', - server_label: 'my-mcp', - error: 'connect ECONNREFUSED http://mcp-server.namespace.svc:8080', - }); - const result = normalizeLlamaStackEvent(raw); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: 'stream.tool.failed', - callId: 'call-123', - name: 'tool_name', - serverLabel: 'my-mcp', - }); - expect((result[0] as any).error).not.toContain( - 'mcp-server.namespace.svc', - ); - expect((result[0] as any).error).not.toContain('8080'); - }); - - it('passes through errors without URLs unchanged', () => { - const raw = JSON.stringify({ - type: 'response.mcp_call.failed', - item_id: 'call-123', - name: 'tool_name', - error: 'Invalid JSON-RPC request', - }); - const result = normalizeLlamaStackEvent(raw); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: 'stream.tool.failed', - callId: 'call-123', - name: 'tool_name', - error: 'Invalid JSON-RPC request', - }); - }); - - it('sanitizes raw URL errors without server_label', () => { - const raw = JSON.stringify({ - type: 'response.mcp_call.failed', - item_id: 'call-123', - name: 'tool_name', - error: - 'Failed to connect to https://internal.example.com:8080/mcp: connection refused', - }); - const result = normalizeLlamaStackEvent(raw); - expect(result).toHaveLength(1); - expect((result[0] as any).error).not.toContain('internal.example.com'); - expect((result[0] as any).error).toContain('MCP server'); - expect((result[0] as any).error).toContain('unreachable'); - }); - - it('does not leak URLs across consecutive sanitization calls', () => { - const error1 = JSON.stringify({ - type: 'response.mcp_call.failed', - item_id: 'call-1', - name: 'tool1', - error: 'https://first-server.example.com/mcp: connection refused', - }); - const result1 = normalizeLlamaStackEvent(error1); - expect((result1[0] as any).error).not.toContain( - 'first-server.example.com', - ); - - const error2 = JSON.stringify({ - type: 'response.mcp_call.failed', - item_id: 'call-2', - name: 'tool2', - error: 'https://second-server.example.com/mcp: timeout', - }); - const result2 = normalizeLlamaStackEvent(error2); - expect((result2[0] as any).error).not.toContain( - 'second-server.example.com', - ); - expect((result2[0] as any).error).toContain('timed out'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/StreamEventNormalizer.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/StreamEventNormalizer.ts deleted file mode 100644 index 5b746a49363..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/StreamEventNormalizer.ts +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { NormalizedStreamEvent } from '../../types'; -import { hasError, hasPart } from './eventTypes'; -import { extractErrorString } from './mcpErrorSanitizer'; -import { - LS_EVENT, - LS_ITEM_TYPE, - handleResponseCreated, - handleResponseCompleted, - handleOutputItemAdded, - handleOutputItemDone, - handleContentPartDone, - handleArgumentsDelta, - handleMcpCallCompleted, - handleMcpCallFailed, - handleMcpCallRequiresApproval, - extractResponseFailedError, -} from './streamEventHandlers'; - -export { LS_EVENT, LS_ITEM_TYPE }; - -function handleTypelessEvent( - event: Record, -): NormalizedStreamEvent[] { - if (hasError(event) && event.error) { - const errorMessage = - typeof event.error === 'string' - ? event.error - : event.error.message || 'Unknown server error'; - return [{ type: 'stream.error', error: errorMessage }]; - } - return []; -} - -export function normalizeLlamaStackEvent( - rawJson: string, - onUnknownEvent?: (type: string) => void, -): NormalizedStreamEvent[] { - let event: Record; - try { - event = JSON.parse(rawJson); - } catch { - return []; - } - - const type = event.type as string | undefined; - if (!type) return handleTypelessEvent(event); - - switch (type) { - case LS_EVENT.RESPONSE_CREATED: - return [handleResponseCreated(event)]; - case LS_EVENT.RESPONSE_IN_PROGRESS: - return []; - case LS_EVENT.RESPONSE_COMPLETED: - return [handleResponseCompleted(event)]; - case LS_EVENT.RESPONSE_FAILED: - return [ - { type: 'stream.error', error: extractResponseFailedError(event) }, - ]; - case LS_EVENT.ERROR: - return [ - { - type: 'stream.error', - error: - (event.message as string) || - (hasError(event) && event.error - ? extractErrorString(event.error) - : '') || - 'Unknown error', - }, - ]; - case LS_EVENT.MCP_LIST_TOOLS_IN_PROGRESS: - return [ - { - type: 'stream.tool.discovery', - serverLabel: event.server_label as string | undefined, - status: 'in_progress', - }, - ]; - case LS_EVENT.MCP_LIST_TOOLS_COMPLETED: - return [ - { - type: 'stream.tool.discovery', - serverLabel: event.server_label as string | undefined, - status: 'completed', - toolCount: event.tool_count as number | undefined, - }, - ]; - case LS_EVENT.OUTPUT_ITEM_ADDED: - return handleOutputItemAdded(event); - case LS_EVENT.OUTPUT_ITEM_DONE: - return handleOutputItemDone(event); - case LS_EVENT.FUNCTION_CALL_ARGUMENTS_DELTA: - case LS_EVENT.MCP_CALL_ARGUMENTS_DELTA: - case LS_EVENT.MCP_CALL_ARGUMENTS_DELTA_LEGACY: - return handleArgumentsDelta(event); - case LS_EVENT.FUNCTION_CALL_ARGUMENTS_DONE: - case LS_EVENT.MCP_CALL_ARGUMENTS_DONE: - return []; - case LS_EVENT.MCP_CALL_IN_PROGRESS: - return []; - case LS_EVENT.MCP_CALL_COMPLETED: - return handleMcpCallCompleted(event); - case LS_EVENT.MCP_CALL_FAILED: - return handleMcpCallFailed(event); - case LS_EVENT.MCP_CALL_REQUIRES_APPROVAL: - return handleMcpCallRequiresApproval(event); - case LS_EVENT.CONTENT_PART_ADDED: - return []; - case LS_EVENT.CONTENT_PART_DONE: - return handleContentPartDone(event); - case LS_EVENT.OUTPUT_TEXT_DELTA: - return event.delta - ? [{ type: 'stream.text.delta', delta: event.delta as string }] - : []; - case LS_EVENT.OUTPUT_TEXT_DONE: - return [ - { - type: 'stream.text.done', - text: - (hasPart(event) ? event.part?.text : undefined) || - (event.text as string) || - '', - }, - ]; - case LS_EVENT.REASONING_TEXT_DELTA: - case LS_EVENT.REASONING_SUMMARY_TEXT_DELTA: - return event.delta - ? [{ type: 'stream.reasoning.delta', delta: event.delta as string }] - : []; - case LS_EVENT.REASONING_TEXT_DONE: - case LS_EVENT.REASONING_SUMMARY_TEXT_DONE: - return [ - { type: 'stream.reasoning.done', text: (event.text as string) || '' }, - ]; - case LS_EVENT.REASONING_SUMMARY_PART_ADDED: - case LS_EVENT.REASONING_SUMMARY_PART_DONE: - return []; - default: - onUnknownEvent?.(type); - return []; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/eventTypes.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/eventTypes.ts deleted file mode 100644 index ef6923376eb..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/eventTypes.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Typed interfaces for Llama Stack SSE event shapes. - * Used by StreamEventNormalizer to avoid Record casts. - */ - -export interface LlamaStackResponseEvent { - type: string; - response_id?: string; - response?: { - id?: string; - model?: string; - status?: string; - status_reason?: string; - created_at?: number; - usage?: { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - input_tokens_details?: { cached_tokens?: number }; - output_tokens_details?: { reasoning_tokens?: number }; - }; - error?: { message?: string; code?: string }; - output?: Array>; - }; -} - -export interface LlamaStackOutputItemEvent { - type: string; - item?: { - type?: string; - id?: string; - call_id?: string; - name?: string; - arguments?: string; - output?: string; - error?: string; - server_label?: string; - role?: string; - status?: string; - content?: Array<{ type?: string; text?: string }>; - queries?: Array; - results?: Array<{ - text?: string; - score?: number; - file_id?: string; - filename?: string; - name?: string; - attributes?: Record; - }>; - }; -} - -export interface LlamaStackErrorEvent { - type: string; - error?: string | { message?: string }; -} - -export interface LlamaStackTextDeltaEvent { - type: string; - delta?: string; - part?: { type?: string; text?: string }; -} - -export function hasResponse(event: unknown): event is LlamaStackResponseEvent { - return ( - typeof event === 'object' && - event !== null && - 'response' in event && - typeof (event as Record).response === 'object' - ); -} - -export function hasItem(event: unknown): event is LlamaStackOutputItemEvent { - return ( - typeof event === 'object' && - event !== null && - 'item' in event && - typeof (event as Record).item === 'object' - ); -} - -export function hasPart(event: unknown): event is LlamaStackTextDeltaEvent { - return ( - typeof event === 'object' && - event !== null && - 'part' in event && - typeof (event as Record).part === 'object' - ); -} - -export function hasError(event: unknown): event is LlamaStackErrorEvent { - return typeof event === 'object' && event !== null && 'error' in event; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/mcpErrorSanitizer.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/mcpErrorSanitizer.ts deleted file mode 100644 index 1ba6345f814..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/mcpErrorSanitizer.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -const INTERNAL_URL_PATTERN = - /https?:\/\/[^\s]+\.svc(\.cluster\.local)?(:\d+)?[^\s]*/; -const RAW_URL_TEST_PATTERN = /https?:\/\/[^\s]+/; - -function buildUserFriendlyError(error: string, label: string): string { - if (/connection refused|ECONNREFUSED/i.test(error)) { - return `MCP server "${label}" is temporarily unreachable (connection refused). Check that the server is running and accessible.`; - } - if (/timeout|ETIMEDOUT/i.test(error)) { - return `MCP server "${label}" timed out. The server may be overloaded or unreachable.`; - } - if (/502/i.test(error)) { - return `MCP server "${label}" returned an error (502). The server may be down or misconfigured.`; - } - if (/401|unauthorized/i.test(error)) { - return `MCP server "${label}" rejected the request (unauthorized). Check the server's authentication configuration.`; - } - if (/403|forbidden/i.test(error)) { - return `MCP server "${label}" denied access (forbidden). Check permissions and credentials.`; - } - return `MCP server "${label}" encountered an error. Contact your administrator if the issue persists.`; -} - -export function sanitizeMcpError(error: string, serverLabel?: string): string { - if (INTERNAL_URL_PATTERN.test(error)) { - const label = serverLabel || 'MCP server'; - return buildUserFriendlyError(error, label); - } - - if (RAW_URL_TEST_PATTERN.test(error)) { - const label = serverLabel || 'MCP server'; - return buildUserFriendlyError(error, label); - } - - return error; -} - -export function extractErrorString( - error: string | { message?: string }, -): string { - if (typeof error === 'string') return error; - return error.message ?? ''; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/streamEventHandlers.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/streamEventHandlers.ts deleted file mode 100644 index fcfc4e8b5c7..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/stream/streamEventHandlers.ts +++ /dev/null @@ -1,329 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { NormalizedStreamEvent } from '../../types'; -import { hasResponse, hasItem, hasPart, hasError } from './eventTypes'; -import { sanitizeMcpError } from './mcpErrorSanitizer'; - -export const LS_EVENT = { - RESPONSE_CREATED: 'response.created', - RESPONSE_IN_PROGRESS: 'response.in_progress', - RESPONSE_COMPLETED: 'response.completed', - RESPONSE_FAILED: 'response.failed', - ERROR: 'error', - MCP_LIST_TOOLS_IN_PROGRESS: 'response.mcp_list_tools.in_progress', - MCP_LIST_TOOLS_COMPLETED: 'response.mcp_list_tools.completed', - OUTPUT_ITEM_ADDED: 'response.output_item.added', - OUTPUT_ITEM_DONE: 'response.output_item.done', - FUNCTION_CALL_ARGUMENTS_DELTA: 'response.function_call_arguments.delta', - FUNCTION_CALL_ARGUMENTS_DONE: 'response.function_call_arguments.done', - MCP_CALL_IN_PROGRESS: 'response.mcp_call.in_progress', - MCP_CALL_COMPLETED: 'response.mcp_call.completed', - MCP_CALL_FAILED: 'response.mcp_call.failed', - MCP_CALL_REQUIRES_APPROVAL: 'response.mcp_call.requires_approval', - MCP_CALL_ARGUMENTS_DELTA: 'response.mcp_call.arguments.delta', - MCP_CALL_ARGUMENTS_DONE: 'response.mcp_call.arguments.done', - MCP_CALL_ARGUMENTS_DELTA_LEGACY: 'response.mcp_call_arguments.delta', - CONTENT_PART_ADDED: 'response.content_part.added', - CONTENT_PART_DONE: 'response.content_part.done', - OUTPUT_TEXT_DELTA: 'response.output_text.delta', - OUTPUT_TEXT_DONE: 'response.output_text.done', - REASONING_TEXT_DELTA: 'response.reasoning_text.delta', - REASONING_TEXT_DONE: 'response.reasoning_text.done', - REASONING_SUMMARY_PART_ADDED: 'response.reasoning_summary_part.added', - REASONING_SUMMARY_PART_DONE: 'response.reasoning_summary_part.done', - REASONING_SUMMARY_TEXT_DELTA: 'response.reasoning_summary_text.delta', - REASONING_SUMMARY_TEXT_DONE: 'response.reasoning_summary_text.done', -} as const; - -export const LS_ITEM_TYPE = { - FUNCTION_CALL: 'function_call', - FUNCTION_CALL_OUTPUT: 'function_call_output', - MCP_CALL: 'mcp_call', - MCP_APPROVAL_REQUEST: 'mcp_approval_request', - FILE_SEARCH_CALL: 'file_search_call', - MESSAGE: 'message', - MCP_LIST_TOOLS: 'mcp_list_tools', -} as const; - -export function handleResponseCreated( - event: Record, -): NormalizedStreamEvent { - const responseId = hasResponse(event) - ? event.response?.id || event.response_id || '' - : (event.response_id as string) || ''; - const model = hasResponse(event) ? event.response?.model : undefined; - const createdAt = hasResponse(event) ? event.response?.created_at : undefined; - return { - type: 'stream.started', - responseId: String(responseId), - model, - createdAt: typeof createdAt === 'number' ? createdAt : undefined, - }; -} - -export function handleResponseCompleted( - event: Record, -): NormalizedStreamEvent { - if (!hasResponse(event)) { - return { - type: 'stream.completed', - responseId: undefined, - usage: undefined, - }; - } - const { response } = event; - const usage = response?.usage; - return { - type: 'stream.completed', - responseId: response?.id, - usage: usage - ? { - input_tokens: usage.input_tokens ?? 0, - output_tokens: usage.output_tokens ?? 0, - total_tokens: usage.total_tokens ?? 0, - input_tokens_details: usage.input_tokens_details, - output_tokens_details: usage.output_tokens_details ?? undefined, - } - : undefined, - }; -} - -export function handleOutputItemAdded( - event: Record, -): NormalizedStreamEvent[] { - if (!hasItem(event) || !event.item) return []; - const item = event.item; - const itemType = item.type ?? ''; - - switch (itemType) { - case LS_ITEM_TYPE.MCP_APPROVAL_REQUEST: - return [ - { - type: 'stream.tool.approval', - callId: item.id ?? '', - name: item.name ?? '', - serverLabel: item.server_label, - arguments: item.arguments, - }, - ]; - case LS_ITEM_TYPE.FUNCTION_CALL_OUTPUT: - return []; - case LS_ITEM_TYPE.FILE_SEARCH_CALL: - return []; - case LS_ITEM_TYPE.FUNCTION_CALL: - case LS_ITEM_TYPE.MCP_CALL: - return [ - { - type: 'stream.tool.started', - callId: item.id ?? '', - name: item.name ?? '', - serverLabel: item.server_label, - }, - ]; - default: - return []; - } -} - -function stringifyOutput(output: unknown): string | undefined { - if (typeof output === 'string') return output; - if (output !== null && output !== undefined) - return JSON.stringify(output, null, 2); - return undefined; -} - -function mapFileSearchResult( - item: Record, -): NormalizedStreamEvent | undefined { - if (!Array.isArray(item.results)) return undefined; - const validResults = item.results.filter( - (r: unknown) => r !== null && r !== undefined && typeof r === 'object', - ) as Record[]; - return { - type: 'stream.rag.results' as const, - sources: validResults.map(r => { - const attrs = r.attributes as Record | undefined; - return { - filename: (r.filename ?? r.file_id ?? '') as string, - fileId: r.file_id as string | undefined, - text: r.text as string | undefined, - score: r.score as number | undefined, - title: attrs?.title as string | undefined, - sourceUrl: attrs?.source_url as string | undefined, - contentType: attrs?.content_type as string | undefined, - attributes: attrs, - }; - }), - filesSearched: validResults - .map(r => (r.filename ?? r.name ?? r.file_id ?? '') as string) - .filter(Boolean), - }; -} - -function mapToolCallResult( - item: Record, -): NormalizedStreamEvent { - const error = item.error; - if (error) { - let errorStr: string; - if (typeof error === 'string') errorStr = error; - else if (error && typeof error === 'object' && 'message' in error) - errorStr = String((error as { message?: string }).message ?? ''); - else errorStr = String(error ?? ''); - return { - type: 'stream.tool.failed', - callId: (item.id as string) ?? '', - name: (item.name as string) ?? '', - serverLabel: item.server_label as string | undefined, - error: errorStr, - }; - } - return { - type: 'stream.tool.completed', - callId: (item.id as string) ?? '', - name: (item.name as string) ?? '', - serverLabel: item.server_label as string | undefined, - output: stringifyOutput(item.output), - }; -} - -function mapFunctionCallOutput( - item: Record, -): NormalizedStreamEvent | undefined { - const callId = item.call_id as string | undefined; - if (!callId) return undefined; - return { - type: 'stream.tool.completed', - callId, - name: '', - output: stringifyOutput(item.output), - }; -} - -export function handleOutputItemDone( - event: Record, -): NormalizedStreamEvent[] { - if (!hasItem(event) || !event.item) return []; - const item = event.item; - const itemType = item.type ?? ''; - const results: NormalizedStreamEvent[] = []; - if (itemType === LS_ITEM_TYPE.FILE_SEARCH_CALL) { - const r = mapFileSearchResult(item); - if (r) results.push(r); - } - if ( - itemType === LS_ITEM_TYPE.FUNCTION_CALL || - itemType === LS_ITEM_TYPE.MCP_CALL - ) { - results.push(mapToolCallResult(item)); - } - if (itemType === LS_ITEM_TYPE.FUNCTION_CALL_OUTPUT) { - const r = mapFunctionCallOutput(item); - if (r) results.push(r); - } - return results; -} - -export function handleContentPartDone( - event: Record, -): NormalizedStreamEvent[] { - if (!hasPart(event) || !event.part) return []; - const part = event.part; - if (part.type === 'output_text' && typeof part.text === 'string') - return [{ type: 'stream.text.done', text: part.text }]; - return []; -} - -export function handleArgumentsDelta( - event: Record, -): NormalizedStreamEvent[] { - const delta = event.delta as string | undefined; - const itemId = event.item_id as string | undefined; - if (!delta || !itemId) return []; - return [{ type: 'stream.tool.delta', callId: itemId, delta }]; -} - -export function handleMcpCallCompleted( - event: Record, -): NormalizedStreamEvent[] { - const itemId = event.item_id as string | undefined; - if (!itemId) return []; - return [ - { - type: 'stream.tool.completed', - callId: itemId, - name: (event.name as string) || '', - serverLabel: event.server_label as string | undefined, - output: event.output as string | undefined, - }, - ]; -} - -export function handleMcpCallFailed( - event: Record, -): NormalizedStreamEvent[] { - const itemId = event.item_id as string | undefined; - if (!itemId) return []; - const rawError = (event.error as string) || 'Tool call failed'; - const serverLabel = event.server_label as string | undefined; - return [ - { - type: 'stream.tool.failed', - callId: itemId, - name: (event.name as string) || '', - serverLabel, - error: sanitizeMcpError(rawError, serverLabel), - }, - ]; -} - -export function extractResponseFailedError( - event: Record, -): string { - let raw: string | undefined; - if (hasResponse(event) && event.response) { - const errObj = event.response.error; - if (typeof errObj === 'string') raw = errObj; - else if (errObj?.message) raw = errObj.message; - else if (event.response.status_reason) raw = event.response.status_reason; - } - if (!raw && hasError(event) && event.error) { - if (typeof event.error === 'string') raw = event.error; - else if (event.error.message) raw = event.error.message; - } - if (!raw && event.message) raw = event.message as string; - return sanitizeMcpError(raw || 'Response generation failed'); -} - -export function handleMcpCallRequiresApproval( - event: Record, -): NormalizedStreamEvent[] { - const itemId = - (event.item_id as string | undefined) || - (event.id as string | undefined) || - (event.call_id as string | undefined); - if (!itemId) return []; - return [ - { - type: 'stream.tool.approval', - callId: itemId, - name: (event.name as string) || '', - serverLabel: event.server_label as string | undefined, - arguments: event.arguments as string | undefined, - }, - ]; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalContinuationExecutor.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalContinuationExecutor.test.ts deleted file mode 100644 index 6b82bf48c15..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalContinuationExecutor.test.ts +++ /dev/null @@ -1,548 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import type { MCPServerConfig } from '../../../types'; -import { - executeApprovalContinuation, - type ApprovalContinuationDeps, - type ApprovalContinuationParams, -} from './ApprovalContinuationExecutor'; -import { createMockLogger } from '../../../test-utils/mocks'; - -function createMockClient(): ResponsesApiClient & { request: jest.Mock } { - const request = jest.fn(); - return { request } as unknown as ResponsesApiClient & { request: jest.Mock }; -} - -function createMockMcpAuth(): { - getServerHeaders: jest.Mock; - getApiApprovalConfig: jest.Mock; -} { - return { - getServerHeaders: jest.fn().mockResolvedValue({}), - getApiApprovalConfig: jest.fn().mockReturnValue('never'), - }; -} - -function createBaseDeps( - overrides?: Partial, -): ApprovalContinuationDeps { - const mockClient = createMockClient(); - const mcpAuth = createMockMcpAuth(); - return { - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - mcpAuth: mcpAuth as unknown as ApprovalContinuationDeps['mcpAuth'], - mcpServers: [], - getConversationForResponse: jest.fn().mockResolvedValue(undefined), - registerResponse: jest.fn().mockResolvedValue(undefined), - logger: createMockLogger(), - ...overrides, - }; -} - -function createBaseParams( - overrides?: Partial, -): ApprovalContinuationParams { - return { - responseId: 'resp-1', - approvalRequestId: 'apr-1', - approved: true, - toolName: 'projects_list', - toolArguments: '{"org":"acme"}', - attempt: 0, - maxAttempts: 3, - ...overrides, - }; -} - -describe('executeApprovalContinuation', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('1. Successful approval flow', () => { - it('approved tool call executes and returns response', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [ - { - type: 'mcp_call', - name: 'projects_list', - output: '{"projects":["alpha","beta"]}', - }, - { - type: 'message', - content: [{ type: 'output_text', text: 'Here are your projects.' }], - }, - ], - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: true, - toolName: 'projects_list', - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(result.toolExecuted).toBe(true); - expect(result.responseId).toBe('resp-2'); - expect(result.content).toBe('Here are your projects.'); - expect(result.toolOutput).toBe('{"projects":["alpha","beta"]}'); - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/responses', - expect.objectContaining({ - method: 'POST', - body: expect.any(String), - }), - ); - }); - - it('uses conversationId when provided', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [{ type: 'mcp_call', name: 'projects_list', output: 'ok' }], - }); - - const registerResponse = jest.fn().mockResolvedValue(undefined); - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - registerResponse, - }); - const params = createBaseParams({ - conversationId: 'conv-123', - approved: true, - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(result.responseId).toBe('resp-2'); - expect(registerResponse).toHaveBeenCalledWith('conv-123', 'resp-2'); - }); - - it('resolves conversationId from registry when not provided', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [{ type: 'mcp_call', name: 'projects_list', output: 'ok' }], - }); - - const getConversationForResponse = jest - .fn() - .mockResolvedValue('conv-from-registry'); - const registerResponse = jest.fn().mockResolvedValue(undefined); - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - getConversationForResponse, - registerResponse, - }); - const params = createBaseParams({ approved: true }); - - await executeApprovalContinuation(deps, params); - - expect(getConversationForResponse).toHaveBeenCalledWith('resp-1'); - expect(registerResponse).toHaveBeenCalledWith( - 'conv-from-registry', - 'resp-2', - ); - }); - - it('includes instructions and tool_choice when approved, excludes production params', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [{ type: 'mcp_call', name: 'projects_list', output: 'ok' }], - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ approved: true }); - - await executeApprovalContinuation(deps, params); - - const body = JSON.parse(mockClient.request.mock.calls[0][1].body); - expect(Object.keys(body).sort((a, b) => a.localeCompare(b))).toEqual( - [ - 'input', - 'instructions', - 'model', - 'previous_response_id', - 'store', - 'tool_choice', - 'tools', - ].sort((a, b) => a.localeCompare(b)), - ); - expect(body.instructions).toBeDefined(); - expect(body.tool_choice).toBe('required'); - expect(body).not.toHaveProperty('guardrails'); - expect(body).not.toHaveProperty('temperature'); - expect(body).not.toHaveProperty('max_output_tokens'); - expect(body).not.toHaveProperty('max_tool_calls'); - expect(body).not.toHaveProperty('safety_identifier'); - }); - }); - - describe('2. Rejected tool call', () => { - it('denied tool call returns appropriate response without executing', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [], - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: false, - toolName: 'deploy_app', - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(result.toolExecuted).toBe(false); - expect(result.responseId).toBe('resp-2'); - expect(result.content).toContain('Tool Rejected'); - expect(result.content).toContain('deploy_app'); - expect(mockClient.request).toHaveBeenCalledWith( - '/v1/responses', - expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('"approve":false'), - }), - ); - }); - }); - - describe('3. Auto-reapproval', () => { - it('auto-approves chained approval request for same tool (recursive)', async () => { - const mockClient = createMockClient(); - mockClient.request - .mockResolvedValueOnce({ - id: 'resp-2', - output: [ - { - type: 'mcp_approval_request', - id: 'apr-2', - name: 'projects_list', - server_label: 'ocp-mcp', - arguments: '{"org":"other"}', - }, - ], - }) - .mockResolvedValueOnce({ - id: 'resp-3', - output: [ - { - type: 'mcp_call', - name: 'projects_list', - output: '{"projects":["x","y"]}', - }, - { - type: 'message', - content: [{ type: 'output_text', text: 'Done.' }], - }, - ], - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: true, - toolName: 'projects_list', - attempt: 0, - maxAttempts: 3, - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(mockClient.request).toHaveBeenCalledTimes(2); - expect(result.toolExecuted).toBe(true); - expect(result.responseId).toBe('resp-3'); - expect(result.content).toBe('Done.'); - }); - - it('stops auto-reapproval when maxAttempts reached', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [ - { - type: 'mcp_approval_request', - id: 'apr-2', - name: 'projects_list', - arguments: '{}', - }, - ], - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: true, - toolName: 'projects_list', - attempt: 3, - maxAttempts: 3, - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(mockClient.request).toHaveBeenCalledTimes(1); - expect(result.pendingApproval).toBeDefined(); - expect(result.pendingApproval?.approvalRequestId).toBe('apr-2'); - }); - - it('does not auto-reapprove when chained tool is different', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [ - { - type: 'mcp_approval_request', - id: 'apr-2', - name: 'deploy_app', - arguments: '{}', - }, - ], - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: true, - toolName: 'projects_list', - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(mockClient.request).toHaveBeenCalledTimes(1); - expect(result.pendingApproval).toBeDefined(); - expect(result.pendingApproval?.toolName).toBe('deploy_app'); - }); - - it('does not auto-reapprove when user originally rejected', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [ - { - type: 'mcp_approval_request', - id: 'apr-2', - name: 'projects_list', - arguments: '{}', - }, - ], - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: false, - toolName: 'projects_list', - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(mockClient.request).toHaveBeenCalledTimes(1); - expect(result.toolExecuted).toBe(false); - expect(result.content).toContain('Tool Rejected'); - }); - }); - - describe('4. Error handling', () => { - it('returns error content when API call fails (approved)', async () => { - const mockClient = createMockClient(); - mockClient.request.mockRejectedValue(new Error('Connection refused')); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: true, - toolName: 'projects_list', - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(result.toolExecuted).toBe(false); - expect(result.responseId).toBe('resp-1'); - expect(result.content).toContain('Failed to execute tool'); - expect(result.content).toContain('projects_list'); - expect(result.content).toContain('Connection refused'); - }); - - it('returns error content when API call fails (rejected)', async () => { - const mockClient = createMockClient(); - mockClient.request.mockRejectedValue(new Error('Network timeout')); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: false, - toolName: 'deploy_app', - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(result.toolExecuted).toBe(false); - expect(result.content).toContain('Failed to reject tool'); - expect(result.content).toContain('Network timeout'); - }); - - it('uses "unknown" when toolName is undefined in error', async () => { - const mockClient = createMockClient(); - mockClient.request.mockRejectedValue(new Error('Server error')); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - }); - const params = createBaseParams({ - approved: true, - toolName: undefined, - }); - - const result = await executeApprovalContinuation(deps, params); - - expect(result.content).toContain('unknown'); - expect(result.content).toContain('Server error'); - }); - }); - - describe('5. MCP tool building', () => { - const directModeServer: MCPServerConfig = { - id: 'srv-1', - name: 'Server 1', - type: 'streamable-http', - url: 'http://mcp.example.com/direct', - }; - - it('direct mode: uses server URL and calls getServerHeaders', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [{ type: 'mcp_call', name: 'projects_list', output: 'ok' }], - }); - - const mcpAuth = createMockMcpAuth(); - mcpAuth.getServerHeaders.mockResolvedValue({ - Authorization: 'Bearer token123', - }); - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - mcpServers: [directModeServer], - mcpAuth: mcpAuth as unknown as ApprovalContinuationDeps['mcpAuth'], - }); - const params = createBaseParams(); - - await executeApprovalContinuation(deps, params); - - const body = JSON.parse(mockClient.request.mock.calls[0][1].body); - expect(body.tools).toHaveLength(1); - expect(body.tools[0]).toMatchObject({ - type: 'mcp', - server_url: 'http://mcp.example.com/direct', - server_label: 'srv-1', - headers: { Authorization: 'Bearer token123' }, - }); - expect(mcpAuth.getServerHeaders).toHaveBeenCalledWith(directModeServer); - }); - - it('overrides require_approval to "never" in approval continuation even when server has "always"', async () => { - const mockClient = createMockClient(); - mockClient.request.mockResolvedValue({ - id: 'resp-2', - output: [{ type: 'mcp_call', name: 'projects_list', output: 'ok' }], - }); - - const mcpAuth = createMockMcpAuth(); - mcpAuth.getApiApprovalConfig.mockReturnValue('always'); - - const serverWithApproval: MCPServerConfig = { - ...directModeServer, - requireApproval: 'always', - }; - - const deps = createBaseDeps({ - clientAccessor: { - getClient: () => mockClient, - getModel: () => 'test-model', - }, - mcpServers: [serverWithApproval], - mcpAuth: mcpAuth as unknown as ApprovalContinuationDeps['mcpAuth'], - }); - const params = createBaseParams(); - - await executeApprovalContinuation(deps, params); - - const body = JSON.parse(mockClient.request.mock.calls[0][1].body); - expect(body.tools[0].require_approval).toBe('never'); - expect(mcpAuth.getApiApprovalConfig).toHaveBeenCalledWith('always'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalContinuationExecutor.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalContinuationExecutor.ts deleted file mode 100644 index f452d91d18d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalContinuationExecutor.ts +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { toErrorMessage } from '../../../services/utils'; -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import { McpAuthService } from '../../llamastack/McpAuthService'; -import { MCPServerConfig } from '../../../types'; -import { buildMcpTools } from './McpToolBuilder'; -import { buildApprovalRequest, parseApprovalResponse } from './ApprovalHandler'; -import { createOutputSummaryForLogging } from '../conversations/ConversationHelpers'; -import type { - ApprovalResult, - ConversationClientAccessor, -} from '../conversations/conversationTypes'; - -export interface ApprovalSafetyContext { - guardrails?: string[]; - safetyIdentifier?: string; - reasoning?: { effort?: string; summary?: string }; -} - -export interface ApprovalContinuationDeps { - clientAccessor: ConversationClientAccessor; - mcpAuth: McpAuthService; - mcpServers: MCPServerConfig[]; - getConversationForResponse: ( - responseId: string, - ) => Promise; - registerResponse: ( - conversationId: string, - responseId: string, - ) => Promise; - logger: LoggerService; - safetyContext?: ApprovalSafetyContext; -} - -export interface ApprovalContinuationParams { - responseId: string; - approvalRequestId: string; - approved: boolean; - toolName?: string; - toolArguments?: string; - conversationId?: string; - attempt: number; - maxAttempts: number; -} - -/** - * Executes HITL approval continuation: sends mcp_approval_response to Llama Stack, - * parses the response, handles auto-reapproval for chained duplicate requests. - */ -export async function executeApprovalContinuation( - deps: ApprovalContinuationDeps, - params: ApprovalContinuationParams, -): Promise { - const { - responseId, - approvalRequestId, - approved, - toolName, - conversationId, - attempt, - maxAttempts, - } = params; - - const { - clientAccessor, - mcpAuth, - mcpServers, - getConversationForResponse, - registerResponse, - logger, - } = deps; - - const client: ResponsesApiClient = clientAccessor.getClient(); - - logger.info( - `Continuing conversation ${responseId} after ${ - approved ? 'approval' : 'rejection' - } for approval request ${approvalRequestId}${ - attempt > 0 ? ` (auto-reapproval attempt ${attempt})` : '' - }`, - ); - - try { - const tools = (await buildMcpTools({ - mcpAuth, - mcpServers, - })) as unknown as Array>; - - let resolvedConversationId = conversationId; - if (!resolvedConversationId) { - resolvedConversationId = await getConversationForResponse(responseId); - if (resolvedConversationId) { - logger.info( - `Resolved conversationId=${resolvedConversationId} from registry for response ${responseId}`, - ); - } - } - - const responsesRequest = buildApprovalRequest({ - model: clientAccessor.getModel(), - approved, - responseId, - approvalRequestId, - tools, - guardrails: deps.safetyContext?.guardrails, - safetyIdentifier: deps.safetyContext?.safetyIdentifier, - reasoning: deps.safetyContext?.reasoning, - }); - - logger.info( - `[HITL] Approval continuation request body: ${JSON.stringify(responsesRequest, null, 2)}`, - ); - - const response = await client.request<{ - id: string; - output: Array<{ - type: string; - id?: string; - status?: string; - output?: string; - error?: string; - content?: Array<{ type: string; text: string }>; - name?: string; - server_label?: string; - arguments?: string; - }>; - }>('/v1/responses', { - method: 'POST', - body: JSON.stringify(responsesRequest), - }); - - const outputSummary = createOutputSummaryForLogging(response.output || []); - logger.info( - `Approval continuation response: id=${response.id}, outputItems=${ - response.output?.length ?? 0 - }, items=${JSON.stringify(outputSummary)}`, - ); - - if (resolvedConversationId && response.id) { - await registerResponse(resolvedConversationId, response.id); - } - - const result = parseApprovalResponse(response, approved, toolName, logger); - - // Auto-reapprove: Llama Stack may return a duplicate approval request - // for the same tool because it regenerates the tool call with different - // arguments. If the user originally approved and the chained request - // targets the same tool, auto-approve it. - if ( - approved && - result.pendingApproval && - result.pendingApproval.toolName === toolName && - attempt < maxAttempts - ) { - const argsChanged = - result.pendingApproval.arguments !== params.toolArguments; - const argDetail = argsChanged - ? `, originalArgs=${(params.toolArguments ?? '').substring(0, 200)}, newArgs=${(result.pendingApproval.arguments ?? '').substring(0, 200)}` - : ''; - logger.info( - `[HITL] Auto-reapproving chained approval for same tool "${toolName}" (attempt ${attempt + 1}/${maxAttempts}), argsChanged=${argsChanged}, new approvalId=${result.pendingApproval.approvalRequestId}${argDetail}`, - ); - return executeApprovalContinuation(deps, { - responseId: result.responseId, - approvalRequestId: result.pendingApproval.approvalRequestId, - approved: true, - toolName, - toolArguments: result.pendingApproval.arguments, - conversationId: resolvedConversationId, - attempt: attempt + 1, - maxAttempts, - }); - } - - return result; - } catch (error) { - const msg = toErrorMessage(error); - logger.error( - `Approval continuation failed for tool "${ - toolName ?? 'unknown' - }": ${msg}`, - ); - const action = approved ? 'execute' : 'reject'; - return { - content: `Failed to ${action} tool \`${toolName ?? 'unknown'}\`: ${msg}`, - responseId, - toolExecuted: false, - }; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalHandler.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalHandler.test.ts deleted file mode 100644 index a45b51bfe81..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalHandler.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { buildApprovalRequest, parseApprovalResponse } from './ApprovalHandler'; -import { createMockLogger } from '../../../test-utils/mocks'; - -const mockLogger = createMockLogger(); - -const BASE_OPTS = { - model: 'test-model', - responseId: 'resp-1', - approvalRequestId: 'apr-1', - tools: [ - { type: 'mcp', server_url: 'https://mcp', require_approval: 'always' }, - ], -}; - -describe('buildApprovalRequest', () => { - it('sends native mcp_approval_response with approve: true when approved', () => { - const result = buildApprovalRequest({ ...BASE_OPTS, approved: true }); - - expect(result.input).toEqual([ - { - type: 'mcp_approval_response', - approval_request_id: 'apr-1', - approve: true, - }, - ]); - expect(result.model).toBe('test-model'); - expect(result.store).toBe(true); - expect(result.previous_response_id).toBe('resp-1'); - }); - - it('sends native mcp_approval_response with approve: false when rejected', () => { - const result = buildApprovalRequest({ ...BASE_OPTS, approved: false }); - - expect(result.input).toEqual([ - { - type: 'mcp_approval_response', - approval_request_id: 'apr-1', - approve: false, - }, - ]); - expect(result.store).toBe(true); - expect(result.previous_response_id).toBe('resp-1'); - }); - - it('overrides require_approval to "never" on MCP tools', () => { - const tools = [ - { type: 'mcp', server_url: 'https://mcp-a', require_approval: 'always' }, - { type: 'mcp', server_url: 'https://mcp-b', require_approval: 'always' }, - ]; - const result = buildApprovalRequest({ - ...BASE_OPTS, - approved: true, - tools, - }); - - const resultTools = result.tools as Array>; - expect(resultTools).toHaveLength(2); - for (const t of resultTools) { - expect(t.require_approval).toBe('never'); - expect(t.type).toBe('mcp'); - } - }); - - it('preserves non-MCP tools (stripping type from function tools)', () => { - const tools = [ - { type: 'mcp', server_url: 'https://mcp', require_approval: 'always' }, - { type: 'function', name: 'myFunc' }, - ]; - const result = buildApprovalRequest({ - ...BASE_OPTS, - approved: true, - tools, - }); - - const resultTools = result.tools as Array>; - expect(resultTools[0].require_approval).toBe('never'); - expect(resultTools[1]).toEqual({ name: 'myFunc' }); - }); - - it('includes instructions when approved to guide re-execution', () => { - const result = buildApprovalRequest({ ...BASE_OPTS, approved: true }); - expect(result).toHaveProperty('instructions'); - expect(typeof result.instructions).toBe('string'); - }); - - it('does not include instructions when rejected', () => { - const result = buildApprovalRequest({ ...BASE_OPTS, approved: false }); - expect(result).not.toHaveProperty('instructions'); - }); - - it('includes tool_choice "required" when approved', () => { - const result = buildApprovalRequest({ ...BASE_OPTS, approved: true }); - expect(result.tool_choice).toBe('required'); - }); - - it('does not include tool_choice when rejected', () => { - const result = buildApprovalRequest({ ...BASE_OPTS, approved: false }); - expect(result).not.toHaveProperty('tool_choice'); - }); - - it('does not include guardrails, temperature, or other production params', () => { - const result = buildApprovalRequest({ ...BASE_OPTS, approved: true }); - - expect(result).not.toHaveProperty('guardrails'); - expect(result).not.toHaveProperty('temperature'); - expect(result).not.toHaveProperty('max_output_tokens'); - expect(result).not.toHaveProperty('max_tool_calls'); - expect(result).not.toHaveProperty('safety_identifier'); - expect(result).not.toHaveProperty('conversation'); - }); -}); - -describe('parseApprovalResponse', () => { - it('returns rejection message when not approved', () => { - const result = parseApprovalResponse( - { id: 'resp-2', output: [] }, - false, - 'projects_list', - mockLogger, - ); - - expect(result.content).toContain('Tool Rejected'); - expect(result.content).toContain('projects_list'); - expect(result.toolExecuted).toBe(false); - }); - - it('extracts successful tool execution from mcp_call output', () => { - const result = parseApprovalResponse( - { - id: 'resp-2', - output: [ - { - type: 'mcp_call', - name: 'projects_list', - output: '{"projects":["alpha"]}', - }, - { - type: 'message', - content: [{ type: 'output_text', text: 'Here are your projects.' }], - }, - ], - }, - true, - 'projects_list', - mockLogger, - ); - - expect(result.toolExecuted).toBe(true); - expect(result.toolOutput).toBe('{"projects":["alpha"]}'); - expect(result.content).toBe('Here are your projects.'); - expect(result.responseId).toBe('resp-2'); - }); - - it('uses default success message when LLM message is trivial', () => { - const result = parseApprovalResponse( - { - id: 'resp-2', - output: [ - { type: 'mcp_call', name: 'run_query', output: '42' }, - { type: 'message', content: [{ type: 'output_text', text: 'Ok' }] }, - ], - }, - true, - 'run_query', - mockLogger, - ); - - expect(result.content).toContain('executed successfully'); - }); - - it('reports tool error when mcp_call has error field', () => { - const result = parseApprovalResponse( - { - id: 'resp-2', - output: [ - { type: 'mcp_call', name: 'deploy', error: 'permission denied' }, - ], - }, - true, - 'deploy', - mockLogger, - ); - - expect(result.content).toContain('Tool Failed'); - expect(result.content).toContain('permission denied'); - expect(result.toolExecuted).toBe(true); - }); - - it('handles approved with no tool output', () => { - const result = parseApprovalResponse( - { id: 'resp-2', output: [] }, - true, - 'some_tool', - mockLogger, - ); - - expect(result.content).toContain('approved but no output received'); - expect(result.toolExecuted).toBe(false); - }); - - it('uses "unknown" when toolName is undefined', () => { - const result = parseApprovalResponse( - { id: 'resp-2', output: [] }, - false, - undefined, - mockLogger, - ); - - expect(result.content).toContain('unknown'); - }); - - it('strips proxy prefix from tool name in user-facing content', () => { - const result = parseApprovalResponse( - { - id: 'resp-2', - output: [ - { - type: 'mcp_call', - name: 'ocp_mcp__projects_list', - server_label: 'ocp-mcp', - output: '{"projects":["ns1"]}', - }, - { - type: 'message', - content: [{ type: 'output_text', text: 'Ok' }], - }, - ], - }, - true, - 'ocp_mcp__projects_list', - mockLogger, - ); - - expect(result.content).toContain('projects_list'); - expect(result.content).not.toContain('ocp_mcp__'); - expect(result.toolExecuted).toBe(true); - }); - - it('preserves prefixed name in rejection when no server_label available', () => { - const result = parseApprovalResponse( - { id: 'resp-2', output: [] }, - false, - 'aap_mcp__job_templates_list', - mockLogger, - ); - - expect(result.content).toContain('Tool Rejected'); - expect(result.content).toContain('aap_mcp__job_templates_list'); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalHandler.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalHandler.ts deleted file mode 100644 index dd4d5b21179..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ApprovalHandler.ts +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { APPROVAL_OUTPUT_LOG_MAX_LENGTH } from '../../../constants'; -import type { ApprovalResult } from '../conversations/conversationTypes'; - -/** - * Build the Responses API request body for an approval continuation. - * - * LlamaStack re-initializes MCP sessions on every request that includes - * `tools`, which destroys the pending approval context. To work around - * this we apply three complementary fixes: - * - * 1. `require_approval: "never"` on all MCP tools — the user already - * approved, so another approval loop must not occur. - * 2. `tool_choice: "required"` — forces the LLM to call a tool instead - * of generating a refusal message. Curl testing confirmed this is - * the critical parameter that makes the approval flow succeed. - * 3. `instructions` — gives the LLM context that a tool call was - * approved and should be executed. - */ -export function buildApprovalRequest(opts: { - model: string; - approved: boolean; - responseId: string; - approvalRequestId: string; - tools: Array>; - guardrails?: string[]; - safetyIdentifier?: string; - reasoning?: { effort?: string; summary?: string }; -}): Record { - const toolsWithoutApproval = opts.tools.map(tool => { - if (tool.type === 'mcp') { - const { require_approval: _, ...rest } = tool; - return { ...rest, require_approval: 'never' }; - } - if (tool.type === 'function') { - const { type: _discriminator, ...rest } = tool; - return rest; - } - return tool; - }); - - const body: Record = { - model: opts.model, - input: [ - { - type: 'mcp_approval_response', - approval_request_id: opts.approvalRequestId, - approve: opts.approved, - }, - ], - previous_response_id: opts.responseId, - store: true, - tools: toolsWithoutApproval, - }; - - if (opts.approved) { - body.instructions = - 'The user approved a pending tool call. Execute the tool that was ' + - 'previously requested. Do not ask for confirmation again.'; - body.tool_choice = 'required'; - } - - if (opts.guardrails && opts.guardrails.length > 0) { - body.guardrails = opts.guardrails; - } - if (opts.safetyIdentifier) { - body.safety_identifier = opts.safetyIdentifier; - } - if (opts.reasoning) { - const reasoning: Record = {}; - if (opts.reasoning.effort) reasoning.effort = opts.reasoning.effort; - if (opts.reasoning.summary) reasoning.summary = opts.reasoning.summary; - if (Object.keys(reasoning).length > 0) { - body.reasoning = reasoning; - } - } - - return body; -} - -/** - * Parse the Responses API reply after an approval continuation. - * Extracts tool execution results, LLM message, and error state. - * - * Handles chained tool calls: if the model requests approval for a - * second tool after the first executes, we capture the pending - * approval in the result so the frontend can show the dialog again. - */ -export function parseApprovalResponse( - response: { - id: string; - output: Array<{ - type: string; - id?: string; - output?: string; - error?: string; - content?: Array<{ type: string; text: string }>; - name?: string; - server_label?: string; - arguments?: string; - }>; - }, - approved: boolean, - toolName: string | undefined, - logger: LoggerService, -): ApprovalResult { - let llmMessage = ''; - let toolExecuted = false; - let executedToolName = toolName || 'unknown'; - let toolOutput = ''; - let toolError = ''; - let executedServerLabel: string | undefined; - let pendingApproval: ApprovalResult['pendingApproval']; - - for (const item of response.output) { - logger.info( - `[HITL] Processing output item: type=${item.type}, name=${ - item.name ?? 'n/a' - }, hasOutput=${ - item.output !== null && item.output !== undefined && item.output !== '' - }, hasError=${!!item.error}, snippet=${JSON.stringify(item).substring( - 0, - APPROVAL_OUTPUT_LOG_MAX_LENGTH, - )}`, - ); - - if (item.type === 'message' && item.content) { - for (const part of item.content) { - if (part.type === 'output_text' || part.type === 'text') { - llmMessage += part.text; - } - } - } else if (item.type === 'mcp_call') { - toolExecuted = true; - if (item.name) { - executedToolName = item.name; - } - if (item.server_label) { - executedServerLabel = item.server_label; - } - if (item.output) { - logger.info( - `[HITL] MCP tool "${executedToolName}" executed successfully (output length=${item.output.length})`, - ); - toolOutput = item.output; - } else { - logger.warn( - `[HITL] MCP tool "${executedToolName}" returned no output (output=${JSON.stringify( - item.output, - )})`, - ); - } - if (item.error) { - logger.warn( - `[HITL] MCP tool "${executedToolName}" returned error: ${item.error}`, - ); - toolError = item.error; - } - } else if (item.type === 'mcp_approval_request') { - logger.info( - `[HITL] Chained approval request: id=${item.id}, name=${item.name}, server=${item.server_label}`, - ); - pendingApproval = { - approvalRequestId: item.id || '', - toolName: item.name || '', - serverLabel: item.server_label, - arguments: item.arguments, - }; - } else if (item.type !== 'mcp_list_tools') { - logger.info(`[HITL] Skipping unhandled output item type: ${item.type}`); - } - } - - // Strip proxy prefix (e.g. "ocp_mcp__projects_list" → "projects_list") - // for user-facing messages so the UI shows clean tool names. - const displayName = (() => { - if (executedServerLabel) { - const pfx = `${executedServerLabel.replace(/-/g, '_')}__`; - if (executedToolName.startsWith(pfx)) { - return executedToolName.slice(pfx.length); - } - } - return executedToolName; - })(); - - let content = ''; - if (!approved) { - content = `🚫 **Tool Rejected:** \`${displayName}\`\n\nThe operation was cancelled.`; - } else if (toolError) { - content = `❌ **Tool Failed:** \`${displayName}\`\n\n**Error:** ${toolError}`; - } else if (toolExecuted) { - if ( - llmMessage && - llmMessage.trim() && - llmMessage.trim().toLowerCase() !== 'ok' - ) { - content = llmMessage; - } else { - content = `Tool \`${displayName}\` executed successfully. See tool details for output.`; - } - } else { - content = - llmMessage || `Tool \`${displayName}\` approved but no output received.`; - } - - logger.info( - `[HITL] Approval result: toolExecuted=${toolExecuted}, hasOutput=${!!toolOutput}, hasError=${!!toolError}, hasPendingApproval=${!!pendingApproval}, contentLength=${ - content.length - }`, - ); - - return { - content, - responseId: response.id, - toolExecuted, - toolOutput: toolOutput || undefined, - pendingApproval, - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendApprovalStore.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendApprovalStore.test.ts deleted file mode 100644 index 596081e25e0..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendApprovalStore.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - BackendApprovalStore, - PendingBackendToolApproval, -} from './BackendApprovalStore'; - -function makeApproval( - overrides: Partial = {}, -): PendingBackendToolApproval { - return { - responseId: 'resp-1', - callId: 'call-1', - functionName: 'server__tool', - argumentsJson: '{}', - serverId: 'server', - serverUrl: 'http://localhost', - originalToolName: 'tool', - createdAt: Date.now(), - ...overrides, - }; -} - -describe('BackendApprovalStore', () => { - it('stores and retrieves approvals', () => { - const store = new BackendApprovalStore(); - const approval = makeApproval(); - store.store(approval); - - expect(store.size).toBe(1); - expect(store.get('resp-1', 'call-1')).toEqual(approval); - }); - - it('removes approvals', () => { - const store = new BackendApprovalStore(); - store.store(makeApproval()); - store.remove('resp-1', 'call-1'); - - expect(store.size).toBe(0); - expect(store.get('resp-1', 'call-1')).toBeUndefined(); - }); - - it('expires entries older than TTL on store()', () => { - const store = new BackendApprovalStore(); - const old = makeApproval({ - responseId: 'old', - callId: 'old-call', - createdAt: Date.now() - 31 * 60 * 1000, - }); - store.store(old); - expect(store.size).toBe(1); - - const fresh = makeApproval({ responseId: 'new', callId: 'new-call' }); - store.store(fresh); - - expect(store.get('old', 'old-call')).toBeUndefined(); - expect(store.size).toBe(1); - }); - - it('expires entries older than TTL on get()', () => { - const store = new BackendApprovalStore(); - const old = makeApproval({ - createdAt: Date.now() - 31 * 60 * 1000, - }); - // Bypass cleanup-on-store by directly checking get behavior - store.store(old); - // The store() call already cleaned up the old entry, so size is 0 - // Add a fresh entry and then query for the old one - const fresh = makeApproval({ responseId: 'r2', callId: 'c2' }); - store.store(fresh); - - expect(store.get('resp-1', 'call-1')).toBeUndefined(); - expect(store.get('r2', 'c2')).toBeDefined(); - }); - - it('evicts oldest entry when max size is reached', () => { - const store = new BackendApprovalStore(); - const baseTime = Date.now(); - - for (let i = 0; i < 100; i++) { - store.store( - makeApproval({ - responseId: `resp-${i}`, - callId: `call-${i}`, - createdAt: baseTime + i, - }), - ); - } - expect(store.size).toBe(100); - - store.store( - makeApproval({ - responseId: 'resp-100', - callId: 'call-100', - createdAt: baseTime + 100, - }), - ); - - expect(store.size).toBe(100); - expect(store.get('resp-0', 'call-0')).toBeUndefined(); - expect(store.get('resp-1', 'call-1')).toBeDefined(); - expect(store.get('resp-100', 'call-100')).toBeDefined(); - }); - - it('returns undefined for non-existent keys', () => { - const store = new BackendApprovalStore(); - expect(store.get('no', 'such')).toBeUndefined(); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendApprovalStore.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendApprovalStore.ts deleted file mode 100644 index 53f5a5334b0..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendApprovalStore.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Pending approval for a backend-executed MCP tool. - * Stored when a tool with `requireApproval` is invoked in backend mode - * and the user must confirm before execution proceeds. - */ -export interface PendingBackendToolApproval { - responseId: string; - callId: string; - functionName: string; - argumentsJson: string; - serverId: string; - serverUrl: string; - originalToolName: string; - conversationId?: string; - createdAt: number; - /** Agent key that was active when the approval was requested. */ - agentKey?: string; -} - -const TTL_MS = 30 * 60 * 1000; // 30 minutes -const MAX_PENDING_APPROVALS = 100; - -/** - * In-memory store for pending backend tool approvals. - * - * When the backend intercepts a function_call for a tool that - * requires human approval, it stores the call info here. When - * the user responds via POST /chat/approve, the approval handler - * retrieves the stored info, executes the tool, and feeds the - * result back to LlamaStack. - * - * Bounded by MAX_PENDING_APPROVALS to prevent unbounded growth. - * Expired entries are cleaned up on both store() and get(). - */ -export class BackendApprovalStore { - private readonly pending = new Map(); - - private key(responseId: string, callId: string): string { - return `${responseId}::${callId}`; - } - - store(approval: PendingBackendToolApproval): void { - this.cleanup(); - if (this.pending.size >= MAX_PENDING_APPROVALS) { - let oldestKey: string | undefined; - let oldestTime = Infinity; - for (const [key, val] of this.pending) { - if (val.createdAt < oldestTime) { - oldestTime = val.createdAt; - oldestKey = key; - } - } - if (oldestKey) this.pending.delete(oldestKey); - } - this.pending.set(this.key(approval.responseId, approval.callId), approval); - } - - get( - responseId: string, - callId: string, - ): PendingBackendToolApproval | undefined { - this.cleanup(); - return this.pending.get(this.key(responseId, callId)); - } - - remove(responseId: string, callId: string): void { - this.pending.delete(this.key(responseId, callId)); - } - - get size(): number { - return this.pending.size; - } - - private cleanup(): void { - const now = Date.now(); - for (const [key, val] of this.pending) { - if (now - val.createdAt > TTL_MS) { - this.pending.delete(key); - } - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendToolExecutor.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendToolExecutor.test.ts deleted file mode 100644 index bbdcc1250bf..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendToolExecutor.test.ts +++ /dev/null @@ -1,1018 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { BackendToolExecutor } from './BackendToolExecutor'; -import type { McpAuthService } from '../../llamastack/McpAuthService'; -import type { MCPServerConfig } from '../../../types'; -import { BACKEND_TOOL_DISCOVERY_TTL_MS } from '../../../constants'; - -// Mock the MCP SDK -const mockConnect = jest.fn().mockResolvedValue(undefined); -const mockListTools = jest.fn(); -const mockCallTool = jest.fn(); -const mockClose = jest.fn().mockResolvedValue(undefined); - -jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ - Client: jest.fn().mockImplementation(() => ({ - connect: mockConnect, - listTools: mockListTools, - callTool: mockCallTool, - close: mockClose, - })), -})); - -jest.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ - StreamableHTTPClientTransport: jest.fn().mockImplementation(() => ({})), -})); - -const mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - child: jest.fn().mockReturnThis(), -}; - -function createMockMcpAuth(): McpAuthService { - return { - getServerHeaders: jest - .fn() - .mockResolvedValue({ Authorization: 'Bearer tok' }), - getApiApprovalConfig: jest.fn().mockReturnValue('never'), - } as unknown as McpAuthService; -} - -function makeServer( - id: string, - url: string = `https://${id}.example.com/mcp`, -): MCPServerConfig { - return { id, name: id, type: 'streamable-http' as const, url }; -} - -describe('BackendToolExecutor', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockConnect.mockResolvedValue(undefined); - mockClose.mockResolvedValue(undefined); - }); - - describe('prefixName / unprefixName', () => { - it('prefixes with serverId__toolName', () => { - expect(BackendToolExecutor.prefixName('ocp-mcp', 'list_pods')).toBe( - 'ocp-mcp__list_pods', - ); - }); - - it('unprefixes correctly', () => { - const result = BackendToolExecutor.unprefixName('ocp-mcp__list_pods'); - expect(result).toEqual({ serverId: 'ocp-mcp', toolName: 'list_pods' }); - }); - - it('returns null for names without separator', () => { - expect(BackendToolExecutor.unprefixName('list_pods')).toBeNull(); - }); - }); - - describe('discoverTools', () => { - it('connects via SDK Client and discovers tools', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValue({ - tools: [ - { - name: 'create_namespace', - description: 'Create a K8s namespace', - inputSchema: { - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], - }, - }, - { - name: 'list_pods', - description: 'List pods in a namespace', - inputSchema: { - type: 'object', - properties: { namespace: { type: 'string' } }, - }, - }, - ], - }); - - const tools = await executor.discoverTools([makeServer('ocp-mcp')]); - - expect(mockConnect).toHaveBeenCalledTimes(1); - expect(mockListTools).toHaveBeenCalledTimes(1); - expect(tools).toHaveLength(2); - expect(tools[0].type).toBe('function'); - expect(tools[0].name).toBe('ocp-mcp__create_namespace'); - expect(tools[0].description).toBe('Create a K8s namespace'); - expect(tools[0].parameters).toEqual({ - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], - }); - expect(tools[1].name).toBe('ocp-mcp__list_pods'); - - expect(executor.isBackendTool('ocp-mcp__create_namespace')).toBe(true); - expect(executor.isBackendTool('ocp-mcp__list_pods')).toBe(true); - expect(executor.isBackendTool('unknown_tool')).toBe(false); - expect(executor.getToolCount()).toBe(2); - }); - - it('handles multiple servers', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools - .mockResolvedValueOnce({ - tools: [{ name: 'tool_a', description: 'Tool A' }], - }) - .mockResolvedValueOnce({ - tools: [{ name: 'tool_b', description: 'Tool B' }], - }); - - const tools = await executor.discoverTools([ - makeServer('s1'), - makeServer('s2'), - ]); - - expect(tools).toHaveLength(2); - const names = tools.map(t => t.name).sort((a, b) => a.localeCompare(b)); - expect(names).toEqual(['s1__tool_a', 's2__tool_b']); - expect(mockConnect).toHaveBeenCalledTimes(2); - }); - - it('handles server connection failure gracefully', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockConnect.mockRejectedValueOnce(new Error('Connection refused')); - - const tools = await executor.discoverTools([makeServer('failing')]); - expect(tools).toHaveLength(0); - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Failed to connect'), - ); - }); - - it('handles tools/list failure gracefully', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockRejectedValueOnce(new Error('tools/list failed')); - - const tools = await executor.discoverTools([makeServer('failing')]); - expect(tools).toHaveLength(0); - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('tools/list failed'), - ); - }); - - it('provides default schema when inputSchema is missing', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValue({ - tools: [{ name: 'simple_tool' }], - }); - - const tools = await executor.discoverTools([makeServer('s1')]); - expect(tools[0].parameters).toEqual({ - type: 'object', - properties: {}, - }); - }); - }); - - describe('executeTool', () => { - it('calls tool via SDK Client and returns text result', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValue({ - tools: [ - { - name: 'create_ns', - description: 'Create namespace', - inputSchema: { - type: 'object', - properties: { name: { type: 'string' } }, - }, - }, - ], - }); - await executor.discoverTools([makeServer('ocp')]); - - mockCallTool.mockResolvedValue({ - content: [ - { type: 'text', text: 'Namespace "test" created successfully' }, - ], - }); - - const result = await executor.executeTool( - 'ocp__create_ns', - JSON.stringify({ name: 'test' }), - ); - expect(result).toBe('Namespace "test" created successfully'); - expect(mockCallTool).toHaveBeenCalledWith({ - name: 'create_ns', - arguments: { name: 'test' }, - }); - }); - - it('returns error for unknown tool', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - const result = await executor.executeTool('unknown__tool', '{}'); - expect(JSON.parse(result)).toEqual({ - error: 'Unknown tool: unknown__tool', - }); - }); - - it('handles tool error response (isError flag)', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValue({ - tools: [{ name: 'err_tool', description: 'Returns error' }], - }); - await executor.discoverTools([makeServer('s1')]); - - mockCallTool.mockResolvedValue({ - isError: true, - content: [{ type: 'text', text: 'Permission denied' }], - }); - - const result = await executor.executeTool('s1__err_tool', '{}'); - const parsed = JSON.parse(result); - expect(parsed.error).toBe('Permission denied'); - }); - - it('handles callTool exception', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValue({ - tools: [{ name: 'fail_tool', description: 'Will fail' }], - }); - await executor.discoverTools([makeServer('s1')]); - - mockCallTool.mockRejectedValue(new Error('Network timeout')); - - const result = await executor.executeTool('s1__fail_tool', '{}'); - const parsed = JSON.parse(result); - expect(parsed.error).toContain('Network timeout'); - }); - }); - - describe('ensureToolsDiscovered', () => { - it('returns cached tools within TTL', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - const servers = [makeServer('s1')]; - - mockListTools.mockResolvedValue({ - tools: [{ name: 'tool_a', description: 'Tool A' }], - }); - - const firstResult = await executor.ensureToolsDiscovered(servers); - expect(firstResult).toHaveLength(1); - - const secondResult = await executor.ensureToolsDiscovered(servers); - expect(secondResult).toBe(firstResult); - expect(mockConnect).toHaveBeenCalledTimes(1); - }); - - it('re-fetches after TTL expires', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - const servers = [makeServer('s1')]; - - mockListTools.mockResolvedValue({ - tools: [{ name: 'tool_a', description: 'Tool A' }], - }); - - const firstResult = await executor.ensureToolsDiscovered(servers); - expect(firstResult).toHaveLength(1); - - const realDateNow = Date.now; - Date.now = () => realDateNow() + BACKEND_TOOL_DISCOVERY_TTL_MS + 1; - - try { - mockListTools.mockResolvedValue({ - tools: [ - { name: 'tool_a', description: 'Tool A' }, - { name: 'tool_b', description: 'Tool B' }, - ], - }); - - const secondResult = await executor.ensureToolsDiscovered(servers); - expect(secondResult).toHaveLength(2); - expect(mockConnect).toHaveBeenCalledTimes(2); - } finally { - Date.now = realDateNow; - } - }); - - it('deduplicates concurrent in-flight requests', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - const servers = [makeServer('s1')]; - - mockListTools.mockResolvedValue({ - tools: [{ name: 'tool_a', description: 'Tool A' }], - }); - - const [result1, result2, result3] = await Promise.all([ - executor.ensureToolsDiscovered(servers), - executor.ensureToolsDiscovered(servers), - executor.ensureToolsDiscovered(servers), - ]); - - expect(result1).toBe(result2); - expect(result2).toBe(result3); - expect(mockConnect).toHaveBeenCalledTimes(1); - }); - - it('invalidateCache forces next call to re-fetch', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - const servers = [makeServer('s1')]; - - mockListTools.mockResolvedValue({ - tools: [{ name: 'tool_a', description: 'Tool A' }], - }); - - const firstResult = await executor.ensureToolsDiscovered(servers); - expect(firstResult).toHaveLength(1); - - executor.invalidateCache(); - - mockListTools.mockResolvedValue({ - tools: [ - { name: 'tool_a', description: 'Tool A' }, - { name: 'tool_c', description: 'Tool C' }, - ], - }); - - const secondResult = await executor.ensureToolsDiscovered(servers); - expect(secondResult).toHaveLength(2); - expect(secondResult).not.toBe(firstResult); - }); - }); - - describe('SSRF guard', () => { - it('admin-configured servers bypass SSRF during discovery', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValue({ - tools: [{ name: 'internal_tool', description: 'Internal tool' }], - }); - - const tools = await executor.discoverTools([ - makeServer('internal', 'https://169.254.169.254/latest/meta-data/'), - ]); - - expect(tools).toHaveLength(1); - expect(tools[0].name).toBe('internal__internal_tool'); - expect(mockConnect).toHaveBeenCalledTimes(1); - }); - - it('admin-configured localhost servers are allowed during discovery', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValue({ - tools: [{ name: 'local_tool', description: 'Local tool' }], - }); - - const tools = await executor.discoverTools([ - makeServer('local', 'https://localhost:8080/mcp'), - ]); - - expect(tools).toHaveLength(1); - expect(mockConnect).toHaveBeenCalledTimes(1); - }); - - it('allows public URLs', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValue({ - tools: [{ name: 'pub_tool', description: 'Public tool' }], - }); - - const tools = await executor.discoverTools([ - makeServer('pub', 'https://mcp.example.com/tools'), - ]); - - expect(tools).toHaveLength(1); - expect(mockConnect).toHaveBeenCalledTimes(1); - }); - }); - - describe('suffix-match fallback for unprefixed tool names', () => { - async function createExecutorWithTools() { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValue({ - tools: [ - { - name: 'namespaces_list', - description: 'List namespaces', - inputSchema: { type: 'object', properties: {} }, - }, - { - name: 'pods_list', - description: 'List pods', - inputSchema: { - type: 'object', - properties: { namespace: { type: 'string' } }, - }, - }, - ], - }); - await executor.discoverTools([makeServer('ocp-mcp')]); - return executor; - } - - it('isBackendTool resolves unprefixed names via suffix match', async () => { - const executor = await createExecutorWithTools(); - expect(executor.isBackendTool('namespaces_list')).toBe(true); - expect(executor.isBackendTool('pods_list')).toBe(true); - }); - - it('isBackendTool returns false for truly unknown tools', async () => { - const executor = await createExecutorWithTools(); - expect(executor.isBackendTool('nonexistent_tool')).toBe(false); - }); - - it('resolveTool returns the resolved tool for unprefixed names', async () => { - const executor = await createExecutorWithTools(); - const resolved = executor.resolveTool('namespaces_list'); - expect(resolved).toBeDefined(); - expect(resolved!.prefixedName).toBe('ocp-mcp__namespaces_list'); - expect(resolved!.serverId).toBe('ocp-mcp'); - expect(resolved!.originalName).toBe('namespaces_list'); - }); - - it('resolveTool returns undefined for unknown tools', async () => { - const executor = await createExecutorWithTools(); - expect(executor.resolveTool('nonexistent_tool')).toBeUndefined(); - }); - - it('resolveTool prefers exact match over suffix match', async () => { - const executor = await createExecutorWithTools(); - const resolved = executor.resolveTool('ocp-mcp__namespaces_list'); - expect(resolved).toBeDefined(); - expect(resolved!.prefixedName).toBe('ocp-mcp__namespaces_list'); - // Should NOT log a suffix-match warning for exact matches - expect(mockLogger.warn).not.toHaveBeenCalledWith( - expect.stringContaining('suffix match'), - ); - }); - - it('getToolServerInfo resolves unprefixed names', async () => { - const executor = await createExecutorWithTools(); - const info = executor.getToolServerInfo('pods_list'); - expect(info).toEqual({ serverId: 'ocp-mcp', originalName: 'pods_list' }); - }); - - it('executeTool works with unprefixed names via suffix match', async () => { - const executor = await createExecutorWithTools(); - - mockCallTool.mockResolvedValue({ - content: [{ type: 'text', text: 'namespace-1\nnamespace-2' }], - }); - - const result = await executor.executeTool('namespaces_list', '{}'); - expect(result).toBe('namespace-1\nnamespace-2'); - expect(mockCallTool).toHaveBeenCalledWith({ - name: 'namespaces_list', - arguments: {}, - }); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'resolved to "ocp-mcp__namespaces_list" via suffix match', - ), - ); - }); - - it('logs a warning when suffix match is used', async () => { - const executor = await createExecutorWithTools(); - executor.isBackendTool('namespaces_list'); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'Tool "namespaces_list" resolved to "ocp-mcp__namespaces_list" via suffix match', - ), - ); - }); - - it('handles ambiguous unprefixed names across multiple servers', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools - .mockResolvedValueOnce({ - tools: [ - { name: 'list_items', description: 'List items from server A' }, - ], - }) - .mockResolvedValueOnce({ - tools: [ - { name: 'list_items', description: 'List items from server B' }, - ], - }); - - await executor.discoverTools([ - makeServer('server-a'), - makeServer('server-b'), - ]); - - // With ambiguous names, suffix match returns the first hit - const resolved = executor.resolveTool('list_items'); - expect(resolved).toBeDefined(); - expect(['server-a__list_items', 'server-b__list_items']).toContain( - resolved!.prefixedName, - ); - }); - }); - - describe('client lifecycle', () => { - it('preserves clients on invalidateCache for in-flight requests', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValue({ - tools: [{ name: 'tool_a', description: 'Tool A' }], - }); - await executor.discoverTools([makeServer('s1')]); - - executor.invalidateCache(); - - await new Promise(resolve => setTimeout(resolve, 10)); - expect(mockClose).not.toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('registry and clients preserved'), - ); - }); - - it('reconnects if client is missing during executeTool', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValue({ - tools: [{ name: 'tool_a', description: 'Tool A' }], - }); - await executor.discoverTools([makeServer('s1')]); - - // Remove the client to simulate disconnection - (executor as any).clients.clear(); - - mockCallTool.mockResolvedValue({ - content: [{ type: 'text', text: 'Reconnected result' }], - }); - - const result = await executor.executeTool('s1__tool_a', '{}'); - expect(result).toBe('Reconnected result'); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('reconnecting'), - ); - }); - }); - - describe('zero-tool rediscovery preserves cached tools', () => { - it('preserves tools when server is unreachable during rediscovery', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValue({ - tools: [ - { name: 'tool_a', description: 'A' }, - { name: 'tool_b', description: 'B' }, - ], - }); - - const servers = [makeServer('s1')]; - const firstTools = await executor.ensureToolsDiscovered(servers); - expect(firstTools).toHaveLength(2); - - executor.invalidateCache(); - - mockConnect.mockRejectedValue(new Error('Server unreachable')); - - const secondTools = await executor.ensureToolsDiscovered(servers); - - expect(secondTools).toHaveLength(2); - expect(secondTools[0].name).toBe('s1__tool_a'); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Preserved 2 tool(s) from failed server(s)'), - ); - }); - }); - - describe('partial re-discovery preservation', () => { - it('preserves tools from failed servers when other servers succeed', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - // First discovery: both servers succeed - mockListTools - .mockResolvedValueOnce({ - tools: [{ name: 'tool_a', description: 'Tool A' }], - }) - .mockResolvedValueOnce({ - tools: [{ name: 'tool_b', description: 'Tool B' }], - }); - - const servers = [makeServer('s1'), makeServer('s2')]; - const firstTools = await executor.discoverTools(servers); - expect(firstTools).toHaveLength(2); - - // Second discovery: s2 fails (connection error), s1 succeeds - mockConnect - .mockResolvedValueOnce(undefined) // s1 succeeds - .mockRejectedValueOnce(new Error('Connection refused')); // s2 fails - mockListTools.mockResolvedValueOnce({ - tools: [{ name: 'tool_a', description: 'Tool A updated' }], - }); - - const secondTools = await executor.discoverTools(servers); - - // Should have tools from both: s1 (re-discovered) + s2 (preserved) - expect(secondTools).toHaveLength(2); - const names = secondTools - .map(t => t.name) - .sort((a, b) => a.localeCompare(b)); - expect(names).toEqual(['s1__tool_a', 's2__tool_b']); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Preserved 1 tool(s) from failed server(s)'), - ); - }); - - it('does not preserve tools when a server succeeds with zero tools', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - // First discovery: s1 has tools - mockListTools.mockResolvedValueOnce({ - tools: [{ name: 'tool_a', description: 'A' }], - }); - await executor.discoverTools([makeServer('s1')]); - expect(executor.getToolCount()).toBe(1); - - // Second discovery: s1 succeeds but returns 0 tools (legitimate removal) - mockListTools.mockResolvedValueOnce({ tools: [] }); - await executor.discoverTools([makeServer('s1')]); - - // Server was reachable (client not null) so its tools are not preserved - expect(executor.getToolCount()).toBe(0); - }); - - it('preserves clients for failed servers so executeTool still works', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - // First discovery: both servers succeed - mockListTools - .mockResolvedValueOnce({ - tools: [{ name: 'tool_a', description: 'A' }], - }) - .mockResolvedValueOnce({ - tools: [{ name: 'tool_b', description: 'B' }], - }); - - const servers = [makeServer('s1'), makeServer('s2')]; - await executor.discoverTools(servers); - - // Second discovery: s2 fails - mockConnect - .mockResolvedValueOnce(undefined) // s1 succeeds - .mockRejectedValueOnce(new Error('s2 down')); // s2 fails - mockListTools.mockResolvedValueOnce({ - tools: [{ name: 'tool_a', description: 'A' }], - }); - await executor.discoverTools(servers); - - // s2's tool should still be resolvable - expect(executor.isBackendTool('s2__tool_b')).toBe(true); - const info = executor.getToolServerInfo('s2__tool_b'); - expect(info).toEqual({ serverId: 's2', originalName: 'tool_b' }); - }); - }); - - describe('discoveryGeneration', () => { - it('starts at 0', () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - expect(executor.getDiscoveryGeneration()).toBe(0); - }); - - it('increments after successful discovery', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValue({ - tools: [{ name: 'tool_a', description: 'A' }], - }); - - await executor.discoverTools([makeServer('s1')]); - expect(executor.getDiscoveryGeneration()).toBe(1); - - await executor.discoverTools([makeServer('s1')]); - expect(executor.getDiscoveryGeneration()).toBe(2); - }); - - it('still increments when all servers fail (tools preserved via partial re-discovery)', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - // First discovery succeeds - mockListTools.mockResolvedValueOnce({ - tools: [{ name: 'tool_a', description: 'A' }], - }); - await executor.discoverTools([makeServer('s1')]); - expect(executor.getDiscoveryGeneration()).toBe(1); - - // Second discovery: server fails → tools preserved, swap still happens - mockConnect.mockRejectedValueOnce(new Error('Server unreachable')); - await executor.discoverTools([makeServer('s1')]); - - // Generation increments because swap occurred (with preserved tools) - expect(executor.getDiscoveryGeneration()).toBe(2); - // Tools were preserved - expect(executor.getToolCount()).toBe(1); - }); - - it('increments on partial re-discovery (some servers fail)', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools - .mockResolvedValueOnce({ - tools: [{ name: 'tool_a', description: 'A' }], - }) - .mockResolvedValueOnce({ - tools: [{ name: 'tool_b', description: 'B' }], - }); - - await executor.discoverTools([makeServer('s1'), makeServer('s2')]); - expect(executor.getDiscoveryGeneration()).toBe(1); - - // s2 fails but s1 succeeds → partial re-discovery with preservation - mockConnect - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('s2 down')); - mockListTools.mockResolvedValueOnce({ - tools: [{ name: 'tool_a', description: 'A' }], - }); - - await executor.discoverTools([makeServer('s1'), makeServer('s2')]); - expect(executor.getDiscoveryGeneration()).toBe(2); - }); - }); - - describe('session expiry auto-reconnect', () => { - it('reconnects and retries when execution fails with "session not found"', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValueOnce({ - tools: [{ name: 'pods_list', description: 'List pods' }], - }); - await executor.discoverTools([makeServer('ocp-mcp')]); - - // First call fails with session error, reconnect succeeds, retry succeeds - mockCallTool - .mockRejectedValueOnce( - new Error( - 'Streamable HTTP error: Error POSTing to endpoint: session not found', - ), - ) - .mockResolvedValueOnce({ - content: [{ type: 'text', text: 'pod-1\npod-2' }], - }); - - // Reconnect discovery - mockListTools.mockResolvedValueOnce({ - tools: [{ name: 'pods_list', description: 'List pods' }], - }); - - const result = await executor.executeTool('ocp-mcp__pods_list', '{}'); - expect(result).toBe('pod-1\npod-2'); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Session expired'), - ); - }); - - it('returns error when reconnect fails after session expiry', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValueOnce({ - tools: [{ name: 'pods_list', description: 'List pods' }], - }); - await executor.discoverTools([makeServer('ocp-mcp')]); - - mockCallTool.mockRejectedValueOnce(new Error('session not found')); - - // Reconnect fails - mockConnect.mockRejectedValueOnce(new Error('Server unreachable')); - - const result = await executor.executeTool('ocp-mcp__pods_list', '{}'); - expect(JSON.parse(result).error).toContain('Failed to reconnect'); - }); - - it('does not reconnect for non-session errors', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValueOnce({ - tools: [{ name: 'pods_list', description: 'List pods' }], - }); - await executor.discoverTools([makeServer('ocp-mcp')]); - - mockCallTool.mockRejectedValueOnce(new Error('Permission denied')); - - const result = await executor.executeTool('ocp-mcp__pods_list', '{}'); - expect(JSON.parse(result).error).toContain('Permission denied'); - expect(mockLogger.warn).not.toHaveBeenCalledWith( - expect.stringContaining('Session expired'), - ); - }); - }); - - describe('atomic swap correctness', () => { - it('preserves in-flight tool across a registry swap to different servers', async () => { - const mcpAuth = createMockMcpAuth(); - const executor = new BackendToolExecutor( - mcpAuth, - mockLogger as any, - false, - ); - - mockListTools.mockResolvedValueOnce({ - tools: [{ name: 'tool_x', description: 'X' }], - }); - await executor.discoverTools([makeServer('s1')]); - - // Swap registry to a different set of servers - mockListTools.mockResolvedValueOnce({ - tools: [{ name: 'tool_y', description: 'Y' }], - }); - await executor.discoverTools([makeServer('s2')]); - - // s1's tool was preserved because s1 was not in the new discovery set - expect(executor.isBackendTool('s1__tool_x')).toBe(true); - expect(executor.isBackendTool('s2__tool_y')).toBe(true); - - mockCallTool.mockResolvedValueOnce({ - content: [{ type: 'text', text: 'result-from-preserved-tool' }], - }); - const result = await executor.executeTool('s1__tool_x', '{}'); - expect(result).toBe('result-from-preserved-tool'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendToolExecutor.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendToolExecutor.ts deleted file mode 100644 index 6dc93ce668a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/BackendToolExecutor.ts +++ /dev/null @@ -1,381 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import type { McpAuthService } from '../../llamastack/McpAuthService'; -import type { MCPServerConfig, ResponsesApiFunctionTool } from '../../../types'; -import { BACKEND_TOOL_DISCOVERY_TTL_MS } from '../../../constants'; -import { toErrorMessage } from '../../../services/utils'; -import { - processDiscoveryResults, - collectSucceededServerIds, - preserveFailedServerTools, - swapRegistryAndCleanup, - closeOrphanedClients, -} from './toolDiscoveryHelpers'; -import type { ResolvedTool } from './toolDiscoveryHelpers'; -import { - connectAndListToolsSafe, - executeToolOnClient, - isSessionError, -} from './toolClientOps'; - -const SEPARATOR = '__'; - -export class BackendToolExecutor { - private registry = new Map(); - private clients = new Map(); - private cachedTools: ResponsesApiFunctionTool[] | null = null; - private cachedServerKey = ''; - private lastDiscoveryTimestamp = 0; - private inflightDiscovery: Promise | null = null; - private inflightServerKey = ''; - private discoveryGeneration = 0; - - constructor( - private readonly mcpAuth: McpAuthService, - private readonly logger: LoggerService, - private readonly skipTlsVerify: boolean, - ) {} - - getDiscoveryGeneration(): number { - return this.discoveryGeneration; - } - - static prefixName(serverId: string, toolName: string): string { - return `${serverId}${SEPARATOR}${toolName}`; - } - - static unprefixName( - prefixed: string, - ): { serverId: string; toolName: string } | null { - const idx = prefixed.indexOf(SEPARATOR); - if (idx < 0) return null; - return { - serverId: prefixed.slice(0, idx), - toolName: prefixed.slice(idx + SEPARATOR.length), - }; - } - - static slimSchema(schema: Record): Record { - const STRIP_KEYS = new Set([ - 'description', - 'examples', - 'example', - '$schema', - 'title', - 'default', - 'additionalProperties', - ]); - const slim = (obj: unknown): unknown => { - if (Array.isArray(obj)) return obj.map(slim); - if (obj !== null && typeof obj === 'object') { - const out: Record = {}; - for (const [k, v] of Object.entries(obj as Record)) { - if (!STRIP_KEYS.has(k)) out[k] = slim(v); - } - return out; - } - return obj; - }; - return slim(schema) as Record; - } - - async discoverTools( - servers: MCPServerConfig[], - ): Promise { - const nextRegistry = new Map(); - const nextClients = new Map(); - const tools: ResponsesApiFunctionTool[] = []; - - const results = await Promise.allSettled( - servers.map(async server => { - const { client, tools: serverTools } = await connectAndListToolsSafe( - server, - this.mcpAuth, - this.skipTlsVerify, - this.logger, - { skipSsrfCheck: true }, - ); - return { server, serverTools, client }; - }), - ); - - processDiscoveryResults( - results, - nextRegistry, - nextClients, - tools, - BackendToolExecutor.prefixName, - BackendToolExecutor.slimSchema, - this.logger, - ); - - const succeededServerIds = collectSucceededServerIds(results); - preserveFailedServerTools( - succeededServerIds, - this.registry, - nextRegistry, - nextClients, - this.clients, - tools, - BackendToolExecutor.slimSchema, - this.logger, - ); - - if ( - nextRegistry.size === 0 && - this.registry.size > 0 && - succeededServerIds.size === 0 - ) { - closeOrphanedClients( - nextClients, - this.clients, - this.registry.size, - this.logger, - ); - return this.cachedTools ?? this.rebuildFromRegistry(); - } - - const staleClients = new Map(this.clients); - this.registry = nextRegistry; - this.clients = nextClients; - this.discoveryGeneration++; - swapRegistryAndCleanup( - staleClients, - nextClients, - servers.length, - this.registry.size, - this.discoveryGeneration, - this.logger, - ); - return tools; - } - - private rebuildFromRegistry(): ResponsesApiFunctionTool[] { - const rebuilt: ResponsesApiFunctionTool[] = []; - for (const resolved of this.registry.values()) { - rebuilt.push({ - type: 'function', - name: resolved.prefixedName, - description: resolved.description, - parameters: BackendToolExecutor.slimSchema(resolved.inputSchema), - }); - } - return rebuilt; - } - - private static serverCacheKey(servers: MCPServerConfig[]): string { - return servers - .map(s => s.id) - .sort((a, b) => a.localeCompare(b)) - .join(','); - } - - async ensureToolsDiscovered( - servers: MCPServerConfig[], - ): Promise { - const key = BackendToolExecutor.serverCacheKey(servers); - if ( - this.cachedTools && - this.cachedServerKey === key && - Date.now() - this.lastDiscoveryTimestamp < BACKEND_TOOL_DISCOVERY_TTL_MS - ) - return this.cachedTools; - if (this.inflightDiscovery !== null && this.inflightServerKey === key) - return this.inflightDiscovery; - this.inflightServerKey = key; - this.inflightDiscovery = this.discoverTools(servers) - .then(tools => { - this.cachedTools = tools; - this.cachedServerKey = key; - this.lastDiscoveryTimestamp = Date.now(); - return tools; - }) - .finally(() => { - this.inflightDiscovery = null; - this.inflightServerKey = ''; - }); - return this.inflightDiscovery; - } - - invalidateCache(): void { - this.cachedTools = null; - this.cachedServerKey = ''; - this.lastDiscoveryTimestamp = 0; - this.logger.info( - '[BackendToolExecutor] Tool cache invalidated (registry and clients preserved for in-flight requests)', - ); - } - - isBackendTool(functionName: string): boolean { - return this.resolveTool(functionName) !== undefined; - } - - getToolServerInfo( - functionName: string, - ): { serverId: string; originalName: string } | undefined { - const tool = this.resolveTool(functionName); - return tool - ? { serverId: tool.serverId, originalName: tool.originalName } - : undefined; - } - - private static normalizeFunctionName(name: string): string { - return name.replace(/\.(json|yaml|yml|xml|txt)$/i, ''); - } - - resolveTool(functionName: string): ResolvedTool | undefined { - const exact = this.registry.get(functionName); - if (exact) return exact; - const normalized = BackendToolExecutor.normalizeFunctionName(functionName); - if (normalized !== functionName) { - const r = this.registry.get(normalized); - if (r) { - this.logger.warn( - `[BackendToolExecutor] Tool "${functionName}" resolved to "${normalized}" after stripping extension`, - ); - return r; - } - } - const suffix = `${SEPARATOR}${normalized}`; - for (const [key, value] of this.registry) { - if (key.endsWith(suffix)) { - this.logger.warn( - `[BackendToolExecutor] Tool "${functionName}" resolved to "${key}" via suffix match`, - ); - return value; - } - } - for (const [key, value] of this.registry) { - if (normalized === `${value.serverId}_${value.originalName}`) { - this.logger.warn( - `[BackendToolExecutor] Tool "${functionName}" resolved to "${key}" via collapsed-separator match`, - ); - return value; - } - } - const lower = normalized.toLowerCase(); - for (const [key, value] of this.registry) { - if (key.toLowerCase() === lower) { - this.logger.warn( - `[BackendToolExecutor] Tool "${functionName}" resolved to "${key}" via case-insensitive match`, - ); - return value; - } - } - return undefined; - } - - getToolCount(): number { - return this.registry.size; - } - - async executeTool( - functionName: string, - argumentsJson: string, - ): Promise { - const tool = this.resolveTool(functionName); - if (!tool) - return JSON.stringify({ error: `Unknown tool: ${functionName}` }); - let args: Record; - try { - args = JSON.parse(argumentsJson); - } catch { - args = {}; - } - this.logger.info( - `[BackendToolExecutor] Executing ${tool.originalName} on ${tool.serverId}`, - ); - try { - let client = this.clients.get(tool.serverId); - if (!client) { - this.logger.warn( - `[BackendToolExecutor] No connected client for ${tool.serverId}, reconnecting...`, - ); - client = (await this.reconnectToServer(tool)) ?? undefined; - if (!client) - return JSON.stringify({ - error: `Failed to connect to MCP server ${tool.serverId}`, - }); - } - return await executeToolOnClient(client, tool, args, this.logger); - } catch (error) { - const msg = toErrorMessage(error); - if (isSessionError(msg)) { - this.logger.warn( - `[BackendToolExecutor] Session expired for ${tool.serverId}, reconnecting...`, - ); - this.clients.delete(tool.serverId); - const freshClient = await this.reconnectToServer(tool); - if (!freshClient) - return JSON.stringify({ - error: `Failed to reconnect to MCP server ${tool.serverId}`, - }); - try { - return await executeToolOnClient( - freshClient, - tool, - args, - this.logger, - ); - } catch (retryError) { - return JSON.stringify({ - error: `Tool execution failed after reconnect: ${toErrorMessage(retryError)}`, - }); - } - } - this.logger.error( - `[BackendToolExecutor] Failed to execute ${tool.originalName} on ${tool.serverId}: ${msg}`, - ); - return JSON.stringify({ error: `Tool execution failed: ${msg}` }); - } - } - - private async reconnectToServer(tool: ResolvedTool): Promise { - const server: MCPServerConfig = { - id: tool.serverId, - name: tool.serverId, - type: 'streamable-http', - url: tool.serverUrl, - }; - const { client } = await connectAndListToolsSafe( - server, - this.mcpAuth, - this.skipTlsVerify, - this.logger, - { skipSsrfCheck: true }, - ); - if (client) this.clients.set(tool.serverId, client); - return client; - } - - async closeAllClients(): Promise { - await Promise.allSettled( - Array.from(this.clients.entries()).map(async ([id, client]) => { - try { - await client.close(); - } catch (err) { - this.logger.warn( - `[BackendToolExecutor] Error closing client for ${id}: ${toErrorMessage(err)}`, - ); - } - }), - ); - this.clients.clear(); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/McpToolBuilder.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/McpToolBuilder.ts deleted file mode 100644 index aabbf688920..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/McpToolBuilder.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { McpAuthService } from '../../llamastack/McpAuthService'; -import type { MCPServerConfig, ResponsesApiMcpTool } from '../../../types'; - -function buildSingleMcpTool( - server: MCPServerConfig, - headers: Record, - approvalConfig: ResponsesApiMcpTool['require_approval'], - logger?: LoggerService, - logPrefix?: string, -): ResponsesApiMcpTool { - const log = logger && logPrefix ? logger : undefined; - - log?.info( - `${logPrefix}MCP server ${server.id} HITL require_approval: ${JSON.stringify(approvalConfig)}`, - ); - - const mcpTool: ResponsesApiMcpTool = { - type: 'mcp', - server_url: server.url, - server_label: server.id, - require_approval: approvalConfig, - }; - - if (server.allowedTools && server.allowedTools.length > 0) { - mcpTool.allowed_tools = server.allowedTools; - log?.info( - `${logPrefix}MCP server ${server.id} limited to ${server.allowedTools.length} allowed tools`, - ); - } - - if (Object.keys(headers).length > 0) { - mcpTool.headers = headers; - log?.info( - `${logPrefix}MCP server ${server.id} auth: Authorization=${ - headers.Authorization - ? `present(${headers.Authorization.length} chars)` - : 'absent' - }, totalHeaders=${Object.keys(headers).length}`, - ); - } else { - log?.warn( - `${logPrefix}MCP server ${server.id} has NO headers/auth — Llama Stack will connect unauthenticated`, - ); - } - - return mcpTool; -} - -/** - * Build MCP tool definitions for the Responses API. - * - * Shared between the normal chat path (ToolsBuilder) and the HITL - * approval continuation path (ApprovalContinuationExecutor). - */ -export async function buildMcpTools(opts: { - mcpAuth: McpAuthService; - mcpServers: MCPServerConfig[]; - logger?: LoggerService; - logPrefix?: string; -}): Promise { - const { mcpAuth, mcpServers, logger, logPrefix } = opts; - - const headerResults = await Promise.all( - mcpServers.map(s => mcpAuth.getServerHeaders(s)), - ); - - return mcpServers.map((server, i) => - buildSingleMcpTool( - server, - headerResults[i], - mcpAuth.getApiApprovalConfig(server.requireApproval), - logger, - logPrefix, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ToolsBuilder.test.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ToolsBuilder.test.ts deleted file mode 100644 index 7110fd8a3e7..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ToolsBuilder.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createMockLogger } from '../../../test-utils/mocks'; -import { buildTools, sanitizeToolsForServer } from './ToolsBuilder'; -import type { ToolsBuilderDeps } from './ToolsBuilder'; -import type { CapabilityInfo } from '../types'; -import type { ResponsesApiTool } from '../../../types'; -import type { EffectiveConfig, MCPServerConfig } from '../../../types'; - -function makeConfig(overrides: Partial = {}): EffectiveConfig { - return { - model: 'test-model', - baseUrl: 'https://llama.example.com', - systemPrompt: '', - enableWebSearch: false, - enableCodeInterpreter: false, - vectorStoreIds: [], - vectorStoreName: 'default', - embeddingModel: 'test-embedding', - embeddingDimension: 384, - chunkingStrategy: 'static', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - skipTlsVerify: false, - zdrMode: false, - verboseStreamLogging: false, - ...overrides, - }; -} - -function makeMcpServer( - overrides: Partial = {}, -): MCPServerConfig { - return { - id: 'mcp-1', - name: 'Test MCP', - type: 'streamable-http', - url: 'https://mcp.example.com', - ...overrides, - }; -} - -describe('buildTools', () => { - const logger = createMockLogger(); - - it('returns empty array when RAG disabled and no MCP servers', async () => { - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig(), - mcpServers: [], - mcpAuth: null, - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools).toEqual([]); - }); - - it('skips MCP servers when mcpAuth is null', async () => { - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig(), - mcpServers: [makeMcpServer()], - mcpAuth: null, - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools).toEqual([]); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('McpAuthService not initialized'), - ); - }); - - it('adds file_search tool when RAG enabled and vectorStoreIds configured', async () => { - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig({ - vectorStoreIds: ['vs-1', 'vs-2'], - fileSearchMaxResults: 15, - fileSearchScoreThreshold: 0.5, - }), - mcpServers: [], - mcpAuth: null, - conversations: null, - }; - const tools = await buildTools(true, deps, logger); - expect(tools).toHaveLength(1); - expect(tools[0]).toEqual({ - type: 'file_search', - vector_store_ids: ['vs-1', 'vs-2'], - max_num_results: 15, - ranking_options: { score_threshold: 0.5 }, - }); - }); - - it('does not add file_search when RAG enabled but no vectorStoreIds', async () => { - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig({ vectorStoreIds: [] }), - mcpServers: [], - mcpAuth: null, - conversations: null, - }; - const tools = await buildTools(true, deps, logger); - expect(tools).toEqual([]); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('no vector store IDs configured'), - ); - }); - - it('adds MCP tools when mcpAuth is provided', async () => { - const mcpAuth = { - getApiApprovalConfig: jest.fn().mockReturnValue('never'), - getServerHeaders: jest.fn().mockResolvedValue({}), - }; - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig(), - mcpServers: [ - makeMcpServer({ id: 'mcp-a', url: 'https://mcp-a.example.com' }), - ], - mcpAuth: mcpAuth as unknown as ToolsBuilderDeps['mcpAuth'], - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools).toHaveLength(1); - expect(tools[0]).toEqual({ - type: 'mcp', - server_url: 'https://mcp-a.example.com', - server_label: 'mcp-a', - require_approval: 'never', - }); - expect(mcpAuth.getApiApprovalConfig).toHaveBeenCalledWith(undefined); - expect(mcpAuth.getServerHeaders).toHaveBeenCalledWith(deps.mcpServers[0]); - }); - - it('adds allowed_tools when MCP server has allowedTools', async () => { - const mcpAuth = { - getApiApprovalConfig: jest.fn().mockReturnValue('never'), - getServerHeaders: jest.fn().mockResolvedValue({}), - }; - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig(), - mcpServers: [ - makeMcpServer({ - id: 'mcp-filtered', - allowedTools: ['tool1', 'tool2'], - }), - ], - mcpAuth: mcpAuth as unknown as ToolsBuilderDeps['mcpAuth'], - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools[0]).toMatchObject({ - type: 'mcp', - allowed_tools: ['tool1', 'tool2'], - }); - }); - - it('adds headers when getServerHeaders returns non-empty', async () => { - const mcpAuth = { - getApiApprovalConfig: jest.fn().mockReturnValue('never'), - getServerHeaders: jest.fn().mockResolvedValue({ - Authorization: 'Bearer token123', - }), - }; - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig(), - mcpServers: [makeMcpServer()], - mcpAuth: mcpAuth as unknown as ToolsBuilderDeps['mcpAuth'], - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools[0]).toMatchObject({ - headers: { Authorization: 'Bearer token123' }, - }); - }); - - it('adds custom function tools from config', async () => { - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig({ - functions: [ - { - name: 'search_catalog', - description: 'Search the catalog', - parameters: { - type: 'object', - properties: { q: { type: 'string' } }, - }, - strict: true, - }, - ], - }), - mcpServers: [], - mcpAuth: null, - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools).toHaveLength(1); - expect(tools[0]).toEqual({ - type: 'function', - name: 'search_catalog', - description: 'Search the catalog', - parameters: { type: 'object', properties: { q: { type: 'string' } } }, - strict: true, - }); - }); - - it('adds web_search when enableWebSearch is true', async () => { - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig({ enableWebSearch: true }), - mcpServers: [], - mcpAuth: null, - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools).toContainEqual({ type: 'web_search' }); - }); - - it('adds code_interpreter when enableCodeInterpreter is true', async () => { - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig({ enableCodeInterpreter: true }), - mcpServers: [], - mcpAuth: null, - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools).toContainEqual({ type: 'code_interpreter' }); - }); - - it('uses direct server URL when mcpAuth is provided', async () => { - const mcpAuth = { - getApiApprovalConfig: jest.fn().mockReturnValue('never'), - getServerHeaders: jest - .fn() - .mockResolvedValue({ Authorization: 'Bearer tok' }), - }; - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig(), - mcpServers: [ - makeMcpServer({ id: 'ocp-mcp', url: 'https://ocp.example.com/mcp' }), - ], - mcpAuth: mcpAuth as unknown as ToolsBuilderDeps['mcpAuth'], - conversations: null, - }; - const tools = await buildTools(false, deps, logger); - expect(tools[0]).toMatchObject({ - type: 'mcp', - server_url: 'https://ocp.example.com/mcp', - headers: { Authorization: 'Bearer tok' }, - }); - }); - - it('combines file_search, MCP, functions, web_search, and code_interpreter', async () => { - const mcpAuth = { - getApiApprovalConfig: jest.fn().mockReturnValue('never'), - getServerHeaders: jest.fn().mockResolvedValue({}), - }; - const deps: ToolsBuilderDeps = { - client: {} as ToolsBuilderDeps['client'], - config: makeConfig({ - vectorStoreIds: ['vs-1'], - enableWebSearch: true, - enableCodeInterpreter: true, - functions: [ - { - name: 'my_func', - description: 'desc', - parameters: {}, - }, - ], - }), - mcpServers: [makeMcpServer()], - mcpAuth: mcpAuth as unknown as ToolsBuilderDeps['mcpAuth'], - conversations: null, - }; - const tools = await buildTools(true, deps, logger); - const types = tools.map(t => t.type); - expect(types).toContain('file_search'); - expect(types).toContain('mcp'); - expect(types).toContain('function'); - expect(types).toContain('web_search'); - expect(types).toContain('code_interpreter'); - }); -}); - -describe('sanitizeToolsForServer', () => { - const logger = createMockLogger(); - - const allCaps: CapabilityInfo = { - functionTools: true, - strictField: true, - maxOutputTokens: true, - mcpTools: true, - parallelToolCalls: true, - truncation: true, - }; - - const noStrictCaps: CapabilityInfo = { - ...allCaps, - strictField: false, - }; - - it('passes tools through when server supports strict', () => { - const tools: ResponsesApiTool[] = [ - { - type: 'function', - name: 'f1', - description: 'd', - parameters: {}, - strict: true, - }, - { type: 'web_search' }, - ]; - const result = sanitizeToolsForServer(tools, allCaps, logger); - expect(result).toEqual(tools); - }); - - it('strips strict field from function tools when server does not support it', () => { - const tools: ResponsesApiTool[] = [ - { - type: 'function', - name: 'f1', - description: 'd', - parameters: {}, - strict: true, - }, - { type: 'function', name: 'f2', description: 'd2', parameters: {} }, - { type: 'web_search' }, - ]; - const result = sanitizeToolsForServer(tools, noStrictCaps, logger); - - const f1 = result.find( - t => t.type === 'function' && t.name === 'f1', - ) as unknown as Record; - expect(f1).toBeDefined(); - expect(f1.strict).toBeUndefined(); - - const f2 = result.find( - t => t.type === 'function' && t.name === 'f2', - ) as unknown as Record; - expect(f2).toBeDefined(); - expect(f2.strict).toBeUndefined(); - - expect(result.find(t => t.type === 'web_search')).toEqual({ - type: 'web_search', - }); - }); - - it('does not modify mcp tools', () => { - const tools: ResponsesApiTool[] = [ - { - type: 'mcp', - server_url: 'https://x', - server_label: 'x', - require_approval: 'never', - }, - ]; - const result = sanitizeToolsForServer(tools, noStrictCaps, logger); - expect(result).toEqual(tools); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ToolsBuilder.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ToolsBuilder.ts deleted file mode 100644 index 9fc8a31d2f3..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/ToolsBuilder.ts +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { ResponsesApiClient } from '../client/ResponsesApiClient'; -import type { McpAuthService } from '../../llamastack/McpAuthService'; -import type { ConversationService } from '../conversations/ConversationService'; -import type { - EffectiveConfig, - MCPServerConfig, - ResponsesApiTool, - ResponsesApiFileSearchTool, -} from '../../../types'; -import type { BackendToolExecutor } from './BackendToolExecutor'; -import { buildMcpTools } from './McpToolBuilder'; -import type { CapabilityInfo } from '../types'; - -/** - * Dependencies for building tools (matches ChatDeps). - */ -export interface ToolsBuilderDeps { - client: ResponsesApiClient; - config: EffectiveConfig; - mcpServers: MCPServerConfig[]; - mcpAuth: McpAuthService | null; - conversations: ConversationService | null; - /** When set, MCP tools are converted to function tools and executed by the backend. */ - backendToolExecutor?: BackendToolExecutor; -} - -/** - * Strip fields from tool definitions that the connected Llama Stack - * server does not support. Only modifies `function` type tools; - * `mcp`, `file_search`, and built-in tools are passed through unchanged. - */ -export function sanitizeToolsForServer( - tools: ResponsesApiTool[], - capabilities: CapabilityInfo, - logger: LoggerService, -): ResponsesApiTool[] { - return tools.map(tool => { - if (tool.type !== 'function') return tool; - - if (!capabilities.strictField) { - const { strict, ...rest } = tool; - if (strict !== undefined) { - logger.debug( - `[ToolsBuilder] Stripped 'strict' field from function tool "${rest.name}" (server does not support it)`, - ); - } - return rest as ResponsesApiTool; - } - return tool; - }); -} - -/** - * Build the tools array for the Responses API request. - * All config is read from the passed-in deps. - * - * @param enableRAG - Whether to include file_search tool - * @param deps - Chat dependencies (client, config, mcpServers, mcpAuth, conversations) - * @param logger - Logger for diagnostics - * @param logPrefix - Optional prefix for log messages (e.g., '[Stream] ') - * @param capabilities - Server capabilities for tool sanitization - */ -export async function buildTools( - enableRAG: boolean, - deps: ToolsBuilderDeps, - logger: LoggerService, - logPrefix: string = '', - capabilities?: CapabilityInfo, -): Promise { - const { config, mcpServers, mcpAuth } = deps; - const tools: ResponsesApiTool[] = []; - - if (logPrefix && mcpServers.length > 0) { - logger.info( - `${logPrefix}MCP servers resolved: ${mcpServers - .map( - s => - `${s.id}(staticHeaders=${ - s.headers ? Object.keys(s.headers).join(',') : 'none' - })`, - ) - .join(', ')}`, - ); - } - - if (enableRAG) { - const dedupedStoreIds = [...new Set(config.vectorStoreIds)]; - if (dedupedStoreIds.length > 0) { - const fileSearchTool: ResponsesApiFileSearchTool = { - type: 'file_search', - vector_store_ids: dedupedStoreIds, - }; - if (config.fileSearchMaxResults) { - fileSearchTool.max_num_results = config.fileSearchMaxResults; - } - if (config.fileSearchScoreThreshold) { - fileSearchTool.ranking_options = { - score_threshold: config.fileSearchScoreThreshold, - }; - } - tools.push(fileSearchTool); - logger.info( - `${logPrefix}file_search tool added: stores=${dedupedStoreIds.join( - ',', - )}, maxResults=${config.fileSearchMaxResults ?? 'default'}`, - ); - } else { - logger.warn( - `${logPrefix}RAG enabled but no vector store IDs configured — file_search tool will not be added`, - ); - } - } - - if (deps.backendToolExecutor && mcpServers.length > 0) { - logger.info( - `${logPrefix}Backend-executed tools mode ACTIVE — MCP tools converted to function calls`, - ); - const backendTools = - await deps.backendToolExecutor.ensureToolsDiscovered(mcpServers); - if (backendTools.length === 0) { - logger.error( - `${logPrefix}Backend tool discovery returned 0 tools from ${mcpServers.length} server(s): ` + - `[${mcpServers.map(s => `${s.id} @ ${s.url}`).join(', ')}]. ` + - `The LLM will have no MCP tools available. ` + - `Check MCP server connectivity, authentication, and streamable-http compatibility.`, - ); - } else { - logger.info( - `${logPrefix}Discovered ${backendTools.length} backend tool(s) from ${mcpServers.length} server(s)`, - ); - } - tools.push(...backendTools); - } else { - if (mcpServers.length > 0 && mcpAuth) { - const mcpTools = await buildMcpTools({ - mcpAuth, - mcpServers, - logger, - logPrefix: logPrefix || undefined, - }); - tools.push(...mcpTools); - } else if (mcpServers.length > 0 && !mcpAuth) { - for (const server of mcpServers) { - logger.warn( - `${logPrefix}Skipping MCP server ${server.id}: McpAuthService not initialized`, - ); - } - } - } - - if (config.functions) { - for (const func of config.functions) { - tools.push({ - type: 'function', - name: func.name, - description: func.description, - parameters: func.parameters, - strict: func.strict ?? true, - }); - } - } - - if (config.enableWebSearch) { - tools.push({ type: 'web_search' }); - } - - if (config.enableCodeInterpreter) { - tools.push({ type: 'code_interpreter' }); - } - - if (capabilities) { - return sanitizeToolsForServer(tools, capabilities, logger); - } - return tools; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/toolClientOps.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/toolClientOps.ts deleted file mode 100644 index cb8e88316a5..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/toolClientOps.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import type { McpAuthService } from '../../llamastack/McpAuthService'; -import type { MCPServerConfig } from '../../../types'; -import { toErrorMessage } from '../../../services/utils'; -import { isPrivateUrlWithDns } from '../../../services/utils/SsrfGuard'; -import { connectToMcpServer } from '../../../services/utils/mcpClient'; -import type { ResolvedTool } from './toolDiscoveryHelpers'; - -export async function connectAndListToolsSafe( - server: MCPServerConfig, - mcpAuth: McpAuthService, - skipTlsVerify: boolean, - logger: LoggerService, - options?: { skipSsrfCheck?: boolean }, -): Promise<{ - client: Client | null; - tools: Array<{ name: string; description?: string; inputSchema?: unknown }>; -}> { - if (!options?.skipSsrfCheck) { - const ssrfReason = await isPrivateUrlWithDns(server.url); - if (ssrfReason) { - logger.warn( - `[BackendToolExecutor] Skipping MCP server ${server.id}: URL blocked by SSRF guard (${ssrfReason})`, - ); - return { client: null, tools: [] }; - } - } - logger.info( - `[BackendToolExecutor] Connecting to MCP server ${server.id} @ ${server.url} (skipTlsVerify=${skipTlsVerify})`, - ); - const authHeaders = await mcpAuth.getServerHeaders(server); - try { - const { client, tools } = await connectToMcpServer(server.url, { - headers: authHeaders, - skipTlsVerify, - clientName: 'augment-backend', - }); - logger.info( - `[BackendToolExecutor] ${server.id} tools/list: ${tools.length} tool(s) [${tools.map(t => t.name).join(', ')}]`, - ); - return { client, tools }; - } catch (err) { - logger.error( - `[BackendToolExecutor] Failed to connect to ${server.id}: ${toErrorMessage(err)}`, - ); - return { client: null, tools: [] }; - } -} - -export async function executeToolOnClient( - client: Client, - tool: ResolvedTool, - args: Record, - logger: LoggerService, -): Promise { - const result = await client.callTool({ - name: tool.originalName, - arguments: args, - }); - if (result.isError) { - const errorText = Array.isArray(result.content) - ? result.content - .filter( - (c: { type: string; text?: string }) => c.type === 'text' && c.text, - ) - .map((c: { type: string; text?: string }) => c.text) - .join('\n') - : JSON.stringify(result.content); - logger.error( - `[BackendToolExecutor] Tool ${tool.originalName} returned error: ${errorText}`, - ); - return JSON.stringify({ error: errorText || 'Tool returned an error' }); - } - if (Array.isArray(result.content)) { - const textParts = result.content - .filter( - (c: { type: string; text?: string }) => c.type === 'text' && c.text, - ) - .map((c: { type: string; text?: string }) => c.text!); - return textParts.length > 0 - ? textParts.join('\n') - : JSON.stringify(result.content); - } - return JSON.stringify(result.content ?? result); -} - -export function isSessionError(message: string): boolean { - const lower = message.toLowerCase(); - return ( - lower.includes('session not found') || - lower.includes('session expired') || - lower.includes('session_not_found') || - lower.includes('invalid session') - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/toolDiscoveryHelpers.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/toolDiscoveryHelpers.ts deleted file mode 100644 index 9a88c55081e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/toolDiscoveryHelpers.ts +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import type { MCPServerConfig, ResponsesApiFunctionTool } from '../../../types'; -import { toErrorMessage } from '../../../services/utils'; - -export interface ResolvedTool { - serverId: string; - serverUrl: string; - originalName: string; - prefixedName: string; - description: string; - inputSchema: Record; -} - -export function processDiscoveryResults( - results: PromiseSettledResult<{ - server: MCPServerConfig; - serverTools: Array<{ - name: string; - description?: string; - inputSchema?: unknown; - }>; - client: Client | null; - }>[], - nextRegistry: Map, - nextClients: Map, - tools: ResponsesApiFunctionTool[], - prefixName: (serverId: string, toolName: string) => string, - slimSchema: (schema: Record) => Record, - logger: LoggerService, -): void { - for (const result of results) { - if (result.status === 'rejected') { - logger.error( - `[BackendToolExecutor] Server discovery rejected: ${toErrorMessage(result.reason)}`, - ); - continue; - } - const { server, serverTools, client } = result.value; - if (client) nextClients.set(server.id, client); - for (const tool of serverTools) { - const pName = prefixName(server.id, tool.name); - const resolved: ResolvedTool = { - serverId: server.id, - serverUrl: server.url, - originalName: tool.name, - prefixedName: pName, - description: tool.description || `Tool ${tool.name} from ${server.id}`, - inputSchema: (tool.inputSchema as Record) || { - type: 'object', - properties: {}, - }, - }; - nextRegistry.set(pName, resolved); - tools.push({ - type: 'function', - name: pName, - description: resolved.description, - parameters: slimSchema(resolved.inputSchema), - }); - } - logger.info( - `[BackendToolExecutor] Discovered ${serverTools.length} tools from ${server.id}: [${serverTools.map(t => t.name).join(', ')}]`, - ); - } -} - -export function collectSucceededServerIds( - results: PromiseSettledResult<{ - server: MCPServerConfig; - client: Client | null; - }>[], -): Set { - const ids = new Set(); - for (const result of results) { - if (result.status === 'fulfilled' && result.value.client !== null) - ids.add(result.value.server.id); - } - return ids; -} - -export function preserveFailedServerTools( - succeededServerIds: Set, - currentRegistry: Map, - nextRegistry: Map, - nextClients: Map, - currentClients: Map, - tools: ResponsesApiFunctionTool[], - slimSchema: (schema: Record) => Record, - logger: LoggerService, -): void { - let preservedCount = 0; - for (const [key, resolved] of currentRegistry) { - if (!nextRegistry.has(key) && !succeededServerIds.has(resolved.serverId)) { - nextRegistry.set(key, resolved); - tools.push({ - type: 'function', - name: resolved.prefixedName, - description: resolved.description, - parameters: slimSchema(resolved.inputSchema), - }); - preservedCount++; - } - } - for (const [serverId, client] of currentClients) { - if (!nextClients.has(serverId) && !succeededServerIds.has(serverId)) - nextClients.set(serverId, client); - } - if (preservedCount > 0) - logger.warn( - `[BackendToolExecutor] Preserved ${preservedCount} tool(s) from failed server(s) during partial re-discovery`, - ); -} - -export function swapRegistryAndCleanup( - currentClients: Map, - nextClients: Map, - serverCount: number, - registrySize: number, - generation: number, - logger: LoggerService, -): void { - for (const [id, client] of currentClients) { - if (!nextClients.has(id)) { - client - .close() - .catch(err => - logger.warn( - `[BackendToolExecutor] Error closing stale client ${id}: ${toErrorMessage(err)}`, - ), - ); - } - } - logger.info( - `[BackendToolExecutor] Total: ${registrySize} function tools from ${serverCount} server(s) (generation=${generation})`, - ); -} - -export function closeOrphanedClients( - nextClients: Map, - currentClients: Map, - registrySize: number, - logger: LoggerService, -): void { - logger.warn( - `[BackendToolExecutor] No servers reachable during rediscovery (previous: ${registrySize}). Keeping previous registry and cached tool definitions.`, - ); - for (const [id, client] of nextClients) { - if (!currentClients.has(id) || currentClients.get(id) !== client) { - client - .close() - .catch(err => - logger.warn( - `[BackendToolExecutor] Error closing orphaned client ${id}: ${toErrorMessage(err)}`, - ), - ); - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/toolResolver.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/toolResolver.ts deleted file mode 100644 index d58938d3f84..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/tools/toolResolver.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ diff --git a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/types.ts b/workspaces/augment/plugins/augment-backend/src/providers/responses-api/types.ts deleted file mode 100644 index dda0b5dc915..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/responses-api/types.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { MCPServerConfig, ApprovalFilter } from '../../types'; - -/** - * Minimal configuration required by the generic Responses API HTTP client. - * Providers map their platform-specific config to this shape. - */ -export interface ResponsesApiClientConfig { - baseUrl: string; - token?: string; - skipTlsVerify?: boolean; -} - -/** - * Provides MCP authentication headers and approval configuration. - * Implemented by platform providers (e.g. McpAuthService in LlamaStack). - */ -export interface McpAuthProvider { - getServerHeaders(server: MCPServerConfig): Promise>; - getApiApprovalConfig( - configApproval: 'always' | 'never' | ApprovalFilter | undefined, - ): 'always' | 'never' | { always?: string[]; never?: string[] }; -} - -/** - * Provides access to a configured ResponsesApiClient instance. - * Implemented by platform providers (e.g. ClientManager in LlamaStack). - * - * Uses a generic constraint to avoid circular imports with the client module. - * In practice, consumers parameterize with `ResponsesApiClient`. - */ -export interface ClientProvider { - getExistingClient(): TClient; -} - -/** - * Provides effective configuration for the current request context. - * Implemented by platform providers (e.g. ConfigResolutionService in LlamaStack). - */ -export interface ConfigProvider { - resolve(): Promise; - getCachedConfig(): EffectiveConfigSnapshot | null; - invalidateCache(): void; -} - -/** - * Snapshot of effective configuration as seen by the toolkit. - * Platform providers map their full config to this minimal shape. - */ -export interface EffectiveConfigSnapshot { - model: string; - mcpServers?: MCPServerConfig[]; - guardrails?: string[]; - safetyIdentifier?: string; - reasoning?: Record; -} - -/** - * Describes what the connected Responses API server supports. - * Used by ToolsBuilder and ResponsesApiService to gate features. - */ -export interface CapabilityInfo { - functionTools: boolean; - strictField: boolean; - maxOutputTokens: boolean; - mcpTools: boolean; - parallelToolCalls: boolean; - truncation: boolean; -} diff --git a/workspaces/augment/plugins/augment-backend/src/providers/types.ts b/workspaces/augment/plugins/augment-backend/src/providers/types.ts deleted file mode 100644 index 7d63fcf3731..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/providers/types.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - SyncResult, - ChatAgent, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { - ChatRequest, - ChatResponse, - DocumentInfo, - VectorStoreInfo, - ProviderStatus, - VectorStoreStatus, - MCPServerStatus, - SecurityMode, - RAGSource, -} from '../types'; - -// Re-export types that consumers of the provider interface need -export type { - SyncResult, - ChatRequest, - ChatResponse, - DocumentInfo, - VectorStoreInfo, - ProviderStatus, - VectorStoreStatus, - MCPServerStatus, - SecurityMode, - RAGSource, - ChatAgent, -}; -export type { EvaluationResult } from '../types'; - -// Canonical conversation types — re-exported from the LlamaStack provider -export type { - ConversationSummary, - ConversationListResult, - ConversationDetails, - InputItem, - InputItemsResult, - ConversationItem, - ConversationItemsResult, - ProcessedToolCall, - ProcessedRagSource, - ProcessedMessage, - ToolApproval, - ApprovalResult, -} from './llamastack/conversationTypes'; - -// Re-export normalized streaming types from common -export type { - NormalizedStreamEvent, - StreamStartedEvent, - StreamTextDeltaEvent, - StreamTextDoneEvent, - StreamReasoningDeltaEvent, - StreamReasoningDoneEvent, - StreamToolDiscoveryEvent, - StreamToolStartedEvent, - StreamToolDeltaEvent, - StreamToolCompletedEvent, - StreamToolFailedEvent, - StreamToolApprovalEvent, - StreamRagResultsEvent, - StreamAgentHandoffEvent, - StreamFormRequestEvent, - StreamFormField, - StreamFormDescriptor, - StreamAuthRequiredEvent, - StreamSecretDemand, - StreamArtifactEvent, - StreamCitationEvent, - StreamCitationReference, - StreamCompletedEvent, - StreamErrorEvent, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -export type { - ConversationCapability, - RAGCapability, - SafetyCapability, - EvaluationCapability, - AgenticProviderStatus, - SafetyStatus, - SafetyCheckResult, - EvalStatus, -} from './capabilityTypes'; - -export type { AgenticProvider } from './providerInterface'; diff --git a/workspaces/augment/plugins/augment-backend/src/router.test.ts b/workspaces/augment/plugins/augment-backend/src/router.test.ts deleted file mode 100644 index 70ca0423645..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/router.test.ts +++ /dev/null @@ -1,997 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { mockServices } from '@backstage/backend-test-utils'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import express from 'express'; -import request from 'supertest'; -import knex, { type Knex } from 'knex'; - -import { createRouter } from './router'; -import type { AgenticProvider, AgenticProviderStatus } from './providers'; -import { ProviderManager } from './providers'; -import type { ChatSessionService } from './services/ChatSessionService'; -import { AdminConfigService } from './services/AdminConfigService'; -import { DocumentInfo, FileFormat } from './types'; - -type RouterOptions = Parameters[0]; - -function createTestRouterOptions( - db: Knex, - mockProvider: Partial, - adminConfig: AdminConfigService, - overrides?: Partial, -): RouterOptions { - const mockDatabase = { getClient: jest.fn().mockResolvedValue(db) }; - const mockConfig = mockServices.rootConfig({ - data: { - augment: { - branding: { appName: 'Test App', tagline: 'Test tagline' }, - }, - }, - }); - const mockHttpAuth = { - credentials: jest.fn().mockResolvedValue({ - principal: { type: 'user', userEntityRef: 'user:default/testuser' }, - $$type: '@backstage/BackstageCredentials', - expiresAt: undefined, - }), - issueUserCookie: jest.fn().mockResolvedValue(undefined), - }; - const mockPermissions = { - authorize: jest.fn().mockResolvedValue([{ result: AuthorizeResult.ALLOW }]), - authorizeConditional: jest - .fn() - .mockResolvedValue([{ result: AuthorizeResult.ALLOW }]), - }; - - const provider = mockProvider as unknown as AgenticProvider; - const providerManager = new ProviderManager( - provider, - () => provider, - mockServices.logger.mock(), - ); - - return { - logger: mockServices.logger.mock(), - config: mockConfig, - httpAuth: mockHttpAuth as unknown as RouterOptions['httpAuth'], - permissions: mockPermissions as unknown as RouterOptions['permissions'], - database: mockDatabase as unknown as RouterOptions['database'], - providerManager, - adminConfig, - ...overrides, - }; -} - -describe('createRouter', () => { - let app: express.Express; - let mockProvider: Partial; - let db: Knex; - let mockDatabase: { getClient: jest.Mock }; - let adminConfig: AdminConfigService; - - afterEach(async () => { - if (db) { - await db.destroy(); - } - }); - - beforeEach(async () => { - db = knex({ - client: 'better-sqlite3', - connection: ':memory:', - useNullAsDefault: true, - }); - const mockStatus: AgenticProviderStatus = { - provider: { - id: 'llama-stack', - model: 'test-model', - baseUrl: 'https://llama-stack.test', - connected: true, - }, - vectorStore: { - id: 'vs_test', - connected: true, - totalDocuments: 5, - }, - mcpServers: [], - securityMode: 'none', - timestamp: new Date().toISOString(), - ready: true, - configurationErrors: [], - }; - - mockProvider = { - id: 'llamastack', - displayName: 'Llama Stack', - getStatus: jest.fn().mockResolvedValue(mockStatus), - chat: jest.fn().mockResolvedValue({ role: 'assistant', content: 'test' }), - chatStream: jest.fn().mockResolvedValue(undefined), - conversations: { - create: jest.fn().mockResolvedValue({ conversationId: 'conv-1' }), - list: jest - .fn() - .mockResolvedValue({ conversations: [], hasMore: false }), - get: jest.fn().mockResolvedValue(null), - getInputs: jest.fn().mockResolvedValue({ items: [], hasMore: false }), - getByResponseChain: jest.fn().mockResolvedValue({ items: [] }), - getProcessedMessages: jest.fn().mockResolvedValue([]), - delete: jest.fn().mockResolvedValue(undefined), - submitApproval: jest.fn().mockResolvedValue({ - content: '', - responseId: '', - toolExecuted: false, - }), - }, - rag: { - listDocuments: jest.fn().mockResolvedValue([]), - listVectorStores: jest.fn().mockResolvedValue([]), - getDefaultVectorStoreId: jest.fn().mockReturnValue('vs_test'), - getActiveVectorStoreIds: jest.fn().mockReturnValue(['vs_test']), - syncDocuments: jest.fn().mockResolvedValue({ - added: 0, - updated: 0, - removed: 0, - errors: [], - failed: 0, - unchanged: 0, - }), - }, - safety: { - isEnabled: jest.fn().mockReturnValue(false), - getStatus: jest.fn().mockResolvedValue({ enabled: false, shields: [] }), - checkInput: jest.fn().mockResolvedValue({ safe: true }), - checkOutput: jest.fn().mockResolvedValue({ safe: true }), - }, - evaluation: { - isEnabled: jest.fn().mockReturnValue(false), - getStatus: jest - .fn() - .mockResolvedValue({ enabled: false, scoringFunctions: [] }), - evaluateResponse: jest.fn().mockResolvedValue(undefined), - }, - }; - - mockDatabase = { getClient: jest.fn().mockResolvedValue(db) }; - - adminConfig = new AdminConfigService( - mockDatabase as unknown as Parameters[0]['database'], - mockServices.logger.mock(), - ); - await adminConfig.initialize(); - - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig), - ); - - app = express(); - app.use(router); - }); - - describe('GET /health', () => { - it('returns ok status', async () => { - const response = await request(app).get('/health'); - - expect(response.status).toEqual(200); - expect(response.body).toEqual({ status: 'ok' }); - }); - }); - - describe('GET /status', () => { - it('returns service status when initialized', async () => { - const response = await request(app).get('/status'); - - expect(response.status).toEqual(200); - // Status endpoint returns the status object directly - expect(response.body).toHaveProperty('vectorStore'); - expect(response.body).toHaveProperty('provider'); - expect(response.body).toHaveProperty('ready', true); - }); - }); - - describe('GET /documents', () => { - it('returns empty array when no documents', async () => { - const response = await request(app).get('/documents'); - - expect(response.status).toEqual(200); - // Documents endpoint returns wrapped response - expect(response.body).toHaveProperty('success', true); - expect(response.body).toHaveProperty('documents'); - expect(response.body.documents).toEqual([]); - expect(response.body).toHaveProperty('totalDocuments', 0); - }); - - it('returns documents list', async () => { - const mockDocs: DocumentInfo[] = [ - { - id: 'file1', - fileName: 'test.md', - format: FileFormat.TEXT, - fileSize: 1024, - uploadedAt: '2025-01-01T00:00:00Z', - status: 'completed', - }, - ]; - (mockProvider.rag!.listDocuments as jest.Mock).mockResolvedValue( - mockDocs, - ); - - const response = await request(app).get('/documents'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('success', true); - expect(response.body.documents).toEqual(mockDocs); - expect(response.body).toHaveProperty('totalDocuments', 1); - }); - }); - - describe('GET /branding', () => { - it('returns branding configuration', async () => { - const response = await request(app).get('/branding'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('success', true); - expect(response.body).toHaveProperty('branding'); - expect(response.body.branding).toHaveProperty('appName', 'Test App'); - expect(response.body.branding).toHaveProperty('tagline', 'Test tagline'); - }); - }); - - describe('GET /conversations', () => { - it('returns empty conversations list', async () => { - const response = await request(app).get('/conversations'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('success', true); - expect(response.body).toHaveProperty('conversations', []); - expect(response.body).toHaveProperty('hasMore', false); - }); - - it('accepts query parameters', async () => { - const response = await request(app).get( - '/conversations?limit=5&order=asc', - ); - - expect(response.status).toEqual(200); - expect(mockProvider.conversations!.list).toHaveBeenCalledWith( - 5, - undefined, - ); - }); - }); - - describe('POST /chat', () => { - it.each([ - { body: {}, desc: 'missing messages' }, - { body: { messages: [] }, desc: 'empty messages array' }, - { - body: { messages: [{ role: 'invalid', content: 'test' }] }, - desc: 'invalid message role', - }, - { - body: { messages: [{ role: 'user' }] }, - desc: 'missing message content', - }, - ])('returns 400 for $desc', async ({ body }) => { - const response = await request(app) - .post('/chat') - .set('X-Backstage-Request', 'augment') - .send(body); - - expect(response.status).toBe(400); - }); - - it('processes valid chat request', async () => { - const mockChat = jest.fn().mockResolvedValue({ - role: 'assistant', - content: 'Hello! How can I help?', - responseId: 'resp_123', - }); - (mockProvider as { chat: jest.Mock }).chat = mockChat; - - const response = await request(app) - .post('/chat') - .set('X-Backstage-Request', 'augment') - .send({ messages: [{ role: 'user', content: 'Hello' }] }); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('content', 'Hello! How can I help?'); - expect(response.body).toHaveProperty('role', 'assistant'); - }); - - it('passes enableRAG option to service', async () => { - const mockChat = jest.fn().mockResolvedValue({ - role: 'assistant', - content: 'Response without RAG', - }); - (mockProvider as { chat: jest.Mock }).chat = mockChat; - - await request(app) - .post('/chat') - .set('X-Backstage-Request', 'augment') - .send({ - messages: [{ role: 'user', content: 'Test' }], - enableRAG: false, - }); - - expect(mockChat).toHaveBeenCalledWith( - expect.objectContaining({ enableRAG: false }), - ); - }); - - it('passes previousResponseId for conversation threading', async () => { - const mockChat = jest.fn().mockResolvedValue({ - role: 'assistant', - content: 'Continuing conversation', - }); - (mockProvider as { chat: jest.Mock }).chat = mockChat; - - await request(app) - .post('/chat') - .set('X-Backstage-Request', 'augment') - .send({ - messages: [{ role: 'user', content: 'Continue' }], - previousResponseId: 'resp_prev_123', - }); - - expect(mockChat).toHaveBeenCalledWith( - expect.objectContaining({ previousResponseId: 'resp_prev_123' }), - ); - }); - }); - - describe('POST /sync', () => { - it('triggers document sync', async () => { - const mockSyncDocuments = jest.fn().mockResolvedValue({ - added: 2, - updated: 1, - removed: 0, - failed: 0, - unchanged: 5, - errors: [], - }); - ( - mockProvider.rag! as unknown as { syncDocuments: jest.Mock } - ).syncDocuments = mockSyncDocuments; - - const response = await request(app) - .post('/sync') - .set('X-Backstage-Request', 'augment') - .send({}); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('success', true); - // The response directly contains the sync results - expect(response.body).toHaveProperty('added', 2); - expect(response.body).toHaveProperty('updated', 1); - }); - }); - - describe('GET /safety/status', () => { - it('returns safety status when disabled', async () => { - const response = await request(app).get('/safety/status'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('enabled', false); - expect(response.body).toHaveProperty('shields', []); - }); - - it('returns safety status when enabled', async () => { - (mockProvider.safety!.isEnabled as jest.Mock).mockReturnValue(true); - (mockProvider.safety!.getStatus as jest.Mock).mockResolvedValue({ - enabled: true, - shields: ['llama-guard'], - }); - - const response = await request(app).get('/safety/status'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('enabled', true); - expect(response.body.shields).toHaveLength(1); - expect(response.body.shields[0]).toBe('llama-guard'); - }); - }); - - describe('GET /evaluation/status', () => { - it('returns evaluation status when disabled', async () => { - const response = await request(app).get('/evaluation/status'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('enabled', false); - }); - - it('returns evaluation status with scoring functions', async () => { - (mockProvider.evaluation!.isEnabled as jest.Mock).mockReturnValue(true); - (mockProvider.evaluation!.getStatus as jest.Mock).mockResolvedValue({ - enabled: true, - scoringFunctions: ['relevancy'], - }); - - const response = await request(app).get('/evaluation/status'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('enabled', true); - expect(response.body.scoringFunctions).toHaveLength(1); - }); - }); - - describe('GET /guided-workflows', () => { - it('returns empty workflows list', async () => { - const response = await request(app).get('/guided-workflows'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('workflows', []); - }); - - it('returns configured workflows from app-config', async () => { - const configWithWorkflows = mockServices.rootConfig({ - data: { - augment: { - branding: { appName: 'Test App', tagline: 'Test tagline' }, - workflows: [ - { - id: 'deploy', - name: 'Deploy App', - description: 'Deploy an application', - steps: [{ title: 'Step 1', prompt: 'Start deployment' }], - }, - ], - }, - }, - }); - const router2 = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig, { - config: configWithWorkflows, - }), - ); - const app2 = express(); - app2.use(router2); - - const response = await request(app2).get('/guided-workflows'); - - expect(response.status).toEqual(200); - expect(response.body.workflows).toHaveLength(1); - expect(response.body.workflows[0]).toHaveProperty('id', 'deploy'); - }); - }); - - describe('GET /quick-actions', () => { - it('returns empty quick actions list', async () => { - const response = await request(app).get('/quick-actions'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('success', true); - expect(response.body).toHaveProperty('quickActions', []); - }); - }); - - describe('GET /prompt-groups', () => { - it('returns empty prompt groups list', async () => { - const response = await request(app).get('/prompt-groups'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('success', true); - expect(response.body).toHaveProperty('promptGroups', []); - }); - }); - - describe('GET /swim-lanes (removed)', () => { - it('returns 404 for deprecated swim-lanes endpoint', async () => { - const response = await request(app).get('/swim-lanes'); - expect(response.status).toEqual(404); - }); - }); - - describe('Permission denial', () => { - it('should return 403 when permission is denied', async () => { - const mockPermissionsDeny = { - authorize: jest - .fn() - .mockResolvedValue([{ result: AuthorizeResult.DENY }]), - authorizeConditional: jest - .fn() - .mockResolvedValue([{ result: AuthorizeResult.DENY }]), - }; - - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig, { - permissions: - mockPermissionsDeny as unknown as RouterOptions['permissions'], - }), - ); - const appPerm = express(); - appPerm.use(router); - - const response = await request(appPerm) - .post('/chat') - .set('X-Backstage-Request', 'augment') - .send({ messages: [{ role: 'user', content: 'Hello' }] }); - - expect(response.status).toEqual(403); - expect(response.body).toHaveProperty('error', 'Access Denied'); - }); - - it('should return 403 with custom access denied message', async () => { - const customMessage = 'Custom access denied message for testing'; - const mockPermissionsDeny = { - authorize: jest - .fn() - .mockResolvedValue([{ result: AuthorizeResult.DENY }]), - authorizeConditional: jest - .fn() - .mockResolvedValue([{ result: AuthorizeResult.DENY }]), - }; - const mockConfig = mockServices.rootConfig({ - data: { - augment: { - branding: { appName: 'Test App', tagline: 'Test tagline' }, - security: { - accessDeniedMessage: customMessage, - }, - }, - }, - }); - - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig, { - config: mockConfig, - permissions: - mockPermissionsDeny as unknown as RouterOptions['permissions'], - }), - ); - const appPerm = express(); - appPerm.use(router); - - const response = await request(appPerm) - .post('/chat') - .set('X-Backstage-Request', 'augment') - .send({ messages: [{ role: 'user', content: 'Hello' }] }); - - expect(response.status).toEqual(403); - expect(response.body).toHaveProperty('error', 'Access Denied'); - expect(response.body).toHaveProperty('message', customMessage); - }); - - it('should return 401 when auth fails', async () => { - const mockHttpAuthThrows = { - credentials: jest - .fn() - .mockRejectedValue(new Error('Not authenticated')), - issueUserCookie: jest.fn().mockResolvedValue(undefined), - }; - - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig, { - httpAuth: mockHttpAuthThrows as unknown as RouterOptions['httpAuth'], - }), - ); - const appAuth = express(); - appAuth.use(router); - - const response = await request(appAuth) - .post('/chat') - .set('X-Backstage-Request', 'augment') - .send({ messages: [{ role: 'user', content: 'Hello' }] }); - - expect(response.status).toEqual(401); - expect(response.body).toHaveProperty('error', 'Unauthorized'); - }); - - it('should skip permission check when security mode is "none"', async () => { - const mockConfigNone = mockServices.rootConfig({ - data: { - augment: { - branding: { appName: 'Test App', tagline: 'Test tagline' }, - security: { mode: 'none' }, - }, - }, - }); - const mockHttpAuthNone = { - credentials: jest.fn().mockRejectedValue(new Error('No auth')), - issueUserCookie: jest.fn().mockResolvedValue(undefined), - }; - const mockPermissionsNone = { - authorize: jest - .fn() - .mockResolvedValue([{ result: AuthorizeResult.DENY }]), - authorizeConditional: jest - .fn() - .mockResolvedValue([{ result: AuthorizeResult.DENY }]), - }; - - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig, { - config: mockConfigNone, - httpAuth: mockHttpAuthNone as unknown as RouterOptions['httpAuth'], - permissions: - mockPermissionsNone as unknown as RouterOptions['permissions'], - }), - ); - const appNone = express(); - appNone.use(router); - - const response = await request(appNone).get('/status'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('ready', true); - }); - }); - - describe('Session routes', () => { - const createMockSessions = ( - overrides?: Partial<{ - listSessions: jest.Mock; - createSession: jest.Mock; - getSession: jest.Mock; - deleteSession: jest.Mock; - getMessages: jest.Mock; - }>, - ): ChatSessionService => { - const mock = { - listSessions: jest.fn().mockResolvedValue([]), - createSession: jest.fn().mockResolvedValue({ - id: 'sess-1', - title: 'Test', - userRef: 'user:default/testuser', - conversationId: null, - createdAt: '2025-01-01T00:00:00Z', - updatedAt: '2025-01-01T00:00:00Z', - }), - getSession: jest.fn().mockResolvedValue(null), - deleteSession: jest.fn().mockResolvedValue(false), - getMessages: jest.fn().mockResolvedValue([]), - }; - return { ...mock, ...overrides } as unknown as ChatSessionService; - }; - - it('should return 501 when sessions not configured', async () => { - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig), - ); - const appNoSessions = express(); - appNoSessions.use(router); - - const response = await request(appNoSessions).get('/sessions'); - - expect(response.status).toEqual(501); - expect(response.body).toHaveProperty('error', 'Sessions not available'); - }); - - it('should list sessions', async () => { - const mockSessionsList = [ - { - id: 'sess-1', - title: 'Chat 1', - userRef: 'user:default/testuser', - conversationId: 'conv-1', - createdAt: '2025-01-01T00:00:00Z', - updatedAt: '2025-01-01T00:00:00Z', - }, - ]; - const mockSessions = createMockSessions({ - listSessions: jest.fn().mockResolvedValue(mockSessionsList), - }); - - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig, { - sessions: mockSessions, - }), - ); - const appSessions = express(); - appSessions.use(router); - - const response = await request(appSessions).get('/sessions'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('sessions', mockSessionsList); - }); - - it('should create a session', async () => { - const createdSession = { - id: 'sess-new', - title: 'Test', - userRef: 'user:default/testuser', - conversationId: null, - createdAt: '2025-01-01T00:00:00Z', - updatedAt: '2025-01-01T00:00:00Z', - }; - const mockCreateSession = jest.fn().mockResolvedValue(createdSession); - const mockSessions = createMockSessions({ - createSession: mockCreateSession, - }); - - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig, { - sessions: mockSessions, - }), - ); - const appSessions = express(); - appSessions.use(router); - - const response = await request(appSessions) - .post('/sessions') - .set('X-Backstage-Request', 'augment') - .send({ title: 'Test' }); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('session', createdSession); - expect(mockCreateSession).toHaveBeenCalledWith( - 'user:default/testuser', - 'Test', - undefined, - undefined, - ); - }); - - it('should get a session', async () => { - const sessionData = { - id: '123', - title: 'My Session', - userRef: 'user:default/testuser', - conversationId: 'conv-1', - createdAt: '2025-01-01T00:00:00Z', - updatedAt: '2025-01-01T00:00:00Z', - }; - const mockGetSession = jest.fn().mockResolvedValue(sessionData); - const mockSessions = createMockSessions({ - getSession: mockGetSession, - }); - - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig, { - sessions: mockSessions, - }), - ); - const appSessions = express(); - appSessions.use(router); - - const response = await request(appSessions).get('/sessions/123'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('session', sessionData); - expect(mockGetSession).toHaveBeenCalledWith( - '123', - 'user:default/testuser', - ); - }); - - it('should return 404 for unknown session', async () => { - const mockGetSession = jest.fn().mockResolvedValue(null); - const mockSessions = createMockSessions({ - getSession: mockGetSession, - }); - - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig, { - sessions: mockSessions, - }), - ); - const appSessions = express(); - appSessions.use(router); - - const response = await request(appSessions).get('/sessions/unknown-123'); - - expect(response.status).toEqual(404); - expect(response.body).toHaveProperty('error', 'Session not found'); - }); - - it('should delete a session', async () => { - const sessionData = { - id: '123', - title: 'My Session', - userRef: 'user:default/testuser', - conversationId: null, - createdAt: '2025-01-01T00:00:00Z', - updatedAt: '2025-01-01T00:00:00Z', - }; - const mockGetSession = jest.fn().mockResolvedValue(sessionData); - const mockDeleteSession = jest.fn().mockResolvedValue(true); - const mockSessions = createMockSessions({ - getSession: mockGetSession, - deleteSession: mockDeleteSession, - }); - - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig, { - sessions: mockSessions, - }), - ); - const appSessions = express(); - appSessions.use(router); - - const response = await request(appSessions) - .delete('/sessions/123') - .set('X-Backstage-Request', 'augment'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('success', true); - expect(mockDeleteSession).toHaveBeenCalledWith( - '123', - 'user:default/testuser', - ); - }); - - it('should return 404 when deleting unknown session', async () => { - const mockGetSession = jest.fn().mockResolvedValue(null); - const mockSessions = createMockSessions({ - getSession: mockGetSession, - }); - - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig, { - sessions: mockSessions, - }), - ); - const appSessions = express(); - appSessions.use(router); - - const response = await request(appSessions) - .delete('/sessions/unknown-123') - .set('X-Backstage-Request', 'augment'); - - expect(response.status).toEqual(404); - expect(response.body).toHaveProperty('error', 'Session not found'); - }); - - it('should get processed messages for a session', async () => { - const sessionData = { - id: '123', - title: 'My Session', - userRef: 'user:default/testuser', - conversationId: 'conv-1', - createdAt: '2025-01-01T00:00:00Z', - updatedAt: '2025-01-01T00:00:00Z', - }; - const mockMessages = [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi there!' }, - ]; - const mockGetSession = jest.fn().mockResolvedValue(sessionData); - ( - mockProvider.conversations!.getProcessedMessages as jest.Mock - ).mockResolvedValue(mockMessages); - const mockSessions = createMockSessions({ - getSession: mockGetSession, - }); - - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig, { - sessions: mockSessions, - }), - ); - const appSessions = express(); - appSessions.use(router); - - const response = await request(appSessions).get('/sessions/123/messages'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('messages', mockMessages); - expect(response.body).toHaveProperty('hasConversationId', true); - }); - - it('should cascade-delete conversation container when deleting a session with conversationId', async () => { - const sessionData = { - id: '123', - title: 'My Session', - userRef: 'user:default/testuser', - conversationId: 'conv-cascade', - createdAt: '2025-01-01T00:00:00Z', - updatedAt: '2025-01-01T00:00:00Z', - }; - const mockGetSession = jest.fn().mockResolvedValue(sessionData); - const mockDeleteSession = jest.fn().mockResolvedValue(true); - const mockDeleteContainer = jest.fn().mockResolvedValue(true); - const mockSessions = createMockSessions({ - getSession: mockGetSession, - deleteSession: mockDeleteSession, - }); - - const providerWithDelete = { - ...mockProvider, - conversations: { - ...mockProvider.conversations!, - deleteContainer: mockDeleteContainer, - }, - }; - - const router = await createRouter( - createTestRouterOptions(db, providerWithDelete, adminConfig, { - sessions: mockSessions, - }), - ); - const appSessions = express(); - appSessions.use(router); - - const response = await request(appSessions) - .delete('/sessions/123') - .set('X-Backstage-Request', 'augment'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('success', true); - expect(mockDeleteContainer).toHaveBeenCalledWith('conv-cascade'); - expect(mockDeleteSession).toHaveBeenCalledWith( - '123', - 'user:default/testuser', - ); - }); - - it('should return 502 when getProcessedMessages fails', async () => { - const sessionData = { - id: '123', - title: 'My Session', - userRef: 'user:default/testuser', - conversationId: 'conv-1', - createdAt: '2025-01-01T00:00:00Z', - updatedAt: '2025-01-01T00:00:00Z', - }; - const mockGetSession = jest.fn().mockResolvedValue(sessionData); - ( - mockProvider.conversations!.getProcessedMessages as jest.Mock - ).mockRejectedValue(new Error('Llama Stack unreachable')); - const mockSessions = createMockSessions({ - getSession: mockGetSession, - }); - - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig, { - sessions: mockSessions, - }), - ); - const appSessions = express(); - appSessions.use(router); - - const response = await request(appSessions).get('/sessions/123/messages'); - - expect(response.status).toEqual(502); - expect(response.body).toHaveProperty( - 'error', - 'Failed to retrieve messages from the conversation provider', - ); - expect(response.body).toHaveProperty('messages', []); - expect(response.body).toHaveProperty('hasConversationId', true); - }); - - it('should return empty messages for session without conversationId', async () => { - const sessionData = { - id: '123', - title: 'My Session', - userRef: 'user:default/testuser', - conversationId: null, - createdAt: '2025-01-01T00:00:00Z', - updatedAt: '2025-01-01T00:00:00Z', - }; - const mockGetSession = jest.fn().mockResolvedValue(sessionData); - const mockSessions = createMockSessions({ - getSession: mockGetSession, - }); - - const router = await createRouter( - createTestRouterOptions(db, mockProvider, adminConfig, { - sessions: mockSessions, - }), - ); - const appSessions = express(); - appSessions.use(router); - - const response = await request(appSessions).get('/sessions/123/messages'); - - expect(response.status).toEqual(200); - expect(response.body).toHaveProperty('messages', []); - expect(response.body).toHaveProperty('sessionCreatedAt'); - expect(response.body).toHaveProperty('hasConversationId', false); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/router.ts b/workspaces/augment/plugins/augment-backend/src/router.ts deleted file mode 100644 index d1c74954390..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/router.ts +++ /dev/null @@ -1,440 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - LoggerService, - HttpAuthService, - PermissionsService, - DatabaseService, - CacheService, -} from '@backstage/backend-plugin-api'; -import { InputError } from '@backstage/errors'; -import type { SecurityMode } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import express from 'express'; -import Router from 'express-promise-router'; -import type { ProviderManager } from './providers'; -import { getProviderDescriptor } from './providers'; -import type { ChatSessionService } from './services/ChatSessionService'; -import type { RouteContext } from './routes'; -import { - registerStatusRoutes, - registerChatRoutes, - registerDocumentRoutes, - registerConfigRoutes, - registerSessionRoutes, - registerConversationRoutes, - registerAdminRoutes, - registerKagentiRoutes, - registerKagentiSandboxRoutes, - registerKagentiAdminRoutes, - registerDevSpacesRoutes, - registerAgentRoutes, - registerToolLifecycleRoutes, - registerWorkflowRoutes, - registerSkillsRoutes, -} from './routes'; -import { toErrorMessage } from './services/utils'; -import { sanitizeErrorMessage } from './services/utils/errorSanitizer'; -import type { AdminConfigService } from './services/AdminConfigService'; -import { WorkflowConfigService } from './services/WorkflowConfigService'; -import { AgentApprovalWorkflowService } from './services/AgentApprovalWorkflowService'; -import { ResponsesApiProvider } from './providers/llamastack'; -import { createSecurityMiddleware } from './middleware/security'; -import { createRateLimiter } from './middleware/rateLimiter'; -import { - parseChatRequest, - parseApprovalRequest, -} from './parsers/chatRequestParsers'; - -export interface RouterOptions { - logger: LoggerService; - config: import('@backstage/config').Config; - httpAuth: HttpAuthService; - permissions: PermissionsService; - database: DatabaseService; - providerManager: ProviderManager; - sessions?: ChatSessionService; - adminConfig: AdminConfigService; - cache?: CacheService; -} - -/** - * Creates an Express router for the Augment backend - * - * Documents are managed via config-driven ingestion, not via API endpoints. - * The documents endpoint is read-only for viewing what's in the knowledge base. - * - * Authentication & Authorization: - * - All endpoints (except /health) require user authentication via Backstage httpAuth - * - Authorization uses a single plugin-level permission: augment.access - * - If a user has the permission, they get FULL access to all features - * - If not, they are blocked from the entire plugin - * - * To restrict access to a Keycloak group, configure RBAC policies in app-config.yaml: - * ```yaml - * permission: - * enabled: true - * rbac: - * policies: - * # Map Keycloak group to a role - * - g, group:default/augment-users, role:default/augment-user - * # Grant plugin access to the role (single permission for entire plugin) - * - p, role:default/augment-user, augment.access, read, allow - * ``` - * - * @public - */ -export async function createRouter({ - logger, - config, - httpAuth, - permissions, - database, - providerManager, - sessions, - adminConfig, - cache, -}: RouterOptions): Promise { - const router = Router(); - router.use(express.json({ limit: '1mb' })); - - router.use((_req, res, next) => { - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('X-Frame-Options', 'DENY'); - res.setHeader('X-XSS-Protection', '0'); - res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); - next(); - }); - - const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); - const CSRF_EXEMPT_PATHS = new Set(['/health']); - router.use((req, res, next) => { - if (!MUTATING_METHODS.has(req.method)) { - next(); - return; - } - if (CSRF_EXEMPT_PATHS.has(req.path)) { - next(); - return; - } - if (!req.headers['x-backstage-request']) { - res.status(403).json({ error: 'Missing X-Backstage-Request header' }); - return; - } - next(); - }); - - // ============================================================================= - // Shared Helpers - // ============================================================================= - - function sendRouteError( - res: express.Response, - error: unknown, - logLabel: string, - userFacingError: string, - extra?: Record, - statusCode?: number, - ): void { - const rawMsg = toErrorMessage(error); - logger.error(`${logLabel}: ${rawMsg}`); - const { message, inferredStatus } = sanitizeErrorMessage(rawMsg); - const resolvedStatus = - statusCode ?? - (error instanceof InputError ? 400 : (inferredStatus ?? 500)); - res - .status(resolvedStatus) - .json({ error: userFacingError, message, ...extra }); - } - - function missingSessions(res: express.Response): boolean { - if (!sessions) { - res.status(501).json({ success: false, error: 'Sessions not available' }); - return true; - } - return false; - } - - function missingConversations(res: express.Response): boolean { - const descriptor = getProviderDescriptor(providerManager.provider.id); - if ( - !providerManager.provider.conversations || - descriptor?.capabilities.contextHydration - ) { - res.status(501).json({ - success: false, - error: 'Conversations not supported by current provider', - }); - return true; - } - return false; - } - - // ============================================================================= - // Security Configuration - // ============================================================================= - - const VALID_SECURITY_MODES: ReadonlySet = new Set( - ['none', 'plugin-only', 'full'], - ); - const rawSecurityMode = - config.getOptionalString('augment.security.mode') || 'plugin-only'; - if (!VALID_SECURITY_MODES.has(rawSecurityMode as SecurityMode)) { - throw new InputError( - `Invalid security mode "${rawSecurityMode}". Must be one of: ${[ - ...VALID_SECURITY_MODES, - ].join(', ')}`, - ); - } - const securityMode = rawSecurityMode as SecurityMode; - const securityConfig = { - mode: securityMode, - accessDeniedMessage: config.getOptionalString( - 'augment.security.accessDeniedMessage', - ), - }; - const adminUsers = config.getOptionalStringArray( - 'augment.security.adminUsers', - ); - const permissionsEnabled = - config.getOptionalBoolean('permission.enabled') === true; - - const { requirePluginAccess, checkIsAdmin, requireAdminAccess, getUserRef } = - createSecurityMiddleware({ - logger, - httpAuth, - permissions, - securityMode, - accessDeniedMessage: securityConfig.accessDeniedMessage, - adminUsers, - permissionsEnabled, - }); - - // Log security mode and admin mechanism - if (securityConfig.mode === 'none') { - logger.info('Augment security mode: NONE (no access control)'); - } else if (securityConfig.mode === 'plugin-only') { - logger.info( - 'Augment security mode: PLUGIN-ONLY (Backstage RBAC for plugin access)', - ); - } else if (securityConfig.mode === 'full') { - logger.info('Augment security mode: FULL (Backstage RBAC + MCP OAuth)'); - logger.info(' MCP servers will receive OAuth tokens'); - } - if (securityConfig.mode !== 'none') { - if (permissionsEnabled) { - logger.info( - ' Admin access: determined by Backstage RBAC (augment.admin permission)', - ); - } else if (adminUsers && adminUsers.length > 0) { - logger.info( - ` Admin access: config-based (${adminUsers.length} user${ - adminUsers.length !== 1 ? 's' : '' - } in augment.security.adminUsers)`, - ); - } else { - logger.warn( - ' Admin access: NO admin mechanism configured. ' + - 'Either enable Backstage permissions (permission.enabled: true) ' + - 'or set augment.security.adminUsers in app-config.yaml.', - ); - } - } - - // ============================================================================= - // Route Registration - // ============================================================================= - - const providerDescriptor = getProviderDescriptor(providerManager.provider.id); - let orchestrationProvider: ResponsesApiProvider | undefined; - if (providerDescriptor?.capabilities.agentLifecycle) { - try { - orchestrationProvider = new ResponsesApiProvider({ - logger: logger.child({ provider: 'orchestration-fallback' }), - config, - database, - adminConfig, - }); - await orchestrationProvider.initialize(); - logger.info( - 'Orchestration fallback provider created and initialized for hybrid routing', - ); - } catch (err) { - logger.warn( - `Could not create orchestration fallback provider: ${toErrorMessage(err)}`, - ); - } - } - - const ctx: RouteContext = { - router, - logger, - config, - get provider() { - return providerManager.provider; - }, - orchestrationProvider, - sessions, - toErrorMessage, - sendRouteError, - missingSessions, - missingConversations, - getUserRef, - checkIsAdmin, - requireAdminAccess, - parseChatRequest, - parseApprovalRequest, - cache, - }; - - const onConfigChanged = () => { - providerManager.provider.invalidateRuntimeConfig?.(); - }; - - // Public routes (before auth middleware) - registerStatusRoutes(ctx, adminConfig, providerManager.initializationError); - - // Tours route is public (static config, no sensitive data) - router.get('/tours', (_req, res) => { - try { - const arr = config.getOptionalConfigArray('augment.tours') || []; - const tours = arr.map(tour => { - const steps = (tour.getOptionalConfigArray('steps') || []).map(step => { - const actionCfg = step.getOptionalConfig('action'); - return { - target: step.getOptionalString('target'), - title: step.getString('title'), - description: step.getOptionalString('description'), - side: step.getOptionalString('side'), - action: actionCfg - ? { - type: actionCfg.getString('type'), - panel: actionCfg.getOptionalString('panel'), - selector: actionCfg.getOptionalString('selector'), - step: actionCfg.getOptionalNumber('step'), - method: actionCfg.getOptionalString('method'), - cardId: actionCfg.getOptionalString('cardId'), - } - : undefined, - waitFor: step.getOptionalString('waitFor'), - }; - }); - return { - id: tour.getString('id'), - title: tour.getString('title'), - description: tour.getOptionalString('description'), - category: tour.getOptionalString('category'), - estimatedMinutes: tour.getOptionalNumber('estimatedMinutes'), - persona: tour.getOptionalString('persona'), - steps, - }; - }); - res.json({ - success: true, - tours, - source: tours.length > 0 ? 'yaml' : 'defaults', - }); - } catch { - res.json({ success: true, tours: [], source: 'defaults' }); - } - }); - - // Apply plugin-level access control to ALL subsequent routes - router.use(requirePluginAccess); - - const mutationLimiter = createRateLimiter({ - windowMs: 60_000, - maxRequests: 30, - }); - router.post('/chat/approve', mutationLimiter); - - // Authenticated routes - registerChatRoutes(ctx, adminConfig); - const agentApprovalService = new AgentApprovalWorkflowService(config, logger); - registerAgentRoutes(ctx, adminConfig, agentApprovalService); - registerSkillsRoutes(ctx, adminConfig); - - // Tool lifecycle routes -- unified tool listing with lifecycle overlay - { - const hasToolLifecycle = providerDescriptor?.capabilities.toolLifecycle; - const kagentiProvider = hasToolLifecycle - ? (providerManager.provider as import('./providers/kagenti').KagentiProvider) - : undefined; - - registerToolLifecycleRoutes(ctx, adminConfig, { - async listProviderTools() { - if (!kagentiProvider) return []; - try { - const apiClient = kagentiProvider.getApiClient(); - const kagentiConfig = kagentiProvider.getConfig(); - const { getVisibleNamespaces } = - await import('./providers/kagenti/kagentiNamespaceUtils'); - const namespaces = await getVisibleNamespaces( - apiClient, - kagentiConfig, - logger, - ); - const allTools: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiToolSummary[] = - []; - const toolResults = await Promise.allSettled( - namespaces.map(ns => apiClient.listTools(ns)), - ); - for (const r of toolResults) { - if (r.status === 'fulfilled') - allTools.push(...(r.value.items ?? [])); - } - return allTools; - } catch { - return []; - } - }, - }); - } - - const workflowService = new WorkflowConfigService(adminConfig, logger); - registerWorkflowRoutes(ctx, workflowService, adminConfig); - registerDocumentRoutes(ctx); - registerConfigRoutes(ctx, adminConfig); - registerSessionRoutes(ctx); - registerConversationRoutes(ctx); - - // Admin routes (requireAdminAccess is applied inside registerAdminRoutes) - registerAdminRoutes(ctx, adminConfig, onConfigChanged, providerManager); - - // Provider-specific routes (only when provider has providerRoutes capability) - if (providerDescriptor?.capabilities.providerRoutes) { - registerKagentiRoutes(ctx, adminConfig); - registerKagentiSandboxRoutes(ctx); - registerKagentiAdminRoutes(ctx); - logger.info('Provider-specific routes registered'); - } - - // Dev Spaces routes — use provider's auth token when available - const kagentiTokenFn = providerDescriptor?.capabilities.devSpaces - ? () => providerManager.provider.getAuthToken!() - : undefined; - - registerDevSpacesRoutes({ - router, - logger, - adminConfig, - sendRouteError, - requireAdminAccess, - getAuthToken: kagentiTokenFn, - config, - }); - - return router; -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/adminConfigRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/adminConfigRoutes.ts deleted file mode 100644 index e24ec5c8171..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/adminConfigRoutes.ts +++ /dev/null @@ -1,402 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { InputError } from '@backstage/errors'; -import { - DEFAULT_BRANDING, - isProviderScopedKey, - deriveRoleFromTopology, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ProviderType } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { AdminConfigService } from '../services/AdminConfigService'; -import { validateAdminConfigValue } from '../services/utils/configValidation'; -import { loadBrandingOverrides } from '../providers/llamastack/BrandingConfigLoader'; -import { createWithRoute } from './routeWrapper'; -import type { AdminRouteDeps } from './adminRouteTypes'; -import { AuditLogger } from '../services/AuditLogger'; - -const REDACTED_KEYS = new Set(['devSpacesToken']); - -export function registerAdminConfigRoutes( - router: import('express').Router, - deps: AdminRouteDeps, -): void { - const { - adminConfig, - config, - logger, - sendRouteError, - getUserRef, - onConfigChanged, - } = deps; - - const withRoute = createWithRoute(logger, sendRouteError); - const audit = new AuditLogger(logger); - - router.get( - '/admin/config', - withRoute( - 'GET /admin/config', - 'Failed to list admin configuration', - async (_req, res) => { - const entries = await adminConfig.listAll(); - res.json({ - success: true, - entries, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.get( - '/admin/config/:key', - withRoute( - req => `GET /admin/config/${req.params.key}`, - 'Failed to get admin configuration', - async (req, res) => { - const validKey = AdminConfigService.validateKey(req.params.key); - const providerId = - (req.query.provider as string | undefined) ?? deps.provider.id; - - if (isProviderScopedKey(validKey)) { - const value = await adminConfig.getScopedValue( - validKey, - providerId as ProviderType, - ); - if (value === undefined) { - res.json({ - success: true, - entry: null, - source: 'default', - timestamp: new Date().toISOString(), - }); - return; - } - res.json({ - success: true, - entry: { - configKey: validKey, - configValue: value, - updatedAt: new Date().toISOString(), - updatedBy: 'system', - }, - source: 'database', - timestamp: new Date().toISOString(), - }); - return; - } - - const entry = await adminConfig.getEntry(validKey); - if (!entry) { - res.json({ - success: true, - entry: null, - source: 'default', - timestamp: new Date().toISOString(), - }); - return; - } - const redacted = REDACTED_KEYS.has(validKey); - res.json({ - success: true, - entry: redacted ? { ...entry, configValue: '**REDACTED**' } : entry, - source: 'database', - ...(redacted && { redacted: true }), - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.put( - '/admin/config/:key', - withRoute( - req => `PUT /admin/config/${req.params.key}`, - 'Failed to update admin configuration', - async (req, res) => { - const validKey = AdminConfigService.validateKey(req.params.key); - const { value } = req.body; - if (value === undefined) { - throw new InputError('Request body must contain a "value" field'); - } - - validateAdminConfigValue(validKey, value); - - const userRef = await getUserRef(req); - const providerId = - (req.query.provider as string | undefined) ?? deps.provider.id; - - if (validKey === 'agents') { - try { - const previous = await adminConfig.get('agents'); - if (previous && typeof previous === 'object') { - const prevKeys = Object.keys(previous as Record); - const newKeys = Object.keys(value as Record); - const added = newKeys.filter(k => !prevKeys.includes(k)); - const removed = prevKeys.filter(k => !newKeys.includes(k)); - if (added.length > 0 || removed.length > 0) { - logger.info( - `[AdminConfig] Agent roster changed by ${userRef}: ` + - `added=[${added.join(', ')}], removed=[${removed.join(', ')}]`, - ); - } - } - } catch { - // best-effort audit — don't block the save - } - } - - if (isProviderScopedKey(validKey)) { - await adminConfig.setScopedValue( - validKey, - value, - providerId as ProviderType, - userRef, - ); - } else { - await adminConfig.set(validKey, value, userRef); - } - - audit.log({ - action: 'config.update', - actor: userRef, - target: validKey, - outcome: 'success', - sourceIp: AuditLogger.extractIp(req), - meta: { providerId }, - }); - - onConfigChanged?.(); - - // Auto-register chatAgents entries for agents that can be published - // (router or standalone). New agents start as 'pending' so they go - // through the review queue. Specialists are hidden. - if (validKey === 'agents' && value && typeof value === 'object') { - try { - const agentMap = value as Record< - string, - { handoffs?: string[]; asTools?: string[] } - >; - - const existing = await adminConfig.get('chatAgents'); - const configs: import('@red-hat-developer-hub/backstage-plugin-augment-common').ChatAgentConfig[] = - Array.isArray(existing) - ? [ - ...(existing as import('@red-hat-developer-hub/backstage-plugin-augment-common').ChatAgentConfig[]), - ] - : []; - const existingIds = new Set(configs.map(c => c.agentId)); - let added = 0; - - for (const [agentKey, agentCfg] of Object.entries(agentMap)) { - if (!agentCfg || typeof agentCfg !== 'object') continue; - - const role = deriveRoleFromTopology(agentKey, agentMap); - if (role === 'specialist') continue; - if (existingIds.has(agentKey)) continue; - - configs.push({ - agentId: agentKey, - published: false, - visible: false, - featured: false, - lifecycleStage: 'pending', - version: 1, - promotedAt: new Date().toISOString(), - promotedBy: userRef, - }); - added++; - } - - // Remove chatAgents entries for agents whose role is now specialist - // or that no longer exist in the agents map. - const agentKeys = new Set(Object.keys(agentMap)); - const before = configs.length; - const cleaned = configs.filter(c => { - if (!agentKeys.has(c.agentId)) return false; - const cfg = agentMap[c.agentId]; - if (!cfg || typeof cfg !== 'object') return false; - return ( - deriveRoleFromTopology(c.agentId, agentMap) !== 'specialist' - ); - }); - const removed = before - cleaned.length; - - if (added > 0 || removed > 0) { - const final = removed > 0 ? cleaned : configs; - await adminConfig.set('chatAgents', final, userRef); - const parts: string[] = []; - if (added > 0) parts.push(`registered ${added} new`); - if (removed > 0) - parts.push(`removed ${removed} chatAgent entries`); - logger.info( - `[AdminConfig] chatAgent entries: ${parts.join(', ')}`, - ); - } - } catch (err) { - logger.warn(`Failed to auto-manage chatAgent entries: ${err}`); - } - } - - const warnings: string[] = []; - - if ( - validKey === 'model' && - typeof value === 'string' && - deps.provider.listModels - ) { - try { - const models = await deps.provider.listModels(); - const found = models.some(m => m.id === value.trim()); - if (!found) { - warnings.push( - `Model "${value.trim()}" was not found on the inference server. It will be saved, but chat may fail if the model is unavailable.`, - ); - logger.warn( - `Admin saved model "${value.trim()}" which is not in the server's model list`, - ); - } - } catch (listErr) { - logger.warn(`Could not validate model against server: ${listErr}`); - } - } - - if (validKey === 'defaultAgent' && typeof value === 'string') { - try { - const currentAgents = await adminConfig.get('agents'); - if (currentAgents && typeof currentAgents === 'object') { - const agentKeys = Object.keys( - currentAgents as Record, - ); - if (agentKeys.length > 0 && !agentKeys.includes(value)) { - warnings.push( - `Default agent "${value}" does not match any configured agent. ` + - `Available: [${agentKeys.join(', ')}]. Chat will fall back ` + - `to single-agent mode until this is corrected.`, - ); - logger.warn( - `Admin saved defaultAgent "${value}" which does not exist in agents config`, - ); - } - } - } catch (lookupErr) { - logger.warn(`Could not cross-validate defaultAgent: ${lookupErr}`); - } - } - - res.json({ - success: true, - configKey: validKey, - ...(warnings.length > 0 && { warnings }), - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.delete( - '/admin/config/:key', - withRoute( - req => `DELETE /admin/config/${req.params.key}`, - 'Failed to reset admin configuration', - async (req, res) => { - const validKey = AdminConfigService.validateKey(req.params.key); - const providerId = - (req.query.provider as string | undefined) ?? deps.provider.id; - - const userRef = await getUserRef(req); - let deleted: boolean; - if (isProviderScopedKey(validKey)) { - deleted = await adminConfig.deleteScopedValue( - validKey, - providerId as ProviderType, - ); - } else { - deleted = await adminConfig.delete(validKey); - } - - audit.log({ - action: 'config.delete', - actor: userRef, - target: validKey, - outcome: deleted ? 'success' : 'failure', - sourceIp: AuditLogger.extractIp(req), - meta: { providerId }, - }); - - onConfigChanged?.(); - - res.json({ - success: true, - deleted, - configKey: validKey, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.get( - '/admin/effective-config', - withRoute( - 'GET /admin/effective-config', - 'Failed to get effective configuration', - async (_req, res) => { - let effectiveConfig: Record; - - if (deps.provider.getEffectiveConfig) { - effectiveConfig = await deps.provider.getEffectiveConfig(); - } else { - const ls = config.getOptionalConfig('augment.llamaStack'); - const yamlBranding = loadBrandingOverrides(config, logger); - effectiveConfig = { - model: ls?.getOptionalString('model') ?? '', - baseUrl: ls?.getOptionalString('baseUrl') ?? '', - systemPrompt: - config.getOptionalString('augment.systemPrompt') ?? '', - toolChoice: ls?.getOptionalString('toolChoice') ?? 'auto', - enableWebSearch: ls?.getOptionalBoolean('enableWebSearch') ?? false, - enableCodeInterpreter: - ls?.getOptionalBoolean('enableCodeInterpreter') ?? false, - safetyEnabled: ls?.getOptionalBoolean('safetyEnabled') ?? false, - inputShields: ls?.getOptionalStringArray('inputShields') ?? [], - outputShields: ls?.getOptionalStringArray('outputShields') ?? [], - evaluationEnabled: - ls?.getOptionalBoolean('evaluationEnabled') ?? false, - scoringFunctions: - ls?.getOptionalStringArray('scoringFunctions') ?? [], - minScoreThreshold: ls?.getOptionalNumber('minScoreThreshold'), - branding: { ...DEFAULT_BRANDING, ...yamlBranding }, - }; - } - - const { - token: _t, - skipTlsVerify: _s, - functions: _f, - devSpacesToken: _dst, - ...safeConfig - } = effectiveConfig; - - res.json({ - success: true, - config: safeConfig, - timestamp: new Date().toISOString(), - }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/adminDocumentRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/adminDocumentRoutes.ts deleted file mode 100644 index a723a9eb20b..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/adminDocumentRoutes.ts +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import path from 'path'; -import { InputError } from '@backstage/errors'; -import multer from 'multer'; -import { MAX_FILE_SIZE_BYTES } from '../constants'; -import { createWithRoute } from './routeWrapper'; -import type { AdminRouteDeps } from './adminRouteTypes'; - -const ALLOWED_EXTENSIONS = new Set([ - '.md', - '.txt', - '.pdf', - '.json', - '.yaml', - '.yml', -]); - -const upload = multer({ - storage: multer.memoryStorage(), - limits: { fileSize: MAX_FILE_SIZE_BYTES }, - fileFilter: (_req, file, cb) => { - const ext = path.extname(file.originalname).toLowerCase(); - if (!ALLOWED_EXTENSIONS.has(ext)) { - cb( - new InputError( - `Unsupported file type: ${ext}. Allowed: ${[ - ...ALLOWED_EXTENSIONS, - ].join(', ')}`, - ), - ); - return; - } - cb(null, true); - }, -}); - -export function registerAdminDocumentRoutes( - router: import('express').Router, - deps: AdminRouteDeps, -): void { - const { logger, sendRouteError } = deps; - - const withRoute = createWithRoute(logger, sendRouteError); - - router.post( - '/admin/documents', - withRoute( - 'POST /admin/documents', - 'Failed to upload document', - async (req, res) => { - const provider = deps.provider; - if (!provider.rag?.uploadDocument) { - res.status(501).json({ - success: false, - error: 'Document upload not supported by current provider', - }); - return; - } - - try { - await new Promise((resolve, reject) => { - const multerMiddleware = upload.single('file'); - (multerMiddleware as unknown as import('express').RequestHandler)( - req, - res, - (err: unknown) => { - if (err) reject(err); - else resolve(); - }, - ); - }); - } catch (multerErr) { - if ( - multerErr instanceof multer.MulterError && - multerErr.code === 'LIMIT_FILE_SIZE' - ) { - sendRouteError( - res, - multerErr, - 'File too large', - `File exceeds maximum size of ${ - MAX_FILE_SIZE_BYTES / 1024 / 1024 - }MB`, - { success: false }, - 400, - ); - return; - } - throw multerErr; - } - - if (!req.file) { - throw new InputError('No file provided. Use multipart field "file".'); - } - - const targetStoreId = - typeof req.query.vectorStoreId === 'string' - ? req.query.vectorStoreId - : undefined; - - const existingDocs = await provider.rag.listDocuments(targetStoreId); - const duplicate = existingDocs.find( - d => d.fileName === req.file!.originalname, - ); - if (duplicate) { - const replaceMode = req.query.replace === 'true'; - if (!replaceMode) { - res.status(409).json({ - success: false, - error: `A file named "${req.file.originalname}" already exists`, - existingFile: duplicate, - hint: 'Add ?replace=true to overwrite, or delete the existing file first.', - }); - return; - } - if (!provider.rag.deleteDocument) { - res.status(501).json({ - success: false, - error: - 'Replace mode requires document deletion, which is not supported by the current provider.', - }); - return; - } - await provider.rag.deleteDocument(duplicate.id, targetStoreId); - logger.info( - `Replaced existing document: ${duplicate.id} (${duplicate.fileName})`, - ); - } - - const result = await provider.rag.uploadDocument( - req.file.originalname, - req.file.buffer, - targetStoreId, - ); - - res.json({ - success: true, - ...result, - replaced: !!duplicate, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.delete( - '/admin/documents/:id', - withRoute( - req => `DELETE /admin/documents/${req.params.id}`, - 'Failed to delete document', - async (req, res) => { - const provider = deps.provider; - if (!provider.rag?.deleteDocument) { - res.status(501).json({ - success: false, - error: 'Document deletion not supported by current provider', - }); - return; - } - - const { id } = req.params; - if (!id || id.trim().length === 0) { - throw new InputError('Document ID is required'); - } - - const deleteStoreId = - typeof req.query.vectorStoreId === 'string' - ? req.query.vectorStoreId - : undefined; - const result = await provider.rag.deleteDocument(id, deleteStoreId); - res.json({ - ...result, - documentId: id, - timestamp: new Date().toISOString(), - }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/adminModelRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/adminModelRoutes.ts deleted file mode 100644 index 5f6ab79d2d1..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/adminModelRoutes.ts +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { InputError } from '@backstage/errors'; -import { MAX_DESCRIPTION_LENGTH } from '../constants'; -import { createWithRoute } from './routeWrapper'; -import type { AdminRouteDeps } from './adminRouteTypes'; - -export function registerAdminModelRoutes( - router: import('express').Router, - deps: AdminRouteDeps, -): void { - const { logger, sendRouteError } = deps; - - const withRoute = createWithRoute(logger, sendRouteError); - - router.get( - '/admin/models', - withRoute( - 'GET /admin/models', - 'Failed to list available models', - async (_req, res) => { - const modelSources: Array< - Promise> - > = []; - - if (deps.provider.listModels) { - modelSources.push( - deps.provider.listModels().catch(err => { - logger.warn( - `Primary provider model listing failed: ${err instanceof Error ? err.message : err}`, - ); - return []; - }), - ); - } - - if (deps.orchestrationProvider?.listModels) { - modelSources.push( - deps.orchestrationProvider.listModels().catch(err => { - logger.warn( - `Orchestration provider model listing failed: ${err instanceof Error ? err.message : err}`, - ); - return []; - }), - ); - } - - if (modelSources.length === 0) { - res.status(501).json({ - success: false, - error: 'Model listing not supported by any configured provider', - }); - return; - } - - const results = await Promise.all(modelSources); - const seen = new Set(); - const models: Array<{ - id: string; - owned_by?: string; - model_type?: string; - }> = []; - for (const batch of results) { - for (const model of batch) { - if (!seen.has(model.id)) { - seen.add(model.id); - models.push(model); - } - } - } - - res.json({ - success: true, - models, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.post( - '/admin/test-model', - withRoute( - 'POST /admin/test-model', - 'Failed to test model connection', - async (req, res) => { - if (!deps.provider.testModel) { - res.status(501).json({ - success: false, - error: 'Model testing not supported by current provider', - }); - return; - } - - const { model: requestedModel, baseUrl: requestedBaseUrl } = - req.body as { model?: string; baseUrl?: string }; - if ( - requestedModel !== undefined && - typeof requestedModel !== 'string' - ) { - throw new InputError('model must be a string'); - } - if ( - requestedBaseUrl !== undefined && - typeof requestedBaseUrl !== 'string' - ) { - throw new InputError('baseUrl must be a string'); - } - - const result = await deps.provider.testModel( - requestedModel || undefined, - requestedBaseUrl || undefined, - ); - res.json({ - success: true, - ...result, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.post( - '/admin/generate-system-prompt', - withRoute( - 'POST /admin/generate-system-prompt', - 'Failed to generate system prompt', - async (req, res) => { - const { description, model: requestedModel, capabilities } = req.body; - if ( - typeof description !== 'string' || - description.trim().length === 0 - ) { - throw new InputError('description must be a non-empty string'); - } - if (description.length > MAX_DESCRIPTION_LENGTH) { - throw new InputError( - `description must be at most ${MAX_DESCRIPTION_LENGTH} characters`, - ); - } - if ( - requestedModel !== undefined && - typeof requestedModel !== 'string' - ) { - throw new InputError('model must be a string'); - } - if ( - capabilities !== undefined && - (typeof capabilities !== 'object' || capabilities === null) - ) { - throw new InputError('capabilities must be an object'); - } - - if (!deps.provider.generateSystemPrompt) { - res.status(501).json({ - success: false, - error: 'System prompt generation not supported by current provider', - }); - return; - } - - const prompt = await deps.provider.generateSystemPrompt( - description.trim(), - requestedModel || undefined, - capabilities || undefined, - ); - - res.json({ - success: true, - prompt, - timestamp: new Date().toISOString(), - }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/adminRouteTypes.ts b/workspaces/augment/plugins/augment-backend/src/routes/adminRouteTypes.ts deleted file mode 100644 index 43c0bea292c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/adminRouteTypes.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type express from 'express'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { AgenticProvider } from '../providers'; -import type { ProviderManager } from '../providers'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import type { ChatSessionService } from '../services/ChatSessionService'; - -/** - * Shared dependencies for admin sub-route modules. - * Passed to each registerAdminXxxRoutes function. - */ -export interface AdminRouteDeps { - router: express.Router; - logger: LoggerService; - config: import('@backstage/config').Config; - provider: AgenticProvider; - /** Orchestration fallback provider (LlamaStack) when primary is Kagenti */ - orchestrationProvider?: AgenticProvider; - adminConfig: AdminConfigService; - /** Provider manager for hot-swap operations */ - providerManager?: ProviderManager; - sendRouteError: ( - res: express.Response, - error: unknown, - logLabel: string, - userFacingError: string, - extra?: Record, - statusCode?: number, - ) => void; - getUserRef: (req: express.Request) => Promise; - requireAdminAccess: express.RequestHandler; - onConfigChanged?: () => void; - /** For admin session routes */ - sessions?: ChatSessionService; - missingSessions: (res: express.Response) => boolean; - missingConversations: (res: express.Response) => boolean; -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/adminRoutes.test.ts b/workspaces/augment/plugins/augment-backend/src/routes/adminRoutes.test.ts deleted file mode 100644 index 5790c6e092f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/adminRoutes.test.ts +++ /dev/null @@ -1,1793 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { mockServices } from '@backstage/backend-test-utils'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import express from 'express'; -import request from 'supertest'; -import knex, { type Knex } from 'knex'; -import { createRouter } from '../router'; -import type { AgenticProvider, AgenticProviderStatus } from '../providers'; -import { ProviderManager } from '../providers'; -import { AdminConfigService } from '../services/AdminConfigService'; - -function createApp( - db: Knex, - overrides?: { - permissionResult?: AuthorizeResult; - providerPatch?: Partial; - }, -) { - const permResult = overrides?.permissionResult ?? AuthorizeResult.ALLOW; - const mockStatus: AgenticProviderStatus = { - provider: { - id: 'llama-stack', - model: 'test-model', - baseUrl: 'https://llama-stack.test', - connected: true, - }, - vectorStore: { id: 'vs_test', connected: true, totalDocuments: 3 }, - mcpServers: [], - securityMode: 'plugin-only', - timestamp: new Date().toISOString(), - ready: true, - configurationErrors: [], - }; - - const mockProvider: Partial = { - id: 'llamastack', - displayName: 'Llama Stack', - getStatus: jest.fn().mockResolvedValue(mockStatus), - chat: jest.fn().mockResolvedValue({ role: 'assistant', content: 'hi' }), - chatStream: jest.fn(), - rag: { - listDocuments: jest.fn().mockResolvedValue([]), - listVectorStores: jest.fn().mockResolvedValue([]), - getDefaultVectorStoreId: jest.fn().mockReturnValue('vs_test'), - syncDocuments: jest.fn().mockResolvedValue({ - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [], - }), - uploadDocument: jest.fn().mockResolvedValue({ - fileId: 'file-1', - fileName: 'test.txt', - status: 'completed', - }), - deleteDocument: jest.fn().mockResolvedValue({ success: true }), - searchVectorStore: jest.fn().mockResolvedValue({ - query: 'test query', - chunks: [ - { - text: 'Hello world chunk', - score: 0.95, - fileId: 'file-1', - fileName: 'test.txt', - }, - ], - vectorStoreId: 'vs_test', - totalResults: 1, - }), - getVectorStoreConfig: jest.fn().mockResolvedValue({ - vectorStoreName: 'test-db', - embeddingModel: 'sentence-transformers/all-MiniLM-L6-v2', - embeddingDimension: 384, - chunkingStrategy: 'static', - maxChunkSizeTokens: 200, - chunkOverlapTokens: 50, - }), - createVectorStoreWithConfig: jest.fn().mockResolvedValue({ - vectorStoreId: 'vs_new', - vectorStoreName: 'test-db', - created: true, - embeddingModel: 'sentence-transformers/all-MiniLM-L6-v2', - embeddingDimension: 384, - }), - getVectorStoreStatus: jest.fn().mockResolvedValue({ - exists: true, - ready: true, - vectorStoreId: 'vs_test', - vectorStoreName: 'test-db', - documentCount: 3, - embeddingModel: 'sentence-transformers/all-MiniLM-L6-v2', - }), - getActiveVectorStoreIds: jest.fn().mockReturnValue(['vs_test']), - addVectorStoreId: jest.fn(), - removeVectorStoreId: jest.fn(), - deleteVectorStore: jest - .fn() - .mockResolvedValue({ success: true, filesDeleted: 2 }), - generateAnswer: jest.fn().mockResolvedValue({ - query: 'test question', - answer: 'Generated answer from chunks', - chunks: [ - { - text: 'Hello world chunk', - score: 0.95, - fileId: 'file-1', - fileName: 'test.txt', - }, - ], - model: 'test-model', - totalResults: 1, - }), - }, - ...overrides?.providerPatch, - }; - - const mockHttpAuth = { - credentials: jest.fn().mockResolvedValue({ - principal: { - type: 'user', - userEntityRef: 'user:default/admin', - }, - $$type: '@backstage/BackstageCredentials', - expiresAt: undefined, - }), - issueUserCookie: jest.fn().mockResolvedValue(undefined), - }; - - const mockPermissions = { - authorize: jest.fn().mockResolvedValue([{ result: permResult }]), - authorizeConditional: jest.fn().mockResolvedValue([{ result: permResult }]), - }; - - const mockConfig = mockServices.rootConfig({ - data: { - augment: { - branding: { appName: 'Test', tagline: 'Test' }, - security: { - mode: 'plugin-only', - adminUsers: ['user:default/admin'], - }, - llamaStack: { - vectorStoreName: 'test-db', - embeddingModel: 'sentence-transformers/all-MiniLM-L6-v2', - embeddingDimension: 384, - chunkingStrategy: 'static', - maxChunkSizeTokens: 200, - chunkOverlapTokens: 50, - }, - }, - }, - }); - - const mockDatabase = { - getClient: jest.fn().mockResolvedValue(db), - }; - - return { - create: async () => { - const dbService = mockDatabase as unknown as Parameters< - typeof createRouter - >[0]['database']; - const adminConfig = new AdminConfigService( - dbService, - mockServices.logger.mock(), - ); - await adminConfig.initialize(); - - const typedProvider = mockProvider as unknown as AgenticProvider; - const providerManager = new ProviderManager( - typedProvider, - () => typedProvider, - mockServices.logger.mock(), - ); - - const router = await createRouter({ - logger: mockServices.logger.mock(), - config: mockConfig, - httpAuth: mockHttpAuth as unknown as Parameters< - typeof createRouter - >[0]['httpAuth'], - permissions: mockPermissions as unknown as Parameters< - typeof createRouter - >[0]['permissions'], - database: dbService, - providerManager, - adminConfig, - }); - const app = express(); - app.use(router); - return app; - }, - provider: mockProvider, - }; -} - -describe('Admin routes', () => { - let db: Knex; - - beforeEach(() => { - db = knex({ - client: 'better-sqlite3', - connection: ':memory:', - useNullAsDefault: true, - }); - }); - - afterEach(async () => { - await db.destroy(); - }); - - // --------------------------------------------------------------------------- - // Permission enforcement - // --------------------------------------------------------------------------- - - describe('permission enforcement', () => { - it('returns 403 for non-admin users on admin routes', async () => { - const { create } = createApp(db, { - permissionResult: AuthorizeResult.DENY, - }); - const app = await create(); - - const res = await request(app).get('/admin/config'); - expect(res.status).toBe(403); - }); - }); - - // --------------------------------------------------------------------------- - // Config CRUD - // --------------------------------------------------------------------------- - - describe('GET /admin/config', () => { - it('returns empty list initially', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app).get('/admin/config'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.entries).toEqual([]); - }); - }); - - describe('PUT /admin/config/:key', () => { - it('sets a config value', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .put('/admin/config/promptGroups') - .set('X-Backstage-Request', 'augment') - .send({ value: [{ id: 'lane-1', title: 'Lane 1', cards: [] }] }); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.configKey).toBe('promptGroups'); - }); - - it('rejects invalid keys with 400', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .put('/admin/config/invalid') - .set('X-Backstage-Request', 'augment') - .send({ value: 'test' }); - - expect(res.status).toBe(400); - }); - - it('rejects missing value field with 400', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .put('/admin/config/promptGroups') - .set('X-Backstage-Request', 'augment') - .send({}); - - expect(res.status).toBe(400); - }); - }); - - describe('GET /admin/config/:key', () => { - it('returns null for unset keys', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app).get('/admin/config/promptGroups'); - expect(res.status).toBe(200); - expect(res.body.entry).toBeNull(); - expect(res.body.source).toBe('default'); - }); - - it('returns stored value after set', async () => { - const { create } = createApp(db); - const app = await create(); - - await request(app) - .put('/admin/config/systemPrompt') - .set('X-Backstage-Request', 'augment') - .send({ value: 'Be helpful' }); - - const res = await request(app).get('/admin/config/systemPrompt'); - expect(res.status).toBe(200); - expect(res.body.source).toBe('database'); - expect(res.body.entry.configValue).toBe('Be helpful'); - expect(res.body.entry.updatedBy).toBe('user:default/admin'); - }); - - it('rejects invalid keys', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app).get('/admin/config/invalid'); - expect(res.status).toBe(400); - }); - }); - - describe('DELETE /admin/config/:key', () => { - it('deletes an existing entry', async () => { - const { create } = createApp(db); - const app = await create(); - - await request(app) - .put('/admin/config/branding') - .set('X-Backstage-Request', 'augment') - .send({ value: { appName: 'MyApp' } }); - - const res = await request(app) - .delete('/admin/config/branding') - .set('X-Backstage-Request', 'augment'); - expect(res.status).toBe(200); - expect(res.body.deleted).toBe(true); - - const check = await request(app).get('/admin/config/branding'); - expect(check.body.entry).toBeNull(); - }); - - it('returns deleted=false for missing keys', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .delete('/admin/config/branding') - .set('X-Backstage-Request', 'augment'); - expect(res.status).toBe(200); - expect(res.body.deleted).toBe(false); - }); - }); - - // --------------------------------------------------------------------------- - // Document Upload - // --------------------------------------------------------------------------- - - describe('POST /admin/documents', () => { - it('uploads a file successfully', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/documents') - .set('X-Backstage-Request', 'augment') - .attach('file', Buffer.from('test content'), 'doc.txt'); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.fileId).toBe('file-1'); - expect(res.body.fileName).toBe('test.txt'); - }); - - it('rejects unsupported file types', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/documents') - .set('X-Backstage-Request', 'augment') - .attach('file', Buffer.from('data'), 'script.exe'); - - expect(res.status).toBe(400); - }); - - it('rejects requests without a file', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/documents') - .set('X-Backstage-Request', 'augment') - .send({}); - - expect(res.status).toBe(400); - }); - }); - - describe('returns 501 when RAG feature not supported', () => { - const ragUnsupportedPatch = { - rag: { - listDocuments: jest.fn().mockResolvedValue([]), - listVectorStores: jest.fn().mockResolvedValue([]), - getDefaultVectorStoreId: jest.fn().mockReturnValue('vs_test'), - getActiveVectorStoreIds: jest.fn().mockReturnValue(['vs_test']), - syncDocuments: jest.fn().mockResolvedValue({ - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [], - }), - }, - }; - - it.each([ - { - desc: 'upload not supported', - makeRequest: (a: express.Express) => - request(a) - .post('/admin/documents') - .set('X-Backstage-Request', 'augment') - .attach('file', Buffer.from('test'), 'doc.txt'), - }, - { - desc: 'delete not supported', - makeRequest: (a: express.Express) => - request(a) - .delete('/admin/documents/file-1') - .set('X-Backstage-Request', 'augment'), - }, - { - desc: 'search not supported', - makeRequest: (a: express.Express) => - request(a) - .post('/admin/rag-test') - .set('X-Backstage-Request', 'augment') - .send({ query: 'test' }), - }, - { - desc: 'generate not supported', - makeRequest: (a: express.Express) => - request(a) - .post('/admin/rag-generate') - .set('X-Backstage-Request', 'augment') - .send({ query: 'test' }), - }, - { - desc: 'creation not supported', - makeRequest: (a: express.Express) => - request(a) - .post('/admin/vector-store/create') - .set('X-Backstage-Request', 'augment'), - }, - { - desc: 'status not supported', - makeRequest: (a: express.Express) => - request(a).get('/admin/vector-store/status'), - }, - ])('returns 501 when $desc', async ({ makeRequest }) => { - const { create } = createApp(db, { providerPatch: ragUnsupportedPatch }); - const app = await create(); - const res = await makeRequest(app); - expect(res.status).toBe(501); - }); - }); - - // --------------------------------------------------------------------------- - // Document Delete - // --------------------------------------------------------------------------- - - describe('DELETE /admin/documents/:id', () => { - it('deletes a document', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .delete('/admin/documents/file-1') - .set('X-Backstage-Request', 'augment'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.documentId).toBe('file-1'); - }); - }); - - // --------------------------------------------------------------------------- - // RAG Test - // --------------------------------------------------------------------------- - - describe('POST /admin/rag-test', () => { - it('searches the vector store', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/rag-test') - .set('X-Backstage-Request', 'augment') - .send({ query: 'hello world' }); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.chunks).toHaveLength(1); - expect(res.body.chunks[0].text).toBe('Hello world chunk'); - expect(res.body.vectorStoreId).toBe('vs_test'); - }); - - it('rejects empty query', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/rag-test') - .set('X-Backstage-Request', 'augment') - .send({ query: '' }); - - expect(res.status).toBe(400); - }); - - it('rejects missing query', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/rag-test') - .set('X-Backstage-Request', 'augment') - .send({}); - - expect(res.status).toBe(400); - }); - - it('rejects query exceeding max length', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/rag-test') - .set('X-Backstage-Request', 'augment') - .send({ query: 'x'.repeat(2001) }); - - expect(res.status).toBe(400); - }); - - it('rejects invalid maxResults', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/rag-test') - .set('X-Backstage-Request', 'augment') - .send({ query: 'test', maxResults: 0 }); - - expect(res.status).toBe(400); - }); - }); - - // --------------------------------------------------------------------------- - // RAG Generate (end-to-end) - // --------------------------------------------------------------------------- - - describe('POST /admin/rag-generate', () => { - it('generates an answer from retrieved chunks', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/rag-generate') - .set('X-Backstage-Request', 'augment') - .send({ query: 'What is testing?' }); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.answer).toBe('Generated answer from chunks'); - expect(res.body.model).toBe('test-model'); - expect(res.body.chunks).toHaveLength(1); - expect(res.body.totalResults).toBe(1); - expect(res.body.timestamp).toBeDefined(); - }); - - it('rejects empty query', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/rag-generate') - .set('X-Backstage-Request', 'augment') - .send({ query: '' }); - - expect(res.status).toBe(400); - }); - - it('rejects missing query', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/rag-generate') - .set('X-Backstage-Request', 'augment') - .send({}); - - expect(res.status).toBe(400); - }); - - it('rejects query exceeding max length', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/rag-generate') - .set('X-Backstage-Request', 'augment') - .send({ query: 'x'.repeat(2001) }); - - expect(res.status).toBe(400); - }); - - it('rejects invalid maxResults', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/rag-generate') - .set('X-Backstage-Request', 'augment') - .send({ query: 'test', maxResults: 0 }); - - expect(res.status).toBe(400); - }); - - it('returns 501 when generateAnswer is not available', async () => { - const { create } = createApp(db, { - providerPatch: { - rag: { - listDocuments: jest.fn().mockResolvedValue([]), - listVectorStores: jest.fn().mockResolvedValue([]), - getDefaultVectorStoreId: jest.fn().mockReturnValue('vs_test'), - getActiveVectorStoreIds: jest.fn().mockReturnValue(['vs_test']), - syncDocuments: jest.fn().mockResolvedValue({ - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [], - }), - searchVectorStore: jest.fn().mockResolvedValue({ - query: 'test', - chunks: [], - vectorStoreId: 'vs_test', - totalResults: 0, - }), - }, - }, - }); - const app = await create(); - - const res = await request(app) - .post('/admin/rag-generate') - .set('X-Backstage-Request', 'augment') - .send({ query: 'test' }); - - expect(res.status).toBe(501); - expect(res.body.error).toContain('not supported'); - }); - }); - - // --------------------------------------------------------------------------- - // Vector Store Config - // --------------------------------------------------------------------------- - - describe('GET /admin/vector-store-config', () => { - it('returns effective config from YAML defaults', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app).get('/admin/vector-store-config'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.source).toBe('yaml'); - expect(res.body.config.vectorStoreName).toBe('test-db'); - expect(res.body.config.embeddingModel).toBe( - 'sentence-transformers/all-MiniLM-L6-v2', - ); - expect(res.body.config.embeddingDimension).toBe(384); - }); - - it('merges DB overrides with YAML', async () => { - const { create } = createApp(db, { - providerPatch: { - rag: { - listDocuments: jest.fn().mockResolvedValue([]), - listVectorStores: jest.fn().mockResolvedValue([]), - getDefaultVectorStoreId: jest.fn().mockReturnValue('vs_test'), - getActiveVectorStoreIds: jest.fn().mockReturnValue(['vs_test']), - syncDocuments: jest.fn().mockResolvedValue({ - added: 0, - updated: 0, - removed: 0, - failed: 0, - unchanged: 0, - errors: [], - }), - getVectorStoreConfig: jest.fn().mockResolvedValue({ - vectorStoreName: 'custom-db', - embeddingModel: 'sentence-transformers/all-MiniLM-L6-v2', - embeddingDimension: 768, - chunkingStrategy: 'static', - maxChunkSizeTokens: 200, - chunkOverlapTokens: 50, - }), - }, - }, - }); - const app = await create(); - - // Save a DB override so the route reports source: 'merged' - await request(app) - .put('/admin/config/vectorStoreConfig') - .set('X-Backstage-Request', 'augment') - .send({ - value: { - vectorStoreName: 'custom-db', - embeddingDimension: 768, - }, - }); - - const res = await request(app).get('/admin/vector-store-config'); - expect(res.status).toBe(200); - expect(res.body.source).toBe('merged'); - expect(res.body.config.vectorStoreName).toBe('custom-db'); - expect(res.body.config.embeddingDimension).toBe(768); - expect(res.body.config.embeddingModel).toBe( - 'sentence-transformers/all-MiniLM-L6-v2', - ); - }); - - it('replaces embeddingModel when config model is not on server', async () => { - const { create } = createApp(db, { - providerPatch: { - listModels: jest.fn().mockResolvedValue([ - { id: 'nomic-embed-v1.5', model_type: 'embedding' }, - { id: 'granite-llm', model_type: 'llm' }, - ]), - }, - }); - const app = await create(); - - const res = await request(app).get('/admin/vector-store-config'); - expect(res.status).toBe(200); - expect(res.body.config.embeddingModel).toBe('nomic-embed-v1.5'); - }); - - it('keeps embeddingModel when it exists on server', async () => { - const { create } = createApp(db, { - providerPatch: { - listModels: jest.fn().mockResolvedValue([ - { - id: 'sentence-transformers/all-MiniLM-L6-v2', - model_type: 'embedding', - }, - { id: 'other-embed', model_type: 'embedding' }, - ]), - }, - }); - const app = await create(); - - const res = await request(app).get('/admin/vector-store-config'); - expect(res.status).toBe(200); - expect(res.body.config.embeddingModel).toBe( - 'sentence-transformers/all-MiniLM-L6-v2', - ); - }); - - it('keeps embeddingModel unchanged when listModels throws', async () => { - const { create } = createApp(db, { - providerPatch: { - listModels: jest.fn().mockRejectedValue(new Error('Server down')), - }, - }); - const app = await create(); - - const res = await request(app).get('/admin/vector-store-config'); - expect(res.status).toBe(200); - expect(res.body.config.embeddingModel).toBe( - 'sentence-transformers/all-MiniLM-L6-v2', - ); - }); - }); - - // --------------------------------------------------------------------------- - // Vector Store Create - // --------------------------------------------------------------------------- - - describe('POST /admin/vector-store/create', () => { - it('creates a vector store', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/vector-store/create') - .set('X-Backstage-Request', 'augment'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.vectorStoreId).toBe('vs_new'); - expect(res.body.created).toBe(true); - }); - }); - - // --------------------------------------------------------------------------- - // Vector Store Status - // --------------------------------------------------------------------------- - - describe('GET /admin/vector-store/status', () => { - it('returns vector store status', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app).get('/admin/vector-store/status'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.exists).toBe(true); - expect(res.body.ready).toBe(true); - expect(res.body.vectorStoreId).toBe('vs_test'); - expect(res.body.documentCount).toBe(3); - }); - }); - - // --------------------------------------------------------------------------- - // Duplicate Document Prevention - // --------------------------------------------------------------------------- - - describe('POST /admin/documents (duplicate prevention)', () => { - it('returns 409 when a file with the same name exists', async () => { - const { create, provider } = createApp(db); - (provider.rag!.listDocuments as jest.Mock).mockResolvedValue([ - { - id: 'file-existing', - fileName: 'readme.md', - format: 'md', - fileSize: 100, - status: 'completed', - }, - ]); - const app = await create(); - - const res = await request(app) - .post('/admin/documents') - .set('X-Backstage-Request', 'augment') - .attach('file', Buffer.from('# Hello'), 'readme.md'); - - expect(res.status).toBe(409); - expect(res.body.success).toBe(false); - expect(res.body.error).toContain('already exists'); - expect(res.body.existingFile.id).toBe('file-existing'); - }); - - it('replaces file when ?replace=true is set', async () => { - const { create, provider } = createApp(db); - (provider.rag!.listDocuments as jest.Mock).mockResolvedValue([ - { - id: 'file-old', - fileName: 'readme.md', - format: 'md', - fileSize: 100, - status: 'completed', - }, - ]); - const app = await create(); - - const res = await request(app) - .post('/admin/documents?replace=true') - .set('X-Backstage-Request', 'augment') - .attach('file', Buffer.from('# Updated'), 'readme.md'); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.replaced).toBe(true); - expect(provider.rag!.deleteDocument).toHaveBeenCalledWith( - 'file-old', - undefined, - ); - }); - - it('allows upload when no duplicate exists', async () => { - const { create, provider } = createApp(db); - (provider.rag!.listDocuments as jest.Mock).mockResolvedValue([]); - const app = await create(); - - const res = await request(app) - .post('/admin/documents') - .set('X-Backstage-Request', 'augment') - .attach('file', Buffer.from('hello'), 'newfile.txt'); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.replaced).toBe(false); - expect(provider.rag!.uploadDocument).toHaveBeenCalledWith( - 'newfile.txt', - expect.any(Buffer), - undefined, - ); - }); - }); - - // --------------------------------------------------------------------------- - // Multi-Vector-Store Management - // --------------------------------------------------------------------------- - - describe('GET /admin/vector-stores', () => { - it('returns active vector stores', async () => { - const { create, provider } = createApp(db); - (provider.rag!.listVectorStores as jest.Mock).mockResolvedValue([ - { - id: 'vs_test', - name: 'test-db', - status: 'completed', - fileCount: 3, - createdAt: 1700000000, - }, - ]); - const app = await create(); - - const res = await request(app).get('/admin/vector-stores'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.stores).toHaveLength(1); - expect(res.body.stores[0].id).toBe('vs_test'); - expect(res.body.stores[0].active).toBe(true); - }); - - it('returns empty array when RAG is not available', async () => { - const { create } = createApp(db, { - providerPatch: { rag: undefined }, - }); - const app = await create(); - - const res = await request(app).get('/admin/vector-stores'); - expect(res.status).toBe(200); - expect(res.body.stores).toEqual([]); - }); - - it('returns unconnected stores not in active list', async () => { - const { create, provider } = createApp(db); - (provider.rag!.listVectorStores as jest.Mock).mockResolvedValue([ - { - id: 'vs_test', - name: 'test-db', - status: 'completed', - fileCount: 3, - createdAt: 1700000000, - }, - { - id: 'vs_other', - name: 'other-db', - status: 'completed', - fileCount: 0, - createdAt: 1700002000, - }, - ]); - const app = await create(); - - const res = await request(app).get('/admin/vector-stores'); - expect(res.status).toBe(200); - expect(res.body.stores).toHaveLength(1); - expect(res.body.stores[0].id).toBe('vs_test'); - expect(res.body.unconnected).toHaveLength(1); - expect(res.body.unconnected[0].id).toBe('vs_other'); - expect(res.body.unconnected[0].active).toBe(false); - }); - }); - - describe('POST /admin/vector-stores/connect', () => { - it('connects an existing vector store after validating on server', async () => { - const { create, provider } = createApp(db); - (provider.rag!.listVectorStores as jest.Mock).mockResolvedValueOnce([ - { - id: 'vs_new_store', - name: 'new-store', - status: 'completed', - fileCount: 0, - createdAt: 0, - }, - ]); - const app = await create(); - - const res = await request(app) - .post('/admin/vector-stores/connect') - .set('X-Backstage-Request', 'augment') - .send({ vectorStoreId: 'vs_new_store' }); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(provider.rag!.addVectorStoreId).toHaveBeenCalledWith( - 'vs_new_store', - ); - }); - - it('returns 404 when vector store does not exist on server', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/vector-stores/connect') - .set('X-Backstage-Request', 'augment') - .send({ vectorStoreId: 'vs_nonexistent' }); - - expect(res.status).toBe(404); - expect(res.body.error).toContain('not found on the server'); - }); - - it('returns 400 when vectorStoreId is missing', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/vector-stores/connect') - .set('X-Backstage-Request', 'augment') - .send({}); - - expect(res.status).toBe(400); - }); - - it('returns 409 when vectorStoreId is already connected', async () => { - const { create, provider } = createApp(db); - (provider.rag!.listVectorStores as jest.Mock).mockResolvedValueOnce([ - { - id: 'vs_test', - name: 'test-db', - status: 'completed', - fileCount: 0, - createdAt: 0, - }, - ]); - const app = await create(); - - const res = await request(app) - .post('/admin/vector-stores/connect') - .set('X-Backstage-Request', 'augment') - .send({ vectorStoreId: 'vs_test' }); - - expect(res.status).toBe(409); - expect(res.body.success).toBe(false); - expect(res.body.error).toContain('already connected'); - }); - }); - - describe('DELETE /admin/vector-stores/:id', () => { - it('removes a vector store from active list (disconnect)', async () => { - const { create, provider } = createApp(db); - const app = await create(); - - const res = await request(app) - .delete('/admin/vector-stores/vs_test') - .set('X-Backstage-Request', 'augment'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.removed).toBe('vs_test'); - expect(res.body.permanent).toBe(false); - expect(provider.rag!.removeVectorStoreId).toHaveBeenCalledWith('vs_test'); - expect(provider.rag!.deleteVectorStore).not.toHaveBeenCalled(); - }); - - it('permanently deletes a vector store and files with ?permanent=true', async () => { - const { create, provider } = createApp(db); - const app = await create(); - - const res = await request(app) - .delete('/admin/vector-stores/vs_test?permanent=true') - .set('X-Backstage-Request', 'augment'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.removed).toBe('vs_test'); - expect(res.body.permanent).toBe(true); - expect(res.body.filesDeleted).toBe(2); - expect(provider.rag!.deleteVectorStore).toHaveBeenCalledWith('vs_test'); - }); - - it('still removes from active list when deleteVectorStore throws', async () => { - const { create, provider } = createApp(db); - (provider.rag!.deleteVectorStore as jest.Mock).mockRejectedValueOnce( - new Error('Server exploded'), - ); - const app = await create(); - - const res = await request(app) - .delete('/admin/vector-stores/vs_test?permanent=true') - .set('X-Backstage-Request', 'augment'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.removed).toBe('vs_test'); - expect(res.body.filesDeleted).toBe(0); - expect(provider.rag!.removeVectorStoreId).toHaveBeenCalledWith('vs_test'); - }); - - it('returns 501 when RAG is not available', async () => { - const { create } = createApp(db, { - providerPatch: { rag: undefined } as any, - }); - const app = await create(); - - const res = await request(app) - .delete('/admin/vector-stores/vs_test') - .set('X-Backstage-Request', 'augment'); - expect(res.status).toBe(501); - expect(res.body.error).toMatch(/RAG not available/); - }); - }); - - // --------------------------------------------------------------------------- - // Effective Config - // --------------------------------------------------------------------------- - - describe('GET /admin/effective-config', () => { - it('returns YAML-only fallback when provider has no getEffectiveConfig', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app).get('/admin/effective-config'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.config).toBeDefined(); - expect(res.body.timestamp).toBeDefined(); - }); - - it('returns provider effective config when getEffectiveConfig is implemented', async () => { - const mockEffective = { - model: 'meta-llama/Llama-3.3-8B-Instruct', - baseUrl: 'https://localhost:8321', - systemPrompt: 'You are helpful', - toolChoice: 'auto', - enableWebSearch: false, - enableCodeInterpreter: false, - safetyEnabled: true, - inputShields: ['shield-1'], - outputShields: [], - }; - - const { create } = createApp(db, { - providerPatch: { - getEffectiveConfig: jest.fn().mockResolvedValue(mockEffective), - }, - }); - const app = await create(); - - const res = await request(app).get('/admin/effective-config'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.config.model).toBe('meta-llama/Llama-3.3-8B-Instruct'); - expect(res.body.config.baseUrl).toBe('https://localhost:8321'); - expect(res.body.config.safetyEnabled).toBe(true); - expect(res.body.config.inputShields).toEqual(['shield-1']); - }); - - it('strips sensitive fields from the response', async () => { - const mockEffective = { - model: 'test-model', - baseUrl: 'https://localhost:8321', - token: 'secret-api-key', - skipTlsVerify: true, - }; - - const { create } = createApp(db, { - providerPatch: { - getEffectiveConfig: jest.fn().mockResolvedValue(mockEffective), - }, - }); - const app = await create(); - - const res = await request(app).get('/admin/effective-config'); - expect(res.status).toBe(200); - expect(res.body.config.token).toBeUndefined(); - expect(res.body.config.skipTlsVerify).toBeUndefined(); - expect(res.body.config.model).toBe('test-model'); - }); - }); - - // --------------------------------------------------------------------------- - // List Available Models - // --------------------------------------------------------------------------- - - describe('GET /admin/models', () => { - it('returns 501 when provider does not support listModels', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app).get('/admin/models'); - expect(res.status).toBe(501); - expect(res.body.success).toBe(false); - }); - - it('returns models from provider', async () => { - const { create } = createApp(db, { - providerPatch: { - listModels: jest - .fn() - .mockResolvedValue([ - { id: 'meta-llama/Llama-3.3-8B-Instruct', owned_by: 'meta' }, - { id: 'qwen3:14b-q8_0' }, - ]), - }, - }); - const app = await create(); - - const res = await request(app).get('/admin/models'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.models).toHaveLength(2); - expect(res.body.models[0].id).toBe('meta-llama/Llama-3.3-8B-Instruct'); - expect(res.body.models[0].owned_by).toBe('meta'); - expect(res.body.models[1].id).toBe('qwen3:14b-q8_0'); - }); - - it('returns 200 with empty models when provider throws', async () => { - const { create } = createApp(db, { - providerPatch: { - listModels: jest - .fn() - .mockRejectedValue(new Error('Connection refused')), - }, - }); - const app = await create(); - - const res = await request(app).get('/admin/models'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.models).toEqual([]); - }); - }); - - // --------------------------------------------------------------------------- - // Generate System Prompt - // --------------------------------------------------------------------------- - - describe('POST /admin/generate-system-prompt', () => { - it('rejects empty description', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/generate-system-prompt') - .set('X-Backstage-Request', 'augment') - .send({ description: '' }); - - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - }); - - it('rejects description exceeding 2000 characters', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/generate-system-prompt') - .set('X-Backstage-Request', 'augment') - .send({ description: 'x'.repeat(2001) }); - - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - }); - - it('returns 501 when provider does not support generation', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .post('/admin/generate-system-prompt') - .set('X-Backstage-Request', 'augment') - .send({ description: 'Help with Kubernetes' }); - - expect(res.status).toBe(501); - expect(res.body.success).toBe(false); - }); - - it('returns generated prompt from provider', async () => { - const { create } = createApp(db, { - providerPatch: { - generateSystemPrompt: jest - .fn() - .mockResolvedValue('You are a helpful Kubernetes assistant.'), - }, - }); - const app = await create(); - - const res = await request(app) - .post('/admin/generate-system-prompt') - .set('X-Backstage-Request', 'augment') - .send({ description: 'Help with Kubernetes deployments' }); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.prompt).toBe('You are a helpful Kubernetes assistant.'); - expect(res.body.timestamp).toBeDefined(); - }); - - it('returns 500 when provider throws', async () => { - const { create } = createApp(db, { - providerPatch: { - generateSystemPrompt: jest - .fn() - .mockRejectedValue(new Error('LLM unreachable')), - }, - }); - const app = await create(); - - const res = await request(app) - .post('/admin/generate-system-prompt') - .set('X-Backstage-Request', 'augment') - .send({ description: 'Help me' }); - - expect(res.status).toBe(500); - expect(res.body.success).toBe(false); - }); - }); - - // =========================================================================== - // Step 4 Integration Tests: Validation, Branding Merge, Cache Invalidation - // =========================================================================== - - describe('PUT /admin/config/:key validation', () => { - it('rejects invalid baseUrl', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .put('/admin/config/baseUrl') - .set('X-Backstage-Request', 'augment') - .send({ value: 'not-a-url' }); - - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - }); - - it('accepts valid baseUrl', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .put('/admin/config/baseUrl') - .set('X-Backstage-Request', 'augment') - .send({ value: 'https://llama.example.com' }); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - }); - - it('rejects empty model string', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .put('/admin/config/model') - .set('X-Backstage-Request', 'augment') - .send({ value: ' ' }); - - expect(res.status).toBe(400); - }); - - it('rejects non-boolean enableWebSearch', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .put('/admin/config/enableWebSearch') - .set('X-Backstage-Request', 'augment') - .send({ value: 'yes' }); - - expect(res.status).toBe(400); - }); - - it('rejects invalid toolChoice', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .put('/admin/config/toolChoice') - .set('X-Backstage-Request', 'augment') - .send({ value: 'invalid' }); - - expect(res.status).toBe(400); - }); - - it('accepts valid toolChoice string', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .put('/admin/config/toolChoice') - .set('X-Backstage-Request', 'augment') - .send({ value: 'auto' }); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - }); - }); - - describe('GET /branding — DB + YAML merge', () => { - it('returns YAML branding when no DB override exists', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app).get('/branding'); - expect(res.status).toBe(200); - expect(res.body.branding.appName).toBe('Test'); - expect(res.body.branding.tagline).toBe('Test'); - }); - - it('merges DB branding overrides on top of YAML', async () => { - const { create } = createApp(db); - const app = await create(); - - await request(app) - .put('/admin/config/branding') - .set('X-Backstage-Request', 'augment') - .send({ value: { appName: 'Custom App', primaryColor: '#ff0000' } }); - - const res = await request(app).get('/branding'); - expect(res.status).toBe(200); - expect(res.body.branding.appName).toBe('Custom App'); - expect(res.body.branding.primaryColor).toBe('#ff0000'); - // YAML baseline fields should still be present - expect(res.body.branding.tagline).toBe('Test'); - }); - - it('reverts to YAML after DB branding is deleted', async () => { - const { create } = createApp(db); - const app = await create(); - - await request(app) - .put('/admin/config/branding') - .set('X-Backstage-Request', 'augment') - .send({ value: { appName: 'Custom App' } }); - await request(app) - .delete('/admin/config/branding') - .set('X-Backstage-Request', 'augment'); - - const res = await request(app).get('/branding'); - expect(res.status).toBe(200); - expect(res.body.branding.appName).toBe('Test'); - }); - }); - - describe('Cache invalidation callback', () => { - it('calls onConfigChanged when provider supports it', async () => { - const invalidateFn = jest.fn(); - const { create } = createApp(db, { - providerPatch: { - invalidateRuntimeConfig: invalidateFn, - }, - }); - const app = await create(); - - await request(app) - .put('/admin/config/systemPrompt') - .set('X-Backstage-Request', 'augment') - .send({ value: 'Updated prompt' }); - - expect(invalidateFn).toHaveBeenCalledTimes(1); - }); - - it('calls onConfigChanged on delete', async () => { - const invalidateFn = jest.fn(); - const { create } = createApp(db, { - providerPatch: { - invalidateRuntimeConfig: invalidateFn, - }, - }); - const app = await create(); - - await request(app) - .put('/admin/config/branding') - .set('X-Backstage-Request', 'augment') - .send({ value: { appName: 'X' } }); - invalidateFn.mockClear(); - - await request(app) - .delete('/admin/config/branding') - .set('X-Backstage-Request', 'augment'); - - expect(invalidateFn).toHaveBeenCalledTimes(1); - }); - }); - - // =========================================================================== - // Step 6: MCP servers via admin config - // =========================================================================== - - describe('PUT /admin/config/mcpServers', () => { - it('accepts valid MCP server configs', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .put('/admin/config/mcpServers') - .set('X-Backstage-Request', 'augment') - .send({ - value: [ - { - id: 'mcp-test', - name: 'Test MCP', - url: 'https://mcp.example.com/sse', - type: 'sse', - }, - ], - }); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - }); - - it('rejects MCP server with invalid URL', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .put('/admin/config/mcpServers') - .set('X-Backstage-Request', 'augment') - .send({ - value: [ - { id: 'bad', name: 'Bad MCP', url: 'not-a-url', type: 'sse' }, - ], - }); - - expect(res.status).toBe(400); - }); - - it('rejects MCP server missing required fields', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app) - .put('/admin/config/mcpServers') - .set('X-Backstage-Request', 'augment') - .send({ value: [{ url: 'https://mcp.test' }] }); - - expect(res.status).toBe(400); - }); - - it('persists and retrieves MCP server config', async () => { - const { create } = createApp(db); - const app = await create(); - - const servers = [ - { - id: 'mcp-new', - name: 'New MCP Server', - url: 'https://new-mcp.example.com/sse', - type: 'sse', - }, - ]; - - await request(app) - .put('/admin/config/mcpServers') - .set('X-Backstage-Request', 'augment') - .send({ value: servers }); - - const res = await request(app).get('/admin/config/mcpServers'); - expect(res.status).toBe(200); - expect(res.body.source).toBe('database'); - expect(res.body.entry.configValue).toEqual(servers); - }); - }); - - // =========================================================================== - // Runtime propagation: admin save → immediate effect on non-admin routes - // =========================================================================== - - describe('GET /prompt-groups — DB + YAML round-trip', () => { - it('returns YAML prompt groups when no DB override exists', async () => { - const { create } = createApp(db); - const app = await create(); - - const res = await request(app).get('/prompt-groups'); - expect(res.status).toBe(200); - expect(res.body.source).toBe('yaml'); - }); - - it('returns DB prompt groups after admin save', async () => { - const { create } = createApp(db); - const app = await create(); - - const lanes = [ - { - id: 'lane-1', - title: 'Admin Lane', - cards: [{ title: 'Card 1', prompt: 'Do something' }], - }, - ]; - - await request(app) - .put('/admin/config/promptGroups') - .set('X-Backstage-Request', 'augment') - .send({ value: lanes }); - - const res = await request(app).get('/prompt-groups'); - expect(res.status).toBe(200); - expect(res.body.source).toBe('database'); - expect(res.body.promptGroups).toEqual(lanes); - }); - - it('reverts to YAML after admin deletes prompt groups', async () => { - const { create } = createApp(db); - const app = await create(); - - const lanes = [ - { - id: 'lane-temp', - title: 'Temp Lane', - cards: [{ title: 'Card', prompt: 'test' }], - }, - ]; - - await request(app) - .put('/admin/config/promptGroups') - .set('X-Backstage-Request', 'augment') - .send({ value: lanes }); - - let res = await request(app).get('/prompt-groups'); - expect(res.body.source).toBe('database'); - - await request(app) - .delete('/admin/config/promptGroups') - .set('X-Backstage-Request', 'augment'); - - res = await request(app).get('/prompt-groups'); - expect(res.body.source).toBe('yaml'); - }); - }); - - describe('Config CRUD — all AdminConfigKey round-trips', () => { - it('safetyOnError: saves and retrieves', async () => { - const { create } = createApp(db); - const app = await create(); - - const putRes = await request(app) - .put('/admin/config/safetyOnError') - .set('X-Backstage-Request', 'augment') - .send({ value: 'block' }); - expect(putRes.status).toBe(200); - - const getRes = await request(app).get('/admin/config/safetyOnError'); - expect(getRes.body.source).toBe('database'); - expect(getRes.body.entry.configValue).toBe('block'); - }); - - it('evaluationOnError: saves and retrieves', async () => { - const { create } = createApp(db); - const app = await create(); - - const putRes = await request(app) - .put('/admin/config/evaluationOnError') - .set('X-Backstage-Request', 'augment') - .send({ value: 'fail' }); - expect(putRes.status).toBe(200); - - const getRes = await request(app).get('/admin/config/evaluationOnError'); - expect(getRes.body.source).toBe('database'); - expect(getRes.body.entry.configValue).toBe('fail'); - }); - - it('disabledMcpServerIds: saves and retrieves', async () => { - const { create } = createApp(db); - const app = await create(); - - const putRes = await request(app) - .put('/admin/config/disabledMcpServerIds') - .set('X-Backstage-Request', 'augment') - .send({ value: ['server-1', 'server-2'] }); - expect(putRes.status).toBe(200); - - const getRes = await request(app).get( - '/admin/config/disabledMcpServerIds', - ); - expect(getRes.body.source).toBe('database'); - expect(getRes.body.entry.configValue).toEqual(['server-1', 'server-2']); - }); - - it('multiple keys saved and retrieved independently', async () => { - const { create } = createApp(db); - const app = await create(); - - await request(app) - .put('/admin/config/model') - .set('X-Backstage-Request', 'augment') - .send({ value: 'test-model-v2' }); - await request(app) - .put('/admin/config/systemPrompt') - .set('X-Backstage-Request', 'augment') - .send({ value: 'Be concise.' }); - await request(app) - .put('/admin/config/safetyEnabled') - .set('X-Backstage-Request', 'augment') - .send({ value: true }); - await request(app) - .put('/admin/config/evaluationEnabled') - .set('X-Backstage-Request', 'augment') - .send({ value: true }); - await request(app) - .put('/admin/config/minScoreThreshold') - .set('X-Backstage-Request', 'augment') - .send({ value: 0.75 }); - - const modelRes = await request(app).get('/admin/config/model'); - expect(modelRes.body.entry.configValue).toBe('test-model-v2'); - expect(modelRes.body.source).toBe('database'); - - const promptRes = await request(app).get('/admin/config/systemPrompt'); - expect(promptRes.body.entry.configValue).toBe('Be concise.'); - - const safetyRes = await request(app).get('/admin/config/safetyEnabled'); - expect(safetyRes.body.entry.configValue).toBe(true); - - const evalRes = await request(app).get('/admin/config/evaluationEnabled'); - expect(evalRes.body.entry.configValue).toBe(true); - - const threshRes = await request(app).get( - '/admin/config/minScoreThreshold', - ); - expect(threshRes.body.entry.configValue).toBe(0.75); - - const listRes = await request(app).get('/admin/config'); - expect(listRes.body.entries).toHaveLength(5); - }); - }); - - describe('MCP test-connection SSRF protection', () => { - it.each([ - { - desc: 'localhost', - url: 'https://localhost:3000/mcp', - errorMatch: 'private/internal network', - }, - { - desc: '127.0.0.1', - url: 'https://127.0.0.1:8080/mcp', - errorMatch: 'private/internal network', - }, - { - desc: '10.x private network', - url: 'https://10.0.0.5:3000/mcp', - errorMatch: 'private/internal network', - }, - { - desc: '192.168.x private network', - url: 'https://192.168.1.1:3000/mcp', - errorMatch: 'private/internal network', - }, - { - desc: '172.16-31.x private network', - url: 'https://172.20.0.1:3000/mcp', - errorMatch: 'private/internal network', - }, - { - desc: 'link-local 169.254.x', - url: 'https://169.254.169.254/latest/meta-data/', - errorMatch: 'private/internal network', - }, - { desc: 'missing URL', url: undefined, errorMatch: 'URL is required' }, - { - desc: 'non-http/https protocol', - url: 'sftp://example.com/mcp', - errorMatch: 'protocol', - }, - ])('rejects $desc URLs', async ({ url, errorMatch }) => { - const { create } = createApp(db); - const app = await create(); - - const body: Record = { type: 'streamable-http' }; - if (url !== undefined) body.url = url; - - const res = await request(app) - .post('/admin/mcp/test-connection') - .set('X-Backstage-Request', 'augment') - .send(body); - - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - const errorText = res.body.message ?? res.body.error ?? ''; - expect(errorText).toContain(errorMatch); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/routes/adminRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/adminRoutes.ts deleted file mode 100644 index d4332f7054f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/adminRoutes.ts +++ /dev/null @@ -1,309 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { InputError } from '@backstage/errors'; -import { AdminConfigService } from '../services/AdminConfigService'; -import { McpTestService } from '../services/McpTestService'; -import { MAX_DESCRIPTION_LENGTH } from '../constants'; -import { createWithRoute } from './routeWrapper'; -import type { RouteContext } from './types'; -import type { AdminRouteDeps } from './adminRouteTypes'; -import { registerAdminConfigRoutes } from './adminConfigRoutes'; -import { registerAdminModelRoutes } from './adminModelRoutes'; -import { registerAdminDocumentRoutes } from './adminDocumentRoutes'; -import { registerAdminVectorStoreRoutes } from './adminVectorStoreRoutes'; -import { registerAdminSessionRoutes } from './adminSessionRoutes'; -import { registerProviderRoutes } from './providerRoutes'; -import { toErrorMessage } from '../services/utils'; -import { isPrivateUrlWithDns } from '../services/utils/SsrfGuard'; - -export type { AdminRouteDeps } from './adminRouteTypes'; - -/** - * Registers admin-only routes under /admin/*. - * All routes in this module are gated by the requireAdminAccess middleware. - * - * Endpoints: - * GET /admin/config/:key — Read a config entry - * PUT /admin/config/:key — Set / update a config entry - * DELETE /admin/config/:key — Delete a config entry (revert to YAML) - * GET /admin/config — List all admin config entries - * GET /admin/effective-config — Effective merged config for admin panel - * POST /admin/documents — Upload a document (multipart) - * DELETE /admin/documents/:id — Delete a document - * POST /admin/rag-test — Test a RAG query - * POST /admin/mcp/test-connection — Test MCP server connection - * GET /admin/vector-store-config — Effective vector store config (YAML + DB) - * POST /admin/vector-store/create — Create vector store with config - * GET /admin/vector-store/status — Vector store status - * GET /admin/vector-stores — List active vector stores - * POST /admin/vector-stores/connect — Connect a vector store - * DELETE /admin/vector-stores/:id — Remove/disconnect vector store - * GET /admin/models — List available models from inference server - * POST /admin/test-model — Test model connection - * POST /admin/generate-system-prompt — Generate system prompt from description - * GET /admin/sessions — List all sessions - * GET /admin/sessions/:sessionId/messages — Get session messages - */ -export function registerAdminRoutes( - ctx: RouteContext, - adminConfig: AdminConfigService, - onConfigChanged?: () => void, - providerManager?: import('../providers').ProviderManager, -): void { - const { - router, - logger, - config, - sessions, - sendRouteError, - getUserRef, - requireAdminAccess, - missingSessions, - missingConversations, - } = ctx; - - const withRoute = createWithRoute(logger, sendRouteError); - - router.use('/admin', requireAdminAccess); - - const deps: AdminRouteDeps = { - router, - logger, - config, - get provider() { - return ctx.provider; - }, - orchestrationProvider: ctx.orchestrationProvider, - adminConfig, - providerManager, - sendRouteError, - getUserRef, - requireAdminAccess, - onConfigChanged, - sessions, - missingSessions, - missingConversations, - }; - - registerProviderRoutes(router, deps); - registerAdminConfigRoutes(router, deps); - registerAdminModelRoutes(router, deps); - registerAdminDocumentRoutes(router, deps); - registerAdminVectorStoreRoutes(router, deps); - registerAdminSessionRoutes(router, deps); - - // --------------------------------------------------------------------------- - // RAG Test (kept inline — small) - // --------------------------------------------------------------------------- - - router.post( - '/admin/rag-test', - withRoute( - 'POST /admin/rag-test', - 'Failed to test RAG query', - async (req, res) => { - if (!ctx.provider.rag?.searchVectorStore) { - res.status(501).json({ - success: false, - error: 'RAG search not supported by current provider', - }); - return; - } - - const { query, maxResults, vectorStoreId, vectorStoreIds } = req.body; - if (typeof query !== 'string' || query.trim().length === 0) { - throw new InputError('query must be a non-empty string'); - } - if (query.length > MAX_DESCRIPTION_LENGTH) { - throw new InputError( - `query must be at most ${MAX_DESCRIPTION_LENGTH} characters`, - ); - } - if ( - maxResults !== undefined && - (typeof maxResults !== 'number' || maxResults < 1) - ) { - throw new InputError('maxResults must be a positive number'); - } - if ( - vectorStoreId !== undefined && - (typeof vectorStoreId !== 'string' || - vectorStoreId.trim().length === 0) - ) { - throw new InputError('vectorStoreId must be a non-empty string'); - } - if ( - vectorStoreIds !== undefined && - (!Array.isArray(vectorStoreIds) || - vectorStoreIds.some((id: unknown) => typeof id !== 'string')) - ) { - throw new InputError('vectorStoreIds must be an array of strings'); - } - - const result = await ctx.provider.rag!.searchVectorStore( - query.trim(), - maxResults, - vectorStoreId, - vectorStoreIds, - ); - res.json({ - success: true, - ...result, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - // --------------------------------------------------------------------------- - // RAG Generate (end-to-end: retrieval + LLM answer) - // --------------------------------------------------------------------------- - - router.post( - '/admin/rag-generate', - withRoute( - 'POST /admin/rag-generate', - 'Failed to generate RAG answer', - async (req, res) => { - if (!ctx.provider.rag?.generateAnswer) { - res.status(501).json({ - success: false, - error: 'RAG answer generation not supported by current provider', - }); - return; - } - - const { query, maxResults, vectorStoreId, vectorStoreIds } = req.body; - if (typeof query !== 'string' || query.trim().length === 0) { - throw new InputError('query must be a non-empty string'); - } - if (query.length > MAX_DESCRIPTION_LENGTH) { - throw new InputError( - `query must be at most ${MAX_DESCRIPTION_LENGTH} characters`, - ); - } - if ( - maxResults !== undefined && - (typeof maxResults !== 'number' || maxResults < 1) - ) { - throw new InputError('maxResults must be a positive number'); - } - if ( - vectorStoreId !== undefined && - (typeof vectorStoreId !== 'string' || - vectorStoreId.trim().length === 0) - ) { - throw new InputError('vectorStoreId must be a non-empty string'); - } - if ( - vectorStoreIds !== undefined && - (!Array.isArray(vectorStoreIds) || - vectorStoreIds.some((id: unknown) => typeof id !== 'string')) - ) { - throw new InputError('vectorStoreIds must be an array of strings'); - } - - const result = await ctx.provider.rag!.generateAnswer( - query.trim(), - maxResults, - vectorStoreId, - vectorStoreIds, - ); - res.json({ - success: true, - ...result, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - // --------------------------------------------------------------------------- - // MCP Server Connection Test (kept inline — small) - // --------------------------------------------------------------------------- - - router.post( - '/admin/mcp/test-connection', - withRoute( - 'POST /admin/mcp/test-connection', - 'Failed to test MCP connection', - async (req, res) => { - const { - url, - type, - headers: reqHeaders, - } = req.body as { - url?: string; - type?: string; - headers?: Record; - }; - - if (!url || typeof url !== 'string' || url.trim().length === 0) { - throw new InputError( - 'URL is required and must be a non-empty string', - ); - } - - if (type !== undefined) { - if (type !== 'streamable-http' && type !== 'sse') { - throw new InputError("type must be 'streamable-http' or 'sse'"); - } - } - - if (reqHeaders !== undefined) { - if ( - typeof reqHeaders !== 'object' || - reqHeaders === null || - Array.isArray(reqHeaders) - ) { - throw new InputError('headers must be a plain object'); - } - } - - try { - const parsed = new URL(url); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - throw new InputError('URL must use http:// or https:// protocol'); - } - } catch (e) { - if (e instanceof InputError) throw e; - throw new InputError(`Invalid URL: ${url}`); - } - - const ssrfReason = await isPrivateUrlWithDns(url); - if (ssrfReason) { - throw new InputError( - `URL points to a private/internal network address and is blocked for security (${ssrfReason})`, - ); - } - - const skipTls = - config.getOptionalBoolean('augment.llamaStack.skipTlsVerify') ?? - false; - const service = new McpTestService(skipTls, logger); - - try { - const result = await service.testConnection(url, type, reqHeaders); - res.json(result); - } catch (error) { - const msg = toErrorMessage(error, 'Connection failed'); - logger.warn(`MCP test-connection failed: ${msg}`); - res.json({ success: false, error: msg }); - } - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/adminSessionRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/adminSessionRoutes.ts deleted file mode 100644 index 22196f9430d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/adminSessionRoutes.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { createWithRoute, notFound } from './routeWrapper'; -import { validateSessionId } from './types'; -import type { AdminRouteDeps } from './adminRouteTypes'; - -function safeJsonParse(value: string): unknown { - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -export function registerAdminSessionRoutes( - router: import('express').Router, - deps: AdminRouteDeps, -): void { - const { - logger, - sendRouteError, - sessions, - missingSessions, - missingConversations, - } = deps; - - const withRoute = createWithRoute(logger, sendRouteError); - - router.get( - '/admin/sessions', - withRoute( - 'GET /admin/sessions', - 'Failed to list sessions', - async (req, res) => { - if (missingSessions(res)) return; - const rawLimit = parseInt(String(req.query.limit), 10); - const limit = Number.isFinite(rawLimit) - ? Math.min(Math.max(1, rawLimit), 500) - : undefined; - const list = await sessions!.listAllSessions(limit); - res.json({ sessions: list }); - }, - ), - ); - - router.get( - '/admin/sessions/:sessionId/messages', - withRoute( - req => `GET /admin/sessions/${req.params.sessionId}/messages`, - 'Failed to get session messages', - async (req, res) => { - if (missingSessions(res)) return; - validateSessionId(req.params.sessionId); - - const session = await sessions!.getSessionById(req.params.sessionId); - if (!session) { - notFound(res, 'Session'); - return; - } - - // Local message store first — same pattern as user route. - // Critical for Kagenti where the remote API has no history endpoint. - const localMessages = await sessions!.getMessages(req.params.sessionId); - - if (localMessages.length > 0) { - const formatted = localMessages.map(m => ({ - role: m.role, - text: m.content, - ...(m.agentName ? { agentName: m.agentName } : {}), - ...(m.toolCalls ? { toolCalls: safeJsonParse(m.toolCalls) } : {}), - ...(m.ragSources - ? { ragSources: safeJsonParse(m.ragSources) } - : {}), - ...(m.usage ? { usage: safeJsonParse(m.usage) } : {}), - ...(m.reasoning ? { reasoning: m.reasoning } : {}), - ...(m.citations ? { citations: safeJsonParse(m.citations) } : {}), - createdAt: m.createdAt, - })); - res.json({ - messages: formatted, - sessionCreatedAt: session.createdAt, - hasConversationId: !!session.conversationId, - source: 'local', - }); - return; - } - - if (!session.conversationId) { - res.json({ - messages: [], - sessionCreatedAt: session.createdAt, - hasConversationId: false, - }); - return; - } - - if (missingConversations(res)) return; - - try { - const messages = - await deps.provider.conversations!.getProcessedMessages( - session.conversationId, - ); - if (messages.length === 0) { - logger.warn( - `Admin: session ${req.params.sessionId} has conversationId ${session.conversationId} but returned 0 messages — conversation data may have been lost`, - ); - } - res.json({ - messages, - sessionCreatedAt: session.createdAt, - hasConversationId: true, - source: 'provider', - }); - } catch (fetchErr) { - logger.error( - `Failed to fetch messages for conversation ${session.conversationId}: ${fetchErr}`, - ); - res.status(502).json({ - error: 'Failed to retrieve messages from the conversation provider', - messages: [], - sessionCreatedAt: session.createdAt, - hasConversationId: true, - }); - } - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/adminVectorStoreRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/adminVectorStoreRoutes.ts deleted file mode 100644 index 92f58820c71..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/adminVectorStoreRoutes.ts +++ /dev/null @@ -1,749 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { InputError } from '@backstage/errors'; -import { - DEFAULT_CHUNK_OVERLAP, - DEFAULT_CHUNK_SIZE, - DEFAULT_EMBEDDING_DIMENSION, - DEFAULT_EMBEDDING_MODEL, - DEFAULT_VECTOR_STORE_NAME, -} from '../constants'; -import { createWithRoute } from './routeWrapper'; -import type { AdminRouteDeps } from './adminRouteTypes'; -import type { ProviderType } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { DuplicateVectorStoreError } from '../services/VectorStoreService'; - -function readVectorStoreDefaults( - ls: import('@backstage/config').Config | undefined, -): Record { - return { - vectorStoreName: - ls?.getOptionalString('vectorStoreName') ?? DEFAULT_VECTOR_STORE_NAME, - embeddingModel: - ls?.getOptionalString('embeddingModel') ?? DEFAULT_EMBEDDING_MODEL, - embeddingDimension: - ls?.getOptionalNumber('embeddingDimension') ?? - DEFAULT_EMBEDDING_DIMENSION, - searchMode: ls?.getOptionalString('searchMode') ?? 'semantic', - bm25Weight: ls?.getOptionalNumber('bm25Weight'), - semanticWeight: ls?.getOptionalNumber('semanticWeight'), - chunkingStrategy: ls?.getOptionalString('chunkingStrategy') ?? 'auto', - maxChunkSizeTokens: - ls?.getOptionalNumber('maxChunkSizeTokens') ?? DEFAULT_CHUNK_SIZE, - chunkOverlapTokens: - ls?.getOptionalNumber('chunkOverlapTokens') ?? DEFAULT_CHUNK_OVERLAP, - fileSearchMaxResults: ls?.getOptionalNumber('fileSearchMaxResults'), - fileSearchScoreThreshold: ls?.getOptionalNumber('fileSearchScoreThreshold'), - }; -} - -const VECTOR_STORE_CONFIG_FIELDS = [ - 'vectorStoreName', - 'embeddingModel', - 'embeddingDimension', - 'searchMode', - 'bm25Weight', - 'semanticWeight', - 'chunkingStrategy', - 'maxChunkSizeTokens', - 'chunkOverlapTokens', - 'fileSearchMaxResults', - 'fileSearchScoreThreshold', -] as const; - -/** - * Simple promise-based mutex to serialise read-modify-write on activeVectorStoreIds. - */ -class ActiveIdsMutex { - private pending: Promise = Promise.resolve(); - - async run(fn: () => Promise): Promise { - let release: () => void; - const next = new Promise(resolve => { - release = resolve; - }); - const prev = this.pending; - this.pending = next; - await prev; - try { - return await fn(); - } finally { - release!(); - } - } -} - -export function registerAdminVectorStoreRoutes( - router: import('express').Router, - deps: AdminRouteDeps, -): void { - const { adminConfig, config, logger, sendRouteError, onConfigChanged } = deps; - - const withRoute = createWithRoute(logger, sendRouteError); - const activeIdsMutex = new ActiveIdsMutex(); - - router.get( - '/admin/vector-store-config', - withRoute( - 'GET /admin/vector-store-config', - 'Failed to get vector store configuration', - async (_req, res) => { - const provider = deps.provider; - const resolved = provider.rag?.getVectorStoreConfig - ? await provider.rag.getVectorStoreConfig() - : null; - - let cfgObj: Record; - let source: 'yaml' | 'merged' | 'database'; - - if (resolved) { - const dbOverrides = await adminConfig.getScopedValue( - 'vectorStoreConfig', - provider.id as import('@red-hat-developer-hub/backstage-plugin-augment-common').ProviderType, - ); - source = dbOverrides !== undefined ? 'merged' : 'yaml'; - cfgObj = { ...resolved } as Record; - } else { - const ls = config.getOptionalConfig('augment.llamaStack'); - source = 'yaml'; - cfgObj = readVectorStoreDefaults(ls); - } - - if (provider.listModels) { - try { - const models = await provider.listModels(); - const embeddingIds = models - .filter(m => m.model_type === 'embedding') - .map(m => m.id); - - const currentModel = cfgObj.embeddingModel as string | undefined; - if ( - embeddingIds.length > 0 && - (!currentModel || !embeddingIds.includes(currentModel)) - ) { - const newModel = embeddingIds[0]; - cfgObj.embeddingModel = newModel; - logger.info( - `Config embeddingModel "${currentModel ?? ''}" not available on server; defaulting to "${newModel}"`, - ); - - if (provider.rag?.listVectorStores) { - try { - const existingStores = await provider.rag.listVectorStores(); - const sameModelStore = existingStores.find( - s => s.embeddingModel === newModel && s.embeddingDimension, - ); - if (sameModelStore?.embeddingDimension) { - cfgObj.embeddingDimension = - sameModelStore.embeddingDimension; - logger.info( - `Auto-detected embeddingDimension=${sameModelStore.embeddingDimension} from existing store using "${newModel}"`, - ); - } - } catch { - logger.debug( - 'Could not auto-detect embeddingDimension from existing stores', - ); - } - } - } - } catch (err) { - logger.debug( - `Could not validate embeddingModel against server models: ${err}`, - ); - } - } - - res.json({ - success: true, - config: cfgObj, - source, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.post( - '/admin/vector-store/create', - withRoute( - 'POST /admin/vector-store/create', - 'Failed to create vector store', - async (req, res) => { - const provider = deps.provider; - if (!provider.rag?.createVectorStoreWithConfig) { - res.status(501).json({ - success: false, - error: 'Vector store creation not supported by current provider', - }); - return; - } - - const ls = config.getOptionalConfig('augment.llamaStack'); - const overrides: Record = readVectorStoreDefaults(ls); - - const dbOverrides = await adminConfig.getScopedValue( - 'vectorStoreConfig', - provider.id as ProviderType, - ); - if ( - dbOverrides !== undefined && - typeof dbOverrides === 'object' && - dbOverrides !== null - ) { - Object.assign(overrides, dbOverrides as Record); - } - - const body = req.body; - if (body !== undefined && body !== null) { - if (Array.isArray(body) || typeof body !== 'object') { - throw new InputError( - 'Request body must be an object, not null or array', - ); - } - if (body.vectorStoreName !== undefined) { - if ( - typeof body.vectorStoreName !== 'string' || - (body.vectorStoreName as string).trim().length === 0 - ) { - throw new InputError( - 'vectorStoreName must be a non-empty string', - ); - } - if ((body.vectorStoreName as string).length > 200) { - throw new InputError( - 'vectorStoreName must be at most 200 characters', - ); - } - } - if (body.embeddingModel !== undefined) { - if (typeof body.embeddingModel !== 'string') { - throw new InputError('embeddingModel must be a string'); - } - if (provider.listModels) { - try { - const models = await provider.listModels(); - const embeddingIds = models - .filter(m => m.model_type === 'embedding') - .map(m => m.id); - const candidateIds = - embeddingIds.length > 0 - ? embeddingIds - : models.map(m => m.id); - if ( - candidateIds.length > 0 && - !candidateIds.includes(body.embeddingModel as string) - ) { - const label = - embeddingIds.length > 0 - ? 'embedding models' - : 'models (no embedding-tagged models found)'; - throw new InputError( - `Embedding model "${body.embeddingModel}" not found on server. Available ${label}: ${candidateIds.join(', ')}`, - ); - } - } catch (err) { - if (err instanceof InputError) throw err; - logger.debug( - `Could not validate embedding model against server: ${err}`, - ); - } - } - } - if (body.embeddingDimension !== undefined) { - if ( - typeof body.embeddingDimension !== 'number' || - body.embeddingDimension <= 0 || - !Number.isFinite(body.embeddingDimension) - ) { - throw new InputError( - 'embeddingDimension must be a positive number', - ); - } - } - } - if (body && typeof body === 'object' && !Array.isArray(body)) { - for (const key of VECTOR_STORE_CONFIG_FIELDS) { - if ((body as Record)[key] !== undefined) { - overrides[key] = (body as Record)[key]; - } - } - } - - const selectedModel = overrides.embeddingModel as string | undefined; - if (selectedModel && provider.rag.listVectorStores) { - try { - const existingStores = await provider.rag.listVectorStores(); - const sameModelStore = existingStores.find( - s => s.embeddingModel === selectedModel && s.embeddingDimension, - ); - if (sameModelStore?.embeddingDimension) { - const requestedDim = overrides.embeddingDimension as - | number - | undefined; - if ( - requestedDim && - requestedDim !== sameModelStore.embeddingDimension - ) { - logger.info( - `Auto-correcting embeddingDimension from ${requestedDim} to ${sameModelStore.embeddingDimension} to match model "${selectedModel}"`, - ); - overrides.embeddingDimension = - sameModelStore.embeddingDimension; - } - } - } catch { - logger.debug( - 'Could not validate embeddingDimension from existing stores before create', - ); - } - } - - let result: { vectorStoreId: string; message?: string }; - try { - result = await provider.rag.createVectorStoreWithConfig(overrides); - } catch (createErr) { - if (createErr instanceof DuplicateVectorStoreError) { - res.status(409).json({ - success: false, - error: createErr.message, - }); - return; - } - throw createErr; - } - - const providerId = provider.id as ProviderType; - - if (result.vectorStoreId && result.vectorStoreId.length > 0) { - try { - await activeIdsMutex.run(async () => { - const currentIds = await adminConfig.getScopedValue( - 'activeVectorStoreIds', - providerId, - ); - const ids: string[] = Array.isArray(currentIds) - ? [...currentIds] - : []; - if (!ids.includes(result.vectorStoreId)) { - ids.push(result.vectorStoreId); - await adminConfig.setScopedValue( - 'activeVectorStoreIds', - ids, - providerId, - 'system', - ); - } - }); - if (provider.rag?.addVectorStoreId) { - provider.rag.addVectorStoreId(result.vectorStoreId); - } - onConfigChanged?.(); - } catch (persistErr) { - logger.warn( - `Vector store created but failed to persist active ID: ${ - persistErr instanceof Error ? persistErr.message : persistErr - }. Use "Connect" to add it manually.`, - ); - } - } - - res.json({ - success: true, - ...result, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.get( - '/admin/vector-store/status', - withRoute( - 'GET /admin/vector-store/status', - 'Failed to get vector store status', - async (_req, res) => { - const provider = deps.provider; - if (!provider.rag?.getVectorStoreStatus) { - res.status(501).json({ - success: false, - error: 'Vector store status not supported by current provider', - }); - return; - } - - const status = await provider.rag.getVectorStoreStatus(); - res.json({ - success: true, - ...status, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.get( - '/admin/vector-stores', - withRoute( - 'GET /admin/vector-stores', - 'Failed to list vector stores', - async (_req, res) => { - const provider = deps.provider; - if (!provider.rag) { - res.json({ - success: true, - stores: [], - timestamp: new Date().toISOString(), - }); - return; - } - - const providerId = provider.id as ProviderType; - let activeIds = await Promise.resolve( - provider.rag.getActiveVectorStoreIds(), - ); - const dbValue = await adminConfig.getScopedValue( - 'activeVectorStoreIds', - providerId, - ); - if (Array.isArray(dbValue) && dbValue.length > 0) { - const merged = new Set([ - ...activeIds, - ...dbValue.filter((v): v is string => typeof v === 'string'), - ]); - activeIds = [...merged]; - } - const userHasManagedList = dbValue !== undefined; - - if ( - activeIds.length === 0 && - !userHasManagedList && - provider.rag.getVectorStoreStatus - ) { - try { - const discovered = await activeIdsMutex.run(async () => { - const freshDb = await adminConfig.getScopedValue( - 'activeVectorStoreIds', - providerId, - ); - if (freshDb !== undefined) return null; - - const status = await provider.rag!.getVectorStoreStatus!(); - if (!status.exists || !status.ready || !status.vectorStoreId) { - return null; - } - - const discoveredIds = [status.vectorStoreId]; - await adminConfig.setScopedValue( - 'activeVectorStoreIds', - discoveredIds, - providerId, - 'system', - ); - - if (provider.rag!.addVectorStoreId) { - provider.rag!.addVectorStoreId(status.vectorStoreId); - } - - return discoveredIds; - }); - - if (discovered) { - onConfigChanged?.(); - logger.info( - `Auto-populated active vector store from lazy init: ${discovered[0]}`, - ); - activeIds = discovered; - } - } catch { - logger.warn( - 'Vector store status check failed, continuing with empty IDs', - ); - } - } - - let allStores: import('@red-hat-developer-hub/backstage-plugin-augment-common').VectorStoreInfo[] = - []; - let serverListError: string | undefined; - - try { - allStores = await provider.rag.listVectorStores(); - } catch (listErr) { - serverListError = - listErr instanceof Error ? listErr.message : String(listErr); - logger.warn( - `Failed to list vector stores from server: ${serverListError}`, - ); - allStores = activeIds.map(id => ({ - id, - name: id, - status: 'unknown', - fileCount: 0, - createdAt: 0, - })); - } - - const serverMap = new Map(allStores.map(s => [s.id, s])); - - const stores = activeIds.map(id => { - const fromServer = serverMap.get(id); - return { - id, - name: fromServer?.name ?? id, - status: fromServer?.status ?? 'unknown', - fileCount: fromServer?.fileCount ?? 0, - createdAt: fromServer?.createdAt ?? 0, - embeddingModel: fromServer?.embeddingModel, - embeddingDimension: fromServer?.embeddingDimension, - providerType: fromServer?.providerType, - usageBytes: fromServer?.usageBytes, - lastActiveAt: fromServer?.lastActiveAt, - fileCounts: fromServer?.fileCounts, - active: true, - }; - }); - - const activeSet = new Set(activeIds); - const unconnectedStores = allStores - .filter(s => !activeSet.has(s.id)) - .map(s => ({ ...s, active: false })); - - res.json({ - success: true, - stores, - unconnected: unconnectedStores, - serverListError, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.post( - '/admin/vector-stores/connect', - withRoute( - 'POST /admin/vector-stores/connect', - 'Failed to connect vector store', - async (req, res) => { - const provider = deps.provider; - const { vectorStoreId } = req.body; - if (!vectorStoreId || typeof vectorStoreId !== 'string') { - throw new InputError('vectorStoreId is required'); - } - - if (!provider.rag) { - res.status(501).json({ success: false, error: 'RAG not available' }); - return; - } - - const serverStores = await provider.rag.listVectorStores(); - const found = serverStores.find(s => s.id === vectorStoreId); - if (!found) { - res.status(404).json({ - success: false, - error: `Vector store "${vectorStoreId}" was not found on the server. It may have been deleted.`, - }); - return; - } - - const providerId = provider.id as ProviderType; - const updatedIds = await activeIdsMutex.run(async () => { - const currentIds = await Promise.resolve( - provider.rag!.getActiveVectorStoreIds(), - ); - if (currentIds.includes(vectorStoreId)) { - return null; - } - const ids = [...currentIds, vectorStoreId]; - await adminConfig.setScopedValue( - 'activeVectorStoreIds', - ids, - providerId, - 'system', - ); - return ids; - }); - - if (updatedIds === null) { - res.status(409).json({ - success: false, - error: `Vector store "${vectorStoreId}" is already connected.`, - }); - return; - } - - if (provider.rag.addVectorStoreId) { - provider.rag.addVectorStoreId(vectorStoreId); - } - - onConfigChanged?.(); - - res.json({ - success: true, - activeVectorStoreIds: updatedIds, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.post( - '/admin/vector-stores/:id/update', - withRoute( - req => `POST /admin/vector-stores/${req.params.id}/update`, - 'Failed to update vector store', - async (req, res) => { - const provider = deps.provider; - const { id } = req.params; - - if (!id || id.trim().length === 0) { - throw new InputError('Vector store ID is required'); - } - - if (!provider.rag?.updateVectorStore) { - res.status(501).json({ - success: false, - error: 'Vector store update not supported by current provider', - }); - return; - } - - const body = req.body; - if (!body || typeof body !== 'object' || Array.isArray(body)) { - throw new InputError('Request body must be an object'); - } - - const updates: { name?: string; metadata?: Record } = - {}; - if (body.name !== undefined) { - if (typeof body.name !== 'string' || body.name.trim().length === 0) { - throw new InputError('name must be a non-empty string'); - } - if (body.name.length > 200) { - throw new InputError('name must be at most 200 characters'); - } - updates.name = body.name.trim(); - } - if (body.metadata !== undefined) { - if ( - typeof body.metadata !== 'object' || - Array.isArray(body.metadata) || - body.metadata === null - ) { - throw new InputError('metadata must be an object'); - } - const metaObj = body.metadata as Record; - for (const [k, v] of Object.entries(metaObj)) { - if (typeof v !== 'string') { - throw new InputError( - `metadata value for key "${k}" must be a string`, - ); - } - if (v.length > 1000) { - throw new InputError( - `metadata value for key "${k}" must be at most 1000 characters`, - ); - } - } - updates.metadata = metaObj as Record; - } - - if (Object.keys(updates).length === 0) { - throw new InputError( - 'At least one field to update is required (name, metadata)', - ); - } - - const updated = await provider.rag.updateVectorStore(id, updates); - - res.json({ - success: true, - store: updated, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.delete( - '/admin/vector-stores/:id', - withRoute( - req => - `DELETE /admin/vector-stores/${req.params.id} (permanent=${ - req.query.permanent === 'true' - })`, - 'Failed to remove vector store', - async (req, res) => { - const provider = deps.provider; - const { id } = req.params; - const permanent = req.query.permanent === 'true'; - - if (!id || id.trim().length === 0) { - throw new InputError('Vector store ID is required'); - } - - if (!provider.rag) { - res.status(501).json({ success: false, error: 'RAG not available' }); - return; - } - - let filesDeleted = 0; - - if (permanent && provider.rag.deleteVectorStore) { - try { - const result = await provider.rag.deleteVectorStore(id); - filesDeleted = result.filesDeleted; - } catch (deleteErr) { - logger.warn( - `Server-side delete failed for ${id}: ${ - deleteErr instanceof Error ? deleteErr.message : deleteErr - }. Proceeding with local removal.`, - ); - } - } - - const providerId = provider.id as ProviderType; - const updatedIds = await activeIdsMutex.run(async () => { - const currentIds = await Promise.resolve( - provider.rag!.getActiveVectorStoreIds(), - ); - const ids = currentIds.filter(storeId => storeId !== id); - await adminConfig.setScopedValue( - 'activeVectorStoreIds', - ids, - providerId, - 'system', - ); - return ids; - }); - - if (provider.rag.removeVectorStoreId) { - provider.rag.removeVectorStoreId(id); - } - onConfigChanged?.(); - - res.json({ - success: true, - removed: id, - permanent, - filesDeleted, - activeVectorStoreIds: updatedIds, - timestamp: new Date().toISOString(), - }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.test.ts b/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.test.ts deleted file mode 100644 index b9bddefdd18..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.test.ts +++ /dev/null @@ -1,1304 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import express from 'express'; -import request from 'supertest'; -import type { Config } from '@backstage/config'; -import { registerAgentRoutes } from './agentRoutes'; -import { createMockLogger } from '../test-utils/mocks'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import type { AdminConfigKey } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -function createMockBackstageConfig(): Config { - return { - getOptionalConfig: jest.fn().mockReturnValue(undefined), - getOptionalString: jest.fn().mockReturnValue(undefined), - } as unknown as Config; -} - -// --------------------------------------------------------------------------- -// Test helpers -// --------------------------------------------------------------------------- - -interface SetupOptions { - isAdmin?: boolean; - userRef?: string; - /** Seed the in-memory config store before each test. */ - initialConfig?: Record; - /** Provider agents returned by provider.listAgents */ - providerAgents?: Array>; -} - -function setup(opts: SetupOptions = {}) { - const { isAdmin = true, userRef = 'user:default/admin' } = opts; - - const store: Record = { ...(opts.initialConfig ?? {}) }; - - const adminConfig: jest.Mocked< - Pick - > = { - get: jest.fn(async (key: AdminConfigKey) => store[key] ?? null), - set: jest.fn( - async (key: AdminConfigKey, value: unknown, _updatedBy: string) => { - store[key] = value; - }, - ), - delete: jest.fn(async (key: AdminConfigKey) => { - delete store[key]; - return true; - }), - initialize: jest.fn(), - }; - - const router = express.Router(); - const logger = createMockLogger(); - - const providerAgents = opts.providerAgents ?? []; - - const ctx = { - router, - logger, - config: createMockBackstageConfig(), - provider: { - id: 'llamastack', - displayName: 'Llama Stack', - listAgents: jest.fn().mockResolvedValue(providerAgents), - } as never, - orchestrationProvider: undefined, - sessions: undefined, - toErrorMessage: (e: unknown) => - e instanceof Error ? e.message : String(e), - sendRouteError: jest.fn( - (res: express.Response, err: unknown, _label: string, msg: string) => { - const status = - err instanceof Error && err.name === 'InputError' ? 400 : 500; - res.status(status).json({ error: msg }); - }, - ), - missingSessions: jest.fn().mockReturnValue(false), - missingConversations: jest.fn().mockReturnValue(false), - getUserRef: jest.fn().mockResolvedValue(userRef), - checkIsAdmin: jest.fn().mockResolvedValue(isAdmin), - requireAdminAccess: (( - _req: express.Request, - res: express.Response, - next: express.NextFunction, - ) => { - if (!isAdmin) { - res.status(403).json({ error: 'Forbidden' }); - return; - } - next(); - }) as express.RequestHandler, - parseChatRequest: jest.fn(), - parseApprovalRequest: jest.fn(), - }; - - registerAgentRoutes(ctx, adminConfig as unknown as AdminConfigService); - - const app = express(); - app.use(express.json()); - app.use(router); - - return { app, adminConfig, store, logger }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('agentRoutes', () => { - // ========================================================================= - // GET /agents -- unified listing & scoping - // ========================================================================= - - describe('GET /agents', () => { - it('returns an empty list when no agents exist', async () => { - const { app } = setup(); - const res = await request(app).get('/agents'); - expect(res.status).toBe(200); - expect(res.body.agents).toEqual([]); - expect(res.body.sources).toBeDefined(); - }); - - it('returns orchestration agents from the agents config key', async () => { - const { app } = setup({ - initialConfig: { - agents: { - triage: { name: 'Triage', instructions: 'Route user queries' }, - }, - }, - }); - const res = await request(app).get('/agents'); - expect(res.status).toBe(200); - expect(res.body.agents).toHaveLength(1); - expect(res.body.agents[0].id).toBe('triage'); - expect(res.body.agents[0].source).toBe('orchestration'); - }); - - it('non-admin sees only published, own, or unowned agents', async () => { - const { app } = setup({ - isAdmin: false, - userRef: 'user:default/alice', - initialConfig: { - agents: { - public: { name: 'Public Bot', instructions: 'Help' }, - draft: { name: 'Draft Bot', instructions: 'In progress' }, - other: { name: 'Other Bot', instructions: 'Owned by bob' }, - }, - chatAgents: [ - { - agentId: 'public', - lifecycleStage: 'published', - published: true, - visible: true, - featured: false, - }, - { - agentId: 'draft', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - createdBy: 'user:default/alice', - }, - { - agentId: 'other', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - createdBy: 'user:default/bob', - }, - ], - }, - }); - - const res = await request(app).get('/agents'); - expect(res.status).toBe(200); - const ids = res.body.agents.map((a: { id: string }) => a.id); - expect(ids).toContain('public'); - expect(ids).toContain('draft'); - expect(ids).not.toContain('other'); - }); - - it('admin sees all agents regardless of ownership', async () => { - const { app } = setup({ - isAdmin: true, - initialConfig: { - agents: { - public: { name: 'Public', instructions: 'x' }, - draft: { name: 'Draft', instructions: 'y' }, - }, - chatAgents: [ - { - agentId: 'public', - lifecycleStage: 'published', - published: true, - visible: true, - featured: false, - }, - { - agentId: 'draft', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - createdBy: 'user:default/bob', - }, - ], - }, - }); - - const res = await request(app).get('/agents'); - expect(res.status).toBe(200); - expect(res.body.agents).toHaveLength(2); - }); - - it('filters to only published when ?published=true', async () => { - const { app } = setup({ - initialConfig: { - agents: { - prod: { name: 'Prod', instructions: 'x' }, - draft: { name: 'Draft', instructions: 'y' }, - }, - chatAgents: [ - { - agentId: 'prod', - lifecycleStage: 'published', - published: true, - visible: true, - featured: false, - }, - { - agentId: 'draft', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app).get('/agents?published=true'); - expect(res.status).toBe(200); - expect(res.body.agents).toHaveLength(1); - expect(res.body.agents[0].id).toBe('prod'); - }); - }); - - // ========================================================================= - // PUT /agents/:agentId/promote - // ========================================================================= - - describe('PUT /agents/:agentId/promote', () => { - it('promotes draft to pending for non-admin owner', async () => { - const { app } = setup({ - isAdmin: false, - userRef: 'user:default/alice', - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - createdBy: 'user:default/alice', - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/mybot/promote') - .send({ targetStage: 'pending' }); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.lifecycleStage).toBe('pending'); - }); - - it('rejects non-admin attempting pending → published', async () => { - const { app } = setup({ - isAdmin: false, - userRef: 'user:default/alice', - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'pending', - published: false, - visible: false, - featured: false, - createdBy: 'user:default/alice', - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/mybot/promote') - .send({ targetStage: 'published' }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain('Only admins'); - }); - - it('blocks phantom draft promotion for non-admin', async () => { - const { app } = setup({ - isAdmin: false, - userRef: 'user:default/alice', - initialConfig: { - agents: { ghost: { name: 'Ghost', instructions: 'Boo' } }, - }, - }); - - const res = await request(app) - .put('/agents/ghost/promote') - .send({ targetStage: 'pending' }); - - expect(res.status).toBe(404); - expect(res.body.error).toContain('not found in lifecycle config'); - }); - - it("blocks non-admin from promoting another user's agent", async () => { - const { app } = setup({ - isAdmin: false, - userRef: 'user:default/alice', - initialConfig: { - agents: { bobbot: { name: 'Bob Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'bobbot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - createdBy: 'user:default/bob', - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/bobbot/promote') - .send({ targetStage: 'pending' }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain('only promote agents you created'); - }); - - it('admin promotes pending → published', async () => { - const { app } = setup({ - isAdmin: true, - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'pending', - published: false, - visible: false, - featured: false, - createdBy: 'user:default/alice', - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/mybot/promote') - .send({ targetStage: 'published' }); - - expect(res.status).toBe(200); - expect(res.body.lifecycleStage).toBe('published'); - }); - - it('admin promotes draft → pending', async () => { - const { app } = setup({ - isAdmin: true, - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - version: 2, - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/mybot/promote') - .send({ targetStage: 'pending' }); - - expect(res.status).toBe(200); - expect(res.body.lifecycleStage).toBe('pending'); - }); - - it('rejects invalid transition draft → published', async () => { - const { app } = setup({ - isAdmin: true, - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/mybot/promote') - .send({ targetStage: 'published' }); - - expect(res.status).toBe(400); - }); - - it('rejects invalid targetStage string', async () => { - const { app } = setup({ - isAdmin: true, - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/mybot/promote') - .send({ targetStage: 'invalid' }); - - expect(res.status).toBe(400); - }); - - it('creates new config entry when agent has no existing config', async () => { - const { app, store } = setup({ - isAdmin: true, - userRef: 'user:default/admin', - initialConfig: { - agents: { newbot: { name: 'New Bot', instructions: 'Hello' } }, - }, - }); - - const res = await request(app) - .put('/agents/newbot/promote') - .send({ targetStage: 'pending' }); - - expect(res.status).toBe(200); - expect(res.body.lifecycleStage).toBe('pending'); - - const configs = store.chatAgents as Array<{ - agentId: string; - createdBy?: string; - createdAt?: string; - }>; - const entry = configs.find(c => c.agentId === 'newbot'); - expect(entry).toBeDefined(); - expect(entry?.createdBy).toBe('user:default/admin'); - expect(entry?.createdAt).toBeDefined(); - }); - - it('clears rejection fields when re-promoting from draft', async () => { - const { app, store } = setup({ - isAdmin: false, - userRef: 'user:default/alice', - initialConfig: { - agents: { - rejected: { name: 'Rejected Bot', instructions: 'Retry' }, - }, - chatAgents: [ - { - agentId: 'rejected', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - createdBy: 'user:default/alice', - rejectionReason: 'Needs more work', - rejectedBy: 'user:default/admin', - rejectedAt: '2025-01-01T00:00:00.000Z', - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/rejected/promote') - .send({ targetStage: 'pending' }); - - expect(res.status).toBe(200); - const configs = store.chatAgents as Array<{ - agentId: string; - rejectionReason?: string; - rejectedBy?: string; - rejectedAt?: string; - }>; - const entry = configs.find(c => c.agentId === 'rejected'); - expect(entry?.rejectionReason).toBeUndefined(); - expect(entry?.rejectedBy).toBeUndefined(); - expect(entry?.rejectedAt).toBeUndefined(); - }); - }); - - // ========================================================================= - // PUT /agents/:agentId/demote - // ========================================================================= - - describe('PUT /agents/:agentId/demote', () => { - it('admin demotes pending → draft', async () => { - const { app } = setup({ - isAdmin: true, - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'pending', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/mybot/demote') - .send({ targetStage: 'draft' }); - - expect(res.status).toBe(200); - expect(res.body.lifecycleStage).toBe('draft'); - }); - - it('stores rejection reason when demoting pending → draft', async () => { - const { app, store } = setup({ - isAdmin: true, - userRef: 'user:default/admin', - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'pending', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/mybot/demote') - .send({ targetStage: 'draft', reason: 'Needs better prompt' }); - - expect(res.status).toBe(200); - const configs = store.chatAgents as Array<{ - agentId: string; - rejectionReason?: string; - rejectedBy?: string; - rejectedAt?: string; - }>; - const entry = configs.find(c => c.agentId === 'mybot'); - expect(entry?.rejectionReason).toBe('Needs better prompt'); - expect(entry?.rejectedBy).toBe('user:default/admin'); - expect(entry?.rejectedAt).toBeDefined(); - }); - - it('rejects non-admin access to demote', async () => { - const { app } = setup({ - isAdmin: false, - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'pending', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/mybot/demote') - .send({ targetStage: 'draft' }); - - expect(res.status).toBe(403); - }); - - it('rejects invalid transition draft → draft', async () => { - const { app } = setup({ - isAdmin: true, - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/mybot/demote') - .send({ targetStage: 'draft' }); - - expect(res.status).toBe(400); - }); - }); - - // ========================================================================= - // PUT /agents/:agentId/publish -- admin shortcut - // ========================================================================= - - describe('PUT /agents/:agentId/publish', () => { - it('publishes an agent to published', async () => { - const { app, store } = setup({ - isAdmin: true, - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'pending', - published: false, - visible: false, - featured: false, - version: 2, - }, - ], - }, - }); - - const res = await request(app).put('/agents/mybot/publish'); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.published).toBe(true); - - const configs = store.chatAgents as Array<{ - agentId: string; - lifecycleStage: string; - published: boolean; - version: number; - }>; - const entry = configs.find(c => c.agentId === 'mybot'); - expect(entry?.lifecycleStage).toBe('published'); - expect(entry?.published).toBe(true); - expect(entry?.version).toBe(3); - }); - - it('rejects publish when lifecycle transition is invalid', async () => { - const { app } = setup({ - isAdmin: true, - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app).put('/agents/mybot/publish'); - - expect(res.status).toBe(400); - expect(res.body.error).toBeDefined(); - }); - - it('publishes when agent is in pending', async () => { - const { app } = setup({ - isAdmin: true, - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'pending', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app).put('/agents/mybot/publish'); - - expect(res.status).toBe(200); - expect(res.body.lifecycleStage).toBe('published'); - }); - - it('rejects non-admin access', async () => { - const { app } = setup({ isAdmin: false }); - const res = await request(app).put('/agents/mybot/publish'); - expect(res.status).toBe(403); - }); - - it('rejects publish for agent not registered for governance', async () => { - const { app } = setup({ - isAdmin: true, - initialConfig: { - agents: { newbot: { name: 'New Bot', instructions: 'Hello' } }, - }, - }); - - const res = await request(app).put('/agents/newbot/publish'); - - expect(res.status).toBe(404); - }); - }); - - // ========================================================================= - // PUT /agents/:agentId/register - // ========================================================================= - - describe('PUT /agents/:agentId/register', () => { - it('creates draft chatAgents entry for a catalog agent', async () => { - const { app, store } = setup({ - isAdmin: true, - initialConfig: { - agents: { newbot: { name: 'New Bot', instructions: 'Hello' } }, - }, - }); - - const res = await request(app).put('/agents/newbot/register'); - - expect(res.status).toBe(201); - expect(res.body.lifecycleStage).toBe('draft'); - const configs = store.chatAgents as Array<{ - agentId: string; - lifecycleStage: string; - }>; - expect(configs.find(c => c.agentId === 'newbot')?.lifecycleStage).toBe( - 'draft', - ); - }); - - it('returns 409 when agent is already registered', async () => { - const { app } = setup({ - isAdmin: true, - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app).put('/agents/mybot/register'); - expect(res.status).toBe(409); - }); - }); - - // ========================================================================= - // PUT /agents/:agentId/unpublish - // ========================================================================= - - describe('PUT /agents/:agentId/unpublish', () => { - it('moves agent from published to pending', async () => { - const { app, store } = setup({ - isAdmin: true, - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'published', - published: true, - visible: true, - featured: false, - }, - ], - }, - }); - - const res = await request(app).put('/agents/mybot/unpublish'); - - expect(res.status).toBe(200); - expect(res.body.published).toBe(false); - - const configs = store.chatAgents as Array<{ - agentId: string; - lifecycleStage: string; - published: boolean; - visible: boolean; - }>; - const entry = configs.find(c => c.agentId === 'mybot'); - expect(entry?.lifecycleStage).toBe('pending'); - expect(entry?.published).toBe(false); - expect(entry?.visible).toBe(false); - }); - - it('rejects non-admin access', async () => { - const { app } = setup({ isAdmin: false }); - const res = await request(app).put('/agents/mybot/unpublish'); - expect(res.status).toBe(403); - }); - }); - - // ========================================================================= - // PUT /agents/bulk-publish - // ========================================================================= - - describe('PUT /agents/bulk-publish', () => { - it('publishes multiple agents at once', async () => { - const { app, store } = setup({ - isAdmin: true, - initialConfig: { - agents: { - bot1: { name: 'Bot 1', instructions: 'x' }, - bot2: { name: 'Bot 2', instructions: 'y' }, - }, - chatAgents: [ - { - agentId: 'bot1', - lifecycleStage: 'pending', - published: false, - visible: false, - featured: false, - }, - { - agentId: 'bot2', - lifecycleStage: 'pending', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/bulk-publish') - .send({ agentIds: ['bot1', 'bot2'], published: true }); - - expect(res.status).toBe(200); - expect(res.body.count).toBe(2); - expect(res.body.published).toBe(true); - - const configs = store.chatAgents as Array<{ - agentId: string; - published: boolean; - }>; - for (const c of configs) { - expect(c.published).toBe(true); - } - }); - - it('rejects bulk publish when any agent has invalid transition', async () => { - const { app } = setup({ - isAdmin: true, - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/bulk-publish') - .send({ agentIds: ['mybot'], published: true }); - - expect(res.status).toBe(400); - expect(res.body.invalid).toEqual( - expect.arrayContaining([expect.objectContaining({ agentId: 'mybot' })]), - ); - }); - - it('rejects invalid payload', async () => { - const { app } = setup({ isAdmin: true }); - - const res = await request(app) - .put('/agents/bulk-publish') - .send({ agentIds: 'bad', published: true }); - - expect(res.status).toBe(400); - }); - - it('rejects non-admin access', async () => { - const { app } = setup({ isAdmin: false }); - const res = await request(app) - .put('/agents/bulk-publish') - .send({ agentIds: ['bot1'], published: true }); - expect(res.status).toBe(403); - }); - }); - - // ========================================================================= - // DELETE /agents/:agentId -- cascading delete - // ========================================================================= - - describe('DELETE /agents/:agentId', () => { - it('admin deletes an agent and removes chatAgents config', async () => { - const { app, store } = setup({ - isAdmin: true, - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app).delete('/agents/mybot'); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.cleanupResults.chatAgents).toBe('success'); - - const configs = store.chatAgents as Array<{ agentId: string }>; - expect(configs.find(c => c.agentId === 'mybot')).toBeUndefined(); - }); - - it('cascading delete cleans up orchestration config for orchestration agents', async () => { - const { app, store } = setup({ - isAdmin: true, - initialConfig: { - agents: { - orchbot: { name: 'Orch Bot', instructions: 'Route queries' }, - otherbot: { name: 'Other', instructions: 'Keep me' }, - }, - chatAgents: [ - { - agentId: 'orchbot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app).delete('/agents/orchbot'); - - expect(res.status).toBe(200); - expect(res.body.cleanupResults.orchestration).toBe('success'); - - const agentMap = store.agents as Record; - expect(agentMap.orchbot).toBeUndefined(); - expect(agentMap.otherbot).toBeDefined(); - }); - - it('non-admin can delete their own draft agent', async () => { - const { app } = setup({ - isAdmin: false, - userRef: 'user:default/alice', - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - createdBy: 'user:default/alice', - }, - ], - }, - }); - - const res = await request(app).delete('/agents/mybot'); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - }); - - it("non-admin cannot delete another user's draft agent", async () => { - const { app } = setup({ - isAdmin: false, - userRef: 'user:default/alice', - initialConfig: { - agents: { bobbot: { name: 'Bob Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'bobbot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - createdBy: 'user:default/bob', - }, - ], - }, - }); - - const res = await request(app).delete('/agents/bobbot'); - - expect(res.status).toBe(403); - expect(res.body.error).toContain('only delete agents you created'); - }); - - it('non-admin cannot delete non-draft agents', async () => { - const { app } = setup({ - isAdmin: false, - userRef: 'user:default/alice', - initialConfig: { - agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'pending', - published: false, - visible: false, - featured: false, - createdBy: 'user:default/alice', - }, - ], - }, - }); - - const res = await request(app).delete('/agents/mybot'); - - expect(res.status).toBe(403); - expect(res.body.error).toContain('only delete agents in draft stage'); - }); - - it('returns 404 for non-existent agent', async () => { - const { app } = setup({ isAdmin: true }); - - const res = await request(app).delete('/agents/nonexistent'); - - expect(res.status).toBe(404); - expect(res.body.error).toContain('not found'); - }); - - it('notes kagenti cleanup requirement for kagenti-sourced agents', async () => { - const { app } = setup({ - isAdmin: true, - providerAgents: [ - { - id: 'team1/k8sbot', - name: 'K8s Bot', - status: 'Running', - source: 'kagenti', - }, - ], - initialConfig: { - chatAgents: [ - { - agentId: 'team1/k8sbot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app).delete( - `/agents/${encodeURIComponent('team1/k8sbot')}`, - ); - - expect(res.status).toBe(200); - expect(res.body.cleanupResults.kagenti).toContain( - 'requires DELETE /kagenti/agents', - ); - }); - }); - - // ========================================================================= - // PUT /agents/:agentId/config -- display config updates - // ========================================================================= - - describe('PUT /agents/:agentId/config', () => { - it('updates existing agent config', async () => { - const { app, store } = setup({ - isAdmin: true, - initialConfig: { - chatAgents: [ - { - agentId: 'mybot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - }, - ], - }, - }); - - const res = await request(app) - .put('/agents/mybot/config') - .send({ featured: true, visible: true }); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - - const configs = store.chatAgents as Array<{ - agentId: string; - featured: boolean; - visible: boolean; - }>; - const entry = configs.find(c => c.agentId === 'mybot'); - expect(entry?.featured).toBe(true); - expect(entry?.visible).toBe(true); - }); - - it('creates new config entry for unknown agent', async () => { - const { app, store } = setup({ isAdmin: true }); - - const res = await request(app) - .put('/agents/newbot/config') - .send({ featured: true }); - - expect(res.status).toBe(200); - const configs = store.chatAgents as Array<{ - agentId: string; - featured: boolean; - }>; - expect(configs.find(c => c.agentId === 'newbot')).toBeDefined(); - }); - - it('rejects non-admin access', async () => { - const { app } = setup({ isAdmin: false }); - const res = await request(app) - .put('/agents/mybot/config') - .send({ featured: true }); - expect(res.status).toBe(403); - }); - }); - - // ========================================================================= - // End-to-end lifecycle round-trip - // ========================================================================= - - describe('end-to-end lifecycle', () => { - it('draft → pending → published → pending (unpublish)', async () => { - const { app, store } = setup({ - isAdmin: true, - userRef: 'user:default/admin', - initialConfig: { - agents: { e2ebot: { name: 'E2E Bot', instructions: 'Test' } }, - chatAgents: [ - { - agentId: 'e2ebot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - createdBy: 'user:default/alice', - }, - ], - }, - }); - - // draft → pending (alice submitted, admin approves) - let res = await request(app) - .put('/agents/e2ebot/promote') - .send({ targetStage: 'pending' }); - expect(res.status).toBe(200); - expect(res.body.lifecycleStage).toBe('pending'); - - // pending → published (admin approves alice's agent) - res = await request(app) - .put('/agents/e2ebot/promote') - .send({ targetStage: 'published' }); - expect(res.status).toBe(200); - expect(res.body.lifecycleStage).toBe('published'); - - // verify it's published in listing - res = await request(app).get('/agents?published=true'); - expect(res.body.agents.map((a: { id: string }) => a.id)).toContain( - 'e2ebot', - ); - - // unpublish → pending - res = await request(app).put('/agents/e2ebot/unpublish'); - expect(res.status).toBe(200); - expect(res.body.published).toBe(false); - - const configs = store.chatAgents as Array<{ - agentId: string; - lifecycleStage: string; - }>; - const entry = configs.find(c => c.agentId === 'e2ebot'); - expect(entry?.lifecycleStage).toBe('pending'); - }); - - it('reject round-trip: draft → pending → reject (with reason) → re-submit', async () => { - const { app, store } = setup({ - isAdmin: true, - userRef: 'user:default/admin', - initialConfig: { - agents: { - rejectbot: { name: 'Reject Bot', instructions: 'Test' }, - }, - chatAgents: [ - { - agentId: 'rejectbot', - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - createdBy: 'user:default/admin', - }, - ], - }, - }); - - // draft → pending - let res = await request(app) - .put('/agents/rejectbot/promote') - .send({ targetStage: 'pending' }); - expect(res.status).toBe(200); - - // pending → draft (rejection) - res = await request(app) - .put('/agents/rejectbot/demote') - .send({ targetStage: 'draft', reason: 'Insufficient instructions' }); - expect(res.status).toBe(200); - - let configs = store.chatAgents as Array<{ - agentId: string; - rejectionReason?: string; - rejectedBy?: string; - }>; - let entry = configs.find(c => c.agentId === 'rejectbot'); - expect(entry?.rejectionReason).toBe('Insufficient instructions'); - - // re-promote draft → pending (should clear rejection fields) - res = await request(app) - .put('/agents/rejectbot/promote') - .send({ targetStage: 'pending' }); - expect(res.status).toBe(200); - - configs = store.chatAgents as Array<{ - agentId: string; - rejectionReason?: string; - rejectedBy?: string; - }>; - entry = configs.find(c => c.agentId === 'rejectbot'); - expect(entry?.rejectionReason).toBeUndefined(); - expect(entry?.rejectedBy).toBeUndefined(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts deleted file mode 100644 index a3499dbf6fb..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts +++ /dev/null @@ -1,1304 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - type ChatAgent, - type ChatAgentConfig, - type AgentLifecycleStage, - deriveRoleFromTopology, - LIFECYCLE_STAGE_ORDER, - isValidTransition, - normalizeLifecycleStage, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { InputError } from '@backstage/errors'; -import { createWithRoute } from './routeWrapper'; -import type { RouteContext } from './types'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import { AuditLogger } from '../services/AuditLogger'; -import type { AgentApprovalWorkflowService } from '../services/AgentApprovalWorkflowService'; - -function isValidLifecycleStage(stage: unknown): stage is AgentLifecycleStage { - return ( - typeof stage === 'string' && - (LIFECYCLE_STAGE_ORDER as readonly string[]).includes(stage) - ); -} - -function isPublishedStage(stage: AgentLifecycleStage): boolean { - return stage === 'published'; -} - -interface LifecycleTransitionOpts { - configs: ChatAgentConfig[]; - agentId: string; - targetStage: AgentLifecycleStage; - userRef: string; - direction: string; - rejection?: { reason?: string }; - saveFn: (c: ChatAgentConfig[], actor: string) => Promise; - audit: AuditLogger; - logger: { info: (msg: string) => void }; - req: { headers: Record }; -} - -async function applyLifecycleTransition( - opts: LifecycleTransitionOpts, -): Promise<{ agentId: string; to: AgentLifecycleStage; version: number }> { - const { - configs, - agentId, - targetStage, - userRef, - direction, - rejection, - saveFn, - audit, - logger, - req, - } = opts; - const existing = configs.find(c => c.agentId === agentId); - const currentStage = normalizeLifecycleStage(existing?.lifecycleStage); - - if (!isValidTransition(currentStage, targetStage)) { - throw new InputError( - `Cannot transition from "${currentStage}" to "${targetStage}". ` + - `Check available transitions for the current stage.`, - ); - } - - const now = new Date().toISOString(); - const isProd = isPublishedStage(targetStage); - - if (existing) { - existing.lifecycleStage = targetStage; - existing.published = isProd; - existing.visible = isProd; - if (!isProd) existing.featured = false; - existing.version = (existing.version ?? 0) + 1; - existing.promotedAt = now; - existing.promotedBy = userRef; - if (rejection) { - existing.rejectionReason = rejection.reason || undefined; - existing.rejectedBy = userRef; - existing.rejectedAt = now; - } else if (existing.rejectionReason) { - existing.rejectionReason = undefined; - existing.rejectedBy = undefined; - existing.rejectedAt = undefined; - } - } else { - configs.push({ - agentId, - lifecycleStage: targetStage, - published: isProd, - visible: isProd, - featured: false, - version: 1, - promotedAt: now, - promotedBy: userRef, - createdBy: userRef, - createdAt: now, - ...(rejection - ? { - rejectionReason: rejection.reason || undefined, - rejectedBy: userRef, - rejectedAt: now, - } - : {}), - }); - } - - await saveFn(configs, userRef); - const version = existing?.version ?? 1; - audit.log({ - action: 'agent.lifecycle', - actor: userRef, - target: agentId, - outcome: 'success', - sourceIp: AuditLogger.extractIp( - req as Parameters[0], - ), - meta: { - from: currentStage, - to: targetStage, - direction, - version, - ...(rejection?.reason ? { rejectionReason: rejection.reason } : {}), - }, - }); - logger.info( - `Agent "${agentId}" ${direction} to ${targetStage} (v${version}) by ${userRef}`, - ); - return { agentId, to: targetStage, version }; -} - -/** - * Registers provider-agnostic agent listing and publish/unpublish endpoints. - * Merges agents from all sources (Kagenti deployed, orchestration config) - * into a unified catalog with publish-state gating. - */ -export function registerAgentRoutes( - ctx: RouteContext, - adminConfig: AdminConfigService, - approvalService?: AgentApprovalWorkflowService, -): void { - const { router, logger, sendRouteError } = ctx; - const withRoute = createWithRoute(logger, sendRouteError); - const audit = new AuditLogger(logger); - - /** - * Load the ChatAgentConfig[] array from the chatAgents DB key. - */ - async function loadChatAgentConfigs(): Promise { - const raw = await adminConfig.get('chatAgents'); - if (Array.isArray(raw)) return raw as ChatAgentConfig[]; - return []; - } - - /** - * Persist the full ChatAgentConfig[] array back to the chatAgents DB key. - */ - async function saveChatAgentConfigs( - configs: ChatAgentConfig[], - updatedBy: string, - ): Promise { - await adminConfig.set('chatAgents', configs, updatedBy); - } - - /** - * Read orchestration agents from the 'agents' DB key and map to ChatAgent[]. - * Roles are always derived from topology via deriveRoleFromTopology. - * Specialists are hidden from the gallery. - */ - async function loadOrchestrationAgents(): Promise { - const raw = await adminConfig.get('agents'); - if (!raw || typeof raw !== 'object') return []; - - const agents: ChatAgent[] = []; - const agentMap = raw as Record< - string, - { - name?: string; - instructions?: string; - model?: string; - handoffs?: string[]; - asTools?: string[]; - } - >; - const defaultAgent = await adminConfig.get('defaultAgent'); - - for (const [key, agentCfg] of Object.entries(agentMap)) { - if (!agentCfg || typeof agentCfg !== 'object') continue; - - const role = deriveRoleFromTopology(key, agentMap); - if (role === 'specialist') continue; - - agents.push({ - id: key, - name: agentCfg.name ?? key, - description: agentCfg.instructions - ? String(agentCfg.instructions).slice(0, 200) - : undefined, - status: 'config', - isDefault: key === defaultAgent, - providerType: 'orchestration', - framework: 'responses-api', - source: 'orchestration', - agentRole: role, - }); - } - return agents; - } - - /** - * Load published workflows as agents (workflow-builder-sourced agents). - */ - async function loadWorkflowAgents(): Promise { - const raw = await adminConfig.get('workflows'); - if (!raw || typeof raw !== 'object') return []; - const workflows = Array.isArray(raw) ? raw : Object.values(raw); - return workflows - .filter((w: Record) => w && w.id && w.name) - .map((w: Record) => ({ - id: w.id as string, - name: w.name as string, - description: (w.description as string) || undefined, - status: (w.status as string) === 'published' ? 'ready' : 'config', - isDefault: false, - providerType: 'orchestration', - framework: 'workflow-builder', - source: 'orchestration', - })); - } - - /** - * Merge agents from all sources and overlay publish state. - */ - async function buildUnifiedAgentList(): Promise<{ - agents: ChatAgent[]; - sources: { byo: number; workflowBuilder: number }; - }> { - const provider = ctx.provider; - - const orchProvider = ctx.orchestrationProvider; - const [ - providerAgents, - orchAgents, - orchProviderAgents, - workflowAgents, - chatConfigs, - ] = await Promise.all([ - provider.listAgents ? provider.listAgents() : Promise.resolve([]), - loadOrchestrationAgents(), - orchProvider?.listAgents - ? orchProvider.listAgents() - : Promise.resolve([]), - loadWorkflowAgents(), - loadChatAgentConfigs(), - ]); - - const configMap = new Map(chatConfigs.map(c => [c.agentId, c])); - const seen = new Set(); - const merged: ChatAgent[] = []; - - function overlayConfig(agent: ChatAgent, cfg?: ChatAgentConfig): ChatAgent { - const stage = normalizeLifecycleStage(cfg?.lifecycleStage); - return { - ...agent, - published: isPublishedStage(stage), - lifecycleStage: stage, - governanceRegistered: cfg !== undefined, - version: cfg?.version ?? 0, - promotedAt: cfg?.promotedAt, - promotedBy: cfg?.promotedBy, - createdBy: cfg?.createdBy, - rejectionReason: cfg?.rejectionReason, - rejectedBy: cfg?.rejectedBy, - rejectedAt: cfg?.rejectedAt, - pendingAction: cfg?.pendingAction, - chatEndpoint: cfg?.chatEndpoint, - }; - } - - for (const agent of providerAgents) { - seen.add(agent.id); - const cfg = configMap.get(agent.id); - merged.push( - overlayConfig( - { - ...agent, - source: agent.source ?? 'kagenti', - namespace: agent.id.includes('/') - ? agent.id.split('/')[0] - : undefined, - }, - cfg, - ), - ); - } - - for (const agent of orchAgents) { - if (seen.has(agent.id)) continue; - seen.add(agent.id); - const cfg = configMap.get(agent.id); - merged.push(overlayConfig(agent, cfg)); - } - - for (const agent of orchProviderAgents) { - if (seen.has(agent.id)) continue; - seen.add(agent.id); - const cfg = configMap.get(agent.id); - merged.push(overlayConfig(agent, cfg)); - } - - for (const agent of workflowAgents) { - if (seen.has(agent.id)) continue; - seen.add(agent.id); - const cfg = configMap.get(agent.id); - merged.push(overlayConfig(agent, cfg)); - } - - for (const cfg of chatConfigs) { - if (seen.has(cfg.agentId)) continue; - seen.add(cfg.agentId); - const stage = normalizeLifecycleStage(cfg.lifecycleStage); - const cfgAny = cfg as unknown as Record; - const storedFramework = cfgAny.framework as string | undefined; - const hasChatEndpoint = !!cfg.chatEndpoint; - const isSkillAgent = storedFramework === 'docsclaw' || hasChatEndpoint; - merged.push({ - id: cfg.agentId, - name: cfg.displayName ?? cfg.agentId, - description: cfg.description, - status: isPublishedStage(stage) ? 'ready' : 'config', - isDefault: false, - providerType: isSkillAgent ? 'augment' : 'orchestration', - framework: - storedFramework ?? (isSkillAgent ? 'docsclaw' : 'workflow-builder'), - source: isSkillAgent ? 'skills' : 'orchestration', - published: isPublishedStage(stage), - lifecycleStage: stage, - governanceRegistered: true, - version: cfg.version ?? 0, - promotedAt: cfg.promotedAt, - promotedBy: cfg.promotedBy, - createdBy: cfg.createdBy, - pendingAction: cfg.pendingAction, - chatEndpoint: cfg.chatEndpoint, - namespace: cfgAny.namespace as string | undefined, - rejectionReason: cfg.rejectionReason, - rejectedBy: cfg.rejectedBy, - rejectedAt: cfg.rejectedAt, - }); - } - - return { - agents: merged, - sources: { - byo: merged.filter( - a => a.source !== 'orchestration' && a.source !== 'skills', - ).length, - workflowBuilder: merged.filter( - a => a.source === 'orchestration' || a.source === 'skills', - ).length, - }, - }; - } - - // --------------------------------------------------------------------------- - // GET /agents -- unified agent listing - // Non-admins see: published agents + their own agents + legacy unowned agents. - // Admins see everything. - // --------------------------------------------------------------------------- - router.get( - '/agents', - withRoute('GET /agents', 'Failed to list agents', async (req, res) => { - const { agents, sources } = await buildUnifiedAgentList(); - const publishedFilter = req.query.published; - const isAdmin = await ctx.checkIsAdmin(req); - - let filtered = agents; - - if (publishedFilter === 'true') { - filtered = filtered.filter(a => a.published === true); - } else if (!isAdmin) { - const userRef = await ctx.getUserRef(req); - filtered = filtered.filter( - a => a.published === true || a.createdBy === userRef || !a.createdBy, - ); - } - - res.json({ - agents: filtered, - sources, - approvalMode: approvalService?.enabled ? 'workflow' : 'built-in', - }); - }), - ); - - // --------------------------------------------------------------------------- - // PUT /agents/:agentId/register -- add runtime agent to governance (draft) - // --------------------------------------------------------------------------- - router.put( - '/agents/:agentId/register', - ctx.requireAdminAccess, - withRoute( - 'PUT /agents/:agentId/register', - 'Failed to register agent for governance', - async (req, res) => { - const agentId = decodeURIComponent(req.params.agentId); - const userRef = await ctx.getUserRef(req); - const { agents } = await buildUnifiedAgentList(); - if (!agents.some(a => a.id === agentId)) { - res.status(404).json({ - error: `Agent "${agentId}" not found in the unified catalog.`, - }); - return; - } - - const configs = await loadChatAgentConfigs(); - if (configs.some(c => c.agentId === agentId)) { - res.status(409).json({ - error: `Agent "${agentId}" is already registered for governance.`, - }); - return; - } - - const now = new Date().toISOString(); - configs.push({ - agentId, - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - version: 0, - createdBy: userRef, - createdAt: now, - }); - await saveChatAgentConfigs(configs, userRef); - audit.log({ - action: 'agent.lifecycle', - actor: userRef, - target: agentId, - outcome: 'success', - sourceIp: AuditLogger.extractIp(req), - meta: { to: 'draft', direction: 'register' }, - }); - logger.info( - `Agent "${agentId}" registered for governance by ${userRef}`, - ); - res.status(201).json({ - success: true, - agentId, - lifecycleStage: 'draft', - governanceRegistered: true, - }); - }, - ), - ); - - // --------------------------------------------------------------------------- - // PUT /agents/:agentId/promote -- transition agent to the specified target stage - // Enforces valid transitions defined in LIFECYCLE_TRANSITIONS. - // draft → review is open to any authenticated user (submit for approval). - // All other transitions require admin access. - // Non-admins cannot promote phantom drafts (agents with no config entry). - // Non-admins can only promote agents they own (createdBy === userRef). - // --------------------------------------------------------------------------- - router.put( - '/agents/:agentId/promote', - withRoute( - 'PUT /agents/:agentId/promote', - 'Failed to promote agent', - async (req, res) => { - const agentId = decodeURIComponent(req.params.agentId); - const { targetStage } = req.body as { targetStage?: string }; - const resolved = targetStage - ? normalizeLifecycleStage(targetStage) - : undefined; - if (resolved !== undefined && !isValidLifecycleStage(resolved)) { - throw new InputError( - `Invalid targetStage "${targetStage}". Must be one of: ${LIFECYCLE_STAGE_ORDER.join(', ')}`, - ); - } - const userRef = await ctx.getUserRef(req); - const isAdmin = await ctx.checkIsAdmin(req); - const configs = await loadChatAgentConfigs(); - const existing = configs.find(c => c.agentId === agentId); - - const currentStage = normalizeLifecycleStage(existing?.lifecycleStage); - const nextStage = - resolved ?? - (() => { - const idx = LIFECYCLE_STAGE_ORDER.indexOf(currentStage); - const nextIdx = Math.min(idx + 1, LIFECYCLE_STAGE_ORDER.length - 2); - return LIFECYCLE_STAGE_ORDER[nextIdx]; - })(); - - const isSubmitForReview = - currentStage === 'draft' && nextStage === 'pending'; - if (!isSubmitForReview && !isAdmin) { - res.status(403).json({ - error: - 'Only admins can perform this lifecycle transition. ' + - 'Non-admin users may only submit draft agents for review.', - }); - return; - } - if (!isAdmin && !existing) { - res.status(404).json({ - error: - 'Agent not found in lifecycle config. ' + - 'Only agents you have created can be submitted for review.', - }); - return; - } - if (!isAdmin && existing?.createdBy && existing.createdBy !== userRef) { - res.status(403).json({ - error: 'You can only promote agents you created.', - }); - return; - } - - const isWorkflowCallback = - req.headers['x-augment-workflow-callback'] === 'true'; - const isAdminApprove = - currentStage === 'pending' && nextStage === 'published'; - - const secMode = - ctx.config.getOptionalString('augment.security.mode') || - 'plugin-only'; - if ( - isAdminApprove && - !isWorkflowCallback && - secMode !== 'none' && - existing?.createdBy && - existing.createdBy === userRef - ) { - res.status(403).json({ - error: - 'Cannot approve your own submission. A different admin must approve this agent.', - }); - return; - } - - // In SonataFlow mode, admin approve sends CloudEvent instead of - // applying the transition directly. The workflow will callback later - // with X-Augment-Workflow-Callback: true to apply it. - if ( - isAdminApprove && - approvalService?.enabled && - existing?.approvalWorkflowInstanceId && - !isWorkflowCallback - ) { - await approvalService.sendDecision( - existing.approvalWorkflowInstanceId, - true, - userRef, - ); - res.json({ - success: true, - agentId, - lifecycleStage: currentStage, - workflowDecisionSent: true, - }); - return; - } - - const result = await applyLifecycleTransition({ - configs, - agentId, - targetStage: nextStage, - userRef, - direction: 'promote', - saveFn: saveChatAgentConfigs, - audit, - logger, - req, - }); - - // Clear workflow fields after callback-driven promotion - if (isWorkflowCallback && existing) { - existing.approvalWorkflowInstanceId = undefined; - existing.pendingAction = undefined; - await saveChatAgentConfigs(configs, userRef); - } - - let workflowInstanceId: string | undefined; - if (isSubmitForReview && approvalService?.enabled) { - const updated = configs.find(c => c.agentId === agentId); - if (updated) updated.pendingAction = 'publish'; - workflowInstanceId = await approvalService.startWorkflow({ - agentId, - agentName: updated?.displayName ?? agentId, - requestedBy: userRef, - action: 'publish', - currentStage: 'draft', - targetStage: 'pending', - }); - if (workflowInstanceId && updated) { - updated.approvalWorkflowInstanceId = workflowInstanceId; - await saveChatAgentConfigs(configs, userRef); - } else if (!workflowInstanceId) { - // Fail-closed: workflow could not start, revert to draft - logger.warn( - `SonataFlow workflow failed to start for ${agentId}, reverting to draft`, - ); - await applyLifecycleTransition({ - configs, - agentId, - targetStage: 'draft', - userRef: 'system:workflow-revert', - direction: 'demote', - saveFn: saveChatAgentConfigs, - audit, - logger, - req, - }); - res.status(502).json({ - error: - 'Approval workflow could not be started. Agent returned to draft.', - }); - return; - } - } - - res.json({ - success: true, - agentId: result.agentId, - lifecycleStage: result.to, - version: result.version, - workflowInstanceId, - }); - }, - ), - ); - - // --------------------------------------------------------------------------- - // PUT /agents/:agentId/demote -- transition agent backward - // Enforces valid transitions defined in LIFECYCLE_TRANSITIONS. - // Accepts optional `reason` for review → draft (rejection). - // --------------------------------------------------------------------------- - router.put( - '/agents/:agentId/demote', - ctx.requireAdminAccess, - withRoute( - 'PUT /agents/:agentId/demote', - 'Failed to demote agent', - async (req, res) => { - const agentId = decodeURIComponent(req.params.agentId); - const { targetStage, reason } = req.body as { - targetStage?: string; - reason?: string; - }; - const resolved = targetStage - ? normalizeLifecycleStage(targetStage) - : undefined; - if (resolved !== undefined && !isValidLifecycleStage(resolved)) { - throw new InputError( - `Invalid targetStage "${targetStage}". Must be one of: ${LIFECYCLE_STAGE_ORDER.join(', ')}`, - ); - } - const userRef = await ctx.getUserRef(req); - const configs = await loadChatAgentConfigs(); - const existing = configs.find(c => c.agentId === agentId); - - const currentStage = normalizeLifecycleStage(existing?.lifecycleStage); - const nextStage = - resolved ?? - (() => { - const idx = LIFECYCLE_STAGE_ORDER.indexOf(currentStage); - return LIFECYCLE_STAGE_ORDER[Math.max(idx - 1, 0)]; - })(); - - const isRejection = currentStage === 'pending' && nextStage === 'draft'; - const result = await applyLifecycleTransition({ - configs, - agentId, - targetStage: nextStage, - userRef, - direction: 'demote', - rejection: isRejection ? { reason } : undefined, - saveFn: saveChatAgentConfigs, - audit, - logger, - req, - }); - - if ( - isRejection && - approvalService?.enabled && - existing?.approvalWorkflowInstanceId - ) { - await approvalService.sendDecision( - existing.approvalWorkflowInstanceId, - false, - userRef, - reason, - ); - existing.approvalWorkflowInstanceId = undefined; - existing.pendingAction = undefined; - await saveChatAgentConfigs(configs, userRef); - } - - res.json({ - success: true, - agentId: result.agentId, - lifecycleStage: result.to, - }); - }, - ), - ); - - // --------------------------------------------------------------------------- - // PUT /agents/:agentId/publish and /unpublish - // --------------------------------------------------------------------------- - for (const { path, target, dir, notFoundMsg } of [ - { - path: '/agents/:agentId/publish', - target: 'published' as AgentLifecycleStage, - dir: 'publish', - notFoundMsg: - 'Agent is not registered for governance. Register the agent before publishing.', - }, - { - path: '/agents/:agentId/unpublish', - target: 'pending' as AgentLifecycleStage, - dir: 'unpublish', - notFoundMsg: - 'Agent is not registered for governance. Nothing to unpublish.', - }, - ] as const) { - router.put( - path, - ctx.requireAdminAccess, - withRoute(`PUT ${path}`, `Failed to ${dir} agent`, async (req, res) => { - const agentId = decodeURIComponent(req.params.agentId); - const userRef = await ctx.getUserRef(req); - const configs = await loadChatAgentConfigs(); - const existing = configs.find(c => c.agentId === agentId); - if (!existing) { - res.status(404).json({ error: notFoundMsg }); - return; - } - - if ( - dir === 'publish' && - approvalService?.enabled && - existing.approvalWorkflowInstanceId - ) { - await approvalService.sendDecision( - existing.approvalWorkflowInstanceId, - true, - userRef, - ); - res.json({ - success: true, - agentId, - lifecycleStage: normalizeLifecycleStage(existing.lifecycleStage), - workflowDecisionSent: true, - }); - return; - } - - const result = await applyLifecycleTransition({ - configs, - agentId, - targetStage: target, - userRef, - direction: dir, - saveFn: saveChatAgentConfigs, - audit, - logger, - req, - }); - - if (approvalService?.enabled && existing.approvalWorkflowInstanceId) { - const isApprove = dir === 'publish'; - await approvalService.sendDecision( - existing.approvalWorkflowInstanceId, - isApprove, - userRef, - ); - existing.approvalWorkflowInstanceId = undefined; - existing.pendingAction = undefined; - await saveChatAgentConfigs(configs, userRef); - } - - res.json({ - success: true, - agentId: result.agentId, - published: target === 'published', - lifecycleStage: result.to, - }); - }), - ); - } - - // --------------------------------------------------------------------------- - // PUT /agents/bulk-publish -- bulk publish/unpublish (valid transitions only) - // --------------------------------------------------------------------------- - router.put( - '/agents/bulk-publish', - ctx.requireAdminAccess, - withRoute( - 'PUT /agents/bulk-publish', - 'Failed to bulk update publish state', - async (req, res) => { - const { agentIds, published } = req.body as { - agentIds: string[]; - published: boolean; - }; - if (!Array.isArray(agentIds) || typeof published !== 'boolean') { - res.status(400).json({ - error: 'agentIds (string[]) and published (boolean) required', - }); - return; - } - - const userRef = await ctx.getUserRef(req); - const configs = await loadChatAgentConfigs(); - const configMap = new Map(configs.map(c => [c.agentId, c])); - const now = new Date().toISOString(); - const targetStage: AgentLifecycleStage = published - ? 'published' - : 'pending'; - - const invalid: Array<{ - agentId: string; - reason: string; - currentStage?: AgentLifecycleStage; - }> = []; - - for (const agentId of agentIds) { - const existing = configMap.get(agentId); - if (!existing) { - invalid.push({ - agentId, - reason: 'not_registered_for_governance', - }); - continue; - } - const currentStage = normalizeLifecycleStage(existing.lifecycleStage); - if (!isValidTransition(currentStage, targetStage)) { - invalid.push({ - agentId, - reason: 'invalid_lifecycle_transition', - currentStage, - }); - } - } - - if (invalid.length > 0) { - res.status(400).json({ - error: - 'One or more agents cannot be updated. All agents must be registered and in a valid stage.', - invalid, - targetStage, - }); - return; - } - - for (const agentId of agentIds) { - const existing = configMap.get(agentId)!; - existing.lifecycleStage = targetStage; - existing.published = published; - if (published) { - existing.visible = true; - existing.version = (existing.version ?? 0) + 1; - } else { - existing.visible = false; - existing.featured = false; - } - existing.promotedAt = now; - existing.promotedBy = userRef; - } - - await saveChatAgentConfigs(configs, userRef); - audit.log({ - action: 'agent.lifecycle', - actor: userRef, - target: agentIds.join(', '), - outcome: 'success', - sourceIp: AuditLogger.extractIp(req), - meta: { - direction: 'bulk-publish', - to: targetStage, - count: agentIds.length, - }, - }); - logger.info( - `Bulk ${published ? 'publish' : 'unpublish'} of ${agentIds.length} agents by ${userRef}`, - ); - res.json({ - success: true, - count: agentIds.length, - published, - lifecycleStage: targetStage, - }); - }, - ), - ); - - // --------------------------------------------------------------------------- - // PUT /agents/:agentId/request-unpublish and /withdraw - // request-unpublish: published → pending with pendingAction='unpublish' - // withdraw: pending → draft, clears pendingAction - // --------------------------------------------------------------------------- - router.put( - '/agents/:agentId/request-unpublish', - withRoute( - 'PUT /agents/:agentId/request-unpublish', - 'Failed to request unpublish', - async (req, res) => { - const agentId = decodeURIComponent(req.params.agentId); - const userRef = await ctx.getUserRef(req); - const configs = await loadChatAgentConfigs(); - const existing = configs.find(c => c.agentId === agentId); - if (!existing) { - res - .status(404) - .json({ error: 'Agent not found in lifecycle config.' }); - return; - } - const isRequestOwner = existing.createdBy === userRef; - if (!isRequestOwner) { - const isAdmin = await ctx.checkIsAdmin(req); - if (!isAdmin) { - res.status(403).json({ - error: 'You can only request unpublish for agents you created.', - }); - return; - } - } - const result = await applyLifecycleTransition({ - configs, - agentId, - targetStage: 'pending', - userRef, - direction: 'request-unpublish', - saveFn: saveChatAgentConfigs, - audit, - logger, - req, - }); - const updated = configs.find(c => c.agentId === agentId); - if (updated) updated.pendingAction = 'unpublish'; - await saveChatAgentConfigs(configs, userRef); - - let workflowInstanceId: string | undefined; - if (approvalService?.enabled) { - workflowInstanceId = await approvalService.startWorkflow({ - agentId, - agentName: agentId, - requestedBy: userRef, - action: 'unpublish', - currentStage: 'published', - targetStage: 'pending', - }); - if (workflowInstanceId && existing) { - existing.approvalWorkflowInstanceId = workflowInstanceId; - await saveChatAgentConfigs(configs, userRef); - } - } - res.json({ - success: true, - agentId: result.agentId, - lifecycleStage: result.to, - pendingAction: 'unpublish', - workflowInstanceId, - }); - }, - ), - ); - - router.put( - '/agents/:agentId/withdraw', - withRoute( - 'PUT /agents/:agentId/withdraw', - 'Failed to withdraw agent', - async (req, res) => { - const agentId = decodeURIComponent(req.params.agentId); - const userRef = await ctx.getUserRef(req); - const configs = await loadChatAgentConfigs(); - const existing = configs.find(c => c.agentId === agentId); - if (!existing) { - res - .status(404) - .json({ error: 'Agent not found in lifecycle config.' }); - return; - } - const isOwner = existing.createdBy === userRef; - if (!isOwner) { - const isAdmin = await ctx.checkIsAdmin(req); - if (!isAdmin) { - res - .status(403) - .json({ error: 'You can only withdraw agents you created.' }); - return; - } - } - if (existing?.approvalWorkflowInstanceId && approvalService?.enabled) { - await approvalService.cancelWorkflow( - existing.approvalWorkflowInstanceId, - ); - } - const result = await applyLifecycleTransition({ - configs, - agentId, - targetStage: 'draft', - userRef, - direction: 'withdraw', - saveFn: saveChatAgentConfigs, - audit, - logger, - req, - }); - const updated = configs.find(c => c.agentId === agentId); - if (updated) { - updated.pendingAction = undefined; - updated.approvalWorkflowInstanceId = undefined; - } - await saveChatAgentConfigs(configs, userRef); - res.json({ - success: true, - agentId: result.agentId, - lifecycleStage: result.to, - }); - }, - ), - ); - - // --------------------------------------------------------------------------- - // PUT /agents/:agentId/config -- update agent display config - // --------------------------------------------------------------------------- - router.put( - '/agents/:agentId/config', - ctx.requireAdminAccess, - withRoute( - 'PUT /agents/:agentId/config', - 'Failed to update agent config', - async (req, res) => { - const agentId = decodeURIComponent(req.params.agentId); - const userRef = await ctx.getUserRef(req); - const patch = req.body as Partial; - const configs = await loadChatAgentConfigs(); - const existing = configs.find(c => c.agentId === agentId); - - if (existing) { - Object.assign(existing, patch, { agentId }); - } else { - configs.push({ - agentId, - published: false, - visible: false, - featured: false, - ...patch, - }); - } - - await saveChatAgentConfigs(configs, userRef); - res.json({ success: true, agentId }); - }, - ), - ); - - // --------------------------------------------------------------------------- - // DELETE /agents/:agentId -- enterprise-grade cascading delete - // Detects the agent's source and cleans up all stores: - // - chatAgents lifecycle config entry - // - orchestration admin config ('agents' key) for responses-api agents - // Kagenti K8s resource cleanup requires the dedicated admin endpoint. - // Non-admins can only delete their own draft agents. - // Admins can delete any agent's config. - // --------------------------------------------------------------------------- - router.delete( - '/agents/:agentId', - withRoute( - 'DELETE /agents/:agentId', - 'Failed to delete agent config', - async (req, res) => { - const agentId = decodeURIComponent(req.params.agentId); - const userRef = await ctx.getUserRef(req); - const admin = await ctx.checkIsAdmin(req); - - const { agents: allAgents } = await buildUnifiedAgentList(); - const agentRecord = allAgents.find(a => a.id === agentId); - const source = agentRecord?.source ?? 'unknown'; - - const configs = await loadChatAgentConfigs(); - const idx = configs.findIndex(c => c.agentId === agentId); - - if (idx === -1 && !agentRecord) { - res.status(404).json({ error: 'Agent not found' }); - return; - } - - const existing = idx !== -1 ? configs[idx] : undefined; - const stage = normalizeLifecycleStage(existing?.lifecycleStage); - - if (!admin) { - if (stage !== 'draft') { - res.status(403).json({ - error: 'Non-admin users can only delete agents in draft stage.', - }); - return; - } - if (existing?.createdBy && existing.createdBy !== userRef) { - res.status(403).json({ - error: 'You can only delete agents you created.', - }); - return; - } - } - - const cleanupResults: Record = {}; - - if (idx !== -1) { - configs.splice(idx, 1); - await saveChatAgentConfigs(configs, userRef); - cleanupResults.chatAgents = 'success'; - } else { - cleanupResults.chatAgents = 'skipped'; - } - - if (source === 'orchestration') { - try { - const raw = await adminConfig.get('agents'); - if (raw && typeof raw === 'object') { - const agentMap = { ...(raw as Record) }; - if (Object.hasOwn(agentMap, agentId)) { - delete agentMap[agentId]; - await adminConfig.set('agents', agentMap, userRef); - cleanupResults.orchestration = 'success'; - } else { - cleanupResults.orchestration = 'skipped'; - } - } else { - cleanupResults.orchestration = 'skipped'; - } - } catch (err) { - cleanupResults.orchestration = `failed: ${err instanceof Error ? err.message : 'unknown'}`; - logger.warn( - `Orchestration cleanup failed for "${agentId}": ${cleanupResults.orchestration}`, - ); - } - } else { - cleanupResults.orchestration = 'skipped'; - } - - // Workflow agent cleanup: delete workflow definition + versions - if ( - agentId.startsWith('wf-') || - agentRecord?.framework === 'workflow-builder' - ) { - try { - const workflows = - ((await adminConfig.get('workflows')) as Record< - string, - unknown - >) ?? {}; - if (workflows[agentId]) { - delete workflows[agentId]; - await adminConfig.set('workflows', workflows, userRef); - const versions = - ((await adminConfig.get('workflowVersions')) as Record< - string, - unknown - >) ?? {}; - delete versions[agentId]; - await adminConfig.set('workflowVersions', versions, userRef); - cleanupResults.workflow = 'success'; - } else { - cleanupResults.workflow = 'skipped (not in workflows store)'; - } - } catch (err) { - cleanupResults.workflow = `error: ${err instanceof Error ? err.message : 'unknown'}`; - } - } - - if (source === 'kagenti') { - cleanupResults.kagenti = - 'requires DELETE /kagenti/agents/:ns/:name (admin endpoint)'; - } else { - cleanupResults.kagenti = 'skipped'; - } - - if (existing?.chatEndpoint) { - try { - const apiUrl = (await adminConfig.get('devSpacesApiUrl')) as - | string - | undefined; - const token = (await adminConfig.get('devSpacesToken')) as - | string - | undefined; - if (apiUrl && token) { - const agentName = agentId.includes('/') - ? agentId.split('/').pop()! - : agentId; - const ns = existing.namespace ?? 'default'; - const k8sBase = `${apiUrl}/apis/apps/v1/namespaces/${ns}`; - const coreBase = `${apiUrl}/api/v1/namespaces/${ns}`; - const headers = { Authorization: `Bearer ${token}` }; - const { Agent: UndiciAgent } = await import('undici'); - const dispatcher = new UndiciAgent({ - connect: { rejectUnauthorized: false }, - }); - const fetchK8s = (url: string, method: string) => - fetch(url, { - method, - headers, - dispatcher: dispatcher as unknown as undefined, - } as RequestInit); - await Promise.allSettled([ - fetchK8s(`${k8sBase}/deployments/${agentName}`, 'DELETE'), - fetchK8s(`${coreBase}/services/${agentName}`, 'DELETE'), - fetchK8s( - `${coreBase}/configmaps/${agentName}-config`, - 'DELETE', - ), - ]); - cleanupResults.k8s = 'success'; - logger.info(`K8s resources deleted for skill agent ${agentId}`); - } else { - cleanupResults.k8s = 'skipped (no devSpaces credentials)'; - } - } catch (k8sErr) { - cleanupResults.k8s = `error: ${k8sErr instanceof Error ? k8sErr.message : String(k8sErr)}`; - logger.warn( - `K8s cleanup failed for ${agentId}: ${cleanupResults.k8s}`, - ); - } - } - - audit.log({ - action: 'agent.delete', - actor: userRef, - target: agentId, - outcome: 'success', - sourceIp: AuditLogger.extractIp(req), - meta: { - lifecycleStage: stage, - isAdmin: admin, - source, - cleanupResults, - }, - }); - logger.info( - `Agent "${agentId}" deleted (source: ${source}, stage: ${stage}) by ${userRef}`, - ); - res.json({ success: true, agentId, source, cleanupResults }); - }, - ), - ); - - // Startup reconciliation: auto-register runtime agents as draft - // Skip in test environment to avoid async race conditions - if (process.env.NODE_ENV === 'test') return; - void (async () => { - try { - const { agents } = await buildUnifiedAgentList(); - const configs = await loadChatAgentConfigs(); - const registered = new Set(configs.map(c => c.agentId)); - const unregistered = agents.filter( - a => !registered.has(a.id) && a.source !== 'skills', - ); - - if (unregistered.length === 0) return; - - const now = new Date().toISOString(); - for (const agent of unregistered) { - configs.push({ - agentId: agent.id, - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - version: 0, - createdAt: now, - }); - } - await saveChatAgentConfigs(configs, 'system:startup'); - logger.info( - `Startup reconciliation: auto-registered ${unregistered.length} agent(s) as draft`, - ); - } catch (err) { - logger.warn( - `Startup reconciliation failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - })(); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/chatRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/chatRoutes.ts deleted file mode 100644 index 1c5abd7e2e1..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/chatRoutes.ts +++ /dev/null @@ -1,978 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - LoggerService, - CacheService, -} from '@backstage/backend-plugin-api'; -import { InputError } from '@backstage/errors'; -import type { Response } from 'express'; -import type { NormalizedStreamEvent, AgenticProvider } from '../providers'; -import { getProviderDescriptor } from '../providers'; -import type { ChatMessage } from '../types'; -import type { SafetyChatResponse, EvaluatedChatResponse } from '../types'; -import { createWithRoute } from './routeWrapper'; -import { SseHeartbeat } from './sseRouteHelpers'; -import { sanitizeErrorMessage } from '../services/utils/errorSanitizer'; -import type { FlushableResponse, RouteContext } from './types'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import { trySkillChatProxy } from './skillChatProxy'; - -function getLastUserContent(messages: ChatMessage[]): string { - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === 'user') { - return messages[i].content || ''; - } - } - return ''; -} - -function buildSafetyResponse( - violation: string | undefined, - filterReason: 'input_violation' | 'output_violation', -): SafetyChatResponse { - const defaultMessages: Record = { - input_violation: - 'I cannot process this request as it may violate safety guidelines.', - output_violation: - 'The AI response was filtered because it may violate safety guidelines.', - }; - return { - role: 'assistant', - content: violation || defaultMessages[filterReason], - filtered: true, - filterReason, - }; -} - -function setupSseStream( - res: Response, - logger: LoggerService, - onDisconnect?: () => void, -): { - abortController: AbortController; - clientDisconnectedRef: { current: boolean }; -} { - res.setHeader('Content-Encoding', 'identity'); - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.flushHeaders(); - - const clientDisconnectedRef = { current: false }; - const abortController = new AbortController(); - res.on('close', () => { - clientDisconnectedRef.current = true; - abortController.abort(); - onDisconnect?.(); - logger.info('Client disconnected from stream'); - }); - return { abortController, clientDisconnectedRef }; -} - -interface StreamToolCall { - id: string; - name: string; - serverLabel: string; - arguments?: string; - output?: string; - error?: string; - status: string; -} - -interface StreamRagSource { - filename: string; - text?: string; - score?: number; - fileId?: string; -} - -interface StreamCitation { - title?: string; - url?: string; - snippet?: string; -} - -interface StreamMetadata { - agentName?: string; - toolCalls: StreamToolCall[]; - ragSources: StreamRagSource[]; - citations: StreamCitation[]; - usage?: { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - }; - reasoning: string; -} - -function createStreamEventForwarder( - res: Response, - clientDisconnectedRef: { current: boolean }, - logger: LoggerService, -): { - forward: (event: NormalizedStreamEvent) => void; - streamedTextRef: { current: string }; - streamModelRef: { current: string | undefined }; - streamMetadataRef: { current: StreamMetadata }; -} { - const streamedTextRef = { current: '' }; - const streamModelRef = { current: undefined as string | undefined }; - const streamMetadataRef: { current: StreamMetadata } = { - current: { toolCalls: [], ragSources: [], citations: [], reasoning: '' }, - }; - const MAX_PENDING_EVENTS = 500; - const state = { draining: false, terminated: false }; - const pendingQueue: string[] = []; - - function onDrain() { - state.draining = false; - flushQueue(); - } - - function writeAndFlush(payload: string): boolean { - const ok = res.write(payload); - const flushableRes = res as FlushableResponse; - if (flushableRes.flush) flushableRes.flush(); - return ok; - } - - function flushQueue() { - while ( - pendingQueue.length > 0 && - !clientDisconnectedRef.current && - !state.terminated - ) { - const next = pendingQueue.shift()!; - if (!writeAndFlush(next)) { - state.draining = true; - res.once('drain', onDrain); - return; - } - } - } - - const forward = (event: NormalizedStreamEvent) => { - if (event.type === 'stream.started') { - streamModelRef.current = (event as { model?: string }).model; - } else if (event.type === 'stream.text.delta' && event.delta) { - streamedTextRef.current += event.delta; - } else if ( - event.type === 'stream.artifact' && - (event as { content?: string }).content - ) { - streamedTextRef.current += (event as { content?: string }).content; - } else if (event.type === 'stream.completed' && event.usage) { - streamMetadataRef.current.usage = event.usage; - const u = event.usage; - if (u.output_tokens === 0 && !streamedTextRef.current) { - logger.warn( - `Model "${ - streamModelRef.current ?? 'unknown' - }" returned 0 output tokens (input=${ - u.input_tokens - }). The model may not support the Responses API or the request format.`, - ); - } else { - logger.info( - `Token usage: input=${u.input_tokens}, output=${u.output_tokens}, total=${u.total_tokens}`, - ); - } - } else if (event.type === 'stream.error') { - logger.warn(`Stream error: ${event.error}`); - } else if (event.type === 'stream.tool.approval') { - logger.info( - `HITL: Tool approval required for "${event.name}" - waiting for user decision`, - ); - } else if (event.type === 'stream.agent.handoff') { - const he = event as { toAgent?: string }; - if (he.toAgent) streamMetadataRef.current.agentName = he.toAgent; - } else if (event.type === 'stream.reasoning.delta' && event.delta) { - streamMetadataRef.current.reasoning += event.delta; - } else if (event.type === 'stream.tool.started') { - streamMetadataRef.current.toolCalls.push({ - id: event.callId, - name: event.name, - serverLabel: event.serverLabel || '', - status: 'running', - }); - } else if (event.type === 'stream.tool.completed') { - const tc = streamMetadataRef.current.toolCalls.find( - t => t.id === event.callId, - ); - if (tc) { - tc.output = event.output; - tc.status = 'completed'; - } - } else if (event.type === 'stream.tool.failed') { - const tc = streamMetadataRef.current.toolCalls.find( - t => t.id === event.callId, - ); - if (tc) { - tc.error = event.error; - tc.status = 'failed'; - } - } else if (event.type === 'stream.rag.results') { - for (const src of event.sources) { - streamMetadataRef.current.ragSources.push({ - filename: src.filename, - text: src.text, - score: src.score, - fileId: src.fileId, - }); - } - } else if (event.type === 'stream.citation') { - const citationEvent = event as { citations?: StreamCitation[] }; - if (citationEvent.citations) { - for (const c of citationEvent.citations) { - streamMetadataRef.current.citations.push(c); - } - } - } - - if (!clientDisconnectedRef.current && !state.terminated) { - const payload = `data: ${JSON.stringify(event)}\n\n`; - if (state.draining) { - if (pendingQueue.length >= MAX_PENDING_EVENTS) { - logger.warn( - `SSE backpressure queue exceeded ${MAX_PENDING_EVENTS} events — terminating slow client`, - ); - state.terminated = true; - res.end(); - return; - } - pendingQueue.push(payload); - return; - } - if (!writeAndFlush(payload)) { - state.draining = true; - res.once('drain', onDrain); - } - } - }; - - return { forward, streamedTextRef, streamModelRef, streamMetadataRef }; -} - -function handleStreamErrorAndCleanup( - res: Response, - clientDisconnectedRef: { current: boolean }, - error: unknown, - toErrorMessage: (e: unknown) => string, - logger: LoggerService, -): void { - const msg = toErrorMessage(error); - logger.error(`Streaming error: ${msg}`); - if (!clientDisconnectedRef.current) { - const { message: safeMsg } = sanitizeErrorMessage(msg); - const errorEvent: NormalizedStreamEvent = { - type: 'stream.error', - error: safeMsg, - code: 'stream_error', - }; - res.write(`data: ${JSON.stringify(errorEvent)}\n\n`); - res.end(); - } -} - -/** - * Resolve the conversationId for a request, creating one if needed - * when the session doesn't already have a linked conversation. - */ -async function resolveConversationId( - conversationId: string | undefined, - sessionId: string | undefined, - sessions: RouteContext['sessions'], - provider: RouteContext['provider'], - getUserRef: RouteContext['getUserRef'], - req: Parameters[0], - logger: LoggerService, -): Promise<{ resolvedConversationId: string | undefined; userRef: string }> { - const userRef = await getUserRef(req); - let resolvedConversationId = conversationId; - - if (!sessionId || !sessions) { - return { resolvedConversationId, userRef }; - } - - if (resolvedConversationId) { - const ownerSession = await sessions.findSessionByConversation( - resolvedConversationId, - userRef, - ); - if (!ownerSession) { - throw new InputError('Conversation not found or access denied'); - } - if (ownerSession.id !== sessionId) { - throw new InputError( - 'Session ID does not match the conversation. Use the correct session.', - ); - } - return { resolvedConversationId, userRef }; - } - - const session = await sessions.getSession(sessionId, userRef); - if (!session) { - throw new InputError(`Session ${sessionId} not found`); - } - - if (session.conversationId) { - return { resolvedConversationId: session.conversationId, userRef }; - } - - const desc = getProviderDescriptor(provider.id); - if (!provider.conversations || desc?.capabilities.contextHydration) { - return { resolvedConversationId, userRef }; - } - - let newConvId: string | undefined; - try { - ({ conversationId: newConvId } = await provider.conversations.create()); - const linked = await sessions.setConversationIdIfNull( - sessionId, - userRef, - newConvId, - ); - if (linked) { - resolvedConversationId = newConvId; - logger.info(`Created conversation ${newConvId} for session ${sessionId}`); - } else { - await provider.conversations - .deleteContainer?.(newConvId) - .catch(e => logger.debug(`cleanup of ${newConvId} failed: ${e}`)); - const refreshed = await sessions.getSession(sessionId, userRef); - resolvedConversationId = refreshed?.conversationId ?? undefined; - logger.info( - `Race resolved: session ${sessionId} already linked to ${resolvedConversationId}, cleaned up ${newConvId}`, - ); - } - } catch (convErr) { - if (newConvId) { - await provider.conversations - .deleteContainer?.(newConvId) - .catch(e => logger.debug(`cleanup of ${newConvId} failed: ${e}`)); - } - logger.warn( - `Could not create LlamaStack conversation for session ${sessionId}, continuing without: ${convErr}`, - ); - } - - return { resolvedConversationId, userRef }; -} - -/** - * Registers chat, streaming, and human-in-the-loop approval endpoints. - */ -/** - * Determine whether a chat request should be routed to the orchestration - * fallback provider instead of the primary Kagenti provider. - * Returns the orchestration provider when: (1) the primary is Kagenti, - * (2) an orchestration fallback exists, and (3) the requested model is - * NOT a known Kagenti agent. - * - * Uses a cached set of Kagenti agent IDs (refreshed periodically) to - * distinguish Kagenti agents from LlamaStack model IDs. This avoids - * fragile heuristics like slash-counting which break for model IDs - * like "vllm-inference/gemma-4-31b-it". - */ -const KAGENTI_AGENTS_CACHE_KEY = 'kagenti:agent-ids'; -const KAGENTI_CACHE_TTL_MS = 30_000; - -async function refreshKagentiCache( - primary: AgenticProvider, - cache?: CacheService, -): Promise> { - if (cache) { - const cached = await cache.get(KAGENTI_AGENTS_CACHE_KEY); - if (cached) { - return new Set(cached); - } - } - let agentIds: string[] = []; - try { - const agents = (await primary.listAgents?.()) ?? []; - agentIds = agents.map(a => a.id); - } catch { - // On failure, return empty set; short TTL will force retry - } - if (cache) { - await cache.set(KAGENTI_AGENTS_CACHE_KEY, agentIds, { - ttl: agentIds.length > 0 ? KAGENTI_CACHE_TTL_MS : 5_000, - }); - } - return new Set(agentIds); -} - -async function resolveProvider( - primary: AgenticProvider, - orchestration: AgenticProvider | undefined, - model: string | undefined, - cache?: CacheService, -): Promise { - const desc = getProviderDescriptor(primary.id); - if (!desc?.capabilities.agentLifecycle || !orchestration || !model) { - return primary; - } - const knownAgents = await refreshKagentiCache(primary, cache); - if (knownAgents.has(model)) { - return primary; - } - return orchestration; -} - -export function registerChatRoutes( - ctx: RouteContext, - adminConfig?: AdminConfigService, -): void { - const { - router, - logger, - sessions, - sendRouteError, - toErrorMessage, - parseChatRequest, - parseApprovalRequest, - getUserRef, - } = ctx; - - const withRoute = createWithRoute(logger, sendRouteError); - - router.post( - '/chat', - withRoute( - 'POST /chat', - 'Failed to process chat message', - async (req, res) => { - const parsed = parseChatRequest(req.body); - const provider = await resolveProvider( - ctx.provider, - ctx.orchestrationProvider, - parsed.model, - ctx.cache, - ); - const { - messages, - enableRAG, - previousResponseId, - conversationId, - sessionId, - } = parsed; - - const [, { resolvedConversationId, userRef }] = await Promise.all([ - provider.refreshDynamicConfig?.(), - resolveConversationId( - conversationId, - sessionId, - sessions, - provider, - getUserRef, - req, - logger, - ), - ]); - - // Hydrate provider context from DB for non-streaming path - const chatDescriptor = getProviderDescriptor(provider.id); - if ( - chatDescriptor?.capabilities.contextHydration && - sessionId && - sessions - ) { - const existingCtx = await provider.getSessionContextId!(sessionId); - if (!existingCtx) { - const ctxFromDb = - resolvedConversationId || - (await sessions.getSession(sessionId, userRef))?.conversationId; - if (ctxFromDb) { - await provider.hydrateSessionContext!( - sessionId, - ctxFromDb, - parsed.model, - ); - } - } - } - - const userContent = getLastUserContent(messages); - - if (provider.setUserContext) { - provider.setUserContext(userRef); - } - - if (provider.safety?.isEnabled()) { - const safetyResult = await provider.safety.checkInput(userContent); - if (!safetyResult.safe) { - logger.warn(`Input blocked by safety: ${safetyResult.violation}`); - res.json( - buildSafetyResponse(safetyResult.violation, 'input_violation'), - ); - return; - } - } - - const response = await provider.chat({ - messages, - enableRAG, - previousResponseId, - conversationId: resolvedConversationId, - sessionId, - model: parsed.model, - }); - - if (provider.safety?.isEnabled()) { - const outputResult = await provider.safety.checkOutput( - response.content, - ); - if (!outputResult.safe) { - logger.warn(`Output blocked by safety: ${outputResult.violation}`); - res.json( - buildSafetyResponse(outputResult.violation, 'output_violation'), - ); - return; - } - } - - if (provider.evaluation?.isEnabled()) { - const evaluation = await provider.evaluation.evaluateResponse( - userContent, - response.content, - response.ragContext, - ); - - if (evaluation) { - const evaluatedResponse: EvaluatedChatResponse = { - ...response, - evaluation, - }; - if (!evaluation.passedThreshold) { - logger.warn( - `Response scored below threshold: ${evaluation.overallScore.toFixed( - 2, - )} (${evaluation.qualityLevel})`, - ); - } - res.json(evaluatedResponse); - return; - } - } - - // Persist provider context ID for non-streaming path - if ( - chatDescriptor?.capabilities.contextHydration && - sessionId && - sessions - ) { - const ctxId = await provider.getSessionContextId!(sessionId); - if (ctxId) { - await sessions - .setConversationIdIfNull(sessionId, userRef, ctxId) - .catch(err => - logger.warn( - `Failed to link provider context for session ${sessionId}: ${err}`, - ), - ); - } - } - - // Persist messages to local store (non-streaming path) - if (sessionId && sessions) { - try { - if (userContent) { - await sessions.addMessage({ - sessionId, - role: 'user', - content: userContent, - }); - } - if (response.content) { - await sessions.addMessage({ - sessionId, - role: 'assistant', - content: response.content, - }); - } - } catch (persistErr) { - logger.warn( - `Failed to persist messages for session ${sessionId}: ${persistErr}`, - ); - } - } - - res.json(response); - }, - ), - ); - - router.post('/chat/stream', async (req, res) => { - logger.info('POST /chat/stream - Starting streaming response'); - - let parsedRequest: ReturnType; - try { - parsedRequest = parseChatRequest(req.body); - } catch (parseError) { - sendRouteError( - res, - parseError, - 'Invalid stream request', - 'Invalid stream request', - ); - return; - } - - if (adminConfig) { - const handled = await trySkillChatProxy({ - model: parsedRequest.model, - messages: parsedRequest.messages, - adminConfig, - config: ctx.config, - logger, - res: res as FlushableResponse, - }); - if (handled) return; - } - - const provider = await resolveProvider( - ctx.provider, - ctx.orchestrationProvider, - parsedRequest.model, - ctx.cache, - ); - const { - messages, - enableRAG, - previousResponseId, - conversationId, - sessionId, - } = parsedRequest; - - const heartbeat = new SseHeartbeat(res); - const { abortController, clientDisconnectedRef } = setupSseStream( - res, - logger, - () => heartbeat.stop(), - ); - const { forward, streamedTextRef, streamMetadataRef } = - createStreamEventForwarder(res, clientDisconnectedRef, logger); - heartbeat.start(); - - try { - const [, { resolvedConversationId, userRef }] = await Promise.all([ - provider.refreshDynamicConfig?.(), - resolveConversationId( - conversationId, - sessionId, - sessions, - provider, - getUserRef, - req, - logger, - ), - ]); - - // Hydrate provider session cache from DB so conversation continues - // across server restarts. - const streamDescriptor = getProviderDescriptor(provider.id); - if ( - streamDescriptor?.capabilities.contextHydration && - sessionId && - sessions - ) { - const existingCtx = await provider.getSessionContextId!(sessionId); - if (!existingCtx) { - const ctxFromDb = - resolvedConversationId || - (await sessions.getSession(sessionId, userRef))?.conversationId; - if (ctxFromDb) { - await provider.hydrateSessionContext!( - sessionId, - ctxFromDb, - parsedRequest.model, - ); - } - } - } - - if (provider.setUserContext) { - provider.setUserContext(userRef); - } - - if (provider.safety?.isEnabled()) { - const userContent = getLastUserContent(messages); - const safetyResult = await provider.safety.checkInput(userContent); - if (!safetyResult.safe) { - heartbeat.stop(); - const errorEvent: NormalizedStreamEvent = { - type: 'stream.error', - error: - safetyResult.violation || 'Input blocked by safety guardrails', - code: 'safety_violation', - }; - res.write(`data: ${JSON.stringify(errorEvent)}\n\n`); - res.end(); - return; - } - } - - let eventCount = 0; - const streamStartMs = Date.now(); - let firstEventMs: number | undefined; - const wrappedForward = (event: NormalizedStreamEvent) => { - eventCount++; - if (!firstEventMs) firstEventMs = Date.now(); - forward(event); - }; - - await provider.chatStream( - { - messages, - enableRAG, - previousResponseId, - conversationId: resolvedConversationId, - sessionId, - model: parsedRequest.model, - }, - wrappedForward, - abortController.signal, - ); - - const durationMs = Date.now() - streamStartMs; - const ttfbMs = firstEventMs ? firstEventMs - streamStartMs : undefined; - const ttfbInfo = - ttfbMs !== undefined ? `, ${ttfbMs}ms to first event` : ''; - const zeroWarn = - eventCount === 0 - ? ' (WARNING: zero events received from provider)' - : ''; - logger.info( - `Stream completed: ${eventCount} events, ${durationMs}ms total${ttfbInfo}${zeroWarn}`, - ); - - if (eventCount === 0 && !clientDisconnectedRef.current) { - const zeroEventError: NormalizedStreamEvent = { - type: 'stream.error', - error: - 'The agent returned no response. It may not support streaming — check agent capabilities.', - code: 'empty_stream', - }; - res.write(`data: ${JSON.stringify(zeroEventError)}\n\n`); - } - - let outputSafetyBlocked = false; - if ( - streamedTextRef.current && - provider.safety?.isEnabled() && - !clientDisconnectedRef.current - ) { - const outputResult = await provider.safety.checkOutput( - streamedTextRef.current, - ); - if (!outputResult.safe) { - outputSafetyBlocked = true; - logger.warn( - `Streamed output blocked by safety: ${outputResult.violation}`, - ); - const warningEvent: NormalizedStreamEvent = { - type: 'stream.error', - error: - outputResult.violation || - 'The AI response was filtered because it may violate safety guidelines.', - code: 'output_safety_violation', - }; - res.write(`data: ${JSON.stringify(warningEvent)}\n\n`); - } - } - - // Close the SSE connection immediately so the client is unblocked. - // Session bookkeeping runs in the background below. - heartbeat.stop(); - if (!clientDisconnectedRef.current) { - res.write('data: [DONE]\n\n'); - res.end(); - } - - // Fire-and-forget: persist session metadata without blocking the client. - if (sessionId && sessions) { - const lastUserContent = getLastUserContent(messages); - const meta = streamMetadataRef.current; - const streamedText = streamedTextRef.current; - - Promise.resolve() - .then(async () => { - const titleAndTouch: Promise[] = []; - - if (lastUserContent) { - titleAndTouch.push( - sessions.getSession(sessionId, userRef).then(async session => { - if (session && session.title.startsWith('Chat ')) { - const autoTitle = - lastUserContent.slice(0, 80) || session.title; - await sessions.updateTitle(sessionId, userRef, autoTitle); - } - }), - ); - } - - titleAndTouch.push(sessions.touch(sessionId, userRef)); - - if (streamDescriptor?.capabilities.contextHydration) { - const ctxId = await provider.getSessionContextId!(sessionId); - if (ctxId) { - titleAndTouch.push( - sessions - .setConversationIdIfNull(sessionId, userRef, ctxId) - .then(linked => { - if (linked) { - logger.info( - `Linked session ${sessionId} to provider context ${ctxId}`, - ); - } - }), - ); - } - } - - await Promise.all(titleAndTouch); - - const messagePersists: Promise[] = []; - if (lastUserContent) { - messagePersists.push( - sessions.addMessage({ - sessionId, - role: 'user', - content: lastUserContent, - }), - ); - } - if (streamedText && !outputSafetyBlocked) { - messagePersists.push( - sessions.addMessage({ - sessionId, - role: 'assistant', - content: streamedText, - agentName: meta.agentName, - toolCalls: - meta.toolCalls.length > 0 - ? JSON.stringify(meta.toolCalls) - : undefined, - ragSources: - meta.ragSources.length > 0 - ? JSON.stringify(meta.ragSources) - : undefined, - usage: meta.usage ? JSON.stringify(meta.usage) : undefined, - reasoning: meta.reasoning || undefined, - citations: - meta.citations.length > 0 - ? JSON.stringify(meta.citations) - : undefined, - }), - ); - } - await Promise.all(messagePersists); - }) - .catch(bgErr => { - logger.warn( - `Background session bookkeeping failed for ${sessionId}: ${bgErr}`, - ); - }); - } - } catch (error) { - heartbeat.stop(); - handleStreamErrorAndCleanup( - res, - clientDisconnectedRef, - error, - toErrorMessage, - logger, - ); - } - }); - - router.post( - '/chat/approve', - withRoute( - 'POST /chat/approve', - 'Failed to process approval', - async (req, res) => { - const provider = ctx.provider; - const { - responseId, - callId, - approved, - toolName, - toolArguments, - reason, - } = parseApprovalRequest(req.body); - - logger.info( - `Processing ${ - approved ? 'approval' : 'rejection' - } for responseId=${responseId}, callId=${callId}, tool=${toolName}`, - ); - - if (provider.submitApproval) { - const result = await provider.submitApproval({ - responseId, - callId, - approved: approved === true, - toolName, - toolArguments, - reason, - }); - res.json({ - success: true, - rejected: !approved, - content: result.content, - responseId: result.responseId, - toolExecuted: result.toolExecuted, - toolOutput: result.toolOutput, - pendingApproval: result.pendingApproval, - handoff: result.handoff, - }); - return; - } - - if (!provider.conversations) { - res.status(501).json({ - success: false, - error: 'Tool approval is not supported by the current provider', - }); - return; - } - - const result = await provider.conversations.submitApproval({ - responseId, - callId, - approved: approved === true, - toolName, - toolArguments, - }); - - res.json({ - success: true, - rejected: !approved, - content: result.content, - responseId: result.responseId, - toolExecuted: result.toolExecuted, - toolOutput: result.toolOutput, - outputTruncated: result.outputTruncated, - pendingApproval: result.pendingApproval, - handoff: result.handoff, - }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/configRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/configRoutes.ts deleted file mode 100644 index d105c66d9ad..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/configRoutes.ts +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { Workflow, QuickAction } from '../types'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import { createWithRoute } from './routeWrapper'; -import type { RouteContext } from './types'; - -function workflowsFromYaml( - config: import('@backstage/config').Config, -): Workflow[] { - const arr = config.getOptionalConfigArray('augment.workflows') || []; - return arr.map(wf => ({ - id: wf.getString('id'), - name: wf.getString('name'), - description: wf.getOptionalString('description') || '', - icon: wf.getOptionalString('icon'), - category: wf.getOptionalString('category'), - comingSoon: wf.getOptionalBoolean('comingSoon'), - comingSoonLabel: wf.getOptionalString('comingSoonLabel'), - steps: (wf.getOptionalConfigArray('steps') || []).map(step => ({ - title: step.getString('title'), - prompt: step.getString('prompt'), - description: step.getOptionalString('description'), - })), - })); -} - -function quickActionsFromYaml( - config: import('@backstage/config').Config, -): QuickAction[] { - const arr = config.getOptionalConfigArray('augment.quickPrompts') || []; - return arr.map(qp => ({ - title: qp.getString('title'), - description: qp.getOptionalString('description'), - prompt: qp.getString('prompt'), - icon: qp.getOptionalString('icon'), - category: qp.getOptionalString('category'), - comingSoon: qp.getOptionalBoolean('comingSoon'), - comingSoonLabel: qp.getOptionalString('comingSoonLabel'), - })); -} - -function promptGroupsFromYaml(config: import('@backstage/config').Config) { - const arr = config.getOptionalConfigArray('augment.promptGroups') || []; - return arr.map((sl, index) => ({ - id: sl.getString('id'), - title: sl.getString('title'), - description: sl.getOptionalString('description'), - icon: sl.getOptionalString('icon'), - color: sl.getOptionalString('color'), - order: sl.getOptionalNumber('order') ?? index + 1, - cards: (sl.getOptionalConfigArray('cards') || []).map(card => ({ - title: card.getString('title'), - description: card.getOptionalString('description'), - prompt: card.getString('prompt'), - icon: card.getOptionalString('icon'), - comingSoon: card.getOptionalBoolean('comingSoon'), - comingSoonLabel: card.getOptionalString('comingSoonLabel'), - })), - })); -} - -interface TourStepConfig { - target?: string; - title: string; - description?: string; - side?: string; - action?: { - type: string; - panel?: string; - selector?: string; - step?: number; - method?: string; - cardId?: string; - }; - waitFor?: string; -} - -interface TourConfig { - id: string; - title: string; - description?: string; - category?: string; - estimatedMinutes?: number; - persona?: string; - page?: string; - steps: TourStepConfig[]; -} - -function toursFromYaml( - config: import('@backstage/config').Config, -): TourConfig[] { - const arr = config.getOptionalConfigArray('augment.tours') || []; - return arr.map(tour => { - const steps = (tour.getOptionalConfigArray('steps') || []).map(step => { - const actionCfg = step.getOptionalConfig('action'); - const action = actionCfg - ? { - type: actionCfg.getString('type'), - panel: actionCfg.getOptionalString('panel'), - selector: actionCfg.getOptionalString('selector'), - step: actionCfg.getOptionalNumber('step'), - method: actionCfg.getOptionalString('method'), - cardId: actionCfg.getOptionalString('cardId'), - } - : undefined; - - return { - target: step.getOptionalString('target'), - title: step.getString('title'), - description: step.getOptionalString('description'), - side: step.getOptionalString('side'), - action, - waitFor: step.getOptionalString('waitFor'), - }; - }); - - return { - id: tour.getString('id'), - title: tour.getString('title'), - description: tour.getOptionalString('description'), - category: tour.getOptionalString('category'), - estimatedMinutes: tour.getOptionalNumber('estimatedMinutes'), - persona: tour.getOptionalString('persona'), - page: tour.getOptionalString('page'), - steps, - }; - }); -} - -/** - * Registers endpoints for workflows, quick actions, and prompt groups. - * When adminConfig is provided, DB values take precedence over YAML. - */ -export function registerConfigRoutes( - ctx: RouteContext, - adminConfig?: AdminConfigService, -): void { - const { router, logger, config, sendRouteError } = ctx; - const withRoute = createWithRoute(logger, sendRouteError); - - router.get( - '/guided-workflows', - withRoute( - 'GET /guided-workflows', - 'Failed to get guided workflows', - async (_req, res) => { - const workflows = workflowsFromYaml(config); - res.json({ - success: true, - workflows, - totalWorkflows: workflows.length, - source: 'yaml', - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.get( - '/quick-actions', - withRoute( - 'GET /quick-actions', - 'Failed to get quick actions', - async (_req, res) => { - const quickActions = quickActionsFromYaml(config); - res.json({ - success: true, - quickActions, - totalQuickActions: quickActions.length, - source: 'yaml', - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - const handlePromptGroups = async ( - _req: import('express').Request, - res: import('express').Response, - ) => { - let promptGroups: ReturnType; - let source: 'database' | 'yaml' = 'yaml'; - - if (adminConfig) { - const dbValue = await adminConfig.get('promptGroups'); - if (dbValue !== undefined && Array.isArray(dbValue)) { - promptGroups = dbValue; - source = 'database'; - } else { - promptGroups = promptGroupsFromYaml(config); - } - } else { - promptGroups = promptGroupsFromYaml(config); - } - - res.json({ - success: true, - promptGroups, - totalPromptGroups: promptGroups.length, - source, - timestamp: new Date().toISOString(), - }); - }; - - router.get( - '/prompt-groups', - withRoute( - 'GET /prompt-groups', - 'Failed to get prompt groups', - handlePromptGroups, - ), - ); - - router.get( - '/tours', - withRoute('GET /tours', 'Failed to get tours', async (_req, res) => { - const tours = toursFromYaml(config); - res.json({ - success: true, - tours, - totalTours: tours.length, - source: tours.length > 0 ? 'yaml' : 'defaults', - timestamp: new Date().toISOString(), - }); - }), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/conversationRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/conversationRoutes.ts deleted file mode 100644 index cfa89c4e1e3..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/conversationRoutes.ts +++ /dev/null @@ -1,326 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { InputError } from '@backstage/errors'; -import { - DEFAULT_CONVERSATIONS_LIMIT, - MAX_CONVERSATIONS_LIMIT, -} from '../constants'; -import { createWithRoute, notFound } from './routeWrapper'; -import type { RouteContext } from './types'; -import type { ConversationDetails } from '../providers/llamastack/conversationTypes'; - -/** - * Strip system prompt / instructions from a ConversationDetails before - * returning it to the frontend. Llama Stack may store instructions as - * `input_text` content parts in output items or as developer/system - * messages in the input array. - */ -function sanitizeConversationDetails( - details: ConversationDetails, -): ConversationDetails { - const sanitizedOutput = (details.output || []).map(item => { - if (item.role !== 'assistant' || !Array.isArray(item.content)) return item; - return { - ...item, - content: item.content.filter(c => c.type !== 'input_text'), - }; - }); - - let sanitizedInput = details.input; - if (Array.isArray(details.input)) { - sanitizedInput = (details.input as Array>).filter( - item => { - const role = item.role as string | undefined; - return role !== 'developer' && role !== 'system'; - }, - ); - } - - return { - ...details, - input: sanitizedInput, - output: sanitizedOutput, - }; -} - -const VALID_ORDER_VALUES = new Set(['asc', 'desc']); - -/** - * Registers conversation CRUD and history endpoints. - */ -export function registerConversationRoutes(ctx: RouteContext): void { - const { - router, - logger, - provider, - sendRouteError, - missingConversations, - getUserRef, - sessions, - } = ctx; - - const withRoute = createWithRoute(logger, sendRouteError); - - async function verifyConversationOwnership( - conversationId: string, - req: import('express').Request, - ): Promise { - if (!sessions) return; - const userRef = await getUserRef(req); - const ownerSession = await sessions.findSessionByConversation( - conversationId, - userRef, - ); - if (!ownerSession) { - throw new InputError('Conversation not found or access denied'); - } - } - - router.post( - '/conversations/create', - withRoute( - 'POST /conversations/create', - 'Failed to create conversation', - async (_req, res) => { - if (missingConversations(res)) return; - const { conversationId } = await provider.conversations!.create(); - res.json({ - success: true, - conversationId, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.get( - '/conversations/by-conversation/:conversationId/items', - withRoute( - req => - `GET /conversations/by-conversation/${req.params.conversationId}/items`, - 'Failed to get conversation items', - async (req, res) => { - const { conversationId } = req.params; - if (!conversationId || typeof conversationId !== 'string') { - throw new InputError('conversationId path parameter is required'); - } - if (missingConversations(res)) return; - await verifyConversationOwnership(conversationId, req); - const getChain = provider.conversations!.getByResponseChain.bind( - provider.conversations, - ); - const result = await getChain(conversationId); - logger.info( - `Returning ${result.items.length} items for conversation ${conversationId}`, - ); - res.json({ - success: true, - items: result.items, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.get( - '/conversations/by-conversation/:conversationId/messages', - withRoute( - req => - `GET /conversations/by-conversation/${req.params.conversationId}/messages`, - 'Failed to get processed messages', - async (req, res) => { - const { conversationId } = req.params; - if (!conversationId || typeof conversationId !== 'string') { - throw new InputError('conversationId path parameter is required'); - } - if (missingConversations(res)) return; - await verifyConversationOwnership(conversationId, req); - const messages = - await provider.conversations!.getProcessedMessages(conversationId); - logger.info( - `Returning ${messages.length} processed messages for conversation ${conversationId}`, - ); - res.json({ - success: true, - messages, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.get( - '/conversations/by-response/:responseId/chain', - withRoute( - req => `GET /conversations/by-response/${req.params.responseId}/chain`, - 'Failed to walk response chain', - async (req, res) => { - const { responseId } = req.params; - if (!responseId || typeof responseId !== 'string') { - throw new InputError('responseId path parameter is required'); - } - if (missingConversations(res)) return; - const getChain = provider.conversations!.getByResponseChain.bind( - provider.conversations, - ); - const chainResult = await getChain(responseId); - const messages = chainResult.items.map( - (item: { role?: string; content?: unknown }) => ({ - role: item.role as 'user' | 'assistant', - text: typeof item.content === 'string' ? item.content : '', - }), - ); - logger.info( - `Returning ${messages.length} messages from chain walk for ${responseId}`, - ); - res.json({ - success: true, - messages, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.get( - '/conversations', - withRoute( - 'GET /conversations', - 'Failed to list conversations', - async (req, res) => { - const rawLimit = parseInt(String(req.query.limit), 10); - const limit = Number.isFinite(rawLimit) - ? Math.min(Math.max(rawLimit, 1), MAX_CONVERSATIONS_LIMIT) - : DEFAULT_CONVERSATIONS_LIMIT; - const rawOrder = String(req.query.order || 'desc'); - const order: 'asc' | 'desc' = VALID_ORDER_VALUES.has(rawOrder) - ? (rawOrder as 'asc' | 'desc') - : 'desc'; - const after = req.query.after ? String(req.query.after) : undefined; - - logger.info( - `Fetching conversations with limit=${limit}, order=${order}, after=${ - after || 'none' - }`, - ); - if (!provider.conversations) { - res.json({ - success: true, - conversations: [], - hasMore: false, - timestamp: new Date().toISOString(), - }); - return; - } - const result = await provider.conversations.list(limit, after); - logger.info( - `Returning ${result.conversations.length} conversations, hasMore=${result.hasMore}`, - ); - res.json({ - success: true, - ...result, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.get( - '/conversations/:responseId', - withRoute( - req => `GET /conversations/${req.params.responseId}`, - 'Failed to get conversation', - async (req, res) => { - const { responseId } = req.params; - if (!responseId || typeof responseId !== 'string') { - throw new InputError('responseId is required'); - } - if (missingConversations(res)) return; - const conversation = await provider.conversations!.get(responseId); - if (!conversation) { - logger.warn( - `Conversation ${responseId} not found or could not be retrieved`, - ); - notFound(res, 'Conversation'); - return; - } - logger.info( - `Returning conversation ${responseId} with ${ - conversation.output?.length || 0 - } output items`, - ); - res.json({ - success: true, - conversation: sanitizeConversationDetails(conversation), - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.get( - '/conversations/:responseId/inputs', - withRoute( - req => `GET /conversations/${req.params.responseId}/inputs`, - 'Failed to get conversation inputs', - async (req, res) => { - const { responseId } = req.params; - if (!responseId || typeof responseId !== 'string') { - throw new InputError('responseId is required'); - } - if (missingConversations(res)) return; - const result = await provider.conversations!.getInputs(responseId); - const sanitizedItems = result.items.filter( - (item: { role?: string }) => { - const role = item.role; - return role !== 'developer' && role !== 'system'; - }, - ); - logger.info( - `Returning ${sanitizedItems.length} input items for ${responseId} (${result.items.length - sanitizedItems.length} system items stripped)`, - ); - res.json({ - success: true, - items: sanitizedItems, - hasMore: result.hasMore, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.delete( - '/conversations/:responseId', - withRoute( - req => `DELETE /conversations/${req.params.responseId}`, - 'Failed to delete conversation', - async (req, res) => { - const { responseId } = req.params; - if (!responseId || typeof responseId !== 'string') { - throw new InputError('responseId is required'); - } - if (missingConversations(res)) return; - const conversationId = req.query.conversationId as string | undefined; - await provider.conversations!.delete(responseId, conversationId); - res.json({ - success: true, - message: 'Conversation deleted', - timestamp: new Date().toISOString(), - }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/devSpacesRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/devSpacesRoutes.ts deleted file mode 100644 index 7ce4a93d123..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/devSpacesRoutes.ts +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type express from 'express'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { InputError } from '@backstage/errors'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import { DevSpacesService } from '../services/DevSpacesService'; -import { createWithRoute } from './routeWrapper'; - -export interface DevSpacesRouteDeps { - router: express.Router; - logger: LoggerService; - adminConfig: AdminConfigService; - sendRouteError: ( - res: express.Response, - error: unknown, - logLabel: string, - userFacingError: string, - extra?: Record, - statusCode?: number, - ) => void; - requireAdminAccess: express.RequestHandler; - /** Acquire a platform auth token (Keycloak client credentials). */ - getAuthToken?: () => Promise; - /** Root Backstage config for reading static config values. */ - config?: import('@backstage/config').Config; -} - -/** - * Registers routes that proxy requests to an external Dev Spaces API. - * Provides full workspace lifecycle management: health check, create, - * list, get status, stop, and delete. - */ -export function registerDevSpacesRoutes(deps: DevSpacesRouteDeps): void { - const { - router, - logger, - adminConfig, - sendRouteError, - requireAdminAccess, - getAuthToken, - config, - } = deps; - const withRoute = createWithRoute(logger, sendRouteError); - const service = new DevSpacesService({ - logger, - adminConfig, - getAuthToken, - config, - }); - - // ── Health check ────────────────────────────────────────────────────── - router.get( - '/devspaces/health', - requireAdminAccess, - withRoute( - 'GET /devspaces/health', - 'Failed to check Dev Spaces health', - async (_req, res) => { - const result = await service.healthCheck(); - res.json(result); - }, - ), - ); - - // ── Create workspace ────────────────────────────────────────────────── - router.post( - '/devspaces/workspaces', - requireAdminAccess, - withRoute( - 'POST /devspaces/workspaces', - 'Failed to create Dev Spaces workspace', - async (req, res) => { - const { git_repo, memory_limit, cpu_limit } = req.body ?? {}; - let { namespace } = (req.body ?? {}) as { namespace?: string }; - if (!namespace || namespace === 'auto') { - namespace = service.resolveNamespaceForUser('user:default/guest'); - } - if (!git_repo || typeof git_repo !== 'string') { - throw new InputError('git_repo is required'); - } - const result = await service.createWorkspace({ - namespace, - git_repo, - memory_limit, - cpu_limit, - }); - res.json(result); - }, - ), - ); - - // ── List workspaces ─────────────────────────────────────────────────── - router.get( - '/devspaces/workspaces', - requireAdminAccess, - withRoute( - 'GET /devspaces/workspaces', - 'Failed to list Dev Spaces workspaces', - async (req, res) => { - const namespace = req.query.namespace as string | undefined; - if (!namespace) { - throw new InputError('namespace query parameter is required'); - } - const result = await service.listWorkspaces(namespace); - res.json(result); - }, - ), - ); - - // ── Get workspace status ────────────────────────────────────────────── - router.get( - '/devspaces/workspaces/:namespace/:name', - requireAdminAccess, - withRoute( - req => - `GET /devspaces/workspaces/${req.params.namespace}/${req.params.name}`, - 'Failed to get Dev Spaces workspace', - async (req, res) => { - const { namespace, name } = req.params; - const result = await service.getWorkspace(namespace, name); - res.json(result); - }, - ), - ); - - // ── Stop workspace ──────────────────────────────────────────────────── - router.patch( - '/devspaces/workspaces/:namespace/:name/stop', - requireAdminAccess, - withRoute( - req => - `PATCH /devspaces/workspaces/${req.params.namespace}/${req.params.name}/stop`, - 'Failed to stop Dev Spaces workspace', - async (req, res) => { - const { namespace, name } = req.params; - await service.stopWorkspace(namespace, name); - res.json({ - success: true, - message: `Workspace ${name} stop requested`, - }); - }, - ), - ); - - // ── Delete workspace ────────────────────────────────────────────────── - router.delete( - '/devspaces/workspaces/:namespace/:name', - requireAdminAccess, - withRoute( - req => - `DELETE /devspaces/workspaces/${req.params.namespace}/${req.params.name}`, - 'Failed to delete Dev Spaces workspace', - async (req, res) => { - const { namespace, name } = req.params; - await service.deleteWorkspace(namespace, name); - res.json({ success: true, message: `Workspace ${name} deleted` }); - }, - ), - ); - - // ── Frameworks (static config) ───────────────────────────────────── - router.get( - '/devspaces/frameworks', - withRoute( - 'GET /devspaces/frameworks', - 'Failed to read Dev Spaces frameworks', - async (_req, res) => { - const frameworks: unknown[] = []; - const arr = - config - ?.getOptionalConfigArray('augment.devSpaces.frameworks') - ?.map(c => ({ - id: c.getString('id'), - name: c.getString('name'), - description: c.getOptionalString('description') ?? '', - tags: c.getOptionalStringArray('tags') ?? [], - starterRepo: c.getOptionalString('starterRepo') ?? '', - badge: c.getOptionalString('badge') ?? '', - })) ?? []; - frameworks.push(...arr); - res.json({ frameworks }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/documentRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/documentRoutes.ts deleted file mode 100644 index ee8fa249fc3..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/documentRoutes.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { createWithRoute } from './routeWrapper'; -import type { RouteContext } from './types'; - -/** - * Registers document, RAG sync, safety, evaluation, and vector-store endpoints. - */ -export function registerDocumentRoutes(ctx: RouteContext): void { - const { router, logger, sendRouteError } = ctx; - const withRoute = createWithRoute(logger, sendRouteError); - - router.get( - '/documents', - withRoute( - 'GET /documents', - 'Failed to list documents', - async (req, res) => { - const provider = ctx.provider; - if (!provider.rag) { - res.json({ - success: true, - documents: [], - totalDocuments: 0, - timestamp: new Date().toISOString(), - }); - return; - } - const vectorStoreId = - typeof req.query.vectorStoreId === 'string' - ? req.query.vectorStoreId - : undefined; - const documents = await provider.rag.listDocuments(vectorStoreId); - res.json({ - success: true, - documents, - totalDocuments: documents.length, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.post( - '/sync', - withRoute('POST /sync', 'Failed to sync documents', async (_req, res) => { - const provider = ctx.provider; - if (!provider.rag) { - res.status(501).json({ - success: false, - error: 'RAG not supported by current provider', - }); - return; - } - const result = await provider.rag.syncDocuments(); - res.json({ - success: true, - ...result, - timestamp: new Date().toISOString(), - }); - }), - ); - - router.get( - '/safety/status', - withRoute( - 'GET /safety/status', - 'Failed to get safety status', - async (_req, res) => { - const provider = ctx.provider; - await provider.refreshDynamicConfig?.(); - if (provider.safety) { - const safetyStatus = await provider.safety.getStatus(); - res.json({ ...safetyStatus, timestamp: new Date().toISOString() }); - } else { - res.json({ - enabled: false, - shields: [], - timestamp: new Date().toISOString(), - }); - } - }, - ), - ); - - router.get( - '/evaluation/status', - withRoute( - 'GET /evaluation/status', - 'Failed to get evaluation status', - async (_req, res) => { - const provider = ctx.provider; - await provider.refreshDynamicConfig?.(); - if (provider.evaluation) { - const evalStatus = await provider.evaluation.getStatus(); - res.json({ ...evalStatus, timestamp: new Date().toISOString() }); - } else { - res.json({ - enabled: false, - scoringFunctions: [], - timestamp: new Date().toISOString(), - }); - } - }, - ), - ); - - router.get( - '/vector-stores', - withRoute( - 'GET /vector-stores', - 'Failed to list vector stores', - async (_req, res) => { - const provider = ctx.provider; - if (!provider.rag) { - res.json({ - success: true, - vectorStores: [], - timestamp: new Date().toISOString(), - }); - return; - } - const vectorStores = await provider.rag.listVectorStores(); - const defaultId = provider.rag.getDefaultVectorStoreId(); - res.json({ - success: true, - vectorStores, - defaultVectorStoreId: defaultId, - timestamp: new Date().toISOString(), - }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/evaluationRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/evaluationRoutes.ts deleted file mode 100644 index 4e8b70328ae..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/evaluationRoutes.ts +++ /dev/null @@ -1,778 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { randomUUID as uuid } from 'node:crypto'; -import type { - WorkflowTestCase, - WorkflowEvaluationResult, - TestCaseResult, - EvaluationCriterion, - NodeExecutionRecord, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { InputError } from '@backstage/errors'; -import { createWithRoute } from './routeWrapper'; -import type { RouteContext } from './types'; -import type { WorkflowConfigService } from '../services/WorkflowConfigService'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import { ResponsesApiClient } from '../services/ResponsesApiClient'; -import { resolveLlamaStackConfig } from './resolveWorkflowConfig'; - -export interface EvaluationDeps { - workflowService: WorkflowConfigService; - adminConfig: AdminConfigService; - runWorkflow: ( - workflowId: string, - input: string, - ) => Promise<{ - response: string; - agentName?: string; - trace: NodeExecutionRecord[]; - durationMs: number; - tokenUsage?: { inputTokens: number; outputTokens: number }; - }>; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -interface ScoringRow { - score?: number | null; - scores?: Record; - judge_feedback?: string; -} - -interface ScoringFnResult { - score_rows?: ScoringRow[]; - aggregated_results?: Record; -} - -function findFraction(text: string): [number, number] | null { - const len = text.length; - let i = 0; - while (i < len) { - if (text[i] >= '0' && text[i] <= '9') { - let numEnd = i; - while (numEnd < len && text[numEnd] >= '0' && text[numEnd] <= '9') - numEnd++; - let j = numEnd; - while (j < len && (text[j] === ' ' || text[j] === '\t')) j++; - if (j < len && text[j] === '/') { - j++; - while (j < len && (text[j] === ' ' || text[j] === '\t')) j++; - if (j < len && text[j] >= '0' && text[j] <= '9') { - let denomEnd = j; - while ( - denomEnd < len && - text[denomEnd] >= '0' && - text[denomEnd] <= '9' - ) - denomEnd++; - return [ - Number(text.slice(i, numEnd)), - Number(text.slice(j, denomEnd)), - ]; - } - } - i = numEnd; - } else { - i++; - } - } - return null; -} - -function findScoreLabel(text: string): number { - const lower = text.toLowerCase(); - let pos = lower.indexOf('score'); - while (pos !== -1) { - let j = pos + 5; - while ( - j < text.length && - (text[j] === '*' || - text[j] === ':' || - text[j] === ' ' || - text[j] === '\t') - ) - j++; - if (j < text.length && text[j] >= '0' && text[j] <= '9') return j; - pos = lower.indexOf('score', pos + 1); - } - return -1; -} - -function parseNumber(text: string, start: number): [number, number] { - let end = start; - while (end < text.length && text[end] >= '0' && text[end] <= '9') end++; - if (end < text.length && text[end] === '.') { - const dotPos = end; - end++; - while (end < text.length && text[end] >= '0' && text[end] <= '9') end++; - if (end === dotPos + 1) end = dotPos; - } - return [Number(text.slice(start, end)), end]; -} - -function parseScoreFromFeedback(feedback: string): number | null { - const numStart = findScoreLabel(feedback); - if (numStart >= 0) { - const [num, numEnd] = parseNumber(feedback, numStart); - let j = numEnd; - while (j < feedback.length && (feedback[j] === ' ' || feedback[j] === '\t')) - j++; - if (j < feedback.length && feedback[j] === '/') { - j++; - while ( - j < feedback.length && - (feedback[j] === ' ' || feedback[j] === '\t') - ) - j++; - if (j < feedback.length && feedback[j] >= '0' && feedback[j] <= '9') { - const [denom] = parseNumber(feedback, j); - if (denom > 0) return num / denom; - } - } - return num; - } - - const fraction = findFraction(feedback); - if (fraction && fraction[1] > 0) { - return fraction[0] / fraction[1]; - } - - return null; -} - -function extractScore( - response: { results?: Record }, - functionId: string, -): number { - const fnResult = response.results?.[functionId]; - if (!fnResult?.score_rows?.length) return 0; - const row = fnResult.score_rows[0]; - if (row.scores && Object.keys(row.scores).length > 0) { - const vals = Object.values(row.scores) - .map(s => s.value) - .filter(v => typeof v === 'number'); - return vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; - } - if (typeof row.score === 'number') return row.score; - if (row.judge_feedback) { - const parsed = parseScoreFromFeedback(row.judge_feedback); - if (parsed !== null) return parsed; - } - return 0; -} - -async function gradeCriteriaAsync( - criteria: EvaluationCriterion[], - actualOutput: string, - testCaseInput: string, - expectedOutput: string | undefined, - client: ResponsesApiClient, - _actualAgent?: string, -): Promise< - Array<{ - criterionId: string; - passed: boolean; - score: number; - details?: string; - }> -> { - const results: Array<{ - criterionId: string; - passed: boolean; - score: number; - details?: string; - }> = []; - - for (const criterion of criteria) { - switch (criterion.type) { - case 'exact_match': { - const match = actualOutput === criterion.pattern; - results.push({ - criterionId: criterion.id, - passed: match, - score: match ? 1 : 0, - }); - break; - } - case 'contains': { - const contains = criterion.pattern - ? actualOutput.toLowerCase().includes(criterion.pattern.toLowerCase()) - : false; - results.push({ - criterionId: criterion.id, - passed: contains, - score: contains ? 1 : 0, - }); - break; - } - case 'regex': { - try { - const regex = new RegExp(criterion.pattern || '', 'i'); - const matches = regex.test(actualOutput); - results.push({ - criterionId: criterion.id, - passed: matches, - score: matches ? 1 : 0, - }); - } catch { - results.push({ - criterionId: criterion.id, - passed: false, - score: 0, - details: 'Invalid regex pattern', - }); - } - break; - } - case 'llm_graded': { - try { - const fn = criterion.scoringFunction || 'basic::subset_of'; - const threshold = criterion.threshold ?? 0.7; - const resp = await client.request<{ - results?: Record; - }>('/v1/scoring/score', { - method: 'POST', - body: { - input_rows: [ - { - input_query: testCaseInput, - generated_answer: actualOutput, - ...(expectedOutput - ? { expected_answer: expectedOutput } - : {}), - }, - ], - scoring_functions: { [fn]: null }, - }, - }); - const score = extractScore(resp, fn); - const feedback = resp.results?.[fn]?.score_rows?.[0]?.judge_feedback; - results.push({ - criterionId: criterion.id, - passed: score >= threshold, - score, - details: feedback - ? `${fn}: ${feedback.substring(0, 200)}` - : `Scored by ${fn}`, - }); - } catch (err) { - results.push({ - criterionId: criterion.id, - passed: false, - score: 0, - details: `LLM grading failed: ${err instanceof Error ? err.message : String(err)}`, - }); - } - break; - } - default: - results.push({ - criterionId: criterion.id, - passed: true, - score: 1, - details: 'Custom criterion -- auto-pass', - }); - } - } - - return results; -} - -// --------------------------------------------------------------------------- -// Route registration -// --------------------------------------------------------------------------- - -export function registerEvaluationRoutes( - ctx: RouteContext, - deps: EvaluationDeps, -): void { - const { router, logger, sendRouteError, requireAdminAccess, getUserRef } = - ctx; - const withRoute = createWithRoute(logger, sendRouteError); - const { workflowService, runWorkflow, adminConfig } = deps; - - async function getLlamaStackClient(): Promise { - const { url, skipTls } = await resolveLlamaStackConfig(ctx, adminConfig); - return new ResponsesApiClient( - { baseUrl: url, skipTlsVerify: skipTls }, - logger, - ); - } - - // ----------------------------------------------------------------------- - // POST /workflows/:id/evaluate -- run evaluation with test cases - // ----------------------------------------------------------------------- - router.post( - '/workflows/:id/evaluate', - requireAdminAccess, - withRoute( - req => `Run evaluation for workflow ${req.params.id}`, - 'Failed to run evaluation', - async (req, res) => { - const user = await getUserRef(req); - const { - testCases, - suiteId, - scoringFunctions: globalScoringFns, - } = req.body as { - testCases?: WorkflowTestCase[]; - suiteId?: string; - scoringFunctions?: string[]; - }; - - let cases: WorkflowTestCase[] = []; - if (testCases && testCases.length > 0) { - cases = testCases; - } else if (suiteId) { - const suites = await workflowService.getTestSuites(req.params.id); - const suite = suites.find(s => s.id === suiteId); - if (!suite) throw new InputError(`Test suite not found: ${suiteId}`); - cases = suite.testCases; - } else { - throw new InputError('Must provide testCases or suiteId'); - } - - const workflow = await workflowService.getWorkflow(req.params.id); - const startTime = Date.now(); - - const results: TestCaseResult[] = []; - for (const testCase of cases) { - const caseStart = Date.now(); - try { - const runResult = await runWorkflow(req.params.id, testCase.input); - - let criteria = testCase.criteria ?? []; - if ( - criteria.length === 0 && - globalScoringFns && - globalScoringFns.length > 0 - ) { - criteria = globalScoringFns.map((fn, i) => ({ - id: `auto-${i}`, - name: fn.split('::').pop() || fn, - type: 'llm_graded' as const, - weight: 1, - scoringFunction: fn, - threshold: 0.7, - })); - } - if (criteria.length === 0 && testCase.expectedOutput) { - criteria = [ - { - id: 'auto-contains', - name: 'Contains expected', - type: 'contains', - weight: 1, - pattern: testCase.expectedOutput, - }, - ]; - } - - const criterionResults = await gradeCriteriaAsync( - criteria, - runResult.response, - testCase.input, - testCase.expectedOutput, - await getLlamaStackClient(), - runResult.agentName, - ); - const passed = - criterionResults.length > 0 - ? criterionResults.every(c => c.passed) - : true; - const score = - criterionResults.length > 0 - ? criterionResults.reduce((s, c) => s + c.score, 0) / - criterionResults.length - : 1; - - results.push({ - testCaseId: testCase.id, - passed, - score, - actualOutput: runResult.response, - actualAgent: runResult.agentName, - criterionResults, - trace: { - runId: uuid(), - workflowId: req.params.id, - workflowVersion: workflow.version, - status: 'completed', - startedAt: new Date(caseStart).toISOString(), - completedAt: new Date().toISOString(), - nodeExecutions: runResult.trace, - totalTokenUsage: runResult.tokenUsage, - totalDurationMs: runResult.durationMs, - }, - durationMs: Date.now() - caseStart, - }); - } catch (err) { - results.push({ - testCaseId: testCase.id, - passed: false, - score: 0, - actualOutput: err instanceof Error ? err.message : String(err), - criterionResults: [], - trace: { - runId: uuid(), - workflowId: req.params.id, - workflowVersion: workflow.version, - status: 'failed', - startedAt: new Date(caseStart).toISOString(), - completedAt: new Date().toISOString(), - nodeExecutions: [], - error: err instanceof Error ? err.message : String(err), - totalDurationMs: Date.now() - caseStart, - }, - durationMs: Date.now() - caseStart, - }); - } - } - - const passRate = - results.length > 0 - ? results.filter(r => r.passed).length / results.length - : 0; - const overallScore = - results.length > 0 - ? results.reduce((s, r) => s + r.score, 0) / results.length - : 0; - - const evaluation: WorkflowEvaluationResult = { - evaluationId: uuid(), - workflowId: req.params.id, - workflowVersion: workflow.version, - ranAt: new Date().toISOString(), - ranBy: user, - testCaseResults: results, - overallScore, - passRate, - totalDurationMs: Date.now() - startTime, - }; - - await workflowService.saveEvaluation(evaluation, user); - res.json(evaluation); - }, - ), - ); - - // ----------------------------------------------------------------------- - // GET /workflows/:id/evaluations/:evalId - // ----------------------------------------------------------------------- - router.get( - '/workflows/:id/evaluations/:evalId', - requireAdminAccess, - withRoute( - req => `Get evaluation ${req.params.evalId}`, - 'Failed to get evaluation', - async (req, res) => { - const evaluations = await workflowService.getEvaluations(req.params.id); - const found = evaluations.find( - e => e.evaluationId === req.params.evalId, - ); - if (!found) { - res.status(404).json({ error: 'Evaluation not found' }); - return; - } - res.json(found); - }, - ), - ); - - // ----------------------------------------------------------------------- - // GET /scoring-functions -- list available LlamaStack scoring functions - // ----------------------------------------------------------------------- - router.get( - '/scoring-functions', - requireAdminAccess, - withRoute( - () => 'List scoring functions', - 'Failed to list scoring functions', - async (_req, res) => { - const resp = await ( - await getLlamaStackClient() - ).request<{ data?: Array> }>( - '/v1/scoring-functions', - { method: 'GET' }, - ); - const functions = Array.isArray(resp) ? resp : resp.data || []; - res.json({ data: functions }); - }, - ), - ); - - // ----------------------------------------------------------------------- - // POST /scoring/score -- ad-hoc scoring via LlamaStack - // ----------------------------------------------------------------------- - router.post( - '/scoring/score', - requireAdminAccess, - withRoute( - () => 'Score rows', - 'Failed to score', - async (req, res) => { - const resp = await ( - await getLlamaStackClient() - ).request>('/v1/scoring/score', { - method: 'POST', - body: req.body, - }); - res.json(resp); - }, - ), - ); - - // ----------------------------------------------------------------------- - // Benchmarks CRUD + execution (proxy to LlamaStack v1alpha) - // ----------------------------------------------------------------------- - - router.get( - '/benchmarks', - requireAdminAccess, - withRoute( - () => 'List benchmarks', - 'Failed to list benchmarks', - async (_req, res) => { - const resp = await ( - await getLlamaStackClient() - ).request>('/v1alpha/eval/benchmarks', { - method: 'GET', - }); - res.json(resp); - }, - ), - ); - - router.get( - '/benchmarks/:id', - requireAdminAccess, - withRoute( - req => `Get benchmark ${req.params.id}`, - 'Failed to get benchmark', - async (req, res) => { - const resp = await ( - await getLlamaStackClient() - ).request>( - `/v1alpha/eval/benchmarks/${encodeURIComponent(req.params.id)}`, - { method: 'GET' }, - ); - res.json(resp); - }, - ), - ); - - router.post( - '/benchmarks', - requireAdminAccess, - withRoute( - () => 'Create benchmark', - 'Failed to create benchmark', - async (req, res) => { - const { - name, - testCases, - scoringFunctions: scoringFns, - } = req.body as { - name: string; - testCases: Array<{ input: string; expectedOutput?: string }>; - scoringFunctions: string[]; - }; - - if (!name || !testCases?.length || !scoringFns?.length) { - throw new InputError( - 'name, testCases, and scoringFunctions are required', - ); - } - - const datasetId = `eval-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now()}`; - - await ( - await getLlamaStackClient() - ).request>('/v1beta/datasets', { - method: 'POST', - body: { - dataset_id: datasetId, - provider_id: 'localfs', - purpose: 'eval/messages-answer', - source: { - type: 'rows', - rows: testCases.map(tc => ({ - input_query: tc.input, - expected_answer: tc.expectedOutput || '', - chat_completion_input: JSON.stringify([ - { role: 'user', content: tc.input }, - ]), - })), - }, - }, - }); - - const benchmarkId = `bench-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now()}`; - const benchResp = await ( - await getLlamaStackClient() - ).request>('/v1alpha/eval/benchmarks', { - method: 'POST', - body: { - benchmark_id: benchmarkId, - dataset_id: datasetId, - scoring_functions: scoringFns, - provider_id: 'meta-reference', - }, - }); - - res.json({ benchmarkId, datasetId, ...benchResp }); - }, - ), - ); - - router.delete( - '/benchmarks/:id', - requireAdminAccess, - withRoute( - req => `Delete benchmark ${req.params.id}`, - 'Failed to delete benchmark', - async (req, res) => { - await ( - await getLlamaStackClient() - ).request>( - `/v1alpha/eval/benchmarks/${encodeURIComponent(req.params.id)}`, - { method: 'DELETE' }, - ); - res.json({ deleted: true }); - }, - ), - ); - - router.post( - '/benchmarks/:id/run', - requireAdminAccess, - withRoute( - req => `Run benchmark ${req.params.id}`, - 'Failed to run benchmark', - async (req, res) => { - const { model, systemMessage } = req.body as { - model?: string; - systemMessage?: string; - }; - - const { model: resolvedModel } = await resolveLlamaStackConfig( - ctx, - adminConfig, - ); - const resp = await ( - await getLlamaStackClient() - ).request>( - `/v1alpha/eval/benchmarks/${encodeURIComponent(req.params.id)}/jobs`, - { - method: 'POST', - body: { - benchmark_config: { - eval_candidate: { - type: 'model', - model: model || resolvedModel, - sampling_params: { temperature: 0.7 }, - ...(systemMessage ? { system_message: systemMessage } : {}), - }, - }, - }, - }, - ); - res.json(resp); - }, - ), - ); - - router.get( - '/benchmarks/:id/jobs/:jobId', - requireAdminAccess, - withRoute( - req => `Benchmark job status ${req.params.jobId}`, - 'Failed to get job status', - async (req, res) => { - const resp = await ( - await getLlamaStackClient() - ).request>( - `/v1alpha/eval/benchmarks/${encodeURIComponent(req.params.id)}/jobs/${encodeURIComponent(req.params.jobId)}`, - { method: 'GET' }, - ); - res.json(resp); - }, - ), - ); - - router.get( - '/benchmarks/:id/jobs/:jobId/result', - requireAdminAccess, - withRoute( - req => `Benchmark job result ${req.params.jobId}`, - 'Failed to get job result', - async (req, res) => { - const resp = await ( - await getLlamaStackClient() - ).request>( - `/v1alpha/eval/benchmarks/${encodeURIComponent(req.params.id)}/jobs/${encodeURIComponent(req.params.jobId)}/result`, - { method: 'GET' }, - ); - res.json(resp); - }, - ), - ); - - // ----------------------------------------------------------------------- - // POST /benchmarks/:id/evaluate-rows -- inline row evaluation - // ----------------------------------------------------------------------- - router.post( - '/benchmarks/:id/evaluate-rows', - requireAdminAccess, - withRoute( - req => `Evaluate rows for benchmark ${req.params.id}`, - 'Failed to evaluate rows', - async (req, res) => { - const resp = await ( - await getLlamaStackClient() - ).request>( - `/v1alpha/eval/benchmarks/${encodeURIComponent(req.params.id)}/evaluations`, - { method: 'POST', body: req.body }, - ); - res.json(resp); - }, - ), - ); - - // ----------------------------------------------------------------------- - // Datasets CRUD (proxy to LlamaStack v1beta) - // ----------------------------------------------------------------------- - - router.get( - '/datasets', - requireAdminAccess, - withRoute( - () => 'List datasets', - 'Failed to list datasets', - async (_req, res) => { - const resp = await ( - await getLlamaStackClient() - ).request>('/v1beta/datasets', { - method: 'GET', - }); - res.json(resp); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/index.ts b/workspaces/augment/plugins/augment-backend/src/routes/index.ts deleted file mode 100644 index bcae93e9320..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export type { RouteContext, FlushableResponse } from './types'; -export { registerStatusRoutes } from './statusRoutes'; -export { registerChatRoutes } from './chatRoutes'; -export { registerDocumentRoutes } from './documentRoutes'; -export { registerConfigRoutes } from './configRoutes'; -export { registerSessionRoutes } from './sessionRoutes'; -export { registerConversationRoutes } from './conversationRoutes'; -export { registerAdminRoutes } from './adminRoutes'; -export { registerKagentiRoutes } from './kagentiRoutes'; -export { registerKagentiSandboxRoutes } from './kagentiSandboxRoutes'; -export { registerKagentiAdminRoutes } from './kagentiAdminRoutes'; -export { registerDevSpacesRoutes } from './devSpacesRoutes'; -export { registerAgentRoutes } from './agentRoutes'; -export { registerToolLifecycleRoutes } from './toolLifecycleRoutes'; -export { registerWorkflowRoutes } from './workflowRoutes'; -export { registerSkillsRoutes } from './skillsRoutes'; diff --git a/workspaces/augment/plugins/augment-backend/src/routes/k8sCredentials.ts b/workspaces/augment/plugins/augment-backend/src/routes/k8sCredentials.ts deleted file mode 100644 index 4f5c5c67a6b..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/k8sCredentials.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Config } from '@backstage/config'; -import type { AdminConfigService } from '../services/AdminConfigService'; - -export interface K8sCredentials { - apiUrl: string; - token: string; -} - -/** - * Resolves OpenShift/K8s API credentials. - * Reads from `augment.k8s.apiUrl` / `augment.k8s.token` in Backstage config - * first, then falls back to `devSpacesApiUrl` / `devSpacesToken` admin config - * keys (legacy naming from DevSpaces feature). - */ -export async function resolveK8sCredentials( - config: Config, - adminConfig?: AdminConfigService, -): Promise { - const section = config.getOptionalConfig('augment.k8s'); - let apiUrl = section?.getOptionalString('apiUrl'); - let token = section?.getOptionalString('token'); - - if (!apiUrl && adminConfig) { - apiUrl = - ((await adminConfig.get('devSpacesApiUrl')) as string) || undefined; - } - if (!token && adminConfig) { - token = ((await adminConfig.get('devSpacesToken')) as string) || undefined; - } - - apiUrl = apiUrl?.replace(/\/$/, ''); - if (!apiUrl || !token) return undefined; - - return { apiUrl, token }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/kagentiAdminRoutes.test.ts b/workspaces/augment/plugins/augment-backend/src/routes/kagentiAdminRoutes.test.ts deleted file mode 100644 index d197b308a27..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/kagentiAdminRoutes.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import express from 'express'; -import request from 'supertest'; -import { registerKagentiAdminRoutes } from './kagentiAdminRoutes'; -import { createMockLogger } from '../test-utils/mocks'; - -function createMockRouteContext( - flags = { sandbox: true, integrations: true, triggers: true }, -) { - const router = express.Router(); - const logger = createMockLogger(); - - const mockAdminClient = { - listLlmModels: jest.fn().mockResolvedValue([{ id: 'llama3' }]), - createTeam: jest.fn().mockResolvedValue({ teamId: 't1', namespace: 'ns1' }), - listTeams: jest.fn().mockResolvedValue([]), - getTeam: jest.fn().mockResolvedValue({ teamId: 't1', namespace: 'ns1' }), - createKey: jest.fn().mockResolvedValue({ success: true }), - listKeys: jest.fn().mockResolvedValue([]), - deleteKey: jest.fn().mockResolvedValue({ success: true }), - getAgentModels: jest.fn().mockResolvedValue([]), - listIntegrations: jest.fn().mockResolvedValue({ items: [] }), - getIntegration: jest - .fn() - .mockResolvedValue({ name: 'int1', namespace: 'ns1' }), - createIntegration: jest.fn().mockResolvedValue({ success: true }), - updateIntegration: jest.fn().mockResolvedValue({ success: true }), - deleteIntegration: jest.fn().mockResolvedValue({ success: true }), - testIntegration: jest.fn().mockResolvedValue({ success: true }), - createTrigger: jest - .fn() - .mockResolvedValue({ sandbox_claim: 'claim-1', namespace: 'ns1' }), - }; - - const mockProvider = { - id: 'kagenti', - displayName: 'Kagenti', - getAdminClient: () => mockAdminClient, - getFeatureFlags: () => flags, - }; - - const ctx = { - router, - logger, - config: {} as never, - provider: mockProvider as never, - sessions: undefined, - toErrorMessage: (e: unknown) => - e instanceof Error ? e.message : String(e), - sendRouteError: jest.fn( - (res: express.Response, _err: unknown, _label: string, msg: string) => { - res.status(500).json({ error: msg }); - }, - ), - missingSessions: jest.fn().mockReturnValue(false), - missingConversations: jest.fn().mockReturnValue(false), - getUserRef: jest.fn().mockResolvedValue('user:default/test'), - checkIsAdmin: jest.fn().mockResolvedValue(true), - requireAdminAccess: (( - _req: express.Request, - _res: express.Response, - next: express.NextFunction, - ) => next()) as express.RequestHandler, - parseChatRequest: jest.fn(), - parseApprovalRequest: jest.fn(), - }; - - registerKagentiAdminRoutes(ctx); - - const app = express(); - app.use(express.json()); - app.use(router); - - return { app, mockAdminClient }; -} - -describe('kagentiAdminRoutes', () => { - it('GET /kagenti/models lists LLM models when sandbox is on', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/models'); - expect(res.status).toBe(200); - expect(res.body).toHaveLength(1); - }); - - it('GET /kagenti/llm/teams lists teams', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/llm/teams'); - expect(res.status).toBe(200); - }); - - it('POST /kagenti/llm/teams creates a team', async () => { - const { app } = createMockRouteContext(); - const res = await request(app) - .post('/kagenti/llm/teams') - .send({ namespace: 'ns1' }); - expect(res.status).toBe(200); - expect(res.body.teamId).toBe('t1'); - }); - - it('GET /kagenti/llm/keys lists keys', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/llm/keys'); - expect(res.status).toBe(200); - }); - - it('GET /kagenti/integrations lists integrations', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/integrations'); - expect(res.status).toBe(200); - }); - - it('POST /kagenti/integrations creates integration', async () => { - const { app } = createMockRouteContext(); - const res = await request(app) - .post('/kagenti/integrations') - .send({ name: 'int1', namespace: 'ns1' }); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - }); - - it('POST /kagenti/integrations/:ns/:name/test tests integration', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).post('/kagenti/integrations/ns1/int1/test'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - }); - - it('POST /kagenti/sandbox/trigger creates trigger', async () => { - const { app } = createMockRouteContext(); - const res = await request(app) - .post('/kagenti/sandbox/trigger') - .send({ type: 'cron', namespace: 'ns1', schedule: '*/5 * * * *' }); - expect(res.status).toBe(200); - expect(res.body.sandbox_claim).toBe('claim-1'); - }); - - it('does not register model routes when sandbox is off', async () => { - const { app } = createMockRouteContext({ - sandbox: false, - integrations: false, - triggers: false, - }); - const res = await request(app).get('/kagenti/models'); - expect(res.status).toBe(404); - }); - - it('DELETE /kagenti/llm/keys/:ns/:agentName deletes a key', async () => { - const { app, mockAdminClient } = createMockRouteContext(); - const res = await request(app).delete('/kagenti/llm/keys/ns1/agent1'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(mockAdminClient.deleteKey).toHaveBeenCalledWith('ns1', 'agent1'); - }); - - it('DELETE /kagenti/integrations/:ns/:name deletes integration', async () => { - const { app, mockAdminClient } = createMockRouteContext(); - const res = await request(app).delete('/kagenti/integrations/ns1/int1'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(mockAdminClient.deleteIntegration).toHaveBeenCalledWith( - 'ns1', - 'int1', - ); - }); - - it('PUT /kagenti/integrations/:ns/:name updates integration', async () => { - const { app, mockAdminClient } = createMockRouteContext(); - const res = await request(app) - .put('/kagenti/integrations/ns1/int1') - .send({ config: { key: 'val' } }); - expect(res.status).toBe(200); - expect(mockAdminClient.updateIntegration).toHaveBeenCalled(); - }); - - it('GET /kagenti/integrations/:ns/:name gets integration detail', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/integrations/ns1/int1'); - expect(res.status).toBe(200); - expect(res.body.name).toBe('int1'); - }); - - it('POST /kagenti/llm/keys creates a key', async () => { - const { app } = createMockRouteContext(); - const res = await request(app) - .post('/kagenti/llm/keys') - .send({ namespace: 'ns1', agentName: 'a1' }); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - }); - - it('GET /kagenti/llm/teams/:namespace lists teams by namespace', async () => { - const { app, mockAdminClient } = createMockRouteContext(); - const res = await request(app).get('/kagenti/llm/teams/ns1'); - expect(res.status).toBe(200); - expect(mockAdminClient.getTeam).toHaveBeenCalledWith('ns1'); - }); - - it('does not register integration routes when integrations flag is off', async () => { - const { app } = createMockRouteContext({ - sandbox: true, - integrations: false, - triggers: false, - }); - const res = await request(app).get('/kagenti/integrations'); - expect(res.status).toBe(404); - }); - - it('does not register trigger route when triggers flag is off', async () => { - const { app } = createMockRouteContext({ - sandbox: true, - integrations: true, - triggers: false, - }); - const res = await request(app) - .post('/kagenti/sandbox/trigger') - .send({ type: 'cron', namespace: 'ns1', schedule: '*/5 * * * *' }); - expect(res.status).toBe(404); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/routes/kagentiAdminRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/kagentiAdminRoutes.ts deleted file mode 100644 index 6b207495158..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/kagentiAdminRoutes.ts +++ /dev/null @@ -1,264 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createWithRoute } from './routeWrapper'; -import type { RouteContext } from './types'; -import type { KagentiProvider } from '../providers/kagenti'; - -/** - * Registers Kagenti admin routes for models, LLM keys/teams, - * integrations, and triggers. Conditionally registered based on feature flags. - */ -export function registerKagentiAdminRoutes(ctx: RouteContext): void { - const { router, logger, sendRouteError, requireAdminAccess } = ctx; - const withRoute = createWithRoute(logger, sendRouteError); - - if (ctx.provider.id !== 'kagenti') { - return; - } - const kagenti = ctx.provider as KagentiProvider; - const flags = kagenti.getFeatureFlags(); - - const admin = kagenti.getAdminClient(); - if (!admin) { - logger.warn('Admin client not available, skipping admin routes'); - return; - } - - // Admin routes intentionally skip per-namespace validation: - // admins may need cross-namespace access for LLM key management, - // integrations, and model configuration. - router.use('/kagenti/models', requireAdminAccess); - router.use('/kagenti/llm', requireAdminAccess); - router.use('/kagenti/integrations', requireAdminAccess); - - // -- Models (sandbox flag) -------------------------------------------------- - - if (flags.sandbox) { - router.get( - '/kagenti/models', - withRoute( - 'GET /kagenti/models', - 'Failed to list LLM models', - async (_req, res) => { - const result = await admin.listLlmModels(); - res.json(result); - }, - ), - ); - - // -- LLM Teams ------------------------------------------------------------ - - router.post( - '/kagenti/llm/teams', - withRoute( - 'POST /kagenti/llm/teams', - 'Failed to create team', - async (req, res) => { - const result = await admin.createTeam(req.body); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/llm/teams', - withRoute( - 'GET /kagenti/llm/teams', - 'Failed to list teams', - async (_req, res) => { - const result = await admin.listTeams(); - res.json({ teams: Array.isArray(result) ? result : [] }); - }, - ), - ); - - router.get( - '/kagenti/llm/teams/:namespace', - withRoute( - 'GET /kagenti/llm/teams by namespace', - 'Failed to get team', - async (req, res) => { - const result = await admin.getTeam(req.params.namespace); - res.json(result); - }, - ), - ); - - // -- LLM Keys ------------------------------------------------------------- - - router.post( - '/kagenti/llm/keys', - withRoute( - 'POST /kagenti/llm/keys', - 'Failed to create key', - async (req, res) => { - const result = await admin.createKey(req.body); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/llm/keys', - withRoute( - 'GET /kagenti/llm/keys', - 'Failed to list keys', - async (_req, res) => { - const result = await admin.listKeys(); - res.json({ keys: Array.isArray(result) ? result : [] }); - }, - ), - ); - - router.delete( - '/kagenti/llm/keys/:namespace/:agentName', - withRoute( - 'DELETE /kagenti/llm/keys', - 'Failed to delete key', - async (req, res) => { - const result = await admin.deleteKey( - req.params.namespace, - req.params.agentName, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/llm/agent-models/:namespace/:agentName', - withRoute( - 'GET /kagenti/llm/agent-models', - 'Failed to get agent models', - async (req, res) => { - const result = await admin.getAgentModels( - req.params.namespace, - req.params.agentName, - ); - res.json(result); - }, - ), - ); - } - - // -- Integrations (integrations flag) --------------------------------------- - - if (flags.integrations) { - router.get( - '/kagenti/integrations', - withRoute( - 'GET /kagenti/integrations', - 'Failed to list integrations', - async (req, res) => { - const namespace = req.query.namespace as string | undefined; - const result = await admin.listIntegrations(namespace); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/integrations/:namespace/:name', - withRoute( - 'GET /kagenti/integration detail', - 'Failed to get integration', - async (req, res) => { - const result = await admin.getIntegration( - req.params.namespace, - req.params.name, - ); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/integrations', - withRoute( - 'POST /kagenti/integrations', - 'Failed to create integration', - async (req, res) => { - const result = await admin.createIntegration(req.body); - res.json(result); - }, - ), - ); - - router.put( - '/kagenti/integrations/:namespace/:name', - withRoute( - 'PUT /kagenti/integration', - 'Failed to update integration', - async (req, res) => { - const result = await admin.updateIntegration( - req.params.namespace, - req.params.name, - req.body, - ); - res.json(result); - }, - ), - ); - - router.delete( - '/kagenti/integrations/:namespace/:name', - withRoute( - 'DELETE /kagenti/integration', - 'Failed to delete integration', - async (req, res) => { - const result = await admin.deleteIntegration( - req.params.namespace, - req.params.name, - ); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/integrations/:namespace/:name/test', - withRoute( - 'POST /kagenti/integration test', - 'Failed to test integration', - async (req, res) => { - const result = await admin.testIntegration( - req.params.namespace, - req.params.name, - ); - res.json(result); - }, - ), - ); - } - - // -- Triggers (triggers flag) ----------------------------------------------- - - if (flags.triggers) { - router.post( - '/kagenti/sandbox/trigger', - requireAdminAccess, - withRoute( - 'POST /kagenti/sandbox/trigger', - 'Failed to create trigger', - async (req, res) => { - const result = await admin.createTrigger(req.body); - res.json(result); - }, - ), - ); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/kagentiAgentRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/kagentiAgentRoutes.ts deleted file mode 100644 index e4ed910238d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/kagentiAgentRoutes.ts +++ /dev/null @@ -1,454 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { InputError } from '@backstage/errors'; -import type { ChatAgentConfig } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { validatePublicUrl } from './validatePublicUrl'; -import type { KagentiRouteRegistrarContext } from './kagentiRoutes'; -import { getVisibleNamespaces } from '../providers/kagenti/kagentiNamespaceUtils'; - -/** - * Registers Kagenti agent CRUD, migration, builds, and env utility routes. - */ -export function registerKagentiAgentRoutes( - ctx: KagentiRouteRegistrarContext, -): void { - const { - router, - logger, - kagenti, - api, - withRoute, - validateNamespaceParam, - requireAdminAccess, - } = ctx; - - router.get( - '/kagenti/agents', - validateNamespaceParam, - withRoute( - 'GET /kagenti/agents', - 'Failed to list agents', - async (req, res) => { - const namespace = req.query.namespace as string | undefined; - const includeCards = req.query.include === 'cards'; - - let allAgents: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiAgentSummary[]; - - if (namespace) { - const result = await api.listAgents(namespace); - allAgents = result.items ?? []; - } else { - const visibleNs = await getVisibleNamespaces( - api, - kagenti.getConfig(), - logger, - ); - - allAgents = []; - for (const ns of visibleNs) { - try { - const result = await api.listAgents(ns); - allAgents.push(...(result.items ?? [])); - } catch (nsErr) { - logger.warn( - `Failed to list agents in namespace ${ns}: ${nsErr instanceof Error ? nsErr.message : nsErr}`, - ); - } - } - } - - if (!includeCards) { - res.json({ agents: allAgents }); - return; - } - - const CARD_CONCURRENCY = 6; - const enriched: Array< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiAgentSummary & { - agentCard?: unknown; - } - > = new Array(allAgents.length); - let cursor = 0; - async function nextCard() { - while (cursor < allAgents.length) { - const idx = cursor++; - const agent = allAgents[idx]; - try { - const cached = await kagenti.getAgentCardCached( - agent.namespace, - agent.name, - { retries: 0 }, - ); - enriched[idx] = { ...agent, agentCard: cached.card }; - } catch (err) { - logger.warn( - `Failed to fetch agent card for ${agent.namespace}/${agent.name}: ${err instanceof Error ? err.message : err}`, - ); - enriched[idx] = agent; - } - } - } - await Promise.all( - Array.from( - { length: Math.min(CARD_CONCURRENCY, allAgents.length) }, - () => nextCard(), - ), - ); - res.json({ agents: enriched }); - }, - ), - ); - - router.get( - '/kagenti/agents/migration/migratable', - withRoute( - 'GET /kagenti/agents/migration/migratable', - 'Failed to list migratable agents', - async (_req, res) => { - const result = await api.listMigratableAgents(); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/agents/build-strategies', - withRoute( - 'GET /kagenti/agents/build-strategies', - 'Failed to list build strategies', - async (_req, res) => { - const result = await api.listBuildStrategies(); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/agents/:namespace/:name', - validateNamespaceParam, - withRoute( - req => `GET /kagenti/agents/${req.params.namespace}/${req.params.name}`, - 'Failed to get agent', - async (req, res) => { - const { namespace, name } = req.params; - const detail = await api.getAgent(namespace, name); - let agentCard; - let securityDemands: Record | undefined; - try { - const cached = await kagenti.getAgentCardCached(namespace, name); - agentCard = cached.card; - const d = cached.demands; - securityDemands = { - requiresOAuth: !!d.oauthDemands, - requiresSecrets: !!d.secretDemands, - requiresLlm: !!d.llmDemands, - requiresMcp: !!d.mcpDemands, - requiresForm: !!d.formDemands, - }; - } catch (cardErr) { - const msg = - cardErr instanceof Error ? cardErr.message : String(cardErr); - const statusMatch = msg.match(/status (\d+)/); - const status = statusMatch ? Number(statusMatch[1]) : 0; - if (status === 404 || status === 503) { - logger.debug( - `No agent card for ${namespace}/${name} (status ${status})`, - ); - } else { - logger.warn( - `Failed to fetch agent card for ${namespace}/${name}: ${msg}`, - ); - } - } - res.json({ ...detail, agentCard, securityDemands }); - }, - ), - ); - - router.get( - '/kagenti/agents/:namespace/:name/route-status', - validateNamespaceParam, - withRoute( - req => - `GET /kagenti/agents/${req.params.namespace}/${req.params.name}/route-status`, - 'Failed to get agent route status', - async (req, res) => { - const { namespace, name } = req.params; - const result = await api.getAgentRouteStatus(namespace, name); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/agents', - withRoute( - 'POST /kagenti/agents', - 'Failed to create agent', - async (req, res) => { - if (!req.body?.name || typeof req.body.name !== 'string') { - throw new InputError('name is required and must be a string'); - } - if (!req.body?.namespace || typeof req.body.namespace !== 'string') { - throw new InputError('namespace is required and must be a string'); - } - const result = await api.createAgent(req.body); - - if (result.success && ctx.adminConfig) { - const agentId = `${req.body.namespace}/${req.body.name}`; - try { - const userRef = await ctx.getUserRef(req).catch(() => 'anonymous'); - const raw = await ctx.adminConfig.get('chatAgents'); - const configs: ChatAgentConfig[] = Array.isArray(raw) - ? (raw as ChatAgentConfig[]) - : []; - if (!configs.some(c => c.agentId === agentId)) { - configs.push({ - agentId, - lifecycleStage: 'draft', - published: false, - visible: false, - featured: false, - version: 0, - promotedAt: new Date().toISOString(), - promotedBy: userRef, - createdBy: userRef, - }); - await ctx.adminConfig.set('chatAgents', configs, userRef); - logger.info( - `Auto-created chatAgents entry for ${agentId} (draft) by ${userRef}`, - ); - } - } catch (syncErr) { - logger.warn( - `Failed to auto-create chatAgents entry for ${agentId}: ${syncErr instanceof Error ? syncErr.message : syncErr}`, - ); - } - } - - res.json(result); - }, - ), - ); - - router.delete( - '/kagenti/agents/:namespace/:name', - requireAdminAccess, - validateNamespaceParam, - withRoute( - req => - `DELETE /kagenti/agents/${req.params.namespace}/${req.params.name}`, - 'Failed to delete agent', - async (req, res) => { - const { namespace, name } = req.params; - const result = await api.deleteAgent(namespace, name); - - if (ctx.adminConfig) { - const agentId = `${namespace}/${name}`; - try { - const userRef = await ctx.getUserRef(req).catch(() => 'anonymous'); - const raw = await ctx.adminConfig.get('chatAgents'); - const configs: ChatAgentConfig[] = Array.isArray(raw) - ? (raw as ChatAgentConfig[]) - : []; - const updated = configs.filter(c => c.agentId !== agentId); - if (updated.length !== configs.length) { - await ctx.adminConfig.set('chatAgents', updated, userRef); - logger.info( - `Cleaned up chatAgents entry for deleted agent ${agentId}`, - ); - } - } catch (syncErr) { - logger.warn( - `Failed to clean up chatAgents for ${agentId}: ${syncErr instanceof Error ? syncErr.message : syncErr}`, - ); - } - } - - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/agents/:namespace/:name/migrate', - requireAdminAccess, - validateNamespaceParam, - withRoute( - req => - `POST /kagenti/agents/${req.params.namespace}/${req.params.name}/migrate`, - 'Failed to migrate agent', - async (req, res) => { - const { namespace, name } = req.params; - const migrationDefaults = kagenti.getConfig().migration; - const deleteOld = req.body?.delete_old ?? migrationDefaults.deleteOld; - const result = await api.migrateAgent(namespace, name, deleteOld); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/agents/migration/migrate-all', - requireAdminAccess, - withRoute( - 'POST /kagenti/agents/migration/migrate-all', - 'Failed to migrate all agents', - async (req, res) => { - const migrationDefaults = kagenti.getConfig().migration; - const dryRunParam = req.query.dry_run as string | undefined; - const deleteOldParam = req.query.delete_old as string | undefined; - const result = await api.migrateAllAgents({ - namespace: req.query.namespace as string | undefined, - dryRun: - dryRunParam !== undefined - ? dryRunParam === 'true' - : migrationDefaults.dryRun, - deleteOld: - deleteOldParam !== undefined - ? deleteOldParam === 'true' - : migrationDefaults.deleteOld, - }); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/agents/:namespace/:name/build-info', - validateNamespaceParam, - withRoute( - req => - `GET /kagenti/agents/${req.params.namespace}/${req.params.name}/build-info`, - 'Failed to get agent build info', - async (req, res) => { - const { namespace, name } = req.params; - const result = await api.getAgentBuildInfo(namespace, name); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/agents/:namespace/:name/buildrun', - requireAdminAccess, - validateNamespaceParam, - withRoute( - req => - `POST /kagenti/agents/${req.params.namespace}/${req.params.name}/buildrun`, - 'Failed to trigger agent build run', - async (req, res) => { - const { namespace, name } = req.params; - const result = await api.triggerAgentBuildRun(namespace, name); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/agents/:namespace/:name/finalize-build', - requireAdminAccess, - validateNamespaceParam, - withRoute( - req => - `POST /kagenti/agents/${req.params.namespace}/${req.params.name}/finalize-build`, - 'Failed to finalize agent build', - async (req, res) => { - const { namespace, name } = req.params; - const result = await api.finalizeAgentBuild(namespace, name, req.body); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/agents/shipwright-builds', - withRoute( - 'GET /kagenti/agents/shipwright-builds', - 'Failed to list agent builds', - async (req, res) => { - const namespace = req.query.namespace as string | undefined; - const allNamespaces = req.query.all_namespaces === 'true'; - const result = await api.listAgentBuilds( - allNamespaces ? undefined : namespace, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/agents/:namespace/:name/shipwright-build', - validateNamespaceParam, - withRoute( - req => - `GET /kagenti/agents/${req.params.namespace}/${req.params.name}/shipwright-build`, - 'Failed to get agent build status', - async (req, res) => { - const { namespace, name } = req.params; - const result = await api.getAgentBuild(namespace, name); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/agents/:namespace/:name/shipwright-buildrun', - validateNamespaceParam, - withRoute( - req => - `GET /kagenti/agents/${req.params.namespace}/${req.params.name}/shipwright-buildrun`, - 'Failed to get agent build run status', - async (req, res) => { - const { namespace, name } = req.params; - const result = await api.getAgentBuildRun(namespace, name); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/agents/parse-env', - requireAdminAccess, - withRoute( - 'POST /kagenti/agents/parse-env', - 'Failed to parse env', - async (req, res) => { - const result = await api.parseEnv(req.body?.content ?? ''); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/agents/fetch-env-url', - requireAdminAccess, - withRoute( - 'POST /kagenti/agents/fetch-env-url', - 'Failed to fetch env URL', - async (req, res) => { - const url = req.body?.url; - if (!url || typeof url !== 'string') { - throw new InputError('url is required'); - } - await validatePublicUrl(url); - const result = await api.fetchEnvUrl(url); - res.json(result); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/kagentiConfigRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/kagentiConfigRoutes.ts deleted file mode 100644 index f51f46f64e6..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/kagentiConfigRoutes.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { KagentiRouteRegistrarContext } from './kagentiRoutes'; - -/** - * Registers Kagenti health, config, and namespace listing routes. - */ -export function registerKagentiConfigRoutes( - ctx: KagentiRouteRegistrarContext, -): void { - const { router, kagenti, api, withRoute } = ctx; - - router.get( - '/kagenti/health', - withRoute( - 'GET /kagenti/health', - 'Failed to get Kagenti health', - async (_req, res) => { - const [health, ready] = await Promise.all([api.health(), api.ready()]); - res.json({ health: health.status, ready: ready.status === 'ready' }); - }, - ), - ); - - router.get( - '/kagenti/config/features', - withRoute( - 'GET /kagenti/config/features', - 'Failed to get feature flags', - async (_req, res) => { - res.json(kagenti.getFeatureFlags()); - }, - ), - ); - - router.get( - '/kagenti/config/dashboards', - withRoute( - 'GET /kagenti/config/dashboards', - 'Failed to get dashboards', - async (_req, res) => { - const dashboards = await api.getDashboards(); - const overrides = kagenti.getConfig().dashboards; - const merged = { - ...dashboards, - ...(overrides.mcpInspector && { - mcpInspector: overrides.mcpInspector, - }), - ...(overrides.mcpProxy && { mcpProxy: overrides.mcpProxy }), - ...(overrides.traces && { traces: overrides.traces }), - ...(overrides.network && { network: overrides.network }), - ...(overrides.keycloakConsole && { - keycloakConsole: overrides.keycloakConsole, - }), - ...(overrides.domainName && { - domainName: overrides.domainName, - }), - }; - res.json(merged); - }, - ), - ); - - router.get( - '/kagenti/namespaces', - withRoute( - 'GET /kagenti/namespaces', - 'Failed to list namespaces', - async (req, res) => { - const enabledOnly = req.query.enabled_only !== 'false'; - const result = await api.listNamespaces(enabledOnly); - res.json({ - ...result, - defaultNamespace: kagenti.getConfig().namespace, - }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/kagentiRoutes.test.ts b/workspaces/augment/plugins/augment-backend/src/routes/kagentiRoutes.test.ts deleted file mode 100644 index 0dcf040975f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/kagentiRoutes.test.ts +++ /dev/null @@ -1,621 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import express from 'express'; -import request from 'supertest'; -import { InputError } from '@backstage/errors'; -import { registerKagentiRoutes } from './kagentiRoutes'; -import { createMockLogger } from '../test-utils/mocks'; - -function createMockRouteContext() { - const router = express.Router(); - const logger = createMockLogger(); - - const mockApiClient = { - health: jest.fn().mockResolvedValue({ status: 'healthy' }), - ready: jest.fn().mockResolvedValue({ status: 'ready' }), - getFeatureFlags: jest.fn().mockResolvedValue({ - sandbox: false, - integrations: false, - triggers: false, - }), - getDashboards: jest.fn().mockResolvedValue({ traces: 'https://traces' }), - listNamespaces: jest.fn().mockResolvedValue({ namespaces: ['team1'] }), - listAgents: jest.fn().mockResolvedValue({ - items: [ - { - name: 'bot', - namespace: 'team1', - description: '', - status: 'Running', - labels: {}, - workloadType: 'deployment', - }, - ], - }), - getAgent: jest.fn().mockResolvedValue({ - metadata: {}, - spec: {}, - status: {}, - workloadType: 'deployment', - }), - getAgentCard: jest.fn().mockResolvedValue({ - name: 'bot', - description: 'Bot', - version: '1.0', - url: '', - streaming: true, - skills: [], - }), - getAgentRouteStatus: jest.fn().mockResolvedValue({ hasRoute: true }), - createAgent: jest.fn().mockResolvedValue({ - success: true, - name: 'bot', - namespace: 'team1', - message: 'created', - }), - deleteAgent: jest - .fn() - .mockResolvedValue({ success: true, message: 'deleted' }), - listMigratableAgents: jest - .fn() - .mockResolvedValue({ agents: [], total: 0, already_migrated: 0 }), - migrateAgent: jest.fn().mockResolvedValue({ - success: true, - migrated: true, - name: 'bot', - namespace: 'team1', - message: 'migrated', - deployment_created: true, - service_created: true, - agent_crd_deleted: false, - }), - migrateAllAgents: jest.fn().mockResolvedValue({ - namespace: 'team1', - dry_run: true, - delete_old: false, - total: 0, - migrated: [], - skipped: [], - failed: [], - }), - listBuildStrategies: jest.fn().mockResolvedValue({ strategies: [] }), - getAgentBuildInfo: jest.fn().mockResolvedValue({ - name: 'bot', - namespace: 'team1', - buildRegistered: true, - outputImage: '', - strategy: '', - gitUrl: '', - gitRevision: '', - contextDir: '', - hasBuildRun: false, - }), - triggerAgentBuildRun: jest.fn().mockResolvedValue({ - success: true, - buildRunName: 'br1', - namespace: 'team1', - buildName: 'b1', - }), - finalizeAgentBuild: jest.fn().mockResolvedValue({ - success: true, - name: 'bot', - namespace: 'team1', - message: 'finalized', - }), - parseEnv: jest.fn().mockResolvedValue({ envVars: [], warnings: [] }), - fetchEnvUrl: jest.fn().mockResolvedValue({ content: '', url: '' }), - listTools: jest.fn().mockResolvedValue({ items: [] }), - getTool: jest.fn().mockResolvedValue({ - metadata: {}, - spec: {}, - status: {}, - workloadType: 'deployment', - }), - getToolRouteStatus: jest.fn().mockResolvedValue({ hasRoute: false }), - createTool: jest.fn().mockResolvedValue({ - success: true, - name: 'tool1', - namespace: 'team1', - message: 'created', - }), - deleteTool: jest - .fn() - .mockResolvedValue({ success: true, message: 'deleted' }), - getToolBuildInfo: jest.fn().mockResolvedValue({ - name: 'tool1', - namespace: 'team1', - buildRegistered: false, - outputImage: '', - strategy: '', - gitUrl: '', - gitRevision: '', - contextDir: '', - hasBuildRun: false, - }), - triggerToolBuildRun: jest.fn().mockResolvedValue({ - success: true, - buildRunName: 'br1', - namespace: 'team1', - buildName: 'b1', - }), - finalizeToolBuild: jest.fn().mockResolvedValue({ - success: true, - name: 'tool1', - namespace: 'team1', - message: 'finalized', - }), - connectTool: jest.fn().mockResolvedValue({ tools: [{ name: 'search' }] }), - invokeTool: jest.fn().mockResolvedValue({ result: { data: 'ok' } }), - listAllBuilds: jest.fn().mockResolvedValue({ items: [] }), - }; - - const mockProvider = { - id: 'kagenti', - displayName: 'Kagenti', - getApiClient: () => mockApiClient, - setUserContext: jest.fn(), - validateNamespace: jest.fn(), - getAgentCardCached: jest.fn().mockResolvedValue({ - card: { - name: 'bot', - description: 'Bot', - version: '1.0', - url: '', - streaming: true, - skills: [], - }, - demands: {}, - }), - getFeatureFlags: () => ({ - sandbox: false, - integrations: false, - triggers: false, - }), - getConfig: () => ({ - baseUrl: 'https://kagenti.example.com', - namespace: 'team1', - namespaces: undefined, - showAllNamespaces: true, - agentName: 'bot', - agents: undefined, - skipTlsVerify: false, - verboseStreamLogging: false, - requestTimeoutMs: 30_000, - streamTimeoutMs: 300_000, - maxRetries: 3, - retryBaseDelayMs: 1000, - tokenExpiryBufferSeconds: 60, - dashboards: {}, - sandbox: { - sessionTtlMinutes: undefined, - defaultSkill: undefined, - sidecar: { autoApprove: false }, - }, - migration: { deleteOld: false, dryRun: false }, - pagination: { defaultLimit: 50, maxLimit: 200 }, - auth: { - tokenEndpoint: 'https://kc.example.com/token', - clientId: 'client', - clientSecret: 'secret', - }, - }), - }; - - const ctx = { - router, - logger, - config: {} as never, - provider: mockProvider as never, - sessions: undefined, - toErrorMessage: (e: unknown) => - e instanceof Error ? e.message : String(e), - sendRouteError: jest.fn( - (res: express.Response, err: unknown, _label: string, msg: string) => { - const status = err instanceof InputError ? 400 : 500; - res.status(status).json({ error: msg }); - }, - ), - missingSessions: jest.fn().mockReturnValue(false), - missingConversations: jest.fn().mockReturnValue(false), - getUserRef: jest.fn().mockResolvedValue('user:default/test'), - checkIsAdmin: jest.fn().mockResolvedValue(true), - requireAdminAccess: (( - _req: express.Request, - _res: express.Response, - next: express.NextFunction, - ) => next()) as express.RequestHandler, - parseChatRequest: jest.fn(), - parseApprovalRequest: jest.fn(), - }; - - registerKagentiRoutes(ctx); - - const app = express(); - app.use(express.json()); - app.use(router); - - return { app, mockApiClient }; -} - -describe('kagentiRoutes', () => { - it('GET /kagenti/health returns combined health and ready', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/health'); - expect(res.status).toBe(200); - expect(res.body).toEqual({ health: 'healthy', ready: true }); - }); - - it('GET /kagenti/config/features returns feature flags', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/config/features'); - expect(res.status).toBe(200); - expect(res.body).toEqual({ - sandbox: false, - integrations: false, - triggers: false, - }); - }); - - it('GET /kagenti/namespaces returns namespace list', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/namespaces'); - expect(res.status).toBe(200); - expect(res.body.namespaces).toEqual(['team1']); - }); - - it('GET /kagenti/agents returns agent list with namespace', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/agents?namespace=team1'); - expect(res.status).toBe(200); - expect(res.body.agents).toHaveLength(1); - }); - - it('GET /kagenti/agents without namespace iterates visible namespaces', async () => { - const { app, mockApiClient } = createMockRouteContext(); - const res = await request(app).get('/kagenti/agents'); - expect(res.status).toBe(200); - expect(res.body.agents).toHaveLength(1); - expect(mockApiClient.listNamespaces).toHaveBeenCalled(); - expect(mockApiClient.listAgents).toHaveBeenCalledWith('team1'); - }); - - it('GET /kagenti/agents/:ns/:name returns agent detail with card', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/agents/team1/bot'); - expect(res.status).toBe(200); - expect(res.body.agentCard).toBeDefined(); - expect(res.body.agentCard.name).toBe('bot'); - }); - - it('POST /kagenti/agents creates an agent', async () => { - const { app } = createMockRouteContext(); - const res = await request(app) - .post('/kagenti/agents') - .send({ name: 'bot', namespace: 'team1', containerImage: 'img:v1' }); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - }); - - it('DELETE /kagenti/agents/:ns/:name deletes an agent', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).delete('/kagenti/agents/team1/bot'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - }); - - it('GET /kagenti/tools returns tool list with namespace', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/tools?namespace=team1'); - expect(res.status).toBe(200); - expect(res.body.items).toEqual([]); - }); - - it('GET /kagenti/tools without namespace iterates visible namespaces', async () => { - const { app, mockApiClient } = createMockRouteContext(); - const res = await request(app).get('/kagenti/tools'); - expect(res.status).toBe(200); - expect(res.body.items).toEqual([]); - expect(mockApiClient.listNamespaces).toHaveBeenCalled(); - expect(mockApiClient.listTools).toHaveBeenCalledWith('team1'); - }); - - it('POST /kagenti/tools/:ns/:name/connect discovers MCP tools', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).post('/kagenti/tools/team1/my-tool/connect'); - expect(res.status).toBe(200); - expect(res.body.tools).toHaveLength(1); - }); - - it('POST /kagenti/tools/:ns/:name/invoke calls MCP tool', async () => { - const { app } = createMockRouteContext(); - const res = await request(app) - .post('/kagenti/tools/team1/my-tool/invoke') - .send({ tool_name: 'search', arguments: { query: 'hello' } }); - expect(res.status).toBe(200); - expect(res.body.result).toEqual({ data: 'ok' }); - }); - - it('POST /kagenti/tools/:ns/:name/invoke rejects missing tool_name', async () => { - const { app } = createMockRouteContext(); - const res = await request(app) - .post('/kagenti/tools/team1/my-tool/invoke') - .send({ arguments: { query: 'hello' } }); - expect(res.status).toBe(400); - }); - - it('POST /kagenti/agents/fetch-env-url rejects non-http URLs', async () => { - const { app } = createMockRouteContext(); - const res = await request(app) - .post('/kagenti/agents/fetch-env-url') - .send({ url: 'file:///etc/passwd' }); - expect(res.status).toBe(400); - }); - - it('GET /kagenti/config/dashboards merges config overrides', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/config/dashboards'); - expect(res.status).toBe(200); - expect(res.body.traces).toBe('https://traces'); - }); - - it('POST /kagenti/agents/:ns/:name/migrate uses config default for delete_old', async () => { - const { app, mockApiClient } = createMockRouteContext(); - const res = await request(app) - .post('/kagenti/agents/team1/bot/migrate') - .send({}); - expect(res.status).toBe(200); - expect(mockApiClient.migrateAgent).toHaveBeenCalledWith( - 'team1', - 'bot', - false, - ); - }); - - it('GET /kagenti/shipwright/builds returns build list', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/shipwright/builds'); - expect(res.status).toBe(200); - expect(res.body.builds).toEqual([]); - }); - - it('GET /kagenti/agents/build-strategies returns strategies', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/agents/build-strategies'); - expect(res.status).toBe(200); - expect(res.body.strategies).toEqual([]); - }); - - it('GET /kagenti/agents/:ns/:name/build-info returns build info', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/agents/team1/bot/build-info'); - expect(res.status).toBe(200); - expect(res.body.name).toBe('bot'); - expect(res.body.buildRegistered).toBe(true); - }); - - it('POST /kagenti/agents/:ns/:name/buildrun triggers agent build', async () => { - const { app, mockApiClient } = createMockRouteContext(); - const res = await request(app).post('/kagenti/agents/team1/bot/buildrun'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(mockApiClient.triggerAgentBuildRun).toHaveBeenCalledWith( - 'team1', - 'bot', - ); - }); - - it('POST /kagenti/agents/:ns/:name/finalize-build finalizes agent build', async () => { - const { app, mockApiClient } = createMockRouteContext(); - const res = await request(app).post( - '/kagenti/agents/team1/bot/finalize-build', - ); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(mockApiClient.finalizeAgentBuild).toHaveBeenCalledWith( - 'team1', - 'bot', - expect.anything(), - ); - }); - - it('POST /kagenti/agents/parse-env parses env content', async () => { - const { app, mockApiClient } = createMockRouteContext(); - const res = await request(app) - .post('/kagenti/agents/parse-env') - .send({ content: 'FOO=bar' }); - expect(res.status).toBe(200); - expect(mockApiClient.parseEnv).toHaveBeenCalled(); - }); - - it('GET /kagenti/tools/:ns/:name returns tool detail', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/tools/team1/my-tool'); - expect(res.status).toBe(200); - expect(res.body.metadata).toBeDefined(); - }); - - it('POST /kagenti/tools creates a tool', async () => { - const { app } = createMockRouteContext(); - const res = await request(app) - .post('/kagenti/tools') - .send({ name: 'tool1', namespace: 'team1', containerImage: 'img:v1' }); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - }); - - it('POST /kagenti/tools rejects missing name', async () => { - const { app } = createMockRouteContext(); - const res = await request(app) - .post('/kagenti/tools') - .send({ namespace: 'team1', containerImage: 'img:v1' }); - expect(res.status).toBe(400); - }); - - it('DELETE /kagenti/tools/:ns/:name deletes a tool', async () => { - const { app, mockApiClient } = createMockRouteContext(); - const res = await request(app).delete('/kagenti/tools/team1/my-tool'); - expect(res.status).toBe(200); - expect(mockApiClient.deleteTool).toHaveBeenCalledWith('team1', 'my-tool'); - }); - - it('GET /kagenti/tools/:ns/:name/build-info returns tool build info', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/tools/team1/tool1/build-info'); - expect(res.status).toBe(200); - expect(res.body.name).toBe('tool1'); - }); - - it('POST /kagenti/tools/:ns/:name/buildrun triggers tool build', async () => { - const { app, mockApiClient } = createMockRouteContext(); - const res = await request(app).post('/kagenti/tools/team1/tool1/buildrun'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(mockApiClient.triggerToolBuildRun).toHaveBeenCalledWith( - 'team1', - 'tool1', - ); - }); - - it('POST /kagenti/tools/:ns/:name/finalize-build finalizes tool build', async () => { - const { app, mockApiClient } = createMockRouteContext(); - const res = await request(app).post( - '/kagenti/tools/team1/tool1/finalize-build', - ); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(mockApiClient.finalizeToolBuild).toHaveBeenCalledWith( - 'team1', - 'tool1', - expect.anything(), - ); - }); - - it('GET /kagenti/tools/:ns/:name/route-status returns route status', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get( - '/kagenti/tools/team1/my-tool/route-status', - ); - expect(res.status).toBe(200); - expect(res.body.hasRoute).toBe(false); - }); - - it('GET /kagenti/agents/migration/migratable returns migratable agents', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/agents/migration/migratable'); - expect(res.status).toBe(200); - expect(res.body.agents).toEqual([]); - }); - - it('namespace validation rejects disallowed namespace', async () => { - createMockRouteContext(); - const ctx = createMockRouteContext(); - const appWithReject = express(); - const routerWithReject = express.Router(); - const loggerWithReject = createMockLogger(); - const mockApiReject = { - ...ctx.mockApiClient, - }; - const providerReject = { - id: 'kagenti', - displayName: 'Kagenti', - getApiClient: () => mockApiReject, - setUserContext: jest.fn(), - validateNamespace: jest.fn().mockImplementation(() => { - throw new Error('Namespace not allowed'); - }), - getAgentCardCached: jest.fn().mockResolvedValue({ - card: { name: 'bot' }, - demands: {}, - }), - getFeatureFlags: () => ({ - sandbox: false, - integrations: false, - triggers: false, - }), - getConfig: () => ({ - baseUrl: 'https://kagenti.example.com', - namespace: 'team1', - namespaces: undefined, - showAllNamespaces: true, - agentName: 'bot', - agents: undefined, - skipTlsVerify: false, - verboseStreamLogging: false, - requestTimeoutMs: 30_000, - streamTimeoutMs: 300_000, - maxRetries: 3, - retryBaseDelayMs: 1000, - tokenExpiryBufferSeconds: 60, - dashboards: {}, - sandbox: { - sessionTtlMinutes: undefined, - defaultSkill: undefined, - sidecar: { autoApprove: false }, - }, - migration: { deleteOld: false, dryRun: false }, - pagination: { defaultLimit: 50, maxLimit: 200 }, - auth: { - tokenEndpoint: 'https://kc.example.com/token', - clientId: 'client', - clientSecret: 'secret', - }, - }), - }; - const sendErr = jest.fn( - ( - res: express.Response, - _err: unknown, - _label: string, - _msg: string, - _extra?: Record, - statusCode?: number, - ) => { - res.status(statusCode ?? 500).json({ error: _msg }); - }, - ); - const ctxReject = { - router: routerWithReject, - logger: loggerWithReject, - config: {} as never, - provider: providerReject as never, - sessions: undefined, - toErrorMessage: (e: unknown) => - e instanceof Error ? e.message : String(e), - sendRouteError: sendErr, - missingSessions: jest.fn().mockReturnValue(false), - missingConversations: jest.fn().mockReturnValue(false), - getUserRef: jest.fn().mockResolvedValue('user:default/test'), - checkIsAdmin: jest.fn().mockResolvedValue(true), - requireAdminAccess: (( - _req: express.Request, - _res: express.Response, - next: express.NextFunction, - ) => next()) as express.RequestHandler, - parseChatRequest: jest.fn(), - parseApprovalRequest: jest.fn(), - }; - registerKagentiRoutes(ctxReject); - appWithReject.use(express.json()); - appWithReject.use(routerWithReject); - - const res = await request(appWithReject).get( - '/kagenti/agents?namespace=forbidden', - ); - expect(res.status).toBe(403); - expect(res.body.error).toBe('Namespace access denied'); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/routes/kagentiRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/kagentiRoutes.ts deleted file mode 100644 index ce200c7342a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/kagentiRoutes.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type express from 'express'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { createWithRoute } from './routeWrapper'; -import type { RouteContext } from './types'; -import type { KagentiProvider } from '../providers/kagenti'; -import type { KagentiApiClient } from '../providers/kagenti'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import { registerKagentiAgentRoutes } from './kagentiAgentRoutes'; -import { registerKagentiToolRoutes } from './kagentiToolRoutes'; -import { registerKagentiConfigRoutes } from './kagentiConfigRoutes'; - -/** - * Dependencies shared by Kagenti sub-route modules after the provider check. - */ -export interface KagentiRouteRegistrarContext { - router: express.Router; - logger: LoggerService; - kagenti: KagentiProvider; - api: KagentiApiClient; - adminConfig?: AdminConfigService; - getUserRef: (req: express.Request) => Promise; - withRoute: ReturnType; - validateNamespaceParam: express.RequestHandler; - requireAdminAccess: express.RequestHandler; -} - -/** - * Registers core Kagenti routes for agent lifecycle, tool management, - * namespaces, config, and Shipwright builds. - * - * These routes are always registered when the active provider is 'kagenti'. - */ -export function registerKagentiRoutes( - ctx: RouteContext, - adminConfig?: AdminConfigService, -): void { - const { - router, - logger, - provider, - sendRouteError, - requireAdminAccess, - getUserRef, - } = ctx; - const withRoute = createWithRoute(logger, sendRouteError); - - if (provider.id !== 'kagenti') { - logger.warn( - 'registerKagentiRoutes called but provider is not kagenti, skipping', - ); - return; - } - - const kagenti = provider as KagentiProvider; - const api = kagenti.getApiClient(); - - router.use('/kagenti', async (req, _res, next) => { - try { - const userRef = await getUserRef(req); - kagenti.setUserContext(userRef); - } catch (err) { - logger.warn( - `Could not resolve user context for Kagenti request: ${err instanceof Error ? err.message : err}`, - ); - } - next(); - }); - - function validateNamespaceParam( - req: express.Request, - res: express.Response, - next: express.NextFunction, - ) { - const ns = req.params.namespace || (req.query.namespace as string); - if (ns) { - try { - kagenti.validateNamespace(ns); - } catch (err) { - sendRouteError( - res, - err, - 'Namespace validation', - 'Namespace access denied', - undefined, - 403, - ); - return; - } - } - next(); - } - - const registrarCtx: KagentiRouteRegistrarContext = { - router, - logger, - kagenti, - api, - adminConfig, - getUserRef, - withRoute, - validateNamespaceParam, - requireAdminAccess, - }; - - registerKagentiConfigRoutes(registrarCtx); - registerKagentiAgentRoutes(registrarCtx); - registerKagentiToolRoutes(registrarCtx); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/kagentiSandboxRoutes.test.ts b/workspaces/augment/plugins/augment-backend/src/routes/kagentiSandboxRoutes.test.ts deleted file mode 100644 index c01ed701b0b..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/kagentiSandboxRoutes.test.ts +++ /dev/null @@ -1,383 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import express from 'express'; -import request from 'supertest'; -import { registerKagentiSandboxRoutes } from './kagentiSandboxRoutes'; -import { createMockLogger } from '../test-utils/mocks'; - -function createMockRouteContext() { - const router = express.Router(); - const logger = createMockLogger(); - - const mockSandboxClient = { - listSessions: jest.fn().mockResolvedValue({ items: [], total: 0 }), - getSession: jest - .fn() - .mockResolvedValue({ contextId: 'c1', status: 'completed' }), - getSessionChain: jest.fn().mockResolvedValue({ chain: [] }), - getSessionHistory: jest.fn().mockResolvedValue({ items: [] }), - deleteSession: jest.fn().mockResolvedValue(undefined), - renameSession: jest.fn().mockResolvedValue({ title: 'New' }), - killSession: jest.fn().mockResolvedValue({ status: 'cancelled' }), - approveSession: jest.fn().mockResolvedValue({ status: 'approved' }), - denySession: jest.fn().mockResolvedValue({ status: 'denied' }), - setVisibility: jest.fn().mockResolvedValue({ visibility: 'namespace' }), - cleanupSessions: jest.fn().mockResolvedValue({ cleaned: 2 }), - listSandboxAgents: jest.fn().mockResolvedValue([]), - getAgentPodStatus: jest.fn().mockResolvedValue({ running: true }), - getPodMetrics: jest.fn().mockResolvedValue({ cpu: '0.1' }), - getPodEvents: jest.fn().mockResolvedValue({ events: [] }), - sandboxChat: jest.fn().mockResolvedValue({ - content: 'reply', - context_id: 'c1', - status: 'completed', - }), - getSandboxDefaults: jest.fn().mockResolvedValue({}), - createSandbox: jest.fn().mockResolvedValue({ success: true }), - deleteSandbox: jest.fn().mockResolvedValue({ success: true }), - getSandboxConfig: jest.fn().mockResolvedValue({}), - updateSandbox: jest.fn().mockResolvedValue({ success: true }), - browseFiles: jest.fn().mockResolvedValue({ entries: [] }), - listDirectory: jest.fn().mockResolvedValue({ entries: [] }), - getFileContent: jest.fn().mockResolvedValue({ content: 'data' }), - browseContextFiles: jest.fn().mockResolvedValue({ entries: [] }), - getStorageStats: jest.fn().mockResolvedValue({ used: '1G' }), - listSidecars: jest.fn().mockResolvedValue([]), - enableSidecar: jest - .fn() - .mockResolvedValue({ sidecarType: 'observer', enabled: true }), - disableSidecar: jest - .fn() - .mockResolvedValue({ status: 'disabled', sidecar_type: 'observer' }), - updateSidecarConfig: jest - .fn() - .mockResolvedValue({ sidecarType: 'observer', enabled: true }), - resetSidecar: jest.fn().mockResolvedValue({}), - approveSidecar: jest - .fn() - .mockResolvedValue({ status: 'approved', id: 'm1' }), - denySidecar: jest.fn().mockResolvedValue({ status: 'denied', id: 'm1' }), - getSessionTokenUsage: jest.fn().mockResolvedValue({ totalTokens: 100 }), - getSessionTreeUsage: jest.fn().mockResolvedValue({}), - getEvents: jest.fn().mockResolvedValue({ events: [] }), - getPaginatedTasks: jest.fn().mockResolvedValue({ tasks: [] }), - }; - - const mockProvider = { - id: 'kagenti', - displayName: 'Kagenti', - getSandboxClient: () => mockSandboxClient, - validateNamespace: jest.fn(), - getFeatureFlags: () => ({ - sandbox: true, - integrations: false, - triggers: false, - }), - getConfig: () => ({ - verboseStreamLogging: false, - pagination: { defaultLimit: 50, maxLimit: 200 }, - sandbox: { defaultSkill: undefined, sidecar: { autoApprove: false } }, - }), - }; - - const ctx = { - router, - logger, - config: {} as never, - provider: mockProvider as never, - sessions: undefined, - toErrorMessage: (e: unknown) => - e instanceof Error ? e.message : String(e), - sendRouteError: jest.fn( - (res: express.Response, _err: unknown, _label: string, msg: string) => { - res.status(500).json({ error: msg }); - }, - ), - missingSessions: jest.fn().mockReturnValue(false), - missingConversations: jest.fn().mockReturnValue(false), - getUserRef: jest.fn().mockResolvedValue('user:default/test'), - checkIsAdmin: jest.fn().mockResolvedValue(true), - requireAdminAccess: (( - _req: express.Request, - _res: express.Response, - next: express.NextFunction, - ) => next()) as express.RequestHandler, - parseChatRequest: jest.fn(), - parseApprovalRequest: jest.fn(), - }; - - registerKagentiSandboxRoutes(ctx); - - const app = express(); - app.use(express.json()); - app.use(router); - - return { app, mockSandboxClient }; -} - -describe('kagentiSandboxRoutes', () => { - it('GET /kagenti/sandbox/:ns/sessions lists sessions', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/sandbox/ns1/sessions'); - expect(res.status).toBe(200); - expect(res.body).toEqual({ sessions: [], total: 0 }); - }); - - it('GET /kagenti/sandbox/:ns/sessions/:contextId gets session detail', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/sandbox/ns1/sessions/c1'); - expect(res.status).toBe(200); - expect(res.body.contextId).toBe('c1'); - }); - - it('DELETE /kagenti/sandbox/:ns/sessions/:contextId deletes session', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).delete('/kagenti/sandbox/ns1/sessions/c1'); - expect(res.status).toBe(204); - }); - - it('PUT /kagenti/sandbox/:ns/sessions/:contextId/rename renames session', async () => { - const { app } = createMockRouteContext(); - const res = await request(app) - .put('/kagenti/sandbox/ns1/sessions/c1/rename') - .send({ title: 'New' }); - expect(res.status).toBe(200); - expect(res.body.title).toBe('New'); - }); - - it('POST /kagenti/sandbox/:ns/cleanup cleans up sessions', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).post('/kagenti/sandbox/ns1/cleanup'); - expect(res.status).toBe(200); - expect(res.body.cleaned).toBe(2); - }); - - it('POST /kagenti/sandbox/:ns/chat sends sandbox chat', async () => { - const { app } = createMockRouteContext(); - const res = await request(app) - .post('/kagenti/sandbox/ns1/chat') - .send({ message: 'Hello' }); - expect(res.status).toBe(200); - expect(res.body.content).toBe('reply'); - }); - - it('GET /kagenti/sandbox/defaults gets sandbox defaults', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/sandbox/defaults'); - expect(res.status).toBe(200); - }); - - it('GET /kagenti/sandbox/:ns/stats/:agent gets storage stats', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/sandbox/ns1/stats/agent1'); - expect(res.status).toBe(200); - }); - - it('GET /kagenti/sandbox/:ns/token-usage/sessions/:contextId gets token usage', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get( - '/kagenti/sandbox/ns1/token-usage/sessions/ctx1', - ); - expect(res.status).toBe(200); - expect(res.body.totalTokens).toBe(100); - }); - - it('POST /kagenti/sandbox/:ns/sessions/:contextId/kill kills session', async () => { - const { app, mockSandboxClient } = createMockRouteContext(); - const res = await request(app).post( - '/kagenti/sandbox/ns1/sessions/c1/kill', - ); - expect(res.status).toBe(200); - expect(res.body.status).toBe('cancelled'); - expect(mockSandboxClient.killSession).toHaveBeenCalledWith('ns1', 'c1'); - }); - - it('POST /kagenti/sandbox/:ns/sessions/:contextId/approve approves session', async () => { - const { app, mockSandboxClient } = createMockRouteContext(); - const res = await request(app).post( - '/kagenti/sandbox/ns1/sessions/c1/approve', - ); - expect(res.status).toBe(200); - expect(res.body.status).toBe('approved'); - expect(mockSandboxClient.approveSession).toHaveBeenCalledWith('ns1', 'c1'); - }); - - it('POST /kagenti/sandbox/:ns/sessions/:contextId/deny denies session', async () => { - const { app, mockSandboxClient } = createMockRouteContext(); - const res = await request(app).post( - '/kagenti/sandbox/ns1/sessions/c1/deny', - ); - expect(res.status).toBe(200); - expect(res.body.status).toBe('denied'); - expect(mockSandboxClient.denySession).toHaveBeenCalledWith('ns1', 'c1'); - }); - - it('PUT /kagenti/sandbox/:ns/sessions/:contextId/visibility sets visibility', async () => { - const { app, mockSandboxClient } = createMockRouteContext(); - const res = await request(app) - .put('/kagenti/sandbox/ns1/sessions/c1/visibility') - .send({ visibility: 'namespace' }); - expect(res.status).toBe(200); - expect(res.body.visibility).toBe('namespace'); - expect(mockSandboxClient.setVisibility).toHaveBeenCalledWith( - 'ns1', - 'c1', - 'namespace', - ); - }); - - it('GET /kagenti/sandbox/:ns/agents lists sandbox agents', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get('/kagenti/sandbox/ns1/agents'); - expect(res.status).toBe(200); - expect(res.body.agents).toEqual([]); - }); - - it('GET /kagenti/sandbox/:ns/agents/:name/pod-status gets pod status', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get( - '/kagenti/sandbox/ns1/agents/agent1/pod-status', - ); - expect(res.status).toBe(200); - expect(res.body.running).toBe(true); - }); - - it('POST /kagenti/sandbox/:ns/chat rejects empty body', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).post('/kagenti/sandbox/ns1/chat').send({}); - expect(res.status).toBe(500); - }); - - it('GET /kagenti/sandbox/:ns/sessions/:contextId/chain gets session chain', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get( - '/kagenti/sandbox/ns1/sessions/c1/chain', - ); - expect(res.status).toBe(200); - expect(res.body.chain).toEqual([]); - }); - - it('GET /kagenti/sandbox/:ns/sessions/:contextId/history gets session history', async () => { - const { app } = createMockRouteContext(); - const res = await request(app).get( - '/kagenti/sandbox/ns1/sessions/c1/history', - ); - expect(res.status).toBe(200); - }); - - it('filters private sessions for non-admin users', async () => { - const router = express.Router(); - const logger = createMockLogger(); - - const mockSandboxClient = { - listSessions: jest.fn().mockResolvedValue({ - items: [ - { contextId: 'c1', visibility: 'namespace' }, - { contextId: 'c2', visibility: 'private' }, - ], - total: 2, - }), - getSession: jest.fn(), - getSessionChain: jest.fn(), - getSessionHistory: jest.fn(), - deleteSession: jest.fn(), - renameSession: jest.fn(), - killSession: jest.fn(), - approveSession: jest.fn(), - denySession: jest.fn(), - setVisibility: jest.fn(), - cleanupSessions: jest.fn(), - listSandboxAgents: jest.fn(), - getAgentPodStatus: jest.fn(), - getPodMetrics: jest.fn(), - getPodEvents: jest.fn(), - sandboxChat: jest.fn(), - getSandboxDefaults: jest.fn().mockResolvedValue({}), - createSandbox: jest.fn(), - deleteSandbox: jest.fn(), - getSandboxConfig: jest.fn(), - updateSandbox: jest.fn(), - browseFiles: jest.fn(), - listDirectory: jest.fn(), - getFileContent: jest.fn(), - browseContextFiles: jest.fn(), - getStorageStats: jest.fn(), - listSidecars: jest.fn(), - enableSidecar: jest.fn(), - disableSidecar: jest.fn(), - updateSidecarConfig: jest.fn(), - resetSidecar: jest.fn(), - approveSidecar: jest.fn(), - denySidecar: jest.fn(), - getSessionTokenUsage: jest.fn(), - getSessionTreeUsage: jest.fn(), - getEvents: jest.fn(), - getPaginatedTasks: jest.fn(), - }; - - const ctx = { - router, - logger, - config: {} as never, - provider: { - id: 'kagenti', - displayName: 'Kagenti', - getSandboxClient: () => mockSandboxClient, - validateNamespace: jest.fn(), - getFeatureFlags: () => ({ - sandbox: true, - integrations: false, - triggers: false, - }), - getConfig: () => ({ - verboseStreamLogging: false, - pagination: { defaultLimit: 50, maxLimit: 200 }, - sandbox: { defaultSkill: undefined, sidecar: { autoApprove: false } }, - }), - } as never, - sessions: undefined, - toErrorMessage: (e: unknown) => - e instanceof Error ? e.message : String(e), - sendRouteError: jest.fn( - (res: express.Response, _err: unknown, _label: string, msg: string) => { - res.status(500).json({ error: msg }); - }, - ), - missingSessions: jest.fn().mockReturnValue(false), - missingConversations: jest.fn().mockReturnValue(false), - getUserRef: jest.fn().mockResolvedValue('user:default/test'), - checkIsAdmin: jest.fn().mockResolvedValue(false), - requireAdminAccess: (( - _req: express.Request, - _res: express.Response, - next: express.NextFunction, - ) => next()) as express.RequestHandler, - parseChatRequest: jest.fn(), - parseApprovalRequest: jest.fn(), - }; - - registerKagentiSandboxRoutes(ctx); - const app = express(); - app.use(express.json()); - app.use(router); - - const res = await request(app).get('/kagenti/sandbox/ns1/sessions'); - expect(res.status).toBe(200); - expect(res.body.sessions).toHaveLength(1); - expect(res.body.sessions[0].contextId).toBe('c1'); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/routes/kagentiSandboxRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/kagentiSandboxRoutes.ts deleted file mode 100644 index 5f1f3f8e22e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/kagentiSandboxRoutes.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createWithRoute } from './routeWrapper'; -import type { RouteContext } from './types'; -import type { KagentiProvider } from '../providers/kagenti'; -import type { SandboxRouteCtx } from './sandboxRouteContext'; -import { - registerSandboxAgentRoutes, - registerSandboxDeployRoutes, - registerSandboxEventsRoutes, - registerSandboxTokenUsageRoutes, -} from './sandboxAgentDeployTelemetryRoutes'; -import { registerSandboxChatRoutes } from './sandboxChatRoutes'; -import { registerSandboxFileRoutes } from './sandboxFileRoutes'; -import { registerSandboxSessionRoutes } from './sandboxSessionRoutes'; -import { registerSandboxSidecarRoutes } from './sandboxSidecarRoutes'; - -/** - * Registers Kagenti sandbox routes. Only called when sandbox feature flag is on. - */ -export function registerKagentiSandboxRoutes(ctx: RouteContext): void { - const { - router, - logger, - provider, - sendRouteError, - requireAdminAccess, - checkIsAdmin, - } = ctx; - const withRoute = createWithRoute(logger, sendRouteError); - - if (provider.id !== 'kagenti') { - logger.warn( - 'registerKagentiSandboxRoutes called but provider is not kagenti, skipping', - ); - return; - } - const kagenti = provider as KagentiProvider; - - const sandbox = kagenti.getSandboxClient(); - if (!sandbox) { - logger.warn('Sandbox client not available, skipping sandbox routes'); - return; - } - - const kagentiCfg = kagenti.getConfig(); - const { defaultLimit, maxLimit } = kagentiCfg.pagination; - - function validateNamespaceParam( - req: import('express').Request, - res: import('express').Response, - next: import('express').NextFunction, - ) { - const ns = req.params.namespace; - if (ns) { - try { - kagenti.validateNamespace(ns); - } catch (err) { - sendRouteError( - res, - err, - 'Namespace validation', - 'Namespace access denied', - undefined, - 403, - ); - return; - } - } - next(); - } - - router.get( - '/kagenti/sandbox/defaults', - withRoute( - 'GET /kagenti/sandbox defaults', - 'Failed to get sandbox defaults', - async (_req, res) => { - const result = await sandbox.getSandboxDefaults(); - res.json(result); - }, - ), - ); - - router.use('/kagenti/sandbox/:namespace', validateNamespaceParam); - - const sandboxCtx: SandboxRouteCtx = { - router, - logger, - sandbox, - kagentiCfg, - withRoute, - requireAdminAccess, - checkIsAdmin, - defaultLimit, - maxLimit, - }; - - registerSandboxSessionRoutes(sandboxCtx); - registerSandboxAgentRoutes(sandboxCtx); - registerSandboxChatRoutes(sandboxCtx); - registerSandboxDeployRoutes(sandboxCtx); - registerSandboxFileRoutes(sandboxCtx); - registerSandboxSidecarRoutes(sandboxCtx); - registerSandboxTokenUsageRoutes(sandboxCtx); - registerSandboxEventsRoutes(sandboxCtx); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/kagentiToolRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/kagentiToolRoutes.ts deleted file mode 100644 index aad3a0fa9cd..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/kagentiToolRoutes.ts +++ /dev/null @@ -1,253 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { InputError } from '@backstage/errors'; -import type { KagentiRouteRegistrarContext } from './kagentiRoutes'; -import { getVisibleNamespaces } from '../providers/kagenti/kagentiNamespaceUtils'; - -/** - * Registers Kagenti tool CRUD, builds, MCP connect/invoke, and Shipwright routes. - */ -export function registerKagentiToolRoutes( - ctx: KagentiRouteRegistrarContext, -): void { - const { - router, - logger, - kagenti, - api, - withRoute, - validateNamespaceParam, - requireAdminAccess, - } = ctx; - - router.get( - '/kagenti/tools', - validateNamespaceParam, - withRoute( - 'GET /kagenti/tools', - 'Failed to list tools', - async (req, res) => { - const namespace = req.query.namespace as string | undefined; - if (namespace) { - const result = await api.listTools(namespace); - res.json(result); - return; - } - - const visibleNs = await getVisibleNamespaces( - api, - kagenti.getConfig(), - logger, - ); - - const allTools: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiToolSummary[] = - []; - for (const ns of visibleNs) { - try { - const result = await api.listTools(ns); - allTools.push(...(result.items ?? [])); - } catch (nsErr) { - logger.warn( - `Failed to list tools in namespace ${ns}: ${nsErr instanceof Error ? nsErr.message : nsErr}`, - ); - } - } - res.json({ items: allTools }); - }, - ), - ); - - router.get( - '/kagenti/tools/:namespace/:name', - validateNamespaceParam, - withRoute( - req => `GET /kagenti/tools/${req.params.namespace}/${req.params.name}`, - 'Failed to get tool', - async (req, res) => { - const { namespace, name } = req.params; - const result = await api.getTool(namespace, name); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/tools/:namespace/:name/route-status', - validateNamespaceParam, - withRoute( - req => - `GET /kagenti/tools/${req.params.namespace}/${req.params.name}/route-status`, - 'Failed to get tool route status', - async (req, res) => { - const { namespace, name } = req.params; - const result = await api.getToolRouteStatus(namespace, name); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/tools', - withRoute( - 'POST /kagenti/tools', - 'Failed to create tool', - async (req, res) => { - if (!req.body?.name || typeof req.body.name !== 'string') { - throw new InputError('name is required and must be a string'); - } - if (!req.body?.namespace || typeof req.body.namespace !== 'string') { - throw new InputError('namespace is required and must be a string'); - } - const result = await api.createTool(req.body); - res.json(result); - }, - ), - ); - - router.delete( - '/kagenti/tools/:namespace/:name', - requireAdminAccess, - validateNamespaceParam, - withRoute( - req => `DELETE /kagenti/tools/${req.params.namespace}/${req.params.name}`, - 'Failed to delete tool', - async (req, res) => { - const { namespace, name } = req.params; - const result = await api.deleteTool(namespace, name); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/tools/:namespace/:name/build-info', - validateNamespaceParam, - withRoute( - req => - `GET /kagenti/tools/${req.params.namespace}/${req.params.name}/build-info`, - 'Failed to get tool build info', - async (req, res) => { - const { namespace, name } = req.params; - const result = await api.getToolBuildInfo(namespace, name); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/tools/:namespace/:name/buildrun', - requireAdminAccess, - validateNamespaceParam, - withRoute( - req => - `POST /kagenti/tools/${req.params.namespace}/${req.params.name}/buildrun`, - 'Failed to trigger tool build run', - async (req, res) => { - const { namespace, name } = req.params; - const result = await api.triggerToolBuildRun(namespace, name); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/tools/:namespace/:name/finalize-build', - requireAdminAccess, - validateNamespaceParam, - withRoute( - req => - `POST /kagenti/tools/${req.params.namespace}/${req.params.name}/finalize-build`, - 'Failed to finalize tool build', - async (req, res) => { - const { namespace, name } = req.params; - const result = await api.finalizeToolBuild(namespace, name, req.body); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/tools/:namespace/:name/connect', - validateNamespaceParam, - withRoute( - req => - `POST /kagenti/tools/${req.params.namespace}/${req.params.name}/connect`, - 'Failed to connect to MCP tool', - async (req, res) => { - const { namespace, name } = req.params; - const result = await api.connectTool(namespace, name); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/tools/:namespace/:name/invoke', - validateNamespaceParam, - withRoute( - req => - `POST /kagenti/tools/${req.params.namespace}/${req.params.name}/invoke`, - 'Failed to invoke MCP tool', - async (req, res) => { - const { namespace, name } = req.params; - const { tool_name, arguments: args } = req.body ?? {}; - if (!tool_name || typeof tool_name !== 'string') { - throw new InputError('tool_name is required and must be a string'); - } - const result = await api.invokeTool( - namespace, - name, - tool_name, - args ?? {}, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/tools/shipwright-builds', - withRoute( - 'GET /kagenti/tools/shipwright-builds', - 'Failed to list tool builds', - async (req, res) => { - const namespace = req.query.namespace as string | undefined; - const allNamespaces = req.query.all_namespaces === 'true'; - const result = await api.listToolBuilds( - allNamespaces ? undefined : namespace, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/shipwright/builds', - withRoute( - 'GET /kagenti/shipwright/builds', - 'Failed to list Shipwright builds', - async (req, res) => { - const namespace = req.query.namespace as string | undefined; - const allNamespaces = req.query.allNamespaces === 'true'; - const result = await api.listAllBuilds( - allNamespaces ? undefined : namespace, - ); - res.json({ builds: result.items ?? [] }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/providerRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/providerRoutes.ts deleted file mode 100644 index 02fc2849910..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/providerRoutes.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { InputError } from '@backstage/errors'; -import type { ProviderType } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { - getAllProviderDescriptors, - isValidProviderType, -} from '../providers/registry'; -import { toErrorMessage } from '../services/utils'; -import { createWithRoute } from './routeWrapper'; -import type { AdminRouteDeps } from './adminRouteTypes'; - -/** - * Registers provider registry and active-provider endpoints. - * - * Endpoints: - * GET /admin/providers -- List all known providers - * GET /admin/active-provider -- Get the currently active provider ID - * PUT /admin/active-provider -- Set the active provider (triggers hot-swap) - * - * @internal - */ -export function registerProviderRoutes( - router: import('express').Router, - deps: AdminRouteDeps, -): void { - const { logger, sendRouteError, adminConfig, providerManager, getUserRef } = - deps; - const withRoute = createWithRoute(logger, sendRouteError); - - router.get( - '/admin/providers', - withRoute( - 'GET /admin/providers', - 'Failed to list providers', - async (_req, res) => { - const descriptors = getAllProviderDescriptors(); - res.json({ - success: true, - providers: descriptors, - activeProviderId: deps.provider.id, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.get( - '/admin/active-provider', - withRoute( - 'GET /admin/active-provider', - 'Failed to get active provider', - async (_req, res) => { - const dbValue = await adminConfig.get('activeProvider'); - const providerId = - typeof dbValue === 'string' ? dbValue : deps.provider.id; - - res.json({ - success: true, - providerId, - timestamp: new Date().toISOString(), - }); - }, - ), - ); - - router.put( - '/admin/active-provider', - withRoute( - 'PUT /admin/active-provider', - 'Failed to set active provider', - async (req, res) => { - const { providerId } = req.body as { providerId?: string }; - - if (typeof providerId !== 'string' || providerId.trim().length === 0) { - throw new InputError( - 'Request body must contain a non-empty "providerId" string', - ); - } - - const trimmed = providerId.trim(); - - if (!isValidProviderType(trimmed)) { - throw new InputError( - `Unknown provider: "${trimmed}". ` + - `Known providers: ${getAllProviderDescriptors() - .map(d => d.id) - .join(', ')}`, - ); - } - - const descriptor = getAllProviderDescriptors().find( - d => d.id === trimmed, - ); - if (descriptor && !descriptor.implemented) { - throw new InputError( - `Provider "${descriptor.displayName}" is not yet implemented. ` + - `It cannot be activated.`, - ); - } - - const userRef = await getUserRef(req); - await adminConfig.set('activeProvider', trimmed, userRef); - - if (providerManager && trimmed !== deps.provider.id) { - try { - await providerManager.switchProvider(trimmed as ProviderType); - logger.info(`Provider hot-swapped to "${trimmed}" by ${userRef}`); - } catch (swapError) { - logger.error( - `Provider hot-swap to "${trimmed}" failed: ${toErrorMessage( - swapError, - )}`, - ); - res.status(500).json({ - success: false, - providerId: trimmed, - error: `Provider switch failed: ${toErrorMessage( - swapError, - 'Unknown error', - )}. The previous provider remains active.`, - timestamp: new Date().toISOString(), - }); - return; - } - } - - res.json({ - success: true, - providerId: trimmed, - timestamp: new Date().toISOString(), - }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/resolveWorkflowConfig.ts b/workspaces/augment/plugins/augment-backend/src/routes/resolveWorkflowConfig.ts deleted file mode 100644 index 4cd2d2de5fa..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/resolveWorkflowConfig.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ProviderType } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import type { RouteContext } from './types'; - -/** - * Resolve LlamaStack connection config at request time by reading admin DB - * overrides first, falling back to YAML config values. - */ -export async function resolveLlamaStackConfig( - ctx: RouteContext, - adminConfig: AdminConfigService, -): Promise<{ url: string; model: string; skipTls: boolean }> { - const providerId: ProviderType = 'llamastack'; - const dbUrl = await adminConfig - .getScopedValue('baseUrl', providerId) - .catch(() => undefined); - const dbModel = await adminConfig - .getScopedValue('model', providerId) - .catch(() => undefined); - - const url = - (typeof dbUrl === 'string' && dbUrl) || - ctx.config.getOptionalString('augment.llamaStack.baseUrl') || - 'http://localhost:8321'; - const model = - (typeof dbModel === 'string' && dbModel) || - ctx.config.getOptionalString('augment.llamaStack.model') || - 'meta-llama/Llama-3.1-8B-Instruct'; - const skipTls = - ctx.config.getOptionalBoolean('augment.llamaStack.skipTlsVerify') ?? false; - - return { url, model, skipTls }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/routeWrapper.test.ts b/workspaces/augment/plugins/augment-backend/src/routes/routeWrapper.test.ts deleted file mode 100644 index 9a7d603480e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/routeWrapper.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import express from 'express'; -import request from 'supertest'; -import { createWithRoute, notFound } from './routeWrapper'; -import { createMockLogger } from '../test-utils'; - -describe('routeWrapper', () => { - describe('createWithRoute', () => { - it('creates a wrapper that catches errors and delegates to sendRouteError', async () => { - const logger = createMockLogger(); - const sendRouteError = jest.fn((res: express.Response) => { - res.status(500).json({ error: 'Something went wrong' }); - }); - const withRoute = createWithRoute(logger, sendRouteError); - - const handler = jest.fn().mockRejectedValue(new Error('Handler failed')); - const wrapped = withRoute('TestRoute', 'Something went wrong', handler); - - const app = express(); - app.use(express.json()); - app.all('/test', (req, res) => wrapped(req, res)); - - const response = await request(app).get('/test'); - - expect(handler).toHaveBeenCalled(); - expect(sendRouteError).toHaveBeenCalledWith( - expect.anything(), - expect.any(Error), - 'TestRoute', - 'Something went wrong', - { success: false }, - ); - expect(response.status).toBe(500); - }); - - it('logs the label before executing handler', async () => { - const logger = createMockLogger(); - const sendRouteError = jest.fn(); - const withRoute = createWithRoute(logger, sendRouteError); - - const handler = jest - .fn() - .mockImplementation(async (_req, res: express.Response) => { - res.status(200).json({ ok: true }); - }); - const wrapped = withRoute('MyLabel', 'Error', handler); - - const req = {} as express.Request; - const res = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), - } as unknown as express.Response; - - await wrapped(req, res); - - expect(logger.debug).toHaveBeenCalledWith('MyLabel'); - }); - - it('supports function logLabel that derives label from request', async () => { - const logger = createMockLogger(); - const sendRouteError = jest.fn(); - const withRoute = createWithRoute(logger, sendRouteError); - - const handler = jest.fn().mockRejectedValue(new Error('Fail')); - const getLabel = (req: express.Request) => - `Dynamic-${ - (req as express.Request & { params?: { id?: string } }).params?.id ?? - 'unknown' - }`; - const wrapped = withRoute(getLabel, 'Error', handler); - - const req = { - params: { id: '123' }, - } as unknown as express.Request; - const res = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), - } as unknown as express.Response; - - await wrapped(req, res); - - expect(logger.debug).toHaveBeenCalledWith('Dynamic-123'); - expect(sendRouteError).toHaveBeenCalledWith( - expect.anything(), - expect.any(Error), - 'Dynamic-123', - 'Error', - { success: false }, - ); - }); - }); - - describe('notFound', () => { - it.each([ - ['Entity', 'Entity not found'], - ['Conversation', 'Conversation not found'], - ])( - 'sends 404 with correct message for %s', - (entityName, expectedMessage) => { - const res = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), - } as unknown as express.Response; - - notFound(res, entityName); - - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ - error: expectedMessage, - message: expectedMessage, - }); - }, - ); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/routes/routeWrapper.ts b/workspaces/augment/plugins/augment-backend/src/routes/routeWrapper.ts deleted file mode 100644 index 1ae4f635cf7..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/routeWrapper.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type express from 'express'; -import type { LoggerService } from '@backstage/backend-plugin-api'; - -type SendRouteError = ( - res: express.Response, - error: unknown, - logLabel: string, - userFacingError: string, - extra?: Record, - statusCode?: number, -) => void; - -/** - * Creates a `withRoute` helper bound to the given logger and error sender. - * Each route handler gets automatic info-level logging and a standard - * try/catch that delegates to `sendRouteError`. - * - * Handlers that need custom error paths (409, 400, etc.) should handle - * those internally and let unexpected errors bubble up to the wrapper. - */ -export function createWithRoute( - logger: LoggerService, - sendRouteError: SendRouteError, -) { - return function withRoute( - logLabel: string | ((req: express.Request) => string), - userFacingError: string, - handler: (req: express.Request, res: express.Response) => Promise, - ): (req: express.Request, res: express.Response) => Promise { - return async (req, res) => { - const label = typeof logLabel === 'function' ? logLabel(req) : logLabel; - logger.debug(label); - try { - await handler(req, res); - } catch (error) { - sendRouteError(res, error, label, userFacingError, { success: false }); - } - }; - }; -} - -/** - * Sends a standardized 404 JSON response. - */ -export function notFound(res: express.Response, entity: string): void { - res - .status(404) - .json({ error: `${entity} not found`, message: `${entity} not found` }); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/sandboxAgentDeployTelemetryRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/sandboxAgentDeployTelemetryRoutes.ts deleted file mode 100644 index e2ba8a413c1..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/sandboxAgentDeployTelemetryRoutes.ts +++ /dev/null @@ -1,242 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { safeInt, clampLimit } from './sseRouteHelpers'; -import type { SandboxRouteCtx } from './sandboxRouteContext'; - -/** Sandbox agent list and pod inspection routes. */ -export function registerSandboxAgentRoutes(ctx: SandboxRouteCtx): void { - const { router, sandbox, withRoute } = ctx; - - router.get( - '/kagenti/sandbox/:namespace/agents', - withRoute( - 'GET /kagenti/sandbox agents', - 'Failed to list sandbox agents', - async (req, res) => { - const result = await sandbox.listSandboxAgents(req.params.namespace); - res.json({ agents: Array.isArray(result) ? result : [] }); - }, - ), - ); - - router.get( - '/kagenti/sandbox/:namespace/agents/:name/pod-status', - withRoute( - 'GET /kagenti/sandbox pod status', - 'Failed to get pod status', - async (req, res) => { - const result = await sandbox.getAgentPodStatus( - req.params.namespace, - req.params.name, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/sandbox/:namespace/agents/:name/metrics', - withRoute( - 'GET /kagenti/sandbox pod metrics', - 'Failed to get pod metrics', - async (req, res) => { - const result = await sandbox.getPodMetrics( - req.params.namespace, - req.params.name, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/sandbox/:namespace/agents/:name/events', - withRoute( - 'GET /kagenti/sandbox pod events', - 'Failed to get pod events', - async (req, res) => { - const result = await sandbox.getPodEvents( - req.params.namespace, - req.params.name, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/sandbox/:namespace/agent-card/:agent', - withRoute( - 'GET /kagenti/sandbox agent card', - 'Failed to get sandbox agent card', - async (req, res) => { - const result = await sandbox.getSandboxAgentCard( - req.params.namespace, - req.params.agent, - ); - res.json(result); - }, - ), - ); -} - -/** Sandbox create / delete / config / update routes. */ -export function registerSandboxDeployRoutes(ctx: SandboxRouteCtx): void { - const { router, sandbox, withRoute, requireAdminAccess } = ctx; - - router.post( - '/kagenti/sandbox/:namespace/create', - requireAdminAccess, - withRoute( - 'POST /kagenti/sandbox create', - 'Failed to create sandbox', - async (req, res) => { - const result = await sandbox.createSandbox( - req.params.namespace, - req.body, - ); - res.json(result); - }, - ), - ); - - router.delete( - '/kagenti/sandbox/:namespace/:name', - requireAdminAccess, - withRoute( - 'DELETE /kagenti/sandbox', - 'Failed to delete sandbox', - async (req, res) => { - const result = await sandbox.deleteSandbox( - req.params.namespace, - req.params.name, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/sandbox/:namespace/:name/config', - withRoute( - 'GET /kagenti/sandbox config', - 'Failed to get sandbox config', - async (req, res) => { - const result = await sandbox.getSandboxConfig( - req.params.namespace, - req.params.name, - ); - res.json(result); - }, - ), - ); - - router.put( - '/kagenti/sandbox/:namespace/:name', - withRoute( - 'PUT /kagenti/sandbox update', - 'Failed to update sandbox', - async (req, res) => { - const result = await sandbox.updateSandbox( - req.params.namespace, - req.params.name, - req.body, - ); - res.json(result); - }, - ), - ); -} - -/** Per-session token usage routes. */ -export function registerSandboxTokenUsageRoutes(ctx: SandboxRouteCtx): void { - const { router, sandbox, withRoute } = ctx; - - router.get( - '/kagenti/sandbox/:namespace/token-usage/sessions/:contextId', - withRoute( - 'GET /kagenti/token-usage session', - 'Failed to get token usage', - async (req, res) => { - const result = await sandbox.getSessionTokenUsage( - req.params.namespace, - req.params.contextId, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/sandbox/:namespace/token-usage/sessions/:contextId/tree', - withRoute( - 'GET /kagenti/token-usage tree', - 'Failed to get token usage tree', - async (req, res) => { - const result = await sandbox.getSessionTreeUsage( - req.params.namespace, - req.params.contextId, - ); - res.json(result); - }, - ), - ); -} - -/** Namespace events and paginated tasks. */ -export function registerSandboxEventsRoutes(ctx: SandboxRouteCtx): void { - const { router, sandbox, withRoute, defaultLimit, maxLimit } = ctx; - - router.get( - '/kagenti/sandbox/:namespace/events', - withRoute( - 'GET /kagenti/sandbox events', - 'Failed to get events', - async (req, res) => { - const result = await sandbox.getEvents( - req.params.namespace, - req.query.context_id as string, - { - taskId: req.query.task_id as string | undefined, - fromIndex: safeInt(req.query.from_index), - limit: clampLimit(safeInt(req.query.limit), defaultLimit, maxLimit), - }, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/sandbox/:namespace/tasks/paginated', - withRoute( - 'GET /kagenti/sandbox paginated tasks', - 'Failed to get paginated tasks', - async (req, res) => { - const result = await sandbox.getPaginatedTasks( - req.params.namespace, - req.query.context_id as string, - { - limit: clampLimit(safeInt(req.query.limit), defaultLimit, maxLimit), - beforeId: req.query.before_id as string | undefined, - }, - ); - res.json(result); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/sandboxChatRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/sandboxChatRoutes.ts deleted file mode 100644 index 963a6331310..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/sandboxChatRoutes.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { InputError } from '@backstage/errors'; -import { handleSseStream } from './sseRouteHelpers'; -import type { SandboxRouteCtx } from './sandboxRouteContext'; - -export function registerSandboxChatRoutes(ctx: SandboxRouteCtx): void { - const { router, sandbox, logger, kagentiCfg, withRoute } = ctx; - - router.post( - '/kagenti/sandbox/:namespace/chat', - withRoute( - 'POST /kagenti/sandbox chat', - 'Failed to send sandbox chat', - async (req, res) => { - if (!req.body?.message || typeof req.body.message !== 'string') { - throw new InputError('message is required and must be a string'); - } - const result = await sandbox.sandboxChat( - req.params.namespace, - req.body.message, - { - sessionId: req.body.session_id, - agentName: req.body.agent_name, - skill: req.body.skill ?? kagentiCfg.sandbox.defaultSkill, - }, - ); - res.json(result); - }, - ), - ); - - router.post('/kagenti/sandbox/:namespace/chat/stream', (req, res) => { - if (!req.body?.message || typeof req.body.message !== 'string') { - res - .status(400) - .json({ error: 'message is required and must be a string' }); - return; - } - logger.debug('POST /kagenti/sandbox chat/stream'); - handleSseStream(res, logger, kagentiCfg, (onLine, signal) => - sandbox.sandboxChatStream( - req.params.namespace, - req.body.message, - { - sessionId: req.body.session_id, - agentName: req.body.agent_name, - skill: req.body.skill ?? kagentiCfg.sandbox.defaultSkill, - }, - onLine, - signal, - ), - ); - }); - - router.get( - '/kagenti/sandbox/:namespace/sessions/:sessionId/subscribe', - (req, res) => { - logger.debug( - `GET /kagenti/sandbox subscribe session ${req.params.sessionId}`, - ); - handleSseStream(res, logger, kagentiCfg, (onLine, signal) => - sandbox.subscribeSession( - req.params.namespace, - req.params.sessionId, - onLine, - signal, - ), - ); - }, - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/sandboxFileRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/sandboxFileRoutes.ts deleted file mode 100644 index b471ce52641..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/sandboxFileRoutes.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { SandboxRouteCtx } from './sandboxRouteContext'; - -export function registerSandboxFileRoutes(ctx: SandboxRouteCtx): void { - const { router, sandbox, withRoute } = ctx; - - router.get( - '/kagenti/sandbox/:namespace/files/:agent', - withRoute( - 'GET /kagenti/sandbox files', - 'Failed to browse files', - async (req, res) => { - const path = (req.query.path as string) || '/'; - const result = await sandbox.browseFiles( - req.params.namespace, - req.params.agent, - path, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/sandbox/:namespace/files/:agent/list', - withRoute( - 'GET /kagenti/sandbox file list', - 'Failed to list directory', - async (req, res) => { - const path = (req.query.path as string) || '/'; - const result = await sandbox.listDirectory( - req.params.namespace, - req.params.agent, - path, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/sandbox/:namespace/files/:agent/content', - withRoute( - 'GET /kagenti/sandbox file content', - 'Failed to get file content', - async (req, res) => { - const path = req.query.path as string; - const result = await sandbox.getFileContent( - req.params.namespace, - req.params.agent, - path, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/sandbox/:namespace/files/:agent/:contextId', - withRoute( - 'GET /kagenti/sandbox context files', - 'Failed to browse context files', - async (req, res) => { - const path = (req.query.path as string) || '/'; - const result = await sandbox.browseContextFiles( - req.params.namespace, - req.params.agent, - req.params.contextId, - path, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/sandbox/:namespace/stats/:agent', - withRoute( - 'GET /kagenti/sandbox storage stats', - 'Failed to get storage stats', - async (req, res) => { - const result = await sandbox.getStorageStats( - req.params.namespace, - req.params.agent, - ); - res.json(result); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/sandboxRouteContext.ts b/workspaces/augment/plugins/augment-backend/src/routes/sandboxRouteContext.ts deleted file mode 100644 index 4766ec26887..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/sandboxRouteContext.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Router } from 'express'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { KagentiSandboxClient } from '../providers/kagenti/client/KagentiSandboxClient'; -import type { KagentiConfig } from '../providers/kagenti/config/KagentiConfigLoader'; -import type { RouteContext } from './types'; - -/** Shared context for Kagenti sandbox sub-route modules. */ -export interface SandboxRouteCtx { - router: Router; - logger: LoggerService; - sandbox: KagentiSandboxClient; - kagentiCfg: KagentiConfig; - withRoute: ReturnType; - requireAdminAccess: RouteContext['requireAdminAccess']; - checkIsAdmin: RouteContext['checkIsAdmin']; - defaultLimit: number; - maxLimit: number; -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/sandboxSessionRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/sandboxSessionRoutes.ts deleted file mode 100644 index 29adf373b77..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/sandboxSessionRoutes.ts +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { safeInt, clampLimit } from './sseRouteHelpers'; -import type { SandboxRouteCtx } from './sandboxRouteContext'; - -export function registerSandboxSessionRoutes(ctx: SandboxRouteCtx): void { - const { - router, - sandbox, - withRoute, - requireAdminAccess, - checkIsAdmin, - kagentiCfg, - defaultLimit, - maxLimit, - } = ctx; - - router.get( - '/kagenti/sandbox/:namespace/sessions', - withRoute( - 'GET /kagenti/sandbox sessions', - 'Failed to list sessions', - async (req, res) => { - const result = await sandbox.listSessions(req.params.namespace, { - limit: clampLimit(safeInt(req.query.limit), defaultLimit, maxLimit), - offset: safeInt(req.query.offset), - search: req.query.search as string | undefined, - agentName: req.query.agent_name as string | undefined, - }); - const isAdmin = await checkIsAdmin(req); - let sessions = result.items ?? []; - if (!isAdmin) { - sessions = sessions.filter( - s => s.visibility !== 'private' || s.visibility === undefined, - ); - } - res.json({ sessions, total: result.total }); - }, - ), - ); - - router.get( - '/kagenti/sandbox/:namespace/sessions/:contextId', - withRoute( - 'GET /kagenti/sandbox session detail', - 'Failed to get session', - async (req, res) => { - const result = await sandbox.getSession( - req.params.namespace, - req.params.contextId, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/sandbox/:namespace/sessions/:contextId/chain', - withRoute( - 'GET /kagenti/sandbox session chain', - 'Failed to get session chain', - async (req, res) => { - const result = await sandbox.getSessionChain( - req.params.namespace, - req.params.contextId, - ); - res.json(result); - }, - ), - ); - - router.get( - '/kagenti/sandbox/:namespace/sessions/:contextId/history', - withRoute( - 'GET /kagenti/sandbox session history', - 'Failed to get session history', - async (req, res) => { - const result = await sandbox.getSessionHistory( - req.params.namespace, - req.params.contextId, - ); - res.json(result); - }, - ), - ); - - router.delete( - '/kagenti/sandbox/:namespace/sessions/:contextId', - withRoute( - 'DELETE /kagenti/sandbox session', - 'Failed to delete session', - async (req, res) => { - await sandbox.deleteSession(req.params.namespace, req.params.contextId); - res.status(204).send(); - }, - ), - ); - - router.put( - '/kagenti/sandbox/:namespace/sessions/:contextId/rename', - withRoute( - 'PUT /kagenti/sandbox session rename', - 'Failed to rename session', - async (req, res) => { - const result = await sandbox.renameSession( - req.params.namespace, - req.params.contextId, - req.body.title, - ); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/sandbox/:namespace/sessions/:contextId/kill', - withRoute( - 'POST /kagenti/sandbox session kill', - 'Failed to kill session', - async (req, res) => { - const result = await sandbox.killSession( - req.params.namespace, - req.params.contextId, - ); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/sandbox/:namespace/sessions/:contextId/approve', - requireAdminAccess, - withRoute( - 'POST /kagenti/sandbox session approve', - 'Failed to approve session', - async (req, res) => { - const result = await sandbox.approveSession( - req.params.namespace, - req.params.contextId, - ); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/sandbox/:namespace/sessions/:contextId/deny', - requireAdminAccess, - withRoute( - 'POST /kagenti/sandbox session deny', - 'Failed to deny session', - async (req, res) => { - const result = await sandbox.denySession( - req.params.namespace, - req.params.contextId, - ); - res.json(result); - }, - ), - ); - - router.put( - '/kagenti/sandbox/:namespace/sessions/:contextId/visibility', - withRoute( - 'PUT /kagenti/sandbox session visibility', - 'Failed to set visibility', - async (req, res) => { - const result = await sandbox.setVisibility( - req.params.namespace, - req.params.contextId, - req.body.visibility, - ); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/sandbox/:namespace/cleanup', - requireAdminAccess, - withRoute( - 'POST /kagenti/sandbox cleanup', - 'Failed to cleanup sessions', - async (req, res) => { - const ttl = - safeInt(req.query.ttl_minutes) ?? - kagentiCfg.sandbox.sessionTtlMinutes; - const result = await sandbox.cleanupSessions(req.params.namespace, ttl); - res.json(result); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/sandboxSidecarRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/sandboxSidecarRoutes.ts deleted file mode 100644 index 9129412cc6f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/sandboxSidecarRoutes.ts +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { SandboxRouteCtx } from './sandboxRouteContext'; - -export function registerSandboxSidecarRoutes(ctx: SandboxRouteCtx): void { - const { router, sandbox, kagentiCfg, withRoute } = ctx; - - router.get( - '/kagenti/sandbox/:namespace/sessions/:contextId/sidecars', - withRoute( - 'GET /kagenti/sandbox sidecars', - 'Failed to list sidecars', - async (req, res) => { - const result = await sandbox.listSidecars( - req.params.namespace, - req.params.contextId, - ); - res.json({ sidecars: Array.isArray(result) ? result : [] }); - }, - ), - ); - - router.post( - '/kagenti/sandbox/:namespace/sessions/:contextId/sidecars/:type/enable', - withRoute( - 'POST /kagenti/sandbox sidecar enable', - 'Failed to enable sidecar', - async (req, res) => { - const body = { - ...req.body, - auto_approve: - req.body?.auto_approve ?? kagentiCfg.sandbox.sidecar.autoApprove, - }; - const result = await sandbox.enableSidecar( - req.params.namespace, - req.params.contextId, - req.params.type, - body, - ); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/sandbox/:namespace/sessions/:contextId/sidecars/:type/disable', - withRoute( - 'POST /kagenti/sandbox sidecar disable', - 'Failed to disable sidecar', - async (req, res) => { - const result = await sandbox.disableSidecar( - req.params.namespace, - req.params.contextId, - req.params.type, - ); - res.json(result); - }, - ), - ); - - router.put( - '/kagenti/sandbox/:namespace/sessions/:contextId/sidecars/:type/config', - withRoute( - 'PUT /kagenti/sandbox sidecar config', - 'Failed to update sidecar config', - async (req, res) => { - const result = await sandbox.updateSidecarConfig( - req.params.namespace, - req.params.contextId, - req.params.type, - req.body, - ); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/sandbox/:namespace/sessions/:contextId/sidecars/:type/reset', - withRoute( - 'POST /kagenti/sandbox sidecar reset', - 'Failed to reset sidecar', - async (req, res) => { - const result = await sandbox.resetSidecar( - req.params.namespace, - req.params.contextId, - req.params.type, - ); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/sandbox/:namespace/sessions/:contextId/sidecars/:type/approve/:msgId', - withRoute( - 'POST /kagenti/sandbox sidecar approve', - 'Failed to approve sidecar', - async (req, res) => { - const result = await sandbox.approveSidecar( - req.params.namespace, - req.params.contextId, - req.params.type, - req.params.msgId, - ); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/sandbox/:namespace/sessions/:contextId/sidecars/:type/deny/:msgId', - withRoute( - 'POST /kagenti/sandbox sidecar deny', - 'Failed to deny sidecar', - async (req, res) => { - const result = await sandbox.denySidecar( - req.params.namespace, - req.params.contextId, - req.params.type, - req.params.msgId, - ); - res.json(result); - }, - ), - ); - - router.post( - '/kagenti/sandbox/:namespace/sessions/:contextId/sidecars/:type/observations', - async (req, res) => { - const { namespace, contextId, type } = req.params; - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }); - - const controller = new AbortController(); - req.on('close', () => controller.abort()); - - try { - await sandbox.streamObservations( - namespace, - contextId, - type, - (line: string) => { - res.write(`data: ${line}\n\n`); - }, - controller.signal, - ); - } catch (err) { - if (!controller.signal.aborted) { - ctx.logger.warn( - `streamObservations error: ${err instanceof Error ? err.message : err}`, - ); - } - } finally { - res.end(); - } - }, - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/sessionRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/sessionRoutes.ts deleted file mode 100644 index 4e3061ca595..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/sessionRoutes.ts +++ /dev/null @@ -1,425 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { MAX_SESSION_LIST_LIMIT, MAX_SESSION_TITLE_LENGTH } from '../constants'; -import { createWithRoute, notFound } from './routeWrapper'; -import { type RouteContext, validateSessionId } from './types'; -import { AuditLogger } from '../services/AuditLogger'; - -function safeJsonParse(value: string): unknown { - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -/** - * Registers chat session CRUD endpoints (local DB). - */ -export function registerSessionRoutes(ctx: RouteContext): void { - const { - router, - logger, - provider, - sessions, - sendRouteError, - missingSessions, - missingConversations, - getUserRef, - } = ctx; - - const withRoute = createWithRoute(logger, sendRouteError); - const audit = new AuditLogger(logger); - - router.get( - '/sessions', - withRoute('GET /sessions', 'Failed to list sessions', async (req, res) => { - if (missingSessions(res)) return; - const userRef = await getUserRef(req); - const rawLimit = req.query.limit; - const limit = - rawLimit && !isNaN(Number(rawLimit)) - ? Math.min(Number(rawLimit), MAX_SESSION_LIST_LIMIT) - : undefined; - const rawOffset = req.query.offset; - const offset = - rawOffset && !isNaN(Number(rawOffset)) - ? Math.max(0, Number(rawOffset)) - : undefined; - const rawProviderId = req.query.providerId; - const providerId = - typeof rawProviderId === 'string' && rawProviderId.trim() - ? rawProviderId.trim() - : undefined; - const list = await sessions!.listSessions( - userRef, - limit, - offset, - providerId, - ); - res.json({ sessions: list }); - }), - ); - - router.post( - '/sessions', - withRoute( - 'POST /sessions', - 'Failed to create session', - async (req, res) => { - if (missingSessions(res)) return; - const userRef = await getUserRef(req); - const body = req.body as Record; - let title: string | undefined; - if (body.title !== undefined && typeof body.title === 'string') { - title = - body.title.trim().slice(0, MAX_SESSION_TITLE_LENGTH) || undefined; - } - const model = - typeof body.model === 'string' - ? body.model.trim() || undefined - : undefined; - const providerId = - typeof body.providerId === 'string' - ? body.providerId.trim() || undefined - : undefined; - const session = await sessions!.createSession( - userRef, - title, - model, - providerId, - ); - audit.log({ - action: 'session.create', - actor: userRef, - target: session.id, - outcome: 'success', - sourceIp: AuditLogger.extractIp(req), - }); - res.json({ session }); - }, - ), - ); - - router.get( - '/sessions/:sessionId', - withRoute( - req => `GET /sessions/${req.params.sessionId}`, - 'Failed to get session', - async (req, res) => { - if (missingSessions(res)) return; - validateSessionId(req.params.sessionId); - const userRef = await getUserRef(req); - const session = await sessions!.getSession( - req.params.sessionId, - userRef, - ); - if (!session) { - notFound(res, 'Session'); - return; - } - res.json({ session }); - }, - ), - ); - - router.delete( - '/sessions/:sessionId', - withRoute( - req => `DELETE /sessions/${req.params.sessionId}`, - 'Failed to delete session', - async (req, res) => { - if (missingSessions(res)) return; - validateSessionId(req.params.sessionId); - const userRef = await getUserRef(req); - const session = await sessions!.getSession( - req.params.sessionId, - userRef, - ); - if (!session) { - notFound(res, 'Session'); - return; - } - - if (session.conversationId && provider.conversations) { - const containerDeleted = - await provider.conversations.deleteContainer?.( - session.conversationId, - ); - if (containerDeleted) { - logger.info( - `Deleted LlamaStack conversation ${session.conversationId} for session ${req.params.sessionId}`, - ); - } else { - logger.info( - `Session ${req.params.sessionId} had conversation ${session.conversationId} — LlamaStack conversation cleanup skipped or failed`, - ); - } - } - - const deleted = await sessions!.deleteSession( - req.params.sessionId, - userRef, - ); - audit.log({ - action: 'session.delete', - actor: userRef, - target: req.params.sessionId, - outcome: deleted ? 'success' : 'failure', - sourceIp: AuditLogger.extractIp(req), - }); - res.json({ success: deleted }); - }, - ), - ); - - router.patch( - '/sessions/:sessionId', - withRoute( - req => `PATCH /sessions/${req.params.sessionId}`, - 'Failed to update session', - async (req, res) => { - if (missingSessions(res)) return; - validateSessionId(req.params.sessionId); - const userRef = await getUserRef(req); - const { title } = req.body as { title?: string }; - if (typeof title !== 'string' || !title.trim()) { - res.status(400).json({ error: 'title must be a non-empty string' }); - return; - } - const trimmed = title.trim().slice(0, MAX_SESSION_TITLE_LENGTH); - const session = await sessions!.getSession( - req.params.sessionId, - userRef, - ); - if (!session) { - notFound(res, 'Session'); - return; - } - await sessions!.updateTitle(req.params.sessionId, userRef, trimmed); - res.json({ success: true, title: trimmed }); - }, - ), - ); - - // ------------------------------------------------------------------------- - // Session State (debug view) - // ------------------------------------------------------------------------- - - router.get( - '/sessions/:sessionId/state', - withRoute( - req => `GET /sessions/${req.params.sessionId}/state`, - 'Failed to get session state', - async (req, res) => { - if (missingSessions(res)) return; - validateSessionId(req.params.sessionId); - const userRef = await getUserRef(req); - const session = await sessions!.getSession( - req.params.sessionId, - userRef, - ); - if (!session) { - notFound(res, 'Session'); - return; - } - const messages = await sessions!.getMessages( - req.params.sessionId, - undefined, - undefined, - userRef, - ); - const messagesByRole = { - user: messages.filter(m => m.role === 'user').length, - assistant: messages.filter(m => m.role === 'assistant').length, - }; - const agents = [ - ...new Set( - messages.filter(m => m.agentName).map(m => m.agentName as string), - ), - ]; - const lastActivity = - messages.length > 0 - ? messages[messages.length - 1].createdAt - : session.updatedAt; - - res.json({ - session: { - id: session.id, - title: session.title, - conversationId: session.conversationId, - model: session.model, - providerId: session.providerId, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - }, - messages: { - total: messages.length, - byRole: messagesByRole, - }, - agents, - lastActivity, - }); - }, - ), - ); - - // ------------------------------------------------------------------------- - // Message Feedback - // ------------------------------------------------------------------------- - - router.post( - '/feedback', - withRoute('POST /feedback', 'Failed to save feedback', async (req, res) => { - if (missingSessions(res)) return; - const userRef = await getUserRef(req); - const body = req.body as Record; - const messageId = - typeof body.messageId === 'string' ? body.messageId.trim() : ''; - const direction = body.direction; - if ( - !messageId || - (direction !== 'positive' && direction !== 'negative') - ) { - res.status(400).json({ - error: 'messageId and direction (positive|negative) are required', - }); - return; - } - const reasons = Array.isArray(body.reasons) - ? (body.reasons as string[]).filter(r => typeof r === 'string') - : undefined; - const comment = - typeof body.comment === 'string' ? body.comment.trim() : undefined; - const sessionId = - typeof body.sessionId === 'string' - ? body.sessionId.trim() || undefined - : undefined; - - const id = await sessions!.saveFeedback(userRef, { - messageId, - sessionId, - direction, - reasons: reasons && reasons.length > 0 ? reasons : undefined, - comment: comment || undefined, - }); - res.json({ success: true, id }); - }), - ); - - router.get( - '/sessions/:sessionId/messages', - withRoute( - req => `GET /sessions/${req.params.sessionId}/messages`, - 'Failed to get session messages', - async (req, res) => { - if (missingSessions(res)) return; - validateSessionId(req.params.sessionId); - const userRef = await getUserRef(req); - const session = await sessions!.getSession( - req.params.sessionId, - userRef, - ); - if (!session) { - notFound(res, 'Session'); - return; - } - - // Try local message store first — this is the primary source of - // truth, especially for Kagenti where the remote API has no - // history endpoint. - const rawLimit = parseInt(String(req.query.limit), 10); - const rawOffset = parseInt(String(req.query.offset), 10); - const msgLimit = Number.isFinite(rawLimit) - ? Math.min(Math.max(1, rawLimit), 500) - : undefined; - const msgOffset = Number.isFinite(rawOffset) - ? Math.max(0, rawOffset) - : undefined; - const localMessages = await sessions!.getMessages( - req.params.sessionId, - msgLimit, - msgOffset, - userRef, - ); - - if (localMessages.length > 0) { - const formatted = localMessages.map(m => ({ - role: m.role, - text: m.content, - ...(m.agentName ? { agentName: m.agentName } : {}), - ...(m.toolCalls ? { toolCalls: safeJsonParse(m.toolCalls) } : {}), - ...(m.ragSources - ? { ragSources: safeJsonParse(m.ragSources) } - : {}), - ...(m.usage ? { usage: safeJsonParse(m.usage) } : {}), - ...(m.reasoning ? { reasoning: m.reasoning } : {}), - ...(m.citations ? { citations: safeJsonParse(m.citations) } : {}), - createdAt: m.createdAt, - })); - res.json({ - messages: formatted, - sessionCreatedAt: session.createdAt, - hasConversationId: !!session.conversationId, - source: 'local', - }); - return; - } - - // Fall back to provider API for LlamaStack sessions that predate - // local persistence. - if (!session.conversationId) { - res.json({ - messages: [], - sessionCreatedAt: session.createdAt, - hasConversationId: false, - }); - return; - } - - if (missingConversations(res)) return; - - try { - const messages = await provider.conversations!.getProcessedMessages( - session.conversationId, - ); - if (messages.length === 0) { - logger.warn( - `Session ${req.params.sessionId} has conversationId ${session.conversationId} but returned 0 messages — conversation data may have been lost`, - ); - } - res.json({ - messages, - sessionCreatedAt: session.createdAt, - hasConversationId: true, - source: 'provider', - }); - } catch (fetchErr) { - logger.error( - `Failed to fetch messages for conversation ${session.conversationId}: ${fetchErr}`, - ); - res.status(502).json({ - error: 'Failed to retrieve messages from the conversation provider', - messages: [], - sessionCreatedAt: session.createdAt, - hasConversationId: true, - }); - } - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/skillChatProxy.ts b/workspaces/augment/plugins/augment-backend/src/routes/skillChatProxy.ts deleted file mode 100644 index 1e79569503d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/skillChatProxy.ts +++ /dev/null @@ -1,190 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { Config } from '@backstage/config'; -import type { ChatAgentConfig } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import { resolveK8sCredentials } from './k8sCredentials'; -import { SseHeartbeat } from './sseRouteHelpers'; -import type { FlushableResponse } from './types'; - -async function loadChatAgentConfigs( - adminConfig: AdminConfigService, -): Promise { - const raw = await adminConfig.get('chatAgents'); - if (Array.isArray(raw)) return raw as ChatAgentConfig[]; - return []; -} - -function findSkillAgent( - configs: ChatAgentConfig[], - model: string | undefined, -): ChatAgentConfig | undefined { - if (!model) return undefined; - return configs.find( - c => c.chatEndpoint && (c.agentId === model || c.displayName === model), - ); -} - -/** - * Proxy a chat request to a skill agent's direct endpoint. - * Returns true if the request was handled (proxied), false if not a skill agent. - */ -export async function trySkillChatProxy(opts: { - model: string | undefined; - messages: Array<{ role: string; content: string }>; - adminConfig: AdminConfigService; - config: Config; - logger: LoggerService; - res: FlushableResponse; - signal?: AbortSignal; -}): Promise { - const { model, messages, adminConfig, config, logger, res, signal } = opts; - const configs = await loadChatAgentConfigs(adminConfig); - const skillAgent = findSkillAgent(configs, model); - if (!skillAgent?.chatEndpoint) return false; - - // Resolve fetch URL: use K8s API service proxy when OpenShift credentials - // are available (works from local dev), fall back to direct in-cluster URL. - let fetchUrl = `${skillAgent.chatEndpoint}/v1/chat/completions`; - const fetchHeaders: Record = { - 'Content-Type': 'application/json', - }; - - const k8sCreds = await resolveK8sCredentials(config, adminConfig); - const agentNs = (skillAgent as unknown as Record) - .namespace as string | undefined; - - if (k8sCreds && agentNs) { - const k8sApiUrl = k8sCreds.apiUrl; - const k8sToken = k8sCreds.token; - const svcName = skillAgent.agentId.includes('/') - ? skillAgent.agentId.split('/').pop()! - : skillAgent.agentId; - fetchUrl = `${k8sApiUrl}/api/v1/namespaces/${agentNs}/services/${svcName}:8000/proxy/v1/chat/completions`; - fetchHeaders.Authorization = `Bearer ${k8sToken}`; - logger.info( - `Skill chat proxy: routing "${model}" via K8s service proxy to ${agentNs}/${svcName}`, - ); - } else { - logger.info(`Skill chat proxy: routing "${model}" directly to ${fetchUrl}`); - } - - const heartbeat = new SseHeartbeat(res); - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }); - heartbeat.start(); - - try { - const { Agent } = await import('undici'); - const dispatcher = new Agent({ - connect: { rejectUnauthorized: false }, - }); - - const fetchOpts: RequestInit & { dispatcher?: unknown } = { - method: 'POST', - headers: fetchHeaders, - body: JSON.stringify({ messages, stream: true }), - signal, - }; - fetchOpts.dispatcher = dispatcher; - const upstream = await fetch(fetchUrl, fetchOpts as RequestInit); - - if (!upstream.ok) { - const detail = await upstream.text().catch(() => ''); - const errEvent = JSON.stringify({ - type: 'stream.error', - error: `Skill agent returned ${upstream.status}: ${detail.slice(0, 200)}`, - }); - res.write(`data: ${errEvent}\n\n`); - res.end(); - heartbeat.stop(); - return true; - } - - if (!upstream.body) { - res.write( - `data: ${JSON.stringify({ type: 'stream.error', error: 'No response body' })}\n\n`, - ); - res.end(); - heartbeat.stop(); - return true; - } - - res.write( - `data: ${JSON.stringify({ type: 'stream.started', responseId: `skill-${Date.now()}` })}\n\n`, - ); - - const reader = upstream.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - // eslint-disable-next-line no-constant-condition - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const payload = line.slice(6).trim(); - if (payload === '[DONE]') continue; - - try { - const chunk = JSON.parse(payload) as { - choices?: Array<{ delta?: { content?: string } }>; - }; - const content = chunk.choices?.[0]?.delta?.content; - if (content) { - res.write( - `data: ${JSON.stringify({ type: 'stream.text.delta', delta: content })}\n\n`, - ); - (res as FlushableResponse).flush?.(); - } - } catch { - logger.debug( - `Skill proxy: unparseable SSE chunk: ${payload.slice(0, 100)}`, - ); - } - } - } - - res.write(`data: ${JSON.stringify({ type: 'stream.completed' })}\n\n`); - } catch (err) { - if (signal?.aborted) { - logger.debug('Skill chat proxy aborted by client'); - } else { - const msg = err instanceof Error ? err.message : String(err); - logger.warn(`Skill chat proxy error: ${msg}`); - res.write( - `data: ${JSON.stringify({ type: 'stream.error', error: msg })}\n\n`, - ); - } - } finally { - heartbeat.stop(); - res.end(); - } - - return true; -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/skillsManifestBuilder.ts b/workspaces/augment/plugins/augment-backend/src/routes/skillsManifestBuilder.ts deleted file mode 100644 index 1004b45c7d3..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/skillsManifestBuilder.ts +++ /dev/null @@ -1,245 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Generates Kubernetes manifests for skill-based agents. - * Deployment uses an alpine init container that fetches each skill's OCI layer - * from quay.io, then the runtime container serves the agent with those skills. - */ - -export interface SkillRef { - slug?: string; - name: string; - skillName?: string; - gitPath?: string; - pluginName?: string; -} - -export interface SkillAgentManifestInput { - name: string; - namespace: string; - runtime: string; - skills: SkillRef[]; - systemPrompt: string; - llmModel: string; - runtimeImage: string; - llmBaseUrl?: string; - llmProvider?: string; -} - -export interface K8sManifest { - apiVersion: string; - kind: string; - metadata: { - name: string; - namespace: string; - labels?: Record; - }; - [key: string]: unknown; -} - -const LABELS_PREFIX = 'augment.redhat.com'; - -function buildLabels(name: string): Record { - return { - [`${LABELS_PREFIX}/component`]: 'skill-agent', - [`${LABELS_PREFIX}/agent-name`]: name, - 'app.kubernetes.io/managed-by': 'augment-backstage-plugin', - }; -} - -function buildInitContainerScript(skills: SkillRef[]): string { - const fetchCommands = skills.map(s => { - const slug = (s.skillName ?? s.name) - .toLocaleLowerCase('en-US') - .replace(/[^a-z0-9-]/g, '-'); - const ref = (s.gitPath ?? '').replace(/^https?:\/\//, ''); - const tagIdx = ref.lastIndexOf(':'); - const repo = tagIdx > 0 ? ref.slice(0, tagIdx) : ref; - const tag = tagIdx > 0 ? ref.slice(tagIdx + 1) : 'latest'; - const registryHost = repo.split('/')[0]; - const repoPath = repo.slice(registryHost.length + 1); - - return [ - `echo "Fetching skill: ${slug} from ${ref}"`, - `mkdir -p /skills/${slug}`, - `MANIFEST=$(wget -qO- "https://${registryHost}/v2/${repoPath}/manifests/${tag}" --header="Accept: application/vnd.oci.image.manifest.v1+json" 2>/dev/null)`, - `DIGEST=$(echo "$MANIFEST" | grep -o '"sha256:[a-f0-9]*"' | tail -1 | tr -d '"')`, - `wget -qO- "https://${registryHost}/v2/${repoPath}/blobs/$DIGEST" > /tmp/${slug}.blob 2>/dev/null`, - `if tar tzf /tmp/${slug}.blob >/dev/null 2>&1; then`, - ` tar xzf /tmp/${slug}.blob -C /skills/${slug}/`, - `else`, - ` gzip -d -c /tmp/${slug}.blob > /skills/${slug}/SKILL.md 2>/dev/null || cp /tmp/${slug}.blob /skills/${slug}/SKILL.md`, - `fi`, - `rm -f /tmp/${slug}.blob`, - `echo " extracted: $(ls /skills/${slug}/ | tr '\\n' ' ')"`, - ].join('\n'); - }); - - return [ - '#!/bin/sh', - 'set -e', - 'apk add --no-cache wget ca-certificates >/dev/null 2>&1 || true', - ...fetchCommands, - 'echo "All skills fetched successfully"', - ].join('\n'); -} - -export function buildDeploymentManifest( - input: SkillAgentManifestInput, -): K8sManifest { - const { name, namespace, runtimeImage, skills } = input; - const labels = buildLabels(name); - - return { - apiVersion: 'apps/v1', - kind: 'Deployment', - metadata: { name, namespace, labels }, - spec: { - replicas: 1, - selector: { matchLabels: { [`${LABELS_PREFIX}/agent-name`]: name } }, - template: { - metadata: { labels }, - spec: { - initContainers: [ - { - name: 'fetch-skills', - image: 'alpine:3.20', - command: ['sh', '-c', buildInitContainerScript(skills)], - volumeMounts: [{ name: 'skills', mountPath: '/skills' }], - }, - ], - containers: [ - { - name: 'agent-runtime', - image: runtimeImage, - args: [ - 'serve', - '--config-dir', - '/config/agent', - '--skills-dir', - '/skills', - '--listen-plain-http', - ], - ports: [{ containerPort: 8000, protocol: 'TCP' }], - envFrom: [{ secretRef: { name: `${name}-llm-secret` } }], - volumeMounts: [ - { - name: 'agent-config', - mountPath: '/config/agent', - readOnly: true, - }, - { name: 'skills', mountPath: '/skills', readOnly: true }, - { name: 'tmp', mountPath: '/tmp' }, - ], - resources: { - requests: { memory: '256Mi', cpu: '100m' }, - limits: { memory: '512Mi', cpu: '500m' }, - }, - }, - ], - volumes: [ - { name: 'agent-config', configMap: { name: `${name}-config` } }, - { name: 'skills', emptyDir: {} }, - { name: 'tmp', emptyDir: {} }, - ], - }, - }, - }, - }; -} - -export function buildServiceManifest( - input: Pick, -): K8sManifest { - const { name, namespace } = input; - return { - apiVersion: 'v1', - kind: 'Service', - metadata: { name, namespace, labels: buildLabels(name) }, - spec: { - selector: { [`${LABELS_PREFIX}/agent-name`]: name }, - ports: [{ port: 8000, targetPort: 8000, protocol: 'TCP' }], - }, - }; -} - -export function buildConfigMapManifest( - input: Pick< - SkillAgentManifestInput, - 'name' | 'namespace' | 'systemPrompt' | 'skills' - >, -): K8sManifest { - const { name, namespace, systemPrompt, skills } = input; - const skillNames = skills.map(s => s.skillName ?? s.name); - - return { - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: `${name}-config`, namespace, labels: buildLabels(name) }, - data: { - 'system-prompt.txt': - systemPrompt || - `You are a specialized AI agent with expertise in: ${skillNames.join(', ')}. Use the load_skill tool to leverage your skills when answering questions.`, - }, - }; -} - -/** - * E5: Generates the LLM secret manifest. - */ -export function buildLlmSecretManifest( - input: Pick< - SkillAgentManifestInput, - 'name' | 'namespace' | 'llmModel' | 'llmBaseUrl' | 'llmProvider' - >, -): K8sManifest { - const { name, namespace, llmModel, llmBaseUrl, llmProvider } = input; - return { - apiVersion: 'v1', - kind: 'Secret', - metadata: { - name: `${name}-llm-secret`, - namespace, - labels: buildLabels(name), - }, - type: 'Opaque', - stringData: { - LLM_PROVIDER: llmProvider ?? 'openai', - LLM_API_KEY: 'not-needed', - LLM_BASE_URL: llmBaseUrl ?? '', - LLM_MODEL: llmModel, - }, - }; -} - -export interface SkillAgentManifests { - deployment: K8sManifest; - service: K8sManifest; - configMap: K8sManifest; - secret: K8sManifest; -} - -export function generateSkillAgentManifests( - input: SkillAgentManifestInput, -): SkillAgentManifests { - return { - deployment: buildDeploymentManifest(input), - service: buildServiceManifest(input), - configMap: buildConfigMapManifest(input), - secret: buildLlmSecretManifest(input), - }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/skillsRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/skillsRoutes.ts deleted file mode 100644 index ef3ad675582..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/skillsRoutes.ts +++ /dev/null @@ -1,467 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Config } from '@backstage/config'; -import type { ChatAgentConfig } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { InputError } from '@backstage/errors'; -import { createWithRoute } from './routeWrapper'; -import type { RouteContext } from './types'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import { generateSkillAgentManifests } from './skillsManifestBuilder'; -import type { - SkillAgentManifestInput, - SkillRef, -} from './skillsManifestBuilder'; -import { resolveK8sCredentials } from './k8sCredentials'; - -function loadSkillsConfig(config: Config) { - const section = config.getOptionalConfig('augment.skillsMarketplace'); - if (!section) return undefined; - - return { - baseUrl: section.getString('baseUrl'), - agentImage: section.getOptionalString('agentImage'), - advisorUrl: section.getOptionalString('advisorUrl'), - llmBaseUrl: section.getOptionalString('llmBaseUrl'), - llmModel: section.getOptionalString('llmModel'), - runtimes: (section.getOptionalConfigArray('runtimes') ?? []).map(rt => ({ - id: rt.getString('id'), - name: rt.getString('name'), - description: rt.getOptionalString('description') ?? '', - image: rt.getOptionalString('image'), - language: rt.getOptionalString('language'), - footprint: rt.getOptionalString('footprint'), - features: rt.getOptionalStringArray('features'), - status: rt.getOptionalString('status') ?? 'available', - })), - }; -} - -export function registerSkillsRoutes( - ctx: RouteContext, - adminConfig?: AdminConfigService, -): void { - const { router, logger, sendRouteError, config } = ctx; - const withRoute = createWithRoute(logger, sendRouteError); - const skillsConfig = loadSkillsConfig(config); - - if (!skillsConfig) { - logger.info( - 'Skills marketplace not configured (augment.skillsMarketplace missing), skipping routes', - ); - return; - } - - router.get( - '/skills', - withRoute('GET /skills', 'Failed to fetch skills', async (_req, res) => { - const response = await fetch(skillsConfig.baseUrl, { - headers: { Accept: 'application/json' }, - signal: AbortSignal.timeout(15_000), - }); - if (!response.ok) { - res.status(response.status).json({ - error: `Skills marketplace returned ${response.status}`, - }); - return; - } - const data = (await response.json()) as { - skills?: Array>; - }; - const skills = (data.skills ?? []).map(s => ({ - ...s, - domain: (s.pluginName as string) ?? (s.domain as string), - })); - res.json({ skills, configured: true, total: skills.length }); - }), - ); - - router.get( - '/skills/runtimes', - withRoute( - 'GET /skills/runtimes', - 'Failed to fetch runtimes', - async (_req, res) => { - res.json({ runtimes: skillsConfig.runtimes }); - }, - ), - ); - - router.get( - '/skills/domains', - withRoute( - 'GET /skills/domains', - 'Failed to fetch skill domains', - async (_req, res) => { - try { - const response = await fetch(skillsConfig.baseUrl, { - headers: { Accept: 'application/json' }, - signal: AbortSignal.timeout(15_000), - }); - if (!response.ok) { - res.json({ domains: [] }); - return; - } - const data = (await response.json()) as { - skills?: Array<{ domain?: string; pluginName?: string }>; - }; - const domains = [ - ...new Set( - (data.skills ?? []) - .map(s => s.pluginName ?? s.domain) - .filter( - (d): d is string => typeof d === 'string' && d.length > 0, - ), - ), - ].sort((a, b) => a.localeCompare(b, 'en-US')); - res.json({ domains }); - } catch (err) { - logger.warn( - `Failed to extract skill domains: ${err instanceof Error ? err.message : String(err)}`, - ); - res.json({ domains: [] }); - } - }, - ), - ); - - // --------------------------------------------------------------------------- - // POST /agents/from-skills -- generate K8s manifests for a skill agent (E4/E5) - // and register it in the governance lifecycle (E6) - // --------------------------------------------------------------------------- - router.post( - '/agents/from-skills', - withRoute( - 'POST /agents/from-skills', - 'Failed to deploy skill agent', - async (req, res) => { - const body = req.body as { - name?: string; - runtime?: string; - skills?: SkillRef[]; - systemPrompt?: string; - llmModel?: string; - namespace?: string; - }; - const { name, runtime, skills, systemPrompt, llmModel } = body; - let namespace = body.namespace; - - if ( - !name || - !runtime || - !Array.isArray(skills) || - skills.length === 0 - ) { - throw new InputError('name, runtime, and skills[] are required'); - } - - const userRef = await ctx.getUserRef(req); - if (!namespace) { - const match = userRef.match(/^user:[^/]+\/(.+)$/); - const username = match ? match[1] : 'default'; - namespace = `${username.toLocaleLowerCase('en-US').replace(/[^a-z0-9-]/g, '-')}-agents`; - } - - const runtimeEntry = skillsConfig.runtimes.find( - rt => rt.id === runtime, - ); - const runtimeImage = - runtimeEntry?.image ?? 'ghcr.io/redhat-et/docsclaw:latest'; - - const agentName = name - .toLocaleLowerCase('en-US') - .replace(/[^a-z0-9-]/g, '-'); - - const input: SkillAgentManifestInput = { - name: agentName, - namespace, - runtime, - skills, - systemPrompt: systemPrompt ?? '', - llmModel: llmModel ?? skillsConfig.llmModel ?? 'granite-3.3-8b', - runtimeImage, - llmBaseUrl: skillsConfig.llmBaseUrl, - llmProvider: 'openai', - }; - - const manifests = generateSkillAgentManifests(input); - const chatEndpoint = `http://${agentName}.${namespace}.svc:8000`; - - // --- K8s apply via OpenShift credentials --- - let deployed = false; - if (adminConfig) { - const k8sCreds = await resolveK8sCredentials(config, adminConfig); - - if (k8sCreds) { - const k8sApiUrl = k8sCreds.apiUrl; - const k8sToken = k8sCreds.token; - const { Agent } = await import('undici'); - const dispatcher = new Agent({ - connect: { rejectUnauthorized: false }, - }); - const headers: Record = { - 'Content-Type': 'application/json', - Authorization: `Bearer ${k8sToken}`, - }; - const fetchOpts = { dispatcher } as RequestInit; - - // 1. Ensure namespace - const nsResp = await fetch( - `${k8sApiUrl}/api/v1/namespaces/${namespace}`, - { headers, ...fetchOpts }, - ); - if (nsResp.status === 404) { - await fetch(`${k8sApiUrl}/api/v1/namespaces`, { - method: 'POST', - headers, - body: JSON.stringify({ - apiVersion: 'v1', - kind: 'Namespace', - metadata: { name: namespace }, - }), - ...fetchOpts, - }); - logger.info(`Created namespace ${namespace}`); - } - - // 2. Create LLM secret if missing - const secretName = `${agentName}-llm-secret`; - const secResp = await fetch( - `${k8sApiUrl}/api/v1/namespaces/${namespace}/secrets/${secretName}`, - { headers, ...fetchOpts }, - ); - if (secResp.status === 404) { - await fetch( - `${k8sApiUrl}/api/v1/namespaces/${namespace}/secrets`, - { - method: 'POST', - headers, - body: JSON.stringify(manifests.secret), - ...fetchOpts, - }, - ); - logger.info(`Created secret ${secretName} in ${namespace}`); - } - - // 3. Apply ConfigMap - await fetch( - `${k8sApiUrl}/api/v1/namespaces/${namespace}/configmaps`, - { - method: 'POST', - headers, - body: JSON.stringify(manifests.configMap), - ...fetchOpts, - }, - ); - - // 4. Apply Service - await fetch( - `${k8sApiUrl}/api/v1/namespaces/${namespace}/services`, - { - method: 'POST', - headers, - body: JSON.stringify(manifests.service), - ...fetchOpts, - }, - ); - - // 5. Apply Deployment - const depResp = await fetch( - `${k8sApiUrl}/apis/apps/v1/namespaces/${namespace}/deployments`, - { - method: 'POST', - headers, - body: JSON.stringify(manifests.deployment), - ...fetchOpts, - }, - ); - deployed = depResp.ok; - logger.info( - `Applied K8s manifests for ${agentName} in ${namespace}: deployment=${depResp.status}`, - ); - } else { - logger.info( - 'No devSpaces credentials configured; returning manifests only', - ); - } - - // E6: Register agent in governance - try { - const raw = await adminConfig.get('chatAgents'); - const configs: ChatAgentConfig[] = Array.isArray(raw) - ? (raw as ChatAgentConfig[]) - : []; - - const agentId = agentName; - const existing = configs.find(c => c.agentId === agentId); - - if (!existing) { - configs.push({ - agentId, - published: false, - visible: false, - featured: false, - lifecycleStage: 'draft', - version: 0, - createdBy: userRef, - createdAt: new Date().toISOString(), - chatEndpoint, - namespace, - displayName: name, - description: `Skill-based agent with ${skills.length} skills`, - framework: 'docsclaw', - } as ChatAgentConfig); - await adminConfig.set('chatAgents', configs, userRef); - logger.info( - `Skill agent "${agentId}" registered in governance (draft)`, - ); - } - } catch (err) { - logger.warn( - `Failed to register skill agent: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - res.status(201).json({ - success: true, - deployed, - agentName, - namespace, - chatEndpoint, - skillCount: skills.length, - manifests, - }); - }, - ), - ); - - // --------------------------------------------------------------------------- - // GET /skills/agents/:agentId/info -- live pod info (health, models, skills, - // system prompt) fetched from the running DocsClaw agent via K8s API. - // --------------------------------------------------------------------------- - router.get( - '/skills/agents/:agentId/info', - withRoute( - 'GET /skills/agents/:agentId/info', - 'Failed to fetch skill agent info', - async (req, res) => { - const agentId = decodeURIComponent(req.params.agentId); - if (!adminConfig) { - res.json({ error: 'Admin config not available' }); - return; - } - - const raw = await adminConfig.get('chatAgents'); - const configs = Array.isArray(raw) ? (raw as ChatAgentConfig[]) : []; - const cfg = configs.find(c => c.agentId === agentId); - if (!cfg?.chatEndpoint) { - res.json({ error: 'Agent not found or no endpoint configured' }); - return; - } - - const cfgAny = cfg as unknown as Record; - const ns = cfgAny.namespace as string | undefined; - const agentName = agentId.includes('/') - ? agentId.split('/').pop()! - : agentId; - - const k8sCreds = await resolveK8sCredentials(config, adminConfig); - - if (!k8sCreds || !ns) { - res.json({ - agentId, - chatEndpoint: cfg.chatEndpoint, - info: 'K8s credentials or namespace not available', - }); - return; - } - - const { Agent } = await import('undici'); - const dispatcher = new Agent({ - connect: { rejectUnauthorized: false }, - }); - const headers: Record = { - Authorization: `Bearer ${k8sCreds.token}`, - Accept: 'application/json', - }; - const fetchOpts = { dispatcher } as RequestInit; - const proxyBase = `${k8sCreds.apiUrl}/api/v1/namespaces/${ns}/services/${agentName}:8000/proxy`; - - const fetchJson = async (path: string) => { - try { - const r = await fetch(`${proxyBase}${path}`, { - headers, - signal: AbortSignal.timeout(8000), - ...fetchOpts, - }); - if (!r.ok) return { error: `${r.status}` }; - return await r.json(); - } catch (err) { - return { - error: err instanceof Error ? err.message : 'unreachable', - }; - } - }; - - const fetchText = async (url: string) => { - try { - const r = await fetch(url, { - headers, - signal: AbortSignal.timeout(8000), - ...fetchOpts, - }); - if (!r.ok) return undefined; - return await r.text(); - } catch { - return undefined; - } - }; - - const [health, models, skills, systemPrompt] = await Promise.all([ - fetchJson('/health'), - fetchJson('/v1/models'), - fetchJson('/v1/skills'), - fetchText( - `${k8sCreds.apiUrl}/api/v1/namespaces/${ns}/configmaps/${agentName}-config`, - ), - ]); - - let systemPromptText: string | undefined; - if (systemPrompt) { - try { - const cm = JSON.parse(systemPrompt) as { - data?: Record; - }; - systemPromptText = cm.data?.['system-prompt.txt']; - } catch { - logger.debug('Failed to parse configmap for system prompt'); - } - } - - res.json({ - agentId, - namespace: ns, - chatEndpoint: cfg.chatEndpoint, - health, - models, - skills, - systemPrompt: systemPromptText, - }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/sseRouteHelpers.ts b/workspaces/augment/plugins/augment-backend/src/routes/sseRouteHelpers.ts deleted file mode 100644 index 38fa59086f1..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/sseRouteHelpers.ts +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Response } from 'express'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { NormalizedStreamEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { FlushableResponse } from './types'; -import { KagentiStreamNormalizer } from '../providers/kagenti/stream/KagentiStreamNormalizer'; - -export function safeInt(val: unknown): number | undefined { - if (val === undefined || val === null || val === '') return undefined; - const n = Number(val); - return Number.isFinite(n) ? Math.floor(n) : undefined; -} - -export function clampLimit( - raw: number | undefined, - defaultLimit: number, - maxLimit: number, -): number { - const val = raw ?? defaultLimit; - return Math.min(Math.max(1, val), maxLimit); -} - -const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000; - -/** - * Sends SSE comment lines (`: heartbeat`) at a regular interval to prevent - * reverse-proxy idle timeouts (e.g. HAProxy / OpenShift route 30s default). - * SSE comments are ignored by EventSource clients. - */ -export class SseHeartbeat { - private timer: ReturnType | undefined; - - constructor( - private readonly res: Response, - private readonly intervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS, - ) {} - - start(): void { - if (this.timer) return; - this.timer = setInterval(() => { - try { - this.res.write(': heartbeat\n\n'); - } catch { - this.stop(); - } - }, this.intervalMs); - } - - stop(): void { - if (this.timer) { - clearInterval(this.timer); - this.timer = undefined; - } - } -} - -export function setupSse( - res: Response, - logger: LoggerService, -): { disconnected: { current: boolean }; abortController: AbortController } { - res.setHeader('Content-Encoding', 'identity'); - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - res.flushHeaders(); - - const disconnected = { current: false }; - const abortController = new AbortController(); - res.on('close', () => { - disconnected.current = true; - abortController.abort(); - logger.debug('Client disconnected from Kagenti sandbox stream'); - }); - return { disconnected, abortController }; -} - -export function writeSse( - res: Response, - event: NormalizedStreamEvent, - disconnected: { current: boolean }, -): void { - if (!disconnected.current) { - res.write(`data: ${JSON.stringify(event)}\n\n`); - (res as FlushableResponse).flush?.(); - } -} - -export async function handleSseStream( - res: Response, - logger: LoggerService, - kagentiCfg: { verboseStreamLogging?: boolean }, - streamFn: ( - onLine: (line: string) => void, - signal: AbortSignal, - ) => Promise, -): Promise { - const { disconnected, abortController } = setupSse(res, logger); - const verboseLog = kagentiCfg.verboseStreamLogging ? logger : undefined; - const normalizer = new KagentiStreamNormalizer(verboseLog); - const heartbeat = new SseHeartbeat(res); - heartbeat.start(); - - try { - await streamFn((line: string) => { - for (const event of normalizer.normalize(line)) { - writeSse(res, event, disconnected); - } - }, abortController.signal); - heartbeat.stop(); - if (!disconnected.current) { - res.write('data: [DONE]\n\n'); - res.end(); - } - } catch (err) { - heartbeat.stop(); - if (abortController.signal.aborted) { - return; - } - const msg = err instanceof Error ? err.message : String(err); - logger.error(`SSE stream error: ${msg}`); - if (!disconnected.current) { - writeSse( - res, - { type: 'stream.error', error: 'Stream failed', code: 'stream_error' }, - disconnected, - ); - res.end(); - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/statusRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/statusRoutes.ts deleted file mode 100644 index 97fa94f94ec..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/statusRoutes.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { DEFAULT_BRANDING } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { BrandingConfig } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import { loadBrandingOverrides } from '../providers/llamastack/BrandingConfigLoader'; -import { createWithRoute } from './routeWrapper'; -import type { RouteContext } from './types'; - -/** - * Registers unauthenticated status and branding endpoints. - * These are needed for UI bootstrapping and k8s probes. - */ -export function registerStatusRoutes( - ctx: RouteContext, - adminConfig?: AdminConfigService, - initializationError?: string | null, -): void { - const { router, logger, config, sendRouteError } = ctx; - const withRoute = createWithRoute(logger, sendRouteError); - - router.get('/health', (_req, res) => { - if (initializationError) { - res.status(503).json({ - status: 'degraded', - error: 'Provider initialization failed', - }); - return; - } - res.json({ status: 'ok' }); - }); - - router.get( - '/status', - withRoute('GET /status', 'Failed to get status', async (req, res) => { - const currentProvider = ctx.provider; - if (initializationError) { - res.status(503).json({ - providerId: currentProvider.id, - initializationError: 'Provider initialization failed', - provider: { connected: false, baseUrl: '(not connected)' }, - mcpServers: [], - }); - return; - } - - const status = await currentProvider.getStatus(); - const isAdmin = await ctx.checkIsAdmin(req); - - const approvalEnabled = - config.getOptionalBoolean('augment.agentApproval.enabled') ?? false; - - const redacted = { - ...status, - providerId: currentProvider.id, - isAdmin, - approvalMode: approvalEnabled ? 'workflow' : 'built-in', - provider: { - ...status.provider, - baseUrl: status.provider.connected - ? '(connected)' - : '(not connected)', - }, - mcpServers: status.mcpServers.map(s => ({ - ...s, - url: s.connected ? '(connected)' : '(not connected)', - })), - }; - res.json(redacted); - }), - ); - - router.get( - '/branding', - withRoute( - 'GET /branding', - 'Failed to get branding configuration', - async (_req, res) => { - const yamlOverrides = loadBrandingOverrides(config, logger); - const yamlBranding: BrandingConfig = { - ...DEFAULT_BRANDING, - ...yamlOverrides, - }; - - let branding: BrandingConfig = yamlBranding; - if (adminConfig) { - try { - const dbBranding = await adminConfig.get('branding'); - if ( - dbBranding && - typeof dbBranding === 'object' && - !Array.isArray(dbBranding) - ) { - branding = { - ...yamlBranding, - ...(dbBranding as Partial), - }; - } - } catch (err) { - logger.warn( - `Failed to read branding overrides from DB, using YAML only: ${ - err instanceof Error ? err.message : 'Unknown error' - }`, - ); - } - } - - res.json({ - success: true, - branding, - timestamp: new Date().toISOString(), - }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/toolLifecycleRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/toolLifecycleRoutes.ts deleted file mode 100644 index a7429e5b747..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/toolLifecycleRoutes.ts +++ /dev/null @@ -1,432 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - type ChatToolConfig, - type AgentLifecycleStage, - type KagentiToolSummary, - LIFECYCLE_STAGE_ORDER, - isValidTransition, - normalizeLifecycleStage, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { InputError } from '@backstage/errors'; -import { createWithRoute } from './routeWrapper'; -import type { RouteContext } from './types'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import { AuditLogger } from '../services/AuditLogger'; - -function isValidLifecycleStage(stage: unknown): stage is AgentLifecycleStage { - return ( - typeof stage === 'string' && - (LIFECYCLE_STAGE_ORDER as readonly string[]).includes(stage) - ); -} - -function isPublishedStage(stage: AgentLifecycleStage): boolean { - return stage === 'published'; -} - -export interface ToolLifecycleOptions { - listProviderTools(): Promise; -} - -/** - * Registers tool lifecycle endpoints mirroring the agent lifecycle. - * Merges tools from the provider (Kagenti) with lifecycle config from the DB, - * enabling draft -> pending -> published -> archived lifecycle for tools. - */ -export function registerToolLifecycleRoutes( - ctx: RouteContext, - adminConfig: AdminConfigService, - options: ToolLifecycleOptions, -): void { - const { router, logger, sendRouteError } = ctx; - const withRoute = createWithRoute(logger, sendRouteError); - const audit = new AuditLogger(logger); - - async function loadChatToolConfigs(): Promise { - const raw = await adminConfig.get('chatTools'); - if (Array.isArray(raw)) return raw as ChatToolConfig[]; - return []; - } - - async function saveChatToolConfigs( - configs: ChatToolConfig[], - updatedBy: string, - ): Promise { - await adminConfig.set('chatTools', configs, updatedBy); - } - - /** - * Build a unified tool list by merging provider tools with lifecycle config. - */ - async function buildUnifiedToolList(): Promise<{ - tools: (KagentiToolSummary & { - published?: boolean; - lifecycleStage?: AgentLifecycleStage; - version?: number; - promotedAt?: string; - promotedBy?: string; - })[]; - }> { - const [providerTools, chatConfigs] = await Promise.all([ - options.listProviderTools(), - loadChatToolConfigs(), - ]); - - const configMap = new Map(chatConfigs.map(c => [c.toolId, c])); - - const merged = providerTools.map((tool: KagentiToolSummary) => { - const toolId = `${tool.namespace}/${tool.name}`; - const cfg = configMap.get(toolId); - const stage = normalizeLifecycleStage(cfg?.lifecycleStage); - return { - ...tool, - published: isPublishedStage(stage), - lifecycleStage: stage, - version: cfg?.version ?? 0, - promotedAt: cfg?.promotedAt, - promotedBy: cfg?.promotedBy, - }; - }); - - return { tools: merged }; - } - - // --------------------------------------------------------------------------- - // GET /tools -- unified tool listing with lifecycle overlay - // --------------------------------------------------------------------------- - router.get( - '/tools', - withRoute('GET /tools', 'Failed to list tools', async (req, res) => { - const { tools } = await buildUnifiedToolList(); - const publishedFilter = req.query.published; - - const filtered = - publishedFilter === 'true' - ? tools.filter(t => t.published === true) - : tools; - - res.json({ tools: filtered }); - }), - ); - - // --------------------------------------------------------------------------- - // PUT /tools/:toolId/promote -- promote tool to the next lifecycle stage - // draft → pending is open to any authenticated user. - // All other transitions require admin access. - // --------------------------------------------------------------------------- - router.put( - '/tools/:toolId/promote', - withRoute( - 'PUT /tools/:toolId/promote', - 'Failed to promote tool', - async (req, res) => { - const toolId = decodeURIComponent(req.params.toolId); - const { targetStage } = req.body as { targetStage?: string }; - const resolved = targetStage - ? normalizeLifecycleStage(targetStage) - : undefined; - if (resolved !== undefined && !isValidLifecycleStage(resolved)) { - throw new InputError( - `Invalid targetStage "${targetStage}". Must be one of: ${LIFECYCLE_STAGE_ORDER.join(', ')}`, - ); - } - const userRef = await ctx.getUserRef(req); - const configs = await loadChatToolConfigs(); - const existing = configs.find(c => c.toolId === toolId); - - const currentStage = normalizeLifecycleStage(existing?.lifecycleStage); - const nextStage = - resolved ?? - (() => { - const idx = LIFECYCLE_STAGE_ORDER.indexOf(currentStage); - const nextIdx = Math.min(idx + 1, LIFECYCLE_STAGE_ORDER.length - 2); - return LIFECYCLE_STAGE_ORDER[nextIdx]; - })(); - - if (!isValidTransition(currentStage, nextStage)) { - throw new InputError( - `Cannot transition tool from "${currentStage}" to "${nextStage}". ` + - `Check available transitions for the current stage.`, - ); - } - - const isSubmitForReview = - currentStage === 'draft' && nextStage === 'pending'; - if (!isSubmitForReview) { - const isAdmin = await ctx.checkIsAdmin(req); - if (!isAdmin) { - res.status(403).json({ - error: - 'Only admins can perform this lifecycle transition. ' + - 'Non-admin users may only submit draft tools for review.', - }); - return; - } - } - if (isSubmitForReview && !existing) { - res.status(404).json({ - error: - 'Tool not found in lifecycle config. ' + - 'Only tools you have created can be submitted for review.', - }); - return; - } - if ( - isSubmitForReview && - existing && - (existing as { createdBy?: string }).createdBy && - (existing as { createdBy?: string }).createdBy !== userRef - ) { - res.status(403).json({ - error: 'You can only promote tools you created.', - }); - return; - } - - const now = new Date().toISOString(); - const isProd = isPublishedStage(nextStage); - - if (existing) { - existing.lifecycleStage = nextStage; - existing.published = isProd; - existing.visible = isProd; - existing.version = (existing.version ?? 0) + 1; - existing.promotedAt = now; - existing.promotedBy = userRef; - } else { - configs.push({ - toolId, - lifecycleStage: nextStage, - published: isProd, - visible: isProd, - version: 1, - promotedAt: now, - promotedBy: userRef, - createdBy: userRef, - } as ChatToolConfig & { createdBy: string }); - } - - await saveChatToolConfigs(configs, userRef); - audit.log({ - action: 'tool.lifecycle', - actor: userRef, - target: toolId, - outcome: 'success', - sourceIp: AuditLogger.extractIp(req), - meta: { - from: currentStage, - to: nextStage, - direction: 'promote', - version: existing?.version ?? 1, - }, - }); - logger.info( - `Tool "${toolId}" promoted to ${nextStage} (v${existing?.version ?? 1}) by ${userRef}`, - ); - res.json({ - success: true, - toolId, - lifecycleStage: nextStage, - version: existing?.version ?? 1, - }); - }, - ), - ); - - // --------------------------------------------------------------------------- - // PUT /tools/:toolId/demote -- demote tool to a previous lifecycle stage - // --------------------------------------------------------------------------- - router.put( - '/tools/:toolId/demote', - ctx.requireAdminAccess, - withRoute( - 'PUT /tools/:toolId/demote', - 'Failed to demote tool', - async (req, res) => { - const toolId = decodeURIComponent(req.params.toolId); - const { targetStage } = req.body as { targetStage?: string }; - const resolved = targetStage - ? normalizeLifecycleStage(targetStage) - : undefined; - if (resolved !== undefined && !isValidLifecycleStage(resolved)) { - throw new InputError( - `Invalid targetStage "${targetStage}". Must be one of: ${LIFECYCLE_STAGE_ORDER.join(', ')}`, - ); - } - const userRef = await ctx.getUserRef(req); - const configs = await loadChatToolConfigs(); - const existing = configs.find(c => c.toolId === toolId); - - const currentStage = normalizeLifecycleStage(existing?.lifecycleStage); - const nextStage = - resolved ?? - (() => { - const idx = LIFECYCLE_STAGE_ORDER.indexOf(currentStage); - return LIFECYCLE_STAGE_ORDER[Math.max(idx - 1, 0)]; - })(); - - if (!isValidTransition(currentStage, nextStage)) { - throw new InputError( - `Cannot transition tool from "${currentStage}" to "${nextStage}". ` + - `Check available transitions for the current stage.`, - ); - } - - const now = new Date().toISOString(); - const isProd = isPublishedStage(nextStage); - - if (existing) { - existing.lifecycleStage = nextStage; - existing.published = isProd; - if (!isProd) { - existing.visible = false; - } - existing.promotedAt = now; - existing.promotedBy = userRef; - } else { - configs.push({ - toolId, - lifecycleStage: nextStage, - published: false, - visible: false, - promotedAt: now, - promotedBy: userRef, - }); - } - - await saveChatToolConfigs(configs, userRef); - audit.log({ - action: 'tool.lifecycle', - actor: userRef, - target: toolId, - outcome: 'success', - sourceIp: AuditLogger.extractIp(req), - meta: { from: currentStage, to: nextStage, direction: 'demote' }, - }); - logger.info(`Tool "${toolId}" demoted to ${nextStage} by ${userRef}`); - res.json({ success: true, toolId, lifecycleStage: nextStage }); - }, - ), - ); - - // --------------------------------------------------------------------------- - // PUT /tools/:toolId/publish -- shortcut: promote to published - // --------------------------------------------------------------------------- - router.put( - '/tools/:toolId/publish', - ctx.requireAdminAccess, - withRoute( - 'PUT /tools/:toolId/publish', - 'Failed to publish tool', - async (req, res) => { - const toolId = decodeURIComponent(req.params.toolId); - const userRef = await ctx.getUserRef(req); - const configs = await loadChatToolConfigs(); - const existing = configs.find(c => c.toolId === toolId); - const currentStage = normalizeLifecycleStage(existing?.lifecycleStage); - - if (!isValidTransition(currentStage, 'published')) { - throw new InputError( - `Cannot publish tool from "${currentStage}". Tool must be in "pending" stage to be published.`, - ); - } - - const now = new Date().toISOString(); - - if (existing) { - existing.lifecycleStage = 'published'; - existing.published = true; - existing.visible = true; - existing.version = (existing.version ?? 0) + 1; - existing.promotedAt = now; - existing.promotedBy = userRef; - } else { - configs.push({ - toolId, - lifecycleStage: 'published', - published: true, - visible: true, - version: 1, - promotedAt: now, - promotedBy: userRef, - }); - } - - await saveChatToolConfigs(configs, userRef); - audit.log({ - action: 'tool.lifecycle', - actor: userRef, - target: toolId, - outcome: 'success', - sourceIp: AuditLogger.extractIp(req), - meta: { to: 'published', direction: 'publish' }, - }); - logger.info(`Tool "${toolId}" published by ${userRef}`); - res.json({ success: true, toolId, published: true }); - }, - ), - ); - - // --------------------------------------------------------------------------- - // PUT /tools/:toolId/unpublish -- move from published to pending - // --------------------------------------------------------------------------- - router.put( - '/tools/:toolId/unpublish', - ctx.requireAdminAccess, - withRoute( - 'PUT /tools/:toolId/unpublish', - 'Failed to unpublish tool', - async (req, res) => { - const toolId = decodeURIComponent(req.params.toolId); - const userRef = await ctx.getUserRef(req); - const configs = await loadChatToolConfigs(); - const existing = configs.find(c => c.toolId === toolId); - const now = new Date().toISOString(); - - if (existing) { - existing.lifecycleStage = 'pending'; - existing.published = false; - existing.visible = false; - existing.promotedAt = now; - existing.promotedBy = userRef; - } else { - configs.push({ - toolId, - lifecycleStage: 'pending', - published: false, - visible: false, - promotedAt: now, - promotedBy: userRef, - }); - } - - await saveChatToolConfigs(configs, userRef); - audit.log({ - action: 'tool.lifecycle', - actor: userRef, - target: toolId, - outcome: 'success', - sourceIp: AuditLogger.extractIp(req), - meta: { to: 'pending', direction: 'unpublish' }, - }); - logger.info(`Tool "${toolId}" unpublished by ${userRef}`); - res.json({ success: true, toolId, published: false }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/types.ts b/workspaces/augment/plugins/augment-backend/src/routes/types.ts deleted file mode 100644 index 0089ae69439..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/types.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - LoggerService, - CacheService, -} from '@backstage/backend-plugin-api'; -import { InputError } from '@backstage/errors'; -import type express from 'express'; -import type { AgenticProvider } from '../providers'; -import type { ChatSessionService } from '../services/ChatSessionService'; -import type { ChatRequest } from '../types'; - -const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]{1,128}$/; - -/** - * Validates that a session ID is non-empty and contains only safe characters. - * @throws InputError when the ID is empty, missing, or malformed. - */ -export function validateSessionId( - id: string | undefined, -): asserts id is string { - if (!id || !SESSION_ID_PATTERN.test(id)) { - throw new InputError( - `Invalid session ID: must be 1-128 alphanumeric, dash, or underscore characters`, - ); - } -} - -/** - * Extended Express Response interface that includes flush() method - * added by compression middleware for SSE streaming support. - */ -export interface FlushableResponse extends express.Response { - flush?: () => void; -} - -/** - * Shared context passed to each route module. - * Provides dependencies and helper functions created by the top-level router. - */ -export interface RouteContext { - router: express.Router; - logger: LoggerService; - config: import('@backstage/config').Config; - provider: AgenticProvider; - /** Fallback provider for orchestration agents when primary is Kagenti */ - orchestrationProvider?: AgenticProvider; - sessions: ChatSessionService | undefined; - - /** Extract a safe error message string from an unknown caught value. */ - toErrorMessage(error: unknown): string; - - /** Standard route error handler: logs the error and sends a JSON response. */ - sendRouteError( - res: express.Response, - error: unknown, - logLabel: string, - userFacingError: string, - extra?: Record, - statusCode?: number, - ): void; - - /** Returns true (and sends 501) when sessions are unavailable. */ - missingSessions(res: express.Response): boolean; - - /** Returns true (and sends 501) when conversations are unavailable. */ - missingConversations(res: express.Response): boolean; - - /** Resolve the Backstage user entity ref from the request. */ - getUserRef(req: express.Request): Promise; - - /** Check whether the request user has admin permission. */ - checkIsAdmin(req: express.Request): Promise; - - /** Express middleware that enforces admin permission. */ - requireAdminAccess: express.RequestHandler; - - /** Parse and validate a chat request body. */ - parseChatRequest(body: unknown): ChatRequest; - - /** Parse and validate an approval request body. */ - parseApprovalRequest(body: unknown): { - responseId: string; - callId: string; - approved: boolean; - toolName?: string; - toolArguments?: string; - reason?: string; - }; - - /** Backstage CacheService for shared caching across routes. */ - cache?: CacheService; -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/validatePublicUrl.ts b/workspaces/augment/plugins/augment-backend/src/routes/validatePublicUrl.ts deleted file mode 100644 index d47ec816813..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/validatePublicUrl.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as net from 'net'; -import * as dns from 'dns'; -import { promisify } from 'util'; -import { InputError } from '@backstage/errors'; - -const dnsLookup = promisify(dns.lookup); - -const PRIVATE_IP_RANGES = [ - /^127\./, - /^10\./, - /^172\.(1[6-9]|2\d|3[01])\./, - /^192\.168\./, - /^169\.254\./, - /^0\./, - /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, -]; - -export function isPrivateHost(hostname: string): boolean { - if (hostname === 'localhost' || hostname === '::1' || hostname === '[::1]') { - return true; - } - if (net.isIPv4(hostname)) { - return PRIVATE_IP_RANGES.some(r => r.test(hostname)); - } - const bare = hostname.replace(/^\[|\]$/g, ''); - if (net.isIPv6(bare)) { - const lower = bare.toLowerCase(); - if ( - lower === '::1' || - lower.startsWith('fe80:') || - lower.startsWith('fc') || - lower.startsWith('fd') - ) { - return true; - } - if (lower.startsWith('::ffff:')) { - const mapped = lower.substring(7); - if (net.isIPv4(mapped)) { - return PRIVATE_IP_RANGES.some(r => r.test(mapped)); - } - } - return false; - } - return false; -} - -const MAX_URL_LENGTH = 2048; - -/** - * Validates that a URL is safe to fetch from the server side. - * Rejects private/internal addresses, non-HTTP protocols, - * URLs with embedded credentials, and URLs that are too long. - * - * NOTE: This check is pre-connect only. If the downstream HTTP client - * follows redirects, each hop should be re-validated against isPrivateHost. - * Ensure KagentiApiClient.fetchEnvUrl does NOT follow redirects to - * internal addresses. - */ -export async function validatePublicUrl(url: string): Promise { - if (url.length > MAX_URL_LENGTH) { - throw new InputError( - `URL exceeds maximum length of ${MAX_URL_LENGTH} characters`, - ); - } - - const parsed = new URL(url); - - if (!['http:', 'https:'].includes(parsed.protocol)) { - throw new InputError('Only http and https URLs are allowed'); - } - - if (parsed.username || parsed.password) { - throw new InputError('URLs with embedded credentials are not allowed'); - } - - const hostname = parsed.hostname; - if (isPrivateHost(hostname)) { - throw new InputError( - 'Requests to private/internal addresses are not allowed', - ); - } - - if (!net.isIPv4(hostname) && !net.isIPv6(hostname.replace(/^\[|\]$/g, ''))) { - try { - const { address } = await dnsLookup(hostname, { family: 0 }); - if (isPrivateHost(address)) { - throw new InputError('URL resolves to a private/internal address'); - } - } catch (dnsErr) { - if (dnsErr instanceof InputError) throw dnsErr; - throw new InputError(`DNS resolution failed for ${hostname}`); - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/workflow/crud.ts b/workspaces/augment/plugins/augment-backend/src/routes/workflow/crud.ts deleted file mode 100644 index 6da48aa40af..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/workflow/crud.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { WorkflowDefinition } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { InputError, NotFoundError } from '@backstage/errors'; -import { createWithRoute, notFound } from '../routeWrapper'; -import type { RouteContext } from '../types'; -import type { WorkflowConfigService } from '../../services/WorkflowConfigService'; - -export function registerCrudRoutes( - ctx: RouteContext, - workflowService: WorkflowConfigService, -): void { - const { router, logger, sendRouteError, requireAdminAccess, getUserRef } = - ctx; - const withRoute = createWithRoute(logger, sendRouteError); - - router.get( - '/workflows', - requireAdminAccess, - withRoute( - 'List workflows', - 'Failed to list workflows', - async (_req, res) => { - res.json({ workflows: await workflowService.listWorkflows() }); - }, - ), - ); - - router.get( - '/workflows/:id', - requireAdminAccess, - withRoute( - req => `Get workflow ${req.params.id}`, - 'Failed to get workflow', - async (req, res) => { - try { - res.json(await workflowService.getWorkflow(req.params.id)); - } catch (err) { - if (err instanceof NotFoundError) { - notFound(res, 'Workflow'); - return; - } - throw err; - } - }, - ), - ); - - router.post( - '/workflows', - requireAdminAccess, - withRoute( - 'Create workflow', - 'Failed to create workflow', - async (req, res) => { - const body = req.body as Partial; - if (!body.id || !body.name) - throw new InputError('Workflow must have an id and name'); - const user = await getUserRef(req); - res - .status(201) - .json( - await workflowService.createWorkflow( - body as WorkflowDefinition, - user, - ), - ); - }, - ), - ); - - router.put( - '/workflows/:id', - requireAdminAccess, - withRoute( - req => `Update workflow ${req.params.id}`, - 'Failed to update workflow', - async (req, res) => { - const user = await getUserRef(req); - try { - res.json( - await workflowService.updateWorkflow( - req.params.id, - req.body as Partial, - user, - ), - ); - } catch (err) { - if (err instanceof NotFoundError) { - notFound(res, 'Workflow'); - return; - } - throw err; - } - }, - ), - ); - - router.delete( - '/workflows/:id', - requireAdminAccess, - withRoute( - req => `Delete workflow ${req.params.id}`, - 'Failed to delete workflow', - async (req, res) => { - const user = await getUserRef(req); - try { - await workflowService.deleteWorkflow(req.params.id, user); - res.json({ success: true }); - } catch (err) { - if (err instanceof NotFoundError) { - notFound(res, 'Workflow'); - return; - } - throw err; - } - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/workflow/execution.ts b/workspaces/augment/plugins/augment-backend/src/routes/workflow/execution.ts deleted file mode 100644 index 80ed52dd211..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/workflow/execution.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { InputError, NotFoundError } from '@backstage/errors'; -import { createWithRoute, notFound } from '../routeWrapper'; -import type { RouteContext } from '../types'; -import type { WorkflowConfigService } from '../../services/WorkflowConfigService'; -import type { AdminConfigService } from '../../services/AdminConfigService'; -import { ResponsesApiClient } from '../../services/ResponsesApiClient'; -import { WorkflowExecutor } from '../../providers/llamastack/workflow/WorkflowExecutor'; -import { resolveLlamaStackConfig } from '../resolveWorkflowConfig'; - -async function buildClient(ctx: RouteContext, adminConfig: AdminConfigService) { - const { url, model, skipTls } = await resolveLlamaStackConfig( - ctx, - adminConfig, - ); - const client = new ResponsesApiClient( - { baseUrl: url, skipTlsVerify: skipTls }, - ctx.logger, - ); - return { client, defaultModel: model }; -} - -export function registerExecutionRoutes( - ctx: RouteContext, - workflowService: WorkflowConfigService, - adminConfig: AdminConfigService, -): void { - const { router, logger, sendRouteError, requireAdminAccess } = ctx; - const withRoute = createWithRoute(logger, sendRouteError); - - router.post( - '/workflows/:id/run', - requireAdminAccess, - withRoute( - req => `Run workflow ${req.params.id}`, - 'Failed to run workflow', - async (req, res) => { - const { input } = req.body as { input: string }; - if (!input) throw new InputError('Input is required'); - - try { - const workflow = await workflowService.getWorkflow(req.params.id); - const { client, defaultModel } = await buildClient(ctx, adminConfig); - const executor = new WorkflowExecutor(logger, client, defaultModel); - const result = await executor.execute(workflow, input); - res.json({ - response: result.finalOutput, - trace: result.trace, - state: result.state, - totalDurationMs: result.totalDurationMs, - }); - } catch (err) { - if (err instanceof NotFoundError) { - notFound(res, 'Workflow'); - return; - } - throw err; - } - }, - ), - ); - - router.post( - '/workflows/:id/run/stream', - requireAdminAccess, - async (req, res) => { - const { input } = req.body as { input: string }; - if (!input) { - res.status(400).json({ error: 'Input is required' }); - return; - } - - try { - const workflow = await workflowService.getWorkflow(req.params.id); - const { client, defaultModel } = await buildClient(ctx, adminConfig); - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }); - - const abortController = new AbortController(); - req.on('close', () => abortController.abort()); - - const executor = new WorkflowExecutor(logger, client, defaultModel); - await executor.executeStream( - workflow, - input, - event => { - if (!res.writableEnded) - res.write(`data: ${JSON.stringify(event)}\n\n`); - }, - abortController.signal, - ); - - if (!res.writableEnded) { - res.write('data: [DONE]\n\n'); - res.end(); - } - } catch (err) { - if (!res.headersSent) { - if (err instanceof NotFoundError) { - notFound(res, 'Workflow'); - } else { - res.status(500).json({ - error: err instanceof Error ? err.message : 'Unknown error', - }); - } - } else if (!res.writableEnded) { - res.write( - `data: ${JSON.stringify({ type: 'error', data: { message: err instanceof Error ? err.message : 'Unknown error' } })}\n\n`, - ); - res.end(); - } - } - }, - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/workflow/testSuites.ts b/workspaces/augment/plugins/augment-backend/src/routes/workflow/testSuites.ts deleted file mode 100644 index 212a9631e8f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/workflow/testSuites.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { WorkflowTestSuite } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { createWithRoute } from '../routeWrapper'; -import type { RouteContext } from '../types'; -import type { WorkflowConfigService } from '../../services/WorkflowConfigService'; - -export function registerTestSuiteRoutes( - ctx: RouteContext, - workflowService: WorkflowConfigService, -): void { - const { router, logger, sendRouteError, requireAdminAccess, getUserRef } = - ctx; - const withRoute = createWithRoute(logger, sendRouteError); - - router.get( - '/workflows/:id/test-suites', - requireAdminAccess, - withRoute( - req => `List test suites for workflow ${req.params.id}`, - 'Failed to list test suites', - async (req, res) => { - res.json({ - testSuites: await workflowService.getTestSuites(req.params.id), - }); - }, - ), - ); - - router.put( - '/workflows/:id/test-suites/:suiteId', - requireAdminAccess, - withRoute( - req => `Save test suite ${req.params.suiteId}`, - 'Failed to save test suite', - async (req, res) => { - const user = await getUserRef(req); - const suite = req.body as WorkflowTestSuite; - suite.id = req.params.suiteId; - suite.workflowId = req.params.id; - res.json(await workflowService.saveTestSuite(suite, user)); - }, - ), - ); - - router.delete( - '/workflows/:id/test-suites/:suiteId', - requireAdminAccess, - withRoute( - req => `Delete test suite ${req.params.suiteId}`, - 'Failed to delete test suite', - async (req, res) => { - const user = await getUserRef(req); - await workflowService.deleteTestSuite(req.params.suiteId, user); - res.json({ success: true }); - }, - ), - ); - - router.get( - '/workflows/:id/evaluations', - requireAdminAccess, - withRoute( - req => `List evaluations for workflow ${req.params.id}`, - 'Failed to list evaluations', - async (req, res) => { - res.json({ - evaluations: await workflowService.getEvaluations(req.params.id), - }); - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/workflow/versions.ts b/workspaces/augment/plugins/augment-backend/src/routes/workflow/versions.ts deleted file mode 100644 index 2f7d3ad1cd1..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/workflow/versions.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NotFoundError } from '@backstage/errors'; -import { createWithRoute, notFound } from '../routeWrapper'; -import type { RouteContext } from '../types'; -import type { WorkflowConfigService } from '../../services/WorkflowConfigService'; - -export function registerVersionRoutes( - ctx: RouteContext, - workflowService: WorkflowConfigService, -): void { - const { router, logger, sendRouteError, requireAdminAccess, getUserRef } = - ctx; - const withRoute = createWithRoute(logger, sendRouteError); - - router.get( - '/workflows/:id/versions', - requireAdminAccess, - withRoute( - req => `List versions for workflow ${req.params.id}`, - 'Failed to list workflow versions', - async (req, res) => { - res.json({ - versions: await workflowService.getWorkflowVersions(req.params.id), - }); - }, - ), - ); - - router.get( - '/workflows/:id/versions/:version', - requireAdminAccess, - withRoute( - req => `Get version ${req.params.version} of workflow ${req.params.id}`, - 'Failed to get workflow version', - async (req, res) => { - try { - res.json( - await workflowService.getWorkflowVersion( - req.params.id, - parseInt(req.params.version, 10), - ), - ); - } catch (err) { - if (err instanceof NotFoundError) { - notFound(res, 'Workflow version'); - return; - } - throw err; - } - }, - ), - ); - - router.post( - '/workflows/:id/publish', - requireAdminAccess, - withRoute( - req => `Publish workflow ${req.params.id}`, - 'Failed to publish workflow', - async (req, res) => { - const user = await getUserRef(req); - const { changelog } = req.body as { changelog?: string }; - try { - const version = await workflowService.publishWorkflow( - req.params.id, - user, - changelog, - ); - await workflowService.syncWorkflowToChatAgents(req.params.id, user); - res.json(version); - } catch (err) { - if (err instanceof NotFoundError) { - notFound(res, 'Workflow'); - return; - } - throw err; - } - }, - ), - ); - - router.post( - '/workflows/:id/restore/:version', - requireAdminAccess, - withRoute( - req => - `Restore workflow ${req.params.id} to version ${req.params.version}`, - 'Failed to restore workflow version', - async (req, res) => { - const user = await getUserRef(req); - try { - res.json( - await workflowService.restoreVersion( - req.params.id, - parseInt(req.params.version, 10), - user, - ), - ); - } catch (err) { - if (err instanceof NotFoundError) { - notFound(res, 'Workflow version'); - return; - } - throw err; - } - }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/routes/workflowRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/workflowRoutes.ts deleted file mode 100644 index 7b4c14138e1..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/routes/workflowRoutes.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { NodeExecutionRecord } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { RouteContext } from './types'; -import type { WorkflowConfigService } from '../services/WorkflowConfigService'; -import type { AdminConfigService } from '../services/AdminConfigService'; -import { registerCrudRoutes } from './workflow/crud'; -import { registerVersionRoutes } from './workflow/versions'; -import { registerTestSuiteRoutes } from './workflow/testSuites'; -import { registerExecutionRoutes } from './workflow/execution'; -import { registerEvaluationRoutes } from './evaluationRoutes'; -import { ResponsesApiClient } from '../services/ResponsesApiClient'; -import { WorkflowExecutor } from '../providers/llamastack/workflow/WorkflowExecutor'; -import { resolveLlamaStackConfig } from './resolveWorkflowConfig'; - -/** - * Registers all workflow builder REST endpoints by delegating to - * focused sub-modules for CRUD, versioning, test suites, and execution. - */ -export function registerWorkflowRoutes( - ctx: RouteContext, - workflowService: WorkflowConfigService, - adminConfig: AdminConfigService, -): void { - registerCrudRoutes(ctx, workflowService); - registerVersionRoutes(ctx, workflowService); - registerTestSuiteRoutes(ctx, workflowService); - registerExecutionRoutes(ctx, workflowService, adminConfig); - - registerEvaluationRoutes(ctx, { - workflowService, - adminConfig, - runWorkflow: async (workflowId: string, input: string) => { - const { url, model, skipTls } = await resolveLlamaStackConfig( - ctx, - adminConfig, - ); - const client = new ResponsesApiClient( - { baseUrl: url, skipTlsVerify: skipTls }, - ctx.logger, - ); - const workflow = await workflowService.getWorkflow(workflowId); - const executor = new WorkflowExecutor(ctx.logger, client, model); - const result = await executor.execute(workflow, input); - return { - response: - typeof result.finalOutput === 'string' - ? result.finalOutput - : JSON.stringify(result.finalOutput), - trace: result.trace as unknown as NodeExecutionRecord[], - durationMs: result.totalDurationMs, - }; - }, - }); -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/AdminConfigService.scoped.test.ts b/workspaces/augment/plugins/augment-backend/src/services/AdminConfigService.scoped.test.ts deleted file mode 100644 index 4f29b453249..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/AdminConfigService.scoped.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import knex, { type Knex } from 'knex'; -import { mockServices } from '@backstage/backend-test-utils'; -import { AdminConfigService } from './AdminConfigService'; - -const TABLE_NAME = 'augment_admin_config'; - -let db: Knex; -let adminConfig: AdminConfigService; - -beforeEach(async () => { - db = knex({ - client: 'better-sqlite3', - connection: ':memory:', - useNullAsDefault: true, - }); - const mockDatabase = { getClient: jest.fn().mockResolvedValue(db) }; - adminConfig = new AdminConfigService( - mockDatabase as import('@backstage/backend-plugin-api').DatabaseService, - mockServices.logger.mock(), - ); - await adminConfig.initialize(); -}); - -afterEach(async () => { - await db.destroy(); -}); - -describe('AdminConfigService scoped key resolution and migration', () => { - describe('resolveStorageKey', () => { - it('returns provider-scoped key for model', () => { - expect(adminConfig.resolveStorageKey('model', 'llamastack')).toBe( - 'llamastack::model', - ); - }); - - it('returns global key for branding', () => { - expect(adminConfig.resolveStorageKey('branding', 'llamastack')).toBe( - 'branding', - ); - }); - - it('returns activeProvider as-is', () => { - expect( - adminConfig.resolveStorageKey('activeProvider', 'llamastack'), - ).toBe('activeProvider'); - }); - }); - - describe('scoped CRUD', () => { - it('setScopedValue stores under scoped key', async () => { - await adminConfig.setScopedValue( - 'model', - 'test-model', - 'llamastack', - 'user:default/admin', - ); - const value = await adminConfig.getScopedValue('model', 'llamastack'); - expect(value).toBe('test-model'); - }); - - it('getScopedValue returns undefined for different provider', async () => { - await adminConfig.setScopedValue( - 'model', - 'test-model', - 'llamastack', - 'user:default/admin', - ); - const value = await adminConfig.getScopedValue('model', 'googleadk'); - expect(value).toBeUndefined(); - }); - - it('deleteScopedValue removes the scoped key', async () => { - await adminConfig.setScopedValue( - 'model', - 'test-model', - 'llamastack', - 'user:default/admin', - ); - const deleted = await adminConfig.deleteScopedValue( - 'model', - 'llamastack', - ); - expect(deleted).toBe(true); - expect( - await adminConfig.getScopedValue('model', 'llamastack'), - ).toBeUndefined(); - }); - }); - - describe('migration: flat to scoped keys', () => { - it('migrates flat keys to scoped format and is idempotent', async () => { - const now = new Date().toISOString(); - await db(TABLE_NAME).insert([ - { - config_key: 'model', - config_value: JSON.stringify('legacy-model'), - updated_at: now, - updated_by: 'legacy', - }, - { - config_key: 'baseUrl', - config_value: JSON.stringify('https://legacy.example.com'), - updated_at: now, - updated_by: 'legacy', - }, - ]); - - const adminConfig2 = new AdminConfigService( - { - getClient: jest.fn().mockResolvedValue(db), - } as import('@backstage/backend-plugin-api').DatabaseService, - mockServices.logger.mock(), - ); - await adminConfig2.initialize(); - - expect(await adminConfig2.getRawValue('model')).toBeUndefined(); - expect(await adminConfig2.getRawValue('baseUrl')).toBeUndefined(); - expect(await adminConfig2.getRawValue('llamastack::model')).toBe( - 'legacy-model', - ); - expect(await adminConfig2.getRawValue('llamastack::baseUrl')).toBe( - 'https://legacy.example.com', - ); - - await adminConfig2.initialize(); - expect(await adminConfig2.getRawValue('llamastack::model')).toBe( - 'legacy-model', - ); - expect(await adminConfig2.getRawValue('llamastack::baseUrl')).toBe( - 'https://legacy.example.com', - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/AdminConfigService.test.ts b/workspaces/augment/plugins/augment-backend/src/services/AdminConfigService.test.ts deleted file mode 100644 index a0a1a270b28..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/AdminConfigService.test.ts +++ /dev/null @@ -1,347 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - DatabaseService, - LoggerService, -} from '@backstage/backend-plugin-api'; -import { InputError } from '@backstage/errors'; -import knex, { type Knex } from 'knex'; -import { AdminConfigService } from './AdminConfigService'; - -function createMockLogger() { - return { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - child: jest.fn().mockReturnThis(), - }; -} - -describe('AdminConfigService', () => { - const mockLogger = createMockLogger(); - - describe('before initialization', () => { - it('throws on get when not initialized', async () => { - const mockDb = { getClient: jest.fn() }; - const service = new AdminConfigService(mockDb, mockLogger); - await expect(service.get('promptGroups')).rejects.toThrow( - 'AdminConfigService not initialized', - ); - }); - - it('throws on set when not initialized', async () => { - const mockDb = { getClient: jest.fn() }; - const service = new AdminConfigService(mockDb, mockLogger); - await expect( - service.set('promptGroups', [], 'user:default/test'), - ).rejects.toThrow('AdminConfigService not initialized'); - }); - - it('throws on delete when not initialized', async () => { - const mockDb = { getClient: jest.fn() }; - const service = new AdminConfigService(mockDb, mockLogger); - await expect(service.delete('promptGroups')).rejects.toThrow( - 'AdminConfigService not initialized', - ); - }); - - it('throws on listAll when not initialized', async () => { - const mockDb = { getClient: jest.fn() }; - const service = new AdminConfigService(mockDb, mockLogger); - await expect(service.listAll()).rejects.toThrow( - 'AdminConfigService not initialized', - ); - }); - }); - - describe('initialize', () => { - it('propagates database errors', async () => { - const mockDb = { - getClient: jest.fn().mockRejectedValue(new Error('DB unavailable')), - }; - const service = new AdminConfigService(mockDb, mockLogger); - - await expect(service.initialize()).rejects.toThrow('DB unavailable'); - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DB unavailable'), - ); - }); - }); - - describe('validateKey', () => { - it('accepts valid keys', () => { - expect(AdminConfigService.validateKey('promptGroups')).toBe( - 'promptGroups', - ); - expect(AdminConfigService.validateKey('systemPrompt')).toBe( - 'systemPrompt', - ); - expect(AdminConfigService.validateKey('branding')).toBe('branding'); - expect(AdminConfigService.validateKey('safetyPatterns')).toBe( - 'safetyPatterns', - ); - expect(AdminConfigService.validateKey('vectorStoreConfig')).toBe( - 'vectorStoreConfig', - ); - }); - - it('rejects invalid keys with InputError', () => { - expect(() => AdminConfigService.validateKey('invalid')).toThrow( - InputError, - ); - expect(() => AdminConfigService.validateKey('')).toThrow(InputError); - expect(() => AdminConfigService.validateKey('DROP TABLE')).toThrow( - InputError, - ); - }); - }); -}); - -describe('AdminConfigService (with database)', () => { - let db: Knex; - let mockDatabase: { getClient: jest.Mock }; - let mockLogger: Record; - let service: AdminConfigService; - - beforeEach(async () => { - db = knex({ - client: 'better-sqlite3', - connection: ':memory:', - useNullAsDefault: true, - }); - mockDatabase = { - getClient: jest.fn().mockResolvedValue(db), - } as { getClient: jest.Mock }; - mockLogger = createMockLogger(); - service = new AdminConfigService( - mockDatabase as unknown as DatabaseService, - mockLogger as unknown as LoggerService, - ); - await service.initialize(); - }); - - afterEach(async () => { - await db.destroy(); - }); - - describe('get', () => { - it('returns undefined for a key that has not been set', async () => { - const result = await service.get('promptGroups'); - expect(result).toBeUndefined(); - }); - - it('returns the value after it has been set', async () => { - const lanes = [{ id: 'lane-1', title: 'Test Lane', cards: [] }]; - await service.set('promptGroups', lanes, 'user:default/admin'); - const result = await service.get('promptGroups'); - expect(result).toEqual(lanes); - }); - - it('rejects invalid keys', async () => { - await expect( - service.get( - 'invalidKey' as import('@red-hat-developer-hub/backstage-plugin-augment-common').AdminConfigKey, - ), - ).rejects.toThrow(InputError); - }); - }); - - describe('set', () => { - it('inserts a new config entry', async () => { - await service.set('systemPrompt', 'Hello world', 'user:default/admin'); - const result = await service.get('systemPrompt'); - expect(result).toBe('Hello world'); - }); - - it('updates an existing config entry', async () => { - await service.set('systemPrompt', 'Version 1', 'user:default/admin'); - await service.set('systemPrompt', 'Version 2', 'user:default/admin'); - const result = await service.get('systemPrompt'); - expect(result).toBe('Version 2'); - }); - - it('tracks updated_by correctly', async () => { - await service.set('branding', { appName: 'Test' }, 'user:default/alice'); - const entry = await service.getEntry('branding'); - expect(entry).toBeDefined(); - expect(entry!.updatedBy).toBe('user:default/alice'); - - await service.set('branding', { appName: 'Test 2' }, 'user:default/bob'); - const updated = await service.getEntry('branding'); - expect(updated!.updatedBy).toBe('user:default/bob'); - }); - - it('updates the updated_at timestamp on re-set', async () => { - await service.set('safetyPatterns', ['delete'], 'user:default/admin'); - const first = await service.getEntry('safetyPatterns'); - - await new Promise(r => setTimeout(r, 10)); - await service.set( - 'safetyPatterns', - ['delete', 'remove'], - 'user:default/admin', - ); - const second = await service.getEntry('safetyPatterns'); - - expect(first!.updatedAt).not.toBe(second!.updatedAt); - }); - - it('handles complex JSON objects round-trip', async () => { - const complex = { - lanes: [ - { - id: 'l1', - title: 'Lane 1', - color: '#ff0000', - cards: [ - { title: 'Card 1', prompt: 'Do X', icon: 'search' }, - { title: 'Card 2', prompt: 'Do Y', description: 'Desc' }, - ], - }, - ], - nested: { deep: { value: [1, 2, 3] } }, - }; - await service.set('promptGroups', complex, 'user:default/admin'); - const result = await service.get('promptGroups'); - expect(result).toEqual(complex); - }); - - it('handles null and boolean values', async () => { - await service.set('branding', null, 'user:default/admin'); - expect(await service.get('branding')).toBeNull(); - - await service.set('branding', true, 'user:default/admin'); - expect(await service.get('branding')).toBe(true); - - await service.set('branding', false, 'user:default/admin'); - expect(await service.get('branding')).toBe(false); - }); - - it('rejects invalid keys', async () => { - await expect( - service.set( - 'badKey' as import('@red-hat-developer-hub/backstage-plugin-augment-common').AdminConfigKey, - 'value', - 'user:default/admin', - ), - ).rejects.toThrow(InputError); - }); - - it('rejects undefined values', async () => { - await expect( - service.set('systemPrompt', undefined, 'user:default/admin'), - ).rejects.toThrow(InputError); - }); - - it('handles concurrent upserts for the same key without error', async () => { - const writes = Array.from({ length: 10 }, (_, i) => - service.set('systemPrompt', `Version ${i}`, 'user:default/admin'), - ); - await expect(Promise.all(writes)).resolves.not.toThrow(); - const result = await service.get('systemPrompt'); - expect(typeof result).toBe('string'); - }); - }); - - describe('delete', () => { - it('returns true when deleting an existing entry', async () => { - await service.set('promptGroups', [], 'user:default/admin'); - const deleted = await service.delete('promptGroups'); - expect(deleted).toBe(true); - - const result = await service.get('promptGroups'); - expect(result).toBeUndefined(); - }); - - it('returns false when key does not exist in DB', async () => { - const deleted = await service.delete('promptGroups'); - expect(deleted).toBe(false); - }); - - it('rejects invalid keys', async () => { - await expect( - service.delete( - 'badKey' as import('@red-hat-developer-hub/backstage-plugin-augment-common').AdminConfigKey, - ), - ).rejects.toThrow(InputError); - }); - }); - - describe('getEntry', () => { - it('returns full entry with metadata', async () => { - await service.set( - 'branding', - { appName: 'Test App' }, - 'user:default/alice', - ); - const entry = await service.getEntry('branding'); - - expect(entry).toBeDefined(); - expect(entry!.configKey).toBe('branding'); - expect(entry!.configValue).toEqual({ appName: 'Test App' }); - expect(entry!.updatedBy).toBe('user:default/alice'); - expect(entry!.updatedAt).toBeDefined(); - }); - - it('returns undefined for missing entry', async () => { - const entry = await service.getEntry('branding'); - expect(entry).toBeUndefined(); - }); - }); - - describe('listAll', () => { - it('returns empty array when no entries exist', async () => { - const entries = await service.listAll(); - expect(entries).toEqual([]); - }); - - it('returns all entries sorted by key', async () => { - await service.set('promptGroups', [], 'user:default/admin'); - await service.set('branding', {}, 'user:default/admin'); - await service.set('systemPrompt', 'hello', 'user:default/admin'); - - const entries = await service.listAll(); - expect(entries).toHaveLength(3); - expect(entries[0].configKey).toBe('branding'); - expect(entries[1].configKey).toBe('promptGroups'); - expect(entries[2].configKey).toBe('systemPrompt'); - }); - - it('reflects deletions', async () => { - await service.set('promptGroups', [], 'user:default/admin'); - await service.set('branding', {}, 'user:default/admin'); - await service.delete('promptGroups'); - - const entries = await service.listAll(); - expect(entries).toHaveLength(1); - expect(entries[0].configKey).toBe('branding'); - }); - }); - - describe('multiple keys isolation', () => { - it('setting one key does not affect another', async () => { - await service.set('promptGroups', [{ id: 'l1' }], 'user:default/admin'); - await service.set('branding', { appName: 'Test' }, 'user:default/admin'); - - expect(await service.get('promptGroups')).toEqual([{ id: 'l1' }]); - expect(await service.get('branding')).toEqual({ appName: 'Test' }); - - await service.set('promptGroups', [{ id: 'l2' }], 'user:default/admin'); - expect(await service.get('branding')).toEqual({ appName: 'Test' }); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/AdminConfigService.ts b/workspaces/augment/plugins/augment-backend/src/services/AdminConfigService.ts deleted file mode 100644 index 6ebe96c2a70..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/AdminConfigService.ts +++ /dev/null @@ -1,481 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - LoggerService, - DatabaseService, -} from '@backstage/backend-plugin-api'; -import { InputError } from '@backstage/errors'; -import type { - AdminConfigKey, - ProviderType, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { - isProviderScopedKey, - scopedConfigKey, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { toErrorMessage } from './utils'; - -const TABLE_NAME = 'augment_admin_config'; - -const ALLOWED_KEYS: ReadonlySet = new Set([ - 'activeProvider', - 'promptGroups', - 'systemPrompt', - 'branding', - 'safetyPatterns', - 'vectorStoreConfig', - 'activeVectorStoreIds', - 'model', - 'baseUrl', - 'kagentiBaseUrl', - 'toolChoice', - 'enableWebSearch', - 'enableCodeInterpreter', - 'mcpServers', - 'disabledMcpServerIds', - 'safetyEnabled', - 'inputShields', - 'outputShields', - 'safetyOnError', - 'evaluationEnabled', - 'scoringFunctions', - 'minScoreThreshold', - 'evaluationOnError', - 'agents', - 'defaultAgent', - 'maxAgentTurns', - 'maxToolCalls', - 'maxOutputTokens', - 'temperature', - 'safetyIdentifier', - 'chatAgents', - 'devSpacesApiUrl', - 'devSpacesToken', - 'devSpacesNamespace', - 'workflows', - 'workflowVersions', - 'workflowTestSuites', - 'workflowEvaluations', -]); - -interface AdminConfigRow { - config_key: string; - config_value: string; - updated_at: string; - updated_by: string; -} - -/** - * Manages admin-configurable settings in the database. - * Provides a key-value store where values are JSON-serialized. - * - * The config loading precedence is: - * 1. DB entry (admin override via UI) - * 2. YAML config (app-config.yaml fallback) - * - * "Reset to Defaults" deletes the DB entry, reverting to YAML. - * - * @public - */ -export class AdminConfigService { - private readonly logger: LoggerService; - private db: Awaited> | null = null; - - constructor( - private readonly database: DatabaseService, - logger: LoggerService, - ) { - this.logger = logger; - } - - async initialize(): Promise { - try { - const db = await this.database.getClient(); - this.db = db; - - const hasTable = await db.schema.hasTable(TABLE_NAME); - if (!hasTable) { - try { - await db.schema.createTable(TABLE_NAME, table => { - table.string('config_key').primary().notNullable(); - table.text('config_value').notNullable(); - table.timestamp('updated_at').notNullable().defaultTo(db.fn.now()); - table.string('updated_by').notNullable(); - }); - this.logger.info(`Created ${TABLE_NAME} table`); - } catch (createError) { - // Another instance may have created the table concurrently. - // Verify it actually exists now before propagating the error. - const existsNow = await db.schema.hasTable(TABLE_NAME); - if (!existsNow) { - throw createError; - } - this.logger.info( - `${TABLE_NAME} table was created by another instance`, - ); - } - } - await this.migrateFlatToScopedKeys(); - } catch (error) { - const msg = toErrorMessage(error, 'Unknown database error'); - this.logger.error(`Failed to initialize AdminConfigService: ${msg}`); - throw error; - } - } - - /** - * One-time migration: move flat provider-scoped keys to namespaced format. - * - * For backward compatibility, if legacy flat keys exist (e.g., `model` - * without a `::` prefix), they are copied to `llamastack::model` and - * the flat key is deleted. This is idempotent and safe across concurrent - * instances (uses the presence of the flat key as the migration trigger). - */ - private async migrateFlatToScopedKeys(): Promise { - const MIGRATION_KEYS = [ - 'model', - 'baseUrl', - 'toolChoice', - 'enableWebSearch', - 'enableCodeInterpreter', - 'safetyEnabled', - 'inputShields', - 'outputShields', - 'safetyOnError', - 'evaluationEnabled', - 'scoringFunctions', - 'minScoreThreshold', - 'evaluationOnError', - 'vectorStoreConfig', - 'activeVectorStoreIds', - ] as const; - - const DEFAULT_PROVIDER: ProviderType = 'llamastack'; - let migrated = 0; - - for (const key of MIGRATION_KEYS) { - const flatValue = await this.getRawValue(key); - if (flatValue === undefined) continue; - - const scopedKey = scopedConfigKey(DEFAULT_PROVIDER, key); - const alreadyScoped = await this.getRawValue(scopedKey); - - if (alreadyScoped === undefined) { - await this.setRawValue(scopedKey, flatValue, 'system:migration'); - } - - await this.deleteRawValue(key); - migrated++; - } - - if (migrated > 0) { - this.logger.info( - `Migrated ${migrated} flat config key(s) to provider-scoped format (${DEFAULT_PROVIDER}::*)`, - ); - } - } - - /** - * Validates that a key is in the allowed set. - * Throws InputError for invalid keys. - */ - static validateKey(key: string): AdminConfigKey { - if (!ALLOWED_KEYS.has(key as AdminConfigKey)) { - throw new InputError( - `Invalid config key: "${key}". Allowed keys: ${[...ALLOWED_KEYS].join( - ', ', - )}`, - ); - } - return key as AdminConfigKey; - } - - private ensureInitialized(): void { - if (!this.db) { - throw new Error('AdminConfigService not initialized'); - } - } - - private getDb(): NonNullable { - this.ensureInitialized(); - if (!this.db) { - throw new Error('AdminConfigService not initialized'); - } - return this.db; - } - - /** - * Get a config value by key. Returns undefined if not set in DB. - */ - async get(key: AdminConfigKey): Promise { - AdminConfigService.validateKey(key); - - const row = await this.getDb()(TABLE_NAME) - .where('config_key', key) - .first(); - - if (!row) { - return undefined; - } - - try { - return JSON.parse(row.config_value); - } catch { - this.logger.warn( - `Corrupt JSON in admin config for key "${key}", ignoring`, - ); - return undefined; - } - } - - /** - * Get a config entry with metadata. Returns undefined if not set in DB. - */ - async getEntry(key: AdminConfigKey): Promise< - | { - configKey: AdminConfigKey; - configValue: unknown; - updatedAt: string; - updatedBy: string; - } - | undefined - > { - AdminConfigService.validateKey(key); - - const row = await this.getDb()(TABLE_NAME) - .where('config_key', key) - .first(); - - if (!row) { - return undefined; - } - - try { - return { - configKey: row.config_key as AdminConfigKey, - configValue: JSON.parse(row.config_value), - updatedAt: row.updated_at, - updatedBy: row.updated_by, - }; - } catch { - this.logger.warn( - `Corrupt JSON in admin config for key "${key}", ignoring`, - ); - return undefined; - } - } - - /** - * Set a config value. Uses an atomic upsert to avoid race conditions - * when multiple admin requests target the same key concurrently. - */ - async set( - key: AdminConfigKey, - value: unknown, - updatedBy: string, - ): Promise { - AdminConfigService.validateKey(key); - - if (value === undefined) { - throw new InputError( - `Cannot set config key "${key}" to undefined. Use delete() to revert to defaults.`, - ); - } - - const serialized = JSON.stringify(value); - const now = new Date().toISOString(); - const row: AdminConfigRow = { - config_key: key, - config_value: serialized, - updated_at: now, - updated_by: updatedBy, - }; - - await this.getDb()(TABLE_NAME) - .insert(row) - .onConflict('config_key') - .merge({ - config_value: serialized, - updated_at: now, - updated_by: updatedBy, - }); - - this.logger.info(`Admin config "${key}" updated by ${updatedBy}`); - } - - /** - * Delete a config entry (revert to YAML defaults). - * Returns true if a row was deleted, false if key was not in DB. - */ - async delete(key: AdminConfigKey): Promise { - AdminConfigService.validateKey(key); - - const deleted = await this.getDb()(TABLE_NAME) - .where('config_key', key) - .delete(); - - if (deleted > 0) { - this.logger.info(`Admin config "${key}" reset to defaults`); - return true; - } - return false; - } - - /** - * List all admin config entries. - */ - async listAll(): Promise< - Array<{ configKey: AdminConfigKey; updatedAt: string; updatedBy: string }> - > { - const rows = await this.getDb()(TABLE_NAME) - .select('config_key', 'updated_at', 'updated_by') - .orderBy('config_key'); - - return rows.map(row => ({ - configKey: row.config_key as AdminConfigKey, - updatedAt: row.updated_at, - updatedBy: row.updated_by, - })); - } - - // --------------------------------------------------------------------------- - // Provider-scoped key helpers - // --------------------------------------------------------------------------- - - /** - * Resolve a config key to its storage key. - * - * Provider-scoped keys are stored as `::` in the database. - * Global keys and `activeProvider` are stored as-is. - * - * @param key - The logical config key - * @param providerId - The active provider ID for scoping - * @returns The database storage key - */ - resolveStorageKey(key: AdminConfigKey, providerId: ProviderType): string { - if (key === 'activeProvider' || !isProviderScopedKey(key)) { - return key; - } - return scopedConfigKey(providerId, key); - } - - /** - * Get a provider-scoped config value. - * - * @param key - The logical config key - * @param providerId - The provider to read config for - * @returns The stored value, or undefined if not set - */ - async getScopedValue( - key: AdminConfigKey, - providerId: ProviderType, - ): Promise { - const storageKey = this.resolveStorageKey(key, providerId); - return this.getRawValue(storageKey); - } - - /** - * Set a provider-scoped config value. - * - * @param key - The logical config key - * @param value - The value to store - * @param providerId - The provider to store config for - * @param updatedBy - User reference for audit - */ - async setScopedValue( - key: AdminConfigKey, - value: unknown, - providerId: ProviderType, - updatedBy: string, - ): Promise { - const storageKey = this.resolveStorageKey(key, providerId); - await this.setRawValue(storageKey, value, updatedBy); - this.logger.info( - `Admin config "${key}" (scoped to ${providerId}) updated by ${updatedBy}`, - ); - } - - /** - * Delete a provider-scoped config value. - * - * @param key - The logical config key - * @param providerId - The provider to delete config for - * @returns true if a row was deleted - */ - async deleteScopedValue( - key: AdminConfigKey, - providerId: ProviderType, - ): Promise { - const storageKey = this.resolveStorageKey(key, providerId); - return this.deleteRawValue(storageKey); - } - - // --------------------------------------------------------------------------- - // Raw (unvalidated key) database access — used for scoped keys and migration - // --------------------------------------------------------------------------- - - /** @internal Read a raw key from the database. */ - async getRawValue(storageKey: string): Promise { - const row = await this.getDb()(TABLE_NAME) - .where('config_key', storageKey) - .first(); - - if (!row) return undefined; - - try { - return JSON.parse(row.config_value); - } catch { - this.logger.warn( - `Corrupt JSON in admin config for key "${storageKey}", ignoring`, - ); - return undefined; - } - } - - /** @internal Write a raw key to the database. */ - async setRawValue( - storageKey: string, - value: unknown, - updatedBy: string, - ): Promise { - const serialized = JSON.stringify(value); - const now = new Date().toISOString(); - const row: AdminConfigRow = { - config_key: storageKey, - config_value: serialized, - updated_at: now, - updated_by: updatedBy, - }; - - await this.getDb()(TABLE_NAME) - .insert(row) - .onConflict('config_key') - .merge({ - config_value: serialized, - updated_at: now, - updated_by: updatedBy, - }); - } - - /** @internal Delete a raw key from the database. */ - async deleteRawValue(storageKey: string): Promise { - const deleted = await this.getDb()(TABLE_NAME) - .where('config_key', storageKey) - .delete(); - return deleted > 0; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/AgentApprovalWorkflowService.ts b/workspaces/augment/plugins/augment-backend/src/services/AgentApprovalWorkflowService.ts deleted file mode 100644 index 76bc394e38e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/AgentApprovalWorkflowService.ts +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - LoggerService, - RootConfigService, -} from '@backstage/backend-plugin-api'; - -export interface AgentApprovalConfig { - enabled: boolean; - serviceUrl?: string; - workflowId: string; - jiraProjectKey?: string; - jiraBaseUrl?: string; -} - -export interface WorkflowStartInput { - agentId: string; - agentName: string; - requestedBy: string; - action: 'publish' | 'unpublish'; - currentStage: string; - targetStage: string; -} - -interface WorkflowInstanceInfo { - id: string; - processId: string; - state: number; -} - -/** - * Service that integrates with SonataFlow for agent lifecycle approvals. - * Supports dual-mode: when enabled, transitions go through SonataFlow; - * when disabled, transitions happen immediately (built-in mode). - */ -export class AgentApprovalWorkflowService { - private readonly config: AgentApprovalConfig; - private readonly logger: LoggerService; - - constructor(rootConfig: RootConfigService, logger: LoggerService) { - this.logger = logger; - this.config = AgentApprovalWorkflowService.loadConfig(rootConfig); - if (this.config.enabled) { - this.logger.info( - `Agent approval workflow enabled: serviceUrl=${this.config.serviceUrl}, workflowId=${this.config.workflowId}`, - ); - } else { - this.logger.info( - 'Agent approval workflow disabled -- transitions are immediate', - ); - } - } - - get enabled(): boolean { - return this.config.enabled; - } - - async startWorkflow(input: WorkflowStartInput): Promise { - if (!this.config.enabled || !this.config.serviceUrl) { - return undefined; - } - - const url = `${this.config.serviceUrl}/${this.config.workflowId}`; - const payload = { - agentId: input.agentId, - agentName: input.agentName, - submittedBy: input.requestedBy, - jiraProjectKey: this.config.jiraProjectKey ?? 'RHDHPLAN', - jiraBaseUrl: this.config.jiraBaseUrl ?? 'https://redhat.atlassian.net', - }; - try { - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const detail = await response.text().catch(() => ''); - this.logger.warn( - `Failed to start approval workflow: ${response.status} ${detail}`, - ); - return undefined; - } - - const data = (await response.json()) as WorkflowInstanceInfo; - this.logger.info( - `Started approval workflow ${data.id} for agent ${input.agentId} (${input.action})`, - ); - return data.id; - } catch (err) { - this.logger.warn( - `Failed to start approval workflow: ${err instanceof Error ? err.message : String(err)}`, - ); - return undefined; - } - } - - async sendDecision( - instanceId: string, - approved: boolean, - decidedBy: string, - reason?: string, - ): Promise { - if (!this.config.enabled || !this.config.serviceUrl) { - return; - } - - const cloudEvent = { - specversion: '1.0', - id: `decision-${instanceId}-${Date.now()}`, - source: 'augment.admin', - type: 'io.rhdhorchestrator.agent.approval.decision', - kogitoprocrefid: instanceId, - data: { approved, decidedBy, reason }, - }; - - const url = `${this.config.serviceUrl}/${this.config.workflowId}/${instanceId}`; - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/cloudevents+json', - 'ce-specversion': '1.0', - 'ce-id': cloudEvent.id, - 'ce-source': cloudEvent.source, - 'ce-type': cloudEvent.type, - 'ce-kogitoprocrefid': instanceId, - }, - body: JSON.stringify(cloudEvent), - }); - - if (!response.ok) { - const detail = await response.text().catch(() => ''); - this.logger.warn( - `Failed to send approval decision: ${response.status} ${detail}`, - ); - } - } catch (err) { - this.logger.warn( - `Failed to send approval decision: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - async cancelWorkflow(instanceId: string): Promise { - if (!this.config.enabled || !this.config.serviceUrl) { - return; - } - - const url = `${this.config.serviceUrl}/management/processes/${this.config.workflowId}/instances/${instanceId}`; - try { - const response = await fetch(url, { method: 'DELETE' }); - if (!response.ok) { - const detail = await response.text().catch(() => ''); - this.logger.warn( - `Failed to cancel workflow ${instanceId}: ${response.status} ${detail}`, - ); - } - } catch (err) { - this.logger.warn( - `Failed to cancel workflow ${instanceId}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - private static loadConfig( - rootConfig: RootConfigService, - ): AgentApprovalConfig { - const section = rootConfig.getOptionalConfig('augment.agentApproval'); - if (!section) { - return { enabled: false, workflowId: 'agentApproval' }; - } - - return { - enabled: section.getOptionalBoolean('enabled') ?? false, - serviceUrl: section.getOptionalString('serviceUrl'), - workflowId: section.getOptionalString('workflowId') ?? 'agentApproval', - jiraProjectKey: section.getOptionalString('jiraProjectKey'), - jiraBaseUrl: section.getOptionalString('jiraBaseUrl'), - }; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/AuditLogger.test.ts b/workspaces/augment/plugins/augment-backend/src/services/AuditLogger.test.ts deleted file mode 100644 index c683c935537..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/AuditLogger.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { AuditLogger } from './AuditLogger'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { Request } from 'express'; - -function createMockLogger(): LoggerService & { - calls: Array<{ method: string; args: unknown[] }>; -} { - const calls: Array<{ method: string; args: unknown[] }> = []; - const child: LoggerService = { - info: (...args: unknown[]) => calls.push({ method: 'info', args }), - warn: (...args: unknown[]) => calls.push({ method: 'warn', args }), - error: (...args: unknown[]) => calls.push({ method: 'error', args }), - debug: (...args: unknown[]) => calls.push({ method: 'debug', args }), - child: () => child, - }; - return Object.assign(child, { calls }); -} - -describe('AuditLogger', () => { - it('logs audit events as structured metadata (not JSON-in-JSON)', () => { - const logger = createMockLogger(); - const audit = new AuditLogger(logger); - - audit.log({ - action: 'config.update', - actor: 'user:default/admin', - target: 'chatAgents', - outcome: 'success', - sourceIp: '10.0.0.1', - meta: { providerId: 'llamastack' }, - }); - - expect(logger.calls).toHaveLength(1); - const [call] = logger.calls; - expect(call.method).toBe('info'); - expect(call.args[0]).toBe('audit'); - const payload = call.args[1] as Record; - expect(payload.action).toBe('config.update'); - expect(payload.actor).toBe('user:default/admin'); - expect(payload.target).toBe('chatAgents'); - expect(payload.outcome).toBe('success'); - expect(payload.sourceIp).toBe('10.0.0.1'); - expect(payload.providerId).toBe('llamastack'); - expect(payload.timestamp).toBeDefined(); - }); - - it('logs failure events', () => { - const logger = createMockLogger(); - const audit = new AuditLogger(logger); - - audit.log({ - action: 'session.delete', - actor: 'user:default/dev', - target: 'sess-404', - outcome: 'failure', - }); - - const payload = logger.calls[0].args[1] as Record; - expect(payload.outcome).toBe('failure'); - }); -}); - -describe('AuditLogger.extractIp', () => { - it('extracts IP from x-forwarded-for header', () => { - const req = { - headers: { 'x-forwarded-for': '203.0.113.50, 70.41.3.18' }, - socket: { remoteAddress: '127.0.0.1' }, - } as unknown as Request; - - expect(AuditLogger.extractIp(req)).toBe('203.0.113.50'); - }); - - it('falls back to socket remoteAddress', () => { - const req = { - headers: {}, - socket: { remoteAddress: '::1' }, - } as unknown as Request; - - expect(AuditLogger.extractIp(req)).toBe('::1'); - }); - - it('returns unknown when no IP available', () => { - const req = { - headers: {}, - socket: {}, - } as unknown as Request; - - expect(AuditLogger.extractIp(req)).toBe('unknown'); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/AuditLogger.ts b/workspaces/augment/plugins/augment-backend/src/services/AuditLogger.ts deleted file mode 100644 index 9fa8612967a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/AuditLogger.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { Request } from 'express'; - -export type AuditAction = - | 'config.update' - | 'config.delete' - | 'session.create' - | 'session.delete' - | 'tool.approval' - | 'tool.lifecycle' - | 'agent.lifecycle' - | 'agent.delete' - | 'document.upload' - | 'document.delete' - | 'admin.login'; - -export interface AuditEvent { - action: AuditAction; - actor: string; - target?: string; - outcome: 'success' | 'failure'; - sourceIp?: string; - meta?: Record; -} - -export class AuditLogger { - private readonly logger: LoggerService; - - constructor(logger: LoggerService) { - this.logger = logger.child({ label: 'audit' }); - } - - log(event: AuditEvent): void { - const { action, actor, target, outcome, sourceIp, meta } = event; - this.logger.info('audit', { - action, - actor, - target, - outcome, - sourceIp, - ...meta, - timestamp: new Date().toISOString(), - }); - } - - static extractIp(req: Request): string { - const forwarded = req.headers['x-forwarded-for']; - if (typeof forwarded === 'string') return forwarded.split(',')[0].trim(); - return req.socket?.remoteAddress ?? 'unknown'; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/ChatSessionService.test.ts b/workspaces/augment/plugins/augment-backend/src/services/ChatSessionService.test.ts deleted file mode 100644 index 929e1f7946d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/ChatSessionService.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - DatabaseService, - LoggerService, -} from '@backstage/backend-plugin-api'; -import knex, { type Knex } from 'knex'; -import { ChatSessionService } from './ChatSessionService'; - -function createMockLogger() { - return { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - child: jest.fn().mockReturnThis(), - }; -} - -describe('ChatSessionService', () => { - const mockLogger = createMockLogger(); - - describe('before initialization', () => { - it('throws on createSession when not initialized', async () => { - const mockDb = { getClient: jest.fn() }; - const service = new ChatSessionService(mockDb, mockLogger); - await expect(service.createSession('user:default/test')).rejects.toThrow( - 'Database not initialized', - ); - }); - - it('throws on listSessions when not initialized', async () => { - const mockDb = { getClient: jest.fn() }; - const service = new ChatSessionService(mockDb, mockLogger); - await expect(service.listSessions('user:default/test')).rejects.toThrow( - 'Database not initialized', - ); - }); - - it('throws on getSession when not initialized', async () => { - const mockDb = { getClient: jest.fn() }; - const service = new ChatSessionService(mockDb, mockLogger); - await expect( - service.getSession('session-1', 'user:default/test'), - ).rejects.toThrow('Database not initialized'); - }); - - it('throws on deleteSession when not initialized', async () => { - const mockDb = { getClient: jest.fn() }; - const service = new ChatSessionService(mockDb, mockLogger); - await expect( - service.deleteSession('session-1', 'user:default/test'), - ).rejects.toThrow('Database not initialized'); - }); - - it('throws on setConversationId when not initialized', async () => { - const mockDb = { getClient: jest.fn() }; - const service = new ChatSessionService(mockDb, mockLogger); - await expect( - service.setConversationId('s1', 'user:default/test', 'conv1'), - ).rejects.toThrow('Database not initialized'); - }); - - it('throws on updateTitle when not initialized', async () => { - const mockDb = { getClient: jest.fn() }; - const service = new ChatSessionService(mockDb, mockLogger); - await expect( - service.updateTitle('s1', 'user:default/test', 'new title'), - ).rejects.toThrow('Database not initialized'); - }); - - it('throws on touch when not initialized', async () => { - const mockDb = { getClient: jest.fn() }; - const service = new ChatSessionService(mockDb, mockLogger); - await expect(service.touch('s1', 'user:default/test')).rejects.toThrow( - 'Database not initialized', - ); - }); - }); - - describe('initialize', () => { - it('propagates database errors', async () => { - const mockDb = { - getClient: jest.fn().mockRejectedValue(new Error('DB unavailable')), - }; - const service = new ChatSessionService(mockDb, mockLogger); - - await expect(service.initialize()).rejects.toThrow('DB unavailable'); - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DB unavailable'), - ); - }); - }); -}); - -describe('ChatSessionService (with database)', () => { - let db: Knex; - let mockDatabase: { getClient: jest.Mock }; - let mockLogger: Record; - let service: ChatSessionService; - - beforeEach(async () => { - db = knex({ - client: 'better-sqlite3', - connection: ':memory:', - useNullAsDefault: true, - }); - mockDatabase = { - getClient: jest.fn().mockResolvedValue(db), - } as { getClient: jest.Mock }; - mockLogger = createMockLogger(); - service = new ChatSessionService( - mockDatabase as unknown as DatabaseService, - mockLogger as unknown as LoggerService, - ); - await service.initialize(); - }); - - afterEach(async () => { - await db.destroy(); - }); - - describe('createSession', () => { - it('creates session with default title', async () => { - const session = await service.createSession('user:default/alice'); - expect(session.id).toBeDefined(); - expect(session.title).toMatch(/^Chat \d{4}-\d{2}-\d{2} \d{2}:\d{2}/); - expect(session.userRef).toBe('user:default/alice'); - expect(session.conversationId).toBeNull(); - expect(session.createdAt).toBeDefined(); - expect(session.updatedAt).toBeDefined(); - }); - - it('creates session with custom title', async () => { - const session = await service.createSession( - 'user:default/alice', - 'My custom chat', - ); - expect(session.title).toBe('My custom chat'); - expect(session.userRef).toBe('user:default/alice'); - }); - - it('different users get separate sessions', async () => { - const aliceSession = await service.createSession( - 'user:default/alice', - 'Alice chat', - ); - const bobSession = await service.createSession( - 'user:default/bob', - 'Bob chat', - ); - expect(aliceSession.id).not.toBe(bobSession.id); - expect(aliceSession.userRef).toBe('user:default/alice'); - expect(bobSession.userRef).toBe('user:default/bob'); - - const aliceSessions = await service.listSessions('user:default/alice'); - const bobSessions = await service.listSessions('user:default/bob'); - expect(aliceSessions).toHaveLength(1); - expect(bobSessions).toHaveLength(1); - expect(aliceSessions[0].id).toBe(aliceSession.id); - expect(bobSessions[0].id).toBe(bobSession.id); - }); - }); - - describe('listSessions', () => { - it('lists user sessions in updated_at DESC order', async () => { - const s1 = await service.createSession('user:default/alice', 'First'); - await new Promise(r => setTimeout(r, 5)); - const s2 = await service.createSession('user:default/alice', 'Second'); - await new Promise(r => setTimeout(r, 5)); - const s3 = await service.createSession('user:default/alice', 'Third'); - - const sessions = await service.listSessions('user:default/alice'); - expect(sessions).toHaveLength(3); - expect(sessions[0].id).toBe(s3.id); - expect(sessions[1].id).toBe(s2.id); - expect(sessions[2].id).toBe(s1.id); - }); - - it('respects limit param', async () => { - await service.createSession('user:default/alice', '1'); - await service.createSession('user:default/alice', '2'); - await service.createSession('user:default/alice', '3'); - await service.createSession('user:default/alice', '4'); - await service.createSession('user:default/alice', '5'); - - const sessions = await service.listSessions('user:default/alice', 2); - expect(sessions).toHaveLength(2); - }); - - it('returns empty for unknown user', async () => { - await service.createSession('user:default/alice', 'Alice chat'); - const sessions = await service.listSessions('user:default/unknown'); - expect(sessions).toEqual([]); - }); - - it('respects offset param for pagination', async () => { - const created: Awaited>[] = []; - for (let i = 0; i < 5; i++) { - await new Promise(r => setTimeout(r, 5)); - created.push( - await service.createSession('user:default/alice', `Session ${i + 1}`), - ); - } - // newest first: [5, 4, 3, 2, 1] - const page1 = await service.listSessions('user:default/alice', 2, 0); - expect(page1).toHaveLength(2); - expect(page1[0].id).toBe(created[4].id); - expect(page1[1].id).toBe(created[3].id); - - const page2 = await service.listSessions('user:default/alice', 2, 2); - expect(page2).toHaveLength(2); - expect(page2[0].id).toBe(created[2].id); - expect(page2[1].id).toBe(created[1].id); - - const page3 = await service.listSessions('user:default/alice', 2, 4); - expect(page3).toHaveLength(1); - expect(page3[0].id).toBe(created[0].id); - }); - - it('returns empty when offset exceeds total', async () => { - await service.createSession('user:default/alice', 'Only one'); - const sessions = await service.listSessions('user:default/alice', 10, 50); - expect(sessions).toEqual([]); - }); - - it('treats negative offset as 0', async () => { - await service.createSession('user:default/alice', 'A'); - const sessions = await service.listSessions('user:default/alice', 10, -5); - expect(sessions).toHaveLength(1); - }); - }); - - describe('getSession', () => { - it('returns matching session', async () => { - const created = await service.createSession( - 'user:default/alice', - 'My session', - ); - const found = await service.getSession(created.id, 'user:default/alice'); - expect(found).not.toBeNull(); - expect(found!.id).toBe(created.id); - expect(found!.title).toBe('My session'); - expect(found!.userRef).toBe('user:default/alice'); - }); - - it('returns null for wrong user', async () => { - const created = await service.createSession( - 'user:default/alice', - 'Alice session', - ); - const found = await service.getSession(created.id, 'user:default/bob'); - expect(found).toBeNull(); - }); - - it('returns null for non-existent session', async () => { - const found = await service.getSession( - 'non-existent-id', - 'user:default/alice', - ); - expect(found).toBeNull(); - }); - }); - - describe('deleteSession', () => { - it('deletes session', async () => { - const created = await service.createSession( - 'user:default/alice', - 'To delete', - ); - const deleted = await service.deleteSession( - created.id, - 'user:default/alice', - ); - expect(deleted).toBe(true); - - const found = await service.getSession(created.id, 'user:default/alice'); - expect(found).toBeNull(); - }); - - it('returns false if not found', async () => { - const deleted = await service.deleteSession( - 'non-existent-id', - 'user:default/alice', - ); - expect(deleted).toBe(false); - }); - - it("can't delete another user's session", async () => { - const created = await service.createSession( - 'user:default/alice', - 'Alice session', - ); - const deleted = await service.deleteSession( - created.id, - 'user:default/bob', - ); - expect(deleted).toBe(false); - - const found = await service.getSession(created.id, 'user:default/alice'); - expect(found).not.toBeNull(); - }); - }); - - describe('setConversationId', () => { - it('sets conversation ID on a session', async () => { - const created = await service.createSession( - 'user:default/alice', - 'My chat', - ); - expect(created.conversationId).toBeNull(); - - await service.setConversationId( - created.id, - 'user:default/alice', - 'conv-123', - ); - - const found = await service.getSession(created.id, 'user:default/alice'); - expect(found!.conversationId).toBe('conv-123'); - }); - }); - - describe('updateTitle', () => { - it('updates title', async () => { - const created = await service.createSession( - 'user:default/alice', - 'Original title', - ); - await service.updateTitle( - created.id, - 'user:default/alice', - 'Updated title', - ); - - const found = await service.getSession(created.id, 'user:default/alice'); - expect(found!.title).toBe('Updated title'); - }); - }); - - describe('touch', () => { - it('updates the updated_at timestamp', async () => { - const created = await service.createSession( - 'user:default/alice', - 'My chat', - ); - const originalUpdatedAt = created.updatedAt; - - await new Promise(r => setTimeout(r, 10)); - await service.touch(created.id, 'user:default/alice'); - - const found = await service.getSession(created.id, 'user:default/alice'); - expect(found!.updatedAt).not.toBe(originalUpdatedAt); - expect(new Date(found!.updatedAt).getTime()).toBeGreaterThan( - new Date(originalUpdatedAt).getTime(), - ); - }); - }); - - describe('purgeExpiredSessions', () => { - it('purges sessions older than retention period', async () => { - const s1 = await service.createSession('user:default/old', 'Old chat'); - - const past = new Date( - Date.now() - 100 * 24 * 60 * 60 * 1000, - ).toISOString(); - await db('augment_sessions') - .where('id', s1.id) - .update({ updated_at: past }); - - const s2 = await service.createSession('user:default/new', 'New chat'); - - const purged = await service.purgeExpiredSessions(90); - expect(purged).toBe(1); - - const remaining = await service.getSession(s2.id, 'user:default/new'); - expect(remaining).toBeDefined(); - - const gone = await service.getSession(s1.id, 'user:default/old'); - expect(gone).toBeNull(); - }); - - it('returns 0 when no sessions expired', async () => { - await service.createSession('user:default/fresh', 'Fresh chat'); - const purged = await service.purgeExpiredSessions(90); - expect(purged).toBe(0); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/ChatSessionService.ts b/workspaces/augment/plugins/augment-backend/src/services/ChatSessionService.ts deleted file mode 100644 index 636c7b281e4..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/ChatSessionService.ts +++ /dev/null @@ -1,713 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { randomUUID } from 'crypto'; -import type { - LoggerService, - DatabaseService, -} from '@backstage/backend-plugin-api'; -import { - MAX_SESSION_LIST_LIMIT, - DEFAULT_SESSION_LIST_LIMIT, -} from '../constants'; -import { toErrorMessage } from './utils'; - -export interface ChatSession { - id: string; - title: string; - userRef: string; - conversationId: string | null; - createdAt: string; - updatedAt: string; - model?: string; - providerId?: string; -} - -export interface SessionMessage { - id: string; - sessionId: string; - role: 'user' | 'assistant'; - content: string; - agentName?: string; - toolCalls?: string; - ragSources?: string; - usage?: string; - reasoning?: string; - citations?: string; - createdAt: string; -} - -interface ChatSessionRow { - id: string; - title: string; - user_ref: string; - conversation_id: string | null; - created_at: string; - updated_at: string; - model: string | null; - provider_id: string | null; -} - -interface SessionMessageRow { - id: string; - session_id: string; - role: string; - content: string; - agent_name: string | null; - tool_calls: string | null; - rag_sources: string | null; - usage: string | null; - reasoning: string | null; - citations: string | null; - created_at: string; -} - -const TABLE_NAME = 'augment_sessions'; -const MESSAGES_TABLE = 'augment_session_messages'; -const FEEDBACK_TABLE = 'augment_message_feedback'; - -/** - * Manages chat sessions in a local database (SQLite for dev, Postgres for prod). - * Each session links to a LlamaStack conversation_id (set on first message). - * Mirrors the ai-virtual-agent pattern: the DB is the source of truth for - * the sidebar, LlamaStack is the source of truth for message content. - */ -export class ChatSessionService { - private readonly logger: LoggerService; - private db: Awaited> | null = null; - - constructor( - private readonly database: DatabaseService, - logger: LoggerService, - ) { - this.logger = logger; - } - - async initialize(): Promise { - try { - this.db = await this.database.getClient(); - - const hasTable = await this.db.schema.hasTable(TABLE_NAME); - if (hasTable) { - // Add user_ref column if missing (migration for existing tables) - const hasUserRef = await this.db.schema.hasColumn( - TABLE_NAME, - 'user_ref', - ); - if (!hasUserRef) { - try { - await this.db.schema.alterTable(TABLE_NAME, table => { - table - .string('user_ref') - .notNullable() - .defaultTo('user:default/guest'); - table.index(['user_ref', 'updated_at']); - }); - this.logger.info(`Added user_ref column to ${TABLE_NAME} table`); - } catch (alterError) { - const hasUserRefNow = await this.db.schema.hasColumn( - TABLE_NAME, - 'user_ref', - ); - if (!hasUserRefNow) throw alterError; - this.logger.info( - `user_ref column in ${TABLE_NAME} was added by another instance`, - ); - } - } - // Add model column if missing (migration for existing tables) - const hasModel = await this.db.schema.hasColumn(TABLE_NAME, 'model'); - if (!hasModel) { - try { - await this.db.schema.alterTable(TABLE_NAME, table => { - table.string('model').nullable(); - }); - this.logger.info(`Added model column to ${TABLE_NAME} table`); - } catch (alterError) { - const hasModelNow = await this.db.schema.hasColumn( - TABLE_NAME, - 'model', - ); - if (!hasModelNow) throw alterError; - this.logger.info( - `model column in ${TABLE_NAME} was added by another instance`, - ); - } - } - - // Add provider_id column if missing (migration for existing tables) - const hasProviderId = await this.db.schema.hasColumn( - TABLE_NAME, - 'provider_id', - ); - if (!hasProviderId) { - try { - await this.db.schema.alterTable(TABLE_NAME, table => { - table.string('provider_id').nullable(); - }); - this.logger.info(`Added provider_id column to ${TABLE_NAME} table`); - } catch (alterError) { - const hasProviderIdNow = await this.db.schema.hasColumn( - TABLE_NAME, - 'provider_id', - ); - if (!hasProviderIdNow) throw alterError; - this.logger.info( - `provider_id column in ${TABLE_NAME} was added by another instance`, - ); - } - } - - this.logger.info(`Using existing ${TABLE_NAME} table`); - } else { - try { - await this.db.schema.createTable(TABLE_NAME, table => { - table.string('id').primary().notNullable(); - table.string('title').notNullable(); - table - .string('user_ref') - .notNullable() - .defaultTo('user:default/guest'); - table.string('conversation_id').nullable(); - table.string('model').nullable(); - table.string('provider_id').nullable(); - table - .timestamp('created_at') - .notNullable() - .defaultTo(this.db!.fn.now()); - table - .timestamp('updated_at') - .notNullable() - .defaultTo(this.db!.fn.now()); - table.index(['user_ref', 'updated_at']); - }); - this.logger.info(`Created ${TABLE_NAME} table`); - } catch (createError) { - const existsNow = await this.db.schema.hasTable(TABLE_NAME); - if (!existsNow) throw createError; - this.logger.info( - `${TABLE_NAME} table was created by another instance`, - ); - } - } - - // Initialize the session messages table - await this.initializeMessagesTable(); - } catch (error) { - const msg = toErrorMessage(error); - this.logger.error(`Failed to initialize chat sessions database: ${msg}`); - throw error; - } - } - - private async initializeMessagesTable(): Promise { - if (!this.db) return; - - const hasTable = await this.db.schema.hasTable(MESSAGES_TABLE); - if (hasTable) { - const hasCitations = await this.db.schema.hasColumn( - MESSAGES_TABLE, - 'citations', - ); - if (!hasCitations) { - try { - await this.db.schema.alterTable(MESSAGES_TABLE, table => { - table.text('citations').nullable(); - }); - this.logger.info(`Added citations column to ${MESSAGES_TABLE} table`); - } catch (alterError) { - const hasCitationsNow = await this.db.schema.hasColumn( - MESSAGES_TABLE, - 'citations', - ); - if (!hasCitationsNow) throw alterError; - this.logger.info( - `citations column in ${MESSAGES_TABLE} was added by another instance`, - ); - } - } - this.logger.info(`Using existing ${MESSAGES_TABLE} table`); - return; - } - - try { - await this.db.schema.createTable(MESSAGES_TABLE, table => { - table.string('id').primary().notNullable(); - table.string('session_id').notNullable(); - table.string('role').notNullable(); - table.text('content').notNullable(); - table.string('agent_name').nullable(); - table.text('tool_calls').nullable(); - table.text('rag_sources').nullable(); - table.text('usage').nullable(); - table.text('reasoning').nullable(); - table.text('citations').nullable(); - table - .timestamp('created_at') - .notNullable() - .defaultTo(this.db!.fn.now()); - table.index(['session_id', 'created_at']); - table - .foreign('session_id') - .references('id') - .inTable(TABLE_NAME) - .onDelete('CASCADE'); - }); - this.logger.info(`Created ${MESSAGES_TABLE} table`); - } catch (createError) { - const existsNow = await this.db.schema.hasTable(MESSAGES_TABLE); - if (!existsNow) throw createError; - this.logger.info( - `${MESSAGES_TABLE} table was created by another instance`, - ); - } - - await this.initFeedbackTable(); - } - - private async initFeedbackTable(): Promise { - if (!this.db) return; - const hasTable = await this.db.schema.hasTable(FEEDBACK_TABLE); - if (hasTable) return; - - try { - await this.db.schema.createTable(FEEDBACK_TABLE, table => { - table.uuid('id').primary(); - table.string('message_id').notNullable().index(); - table.string('session_id').nullable().index(); - table.string('user_ref').notNullable(); - table.enum('direction', ['positive', 'negative']).notNullable(); - table.text('reasons').nullable(); - table.text('comment').nullable(); - table - .timestamp('created_at') - .defaultTo(this.db!.raw('CURRENT_TIMESTAMP')); - }); - this.logger.info(`Created ${FEEDBACK_TABLE} table`); - } catch (createError) { - const existsNow = await this.db.schema.hasTable(FEEDBACK_TABLE); - if (!existsNow) throw createError; - this.logger.info( - `${FEEDBACK_TABLE} table was created by another instance`, - ); - } - } - - private rowToSession(row: ChatSessionRow): ChatSession { - return { - id: row.id, - title: row.title, - userRef: row.user_ref, - conversationId: row.conversation_id, - createdAt: row.created_at, - updatedAt: row.updated_at, - ...(row.model ? { model: row.model } : {}), - ...(row.provider_id ? { providerId: row.provider_id } : {}), - }; - } - - private rowToMessage(row: SessionMessageRow): SessionMessage { - return { - id: row.id, - sessionId: row.session_id, - role: row.role as 'user' | 'assistant', - content: row.content, - ...(row.agent_name ? { agentName: row.agent_name } : {}), - ...(row.tool_calls ? { toolCalls: row.tool_calls } : {}), - ...(row.rag_sources ? { ragSources: row.rag_sources } : {}), - ...(row.usage ? { usage: row.usage } : {}), - ...(row.reasoning ? { reasoning: row.reasoning } : {}), - ...(row.citations ? { citations: row.citations } : {}), - createdAt: row.created_at, - }; - } - - async createSession( - userRef: string, - title?: string, - model?: string, - providerId?: string, - ): Promise { - if (!this.db) throw new Error('Database not initialized'); - - const id = randomUUID(); - const now = new Date().toISOString(); - const sessionTitle = title || `Chat ${now.slice(0, 16).replace('T', ' ')}`; - - await this.db(TABLE_NAME).insert({ - id, - title: sessionTitle, - user_ref: userRef, - conversation_id: null, - model: model || null, - provider_id: providerId || null, - created_at: now, - updated_at: now, - }); - - this.logger.info(`Created session ${id} for ${userRef}: "${sessionTitle}"`); - return { - id, - title: sessionTitle, - userRef, - conversationId: null, - createdAt: now, - updatedAt: now, - ...(model ? { model } : {}), - ...(providerId ? { providerId } : {}), - }; - } - - async listSessions( - userRef: string, - limit: number = 50, - offset: number = 0, - providerId?: string, - ): Promise { - if (!this.db) throw new Error('Database not initialized'); - - const cappedLimit = Math.min(Math.max(1, limit), MAX_SESSION_LIST_LIMIT); - const cappedOffset = Math.max(0, offset); - let query = this.db(TABLE_NAME) - .select('*') - .where('user_ref', userRef) - .orderBy('updated_at', 'desc') - .limit(cappedLimit) - .offset(cappedOffset); - - if (providerId) { - query = query.where(function providerFilter() { - this.where('provider_id', providerId).orWhereNull('provider_id'); - }); - } - - const rows: ChatSessionRow[] = await query; - return rows.map(r => this.rowToSession(r)); - } - - /** - * List all sessions across all users. - * @remarks Admin-only: callers must verify admin access before invoking. - */ - async listAllSessions( - limit: number = DEFAULT_SESSION_LIST_LIMIT, - ): Promise { - if (!this.db) throw new Error('Database not initialized'); - - const cappedLimit = Math.min(Math.max(1, limit), MAX_SESSION_LIST_LIMIT); - const rows: ChatSessionRow[] = await this.db(TABLE_NAME) - .select('*') - .orderBy('updated_at', 'desc') - .limit(cappedLimit); - - return rows.map(r => this.rowToSession(r)); - } - - async findSessionByConversation( - conversationId: string, - userRef: string, - ): Promise { - if (!this.db) throw new Error('Database not initialized'); - - const row: ChatSessionRow | undefined = await this.db(TABLE_NAME) - .where('conversation_id', conversationId) - .where('user_ref', userRef) - .first(); - - return row ? this.rowToSession(row) : null; - } - - async getSession( - sessionId: string, - userRef: string, - ): Promise { - if (!this.db) throw new Error('Database not initialized'); - - const row: ChatSessionRow | undefined = await this.db(TABLE_NAME) - .where('id', sessionId) - .where('user_ref', userRef) - .first(); - - return row ? this.rowToSession(row) : null; - } - - /** - * Retrieve any session by ID without user_ref filtering. - * @remarks Admin-only: callers must verify admin access before invoking. - */ - async getSessionById(sessionId: string): Promise { - if (!this.db) throw new Error('Database not initialized'); - - const row: ChatSessionRow | undefined = await this.db(TABLE_NAME) - .where('id', sessionId) - .first(); - - return row ? this.rowToSession(row) : null; - } - - async deleteSession(sessionId: string, userRef: string): Promise { - if (!this.db) throw new Error('Database not initialized'); - - const deleted = await this.db.transaction(async (trx): Promise => { - await trx(MESSAGES_TABLE).where('session_id', sessionId).delete(); - - return trx(TABLE_NAME) - .where('id', sessionId) - .where('user_ref', userRef) - .delete(); - }); - - if (deleted > 0) { - this.logger.info(`Deleted session ${sessionId} for ${userRef}`); - } - return deleted > 0; - } - - /** - * Set the LlamaStack conversation_id for a session. - * Called after the first message creates a conversation in LlamaStack. - */ - async setConversationId( - sessionId: string, - userRef: string, - conversationId: string, - ): Promise { - if (!this.db) throw new Error('Database not initialized'); - - await this.db(TABLE_NAME) - .where('id', sessionId) - .where('user_ref', userRef) - .update({ - conversation_id: conversationId, - updated_at: new Date().toISOString(), - }); - - this.logger.info( - `Linked session ${sessionId} to conversation ${conversationId} for ${userRef}`, - ); - } - - /** - * Conditionally set conversation_id only if the session has no conversation - * yet. Prevents race conditions where two concurrent requests both try to - * link a conversation. Returns true if the update was applied. - */ - async setConversationIdIfNull( - sessionId: string, - userRef: string, - conversationId: string, - ): Promise { - if (!this.db) throw new Error('Database not initialized'); - - const updated = await this.db(TABLE_NAME) - .where('id', sessionId) - .where('user_ref', userRef) - .whereNull('conversation_id') - .update({ - conversation_id: conversationId, - updated_at: new Date().toISOString(), - }); - - if (updated > 0) { - this.logger.info( - `Linked session ${sessionId} to conversation ${conversationId} for ${userRef}`, - ); - return true; - } - - this.logger.info( - `Session ${sessionId} already has a conversation_id, skipping link to ${conversationId}`, - ); - return false; - } - - /** - * Update the session title (e.g., from the first user message). - */ - async updateTitle( - sessionId: string, - userRef: string, - title: string, - ): Promise { - if (!this.db) throw new Error('Database not initialized'); - - await this.db(TABLE_NAME) - .where('id', sessionId) - .where('user_ref', userRef) - .update({ - title, - updated_at: new Date().toISOString(), - }); - } - - /** - * Touch the updated_at timestamp (called after each message). - */ - async touch(sessionId: string, userRef: string): Promise { - if (!this.db) throw new Error('Database not initialized'); - - await this.db(TABLE_NAME) - .where('id', sessionId) - .where('user_ref', userRef) - .update({ updated_at: new Date().toISOString() }); - } - - // ========================================================================= - // Session Messages — local persistence for conversation history - // ========================================================================= - - /** - * Persist a message (user or assistant) for a session. - */ - async addMessage( - msg: Omit, - ): Promise { - if (!this.db) throw new Error('Database not initialized'); - - const id = randomUUID(); - const now = new Date().toISOString(); - - await this.db(MESSAGES_TABLE).insert({ - id, - session_id: msg.sessionId, - role: msg.role, - content: msg.content, - agent_name: msg.agentName || null, - tool_calls: msg.toolCalls || null, - rag_sources: msg.ragSources || null, - usage: msg.usage || null, - reasoning: msg.reasoning || null, - citations: msg.citations || null, - created_at: now, - }); - - return { - id, - ...msg, - createdAt: now, - }; - } - - /** - * Retrieve messages for a session, ordered chronologically. - * Optional `limit` and `offset` enable pagination for large conversations. - */ - async getMessages( - sessionId: string, - limit?: number, - offset?: number, - userRef?: string, - ): Promise { - if (!this.db) throw new Error('Database not initialized'); - - let query = this.db(MESSAGES_TABLE) - .select(`${MESSAGES_TABLE}.*`) - .where(`${MESSAGES_TABLE}.session_id`, sessionId) - .orderBy(`${MESSAGES_TABLE}.created_at`, 'asc'); - - if (userRef) { - query = query - .join(TABLE_NAME, `${TABLE_NAME}.id`, `${MESSAGES_TABLE}.session_id`) - .where(`${TABLE_NAME}.user_ref`, userRef); - } - - if (typeof offset === 'number' && offset > 0) { - query = query.offset(offset); - } - if (typeof limit === 'number' && limit > 0) { - query = query.limit(limit); - } - - const rows: SessionMessageRow[] = await query; - return rows.map(r => this.rowToMessage(r)); - } - - /** - * Delete all messages for a session (used when deleting the session). - */ - async deleteMessagesBySession(sessionId: string): Promise { - if (!this.db) throw new Error('Database not initialized'); - - const deleted = await this.db(MESSAGES_TABLE) - .where('session_id', sessionId) - .delete(); - - if (deleted > 0) { - this.logger.info(`Deleted ${deleted} messages for session ${sessionId}`); - } - } - - /** - * Persist per-message feedback (thumbs up/down) with optional reasons and comment. - */ - /** - * Delete sessions (and their messages via CASCADE) older than `retentionDays`. - * Returns the number of sessions purged. - */ - async purgeExpiredSessions(retentionDays: number): Promise { - if (!this.db) throw new Error('Database not initialized'); - - const cutoff = new Date( - Date.now() - retentionDays * 24 * 60 * 60 * 1000, - ).toISOString(); - - const rows: Array<{ id: string }> = await this.db(TABLE_NAME) - .select('id') - .where('updated_at', '<', cutoff); - - if (rows.length === 0) return 0; - - const ids = rows.map(r => r.id); - await this.db.transaction(async trx => { - await trx(MESSAGES_TABLE).whereIn('session_id', ids).delete(); - await trx(TABLE_NAME).whereIn('id', ids).delete(); - }); - - this.logger.info( - `Session retention: purged ${ids.length} session(s) older than ${retentionDays} day(s)`, - ); - return ids.length; - } - - async saveFeedback( - userRef: string, - payload: { - messageId: string; - sessionId?: string; - direction: 'positive' | 'negative'; - reasons?: string[]; - comment?: string; - }, - ): Promise { - if (!this.db) throw new Error('Database not initialized'); - - const id = randomUUID(); - await this.db(FEEDBACK_TABLE).insert({ - id, - message_id: payload.messageId, - session_id: payload.sessionId ?? null, - user_ref: userRef, - direction: payload.direction, - reasons: payload.reasons ? JSON.stringify(payload.reasons) : null, - comment: payload.comment ?? null, - }); - - this.logger.info( - `Saved ${payload.direction} feedback ${id} for message ${payload.messageId}`, - ); - return id; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/DevSpacesService.ts b/workspaces/augment/plugins/augment-backend/src/services/DevSpacesService.ts deleted file mode 100644 index 07a1aba3c4a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/DevSpacesService.ts +++ /dev/null @@ -1,560 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { randomBytes } from 'crypto'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { InputError } from '@backstage/errors'; -import type { - DevSpacesCreateWorkspaceRequest, - DevSpacesCreateWorkspaceResponse, - DevSpacesHealthResponse, - DevSpacesWorkspace, - DevSpacesListWorkspacesResponse, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { AdminConfigService } from './AdminConfigService'; - -function stripTrailingSlashes(s: string): string { - let end = s.length; - while (end > 0 && s[end - 1] === '/') end--; - return end === s.length ? s : s.slice(0, end); -} - -function trimDashes(s: string): string { - let start = 0; - while (start < s.length && s[start] === '-') start++; - let end = s.length; - while (end > start && s[end - 1] === '-') end--; - return s.slice(start, end); -} - -const REQUEST_TIMEOUT_MS = 30_000; -const MAX_RETRIES = 2; -const RETRY_DELAY_MS = 1000; -const RETRYABLE_STATUS_CODES = new Set([502, 503, 504]); - -const DW_API_GROUP = 'workspace.devfile.io'; -const DW_API_VERSION = 'v1alpha2'; -const DW_RESOURCE = 'devworkspaces'; -const DW_BASE_PATH = `/apis/${DW_API_GROUP}/${DW_API_VERSION}`; - -const DEFAULT_DEVSPACES_NAMESPACE = 'admin-devspaces'; -const NAME_SUFFIX_LENGTH = 4; -const MAX_K8S_NAME_LENGTH = 63; - -// --------------------------------------------------------------------------- -// Kubernetes DevWorkspace resource shape (subset we use) -// --------------------------------------------------------------------------- - -interface KubeDevWorkspaceMeta { - name: string; - namespace: string; - uid?: string; - creationTimestamp?: string; -} - -interface KubeDevWorkspaceContainer { - name: string; - memoryLimit?: string; - memoryRequest?: string; - cpuLimit?: string; - cpuRequest?: string; -} - -interface KubeDevWorkspaceSpec { - started?: boolean; - routingClass?: string; - template?: { - projects?: Array<{ - name: string; - git?: { remotes?: Record }; - }>; - components?: Array<{ - name: string; - container?: KubeDevWorkspaceContainer; - }>; - }; -} - -interface KubeDevWorkspaceStatus { - phase?: string; - devworkspaceId?: string; - mainUrl?: string; - message?: string; -} - -interface KubeDevWorkspace { - apiVersion: string; - kind: string; - metadata: KubeDevWorkspaceMeta; - spec?: KubeDevWorkspaceSpec; - status?: KubeDevWorkspaceStatus; -} - -interface KubeDevWorkspaceList { - items: KubeDevWorkspace[]; -} - -// --------------------------------------------------------------------------- -// Service -// --------------------------------------------------------------------------- - -export interface DevSpacesServiceOptions { - logger: LoggerService; - adminConfig: AdminConfigService; - getAuthToken?: () => Promise; - config?: import('@backstage/config').Config; -} - -export class DevSpacesService { - private readonly logger: LoggerService; - private readonly adminConfig: AdminConfigService; - private readonly getAuthToken?: () => Promise; - private readonly rootConfig?: import('@backstage/config').Config; - - constructor(opts: DevSpacesServiceOptions) { - this.logger = opts.logger; - this.adminConfig = opts.adminConfig; - this.getAuthToken = opts.getAuthToken; - this.rootConfig = opts.config; - } - - // ── Health ────────────────────────────────────────────────────────────── - - async healthCheck(): Promise { - const start = Date.now(); - const apiUrl = await this.getApiUrl(); - - if (!apiUrl) { - return { - ok: false, - configured: false, - message: - 'OpenShift API URL is not configured. Set it in Administration → Dev Spaces.', - }; - } - - try { - const token = await this.resolveToken(); - const ns = await this.resolveNamespace(); - const url = `${this.dwUrl(apiUrl, ns)}?limit=1`; - const resp = await this.fetchWithRetry(url, { - method: 'GET', - headers: this.jsonHeaders(token), - }); - const elapsed = Date.now() - start; - - if (resp.ok) { - return { - ok: true, - configured: true, - message: 'Connected to Dev Spaces API', - apiUrl, - responseTimeMs: elapsed, - }; - } - - return { - ok: false, - configured: true, - message: - resp.status === 401 || resp.status === 403 - ? `Kubernetes API returned ${resp.status} — check your OpenShift token` - : `Kubernetes API returned ${resp.status}`, - apiUrl, - responseTimeMs: elapsed, - }; - } catch (err) { - const elapsed = Date.now() - start; - const msg = err instanceof Error ? err.message : String(err); - return { - ok: false, - configured: true, - message: `Cannot reach Kubernetes API: ${msg}`, - apiUrl, - responseTimeMs: elapsed, - }; - } - } - - // ── Create ───────────────────────────────────────────────────────────── - - async createWorkspace( - req: DevSpacesCreateWorkspaceRequest, - ): Promise { - const apiUrl = await this.requireApiUrl(); - const token = await this.resolveToken(); - const ns = await this.resolveNamespace(req.namespace); - const repoName = this.repoNameFromUrl(req.git_repo); - const suffix = randomBytes(NAME_SUFFIX_LENGTH) - .toString('hex') - .slice(0, NAME_SUFFIX_LENGTH); - const workspaceName = this.sanitizeName(`${repoName}-${suffix}`); - - this.logger.info( - `Creating DevWorkspace ${workspaceName} in ${ns} from ${req.git_repo}`, - ); - - const devworkspace: KubeDevWorkspace = { - apiVersion: `${DW_API_GROUP}/${DW_API_VERSION}`, - kind: 'DevWorkspace', - metadata: { name: workspaceName, namespace: ns }, - spec: { - started: true, - routingClass: 'che', - template: { - projects: [ - { - name: repoName, - git: { remotes: { origin: req.git_repo } }, - }, - ], - }, - }, - }; - - const url = this.dwUrl(apiUrl, ns); - const resp = await this.fetchWithRetry(url, { - method: 'POST', - headers: this.jsonHeaders(token), - body: JSON.stringify(devworkspace), - }); - - if (!resp.ok) { - const detail = await this.extractK8sError(resp); - this.logger.error( - `Create DevWorkspace failed (${resp.status}): ${detail}`, - ); - throw new Error(detail); - } - - const created = (await resp.json()) as KubeDevWorkspace; - return this.toCreateResponse(created); - } - - // ── List ─────────────────────────────────────────────────────────────── - - async listWorkspaces( - namespace: string, - ): Promise { - const apiUrl = await this.requireApiUrl(); - const token = await this.resolveToken(); - const ns = await this.resolveNamespace(namespace); - const url = this.dwUrl(apiUrl, ns); - - const resp = await this.fetchWithRetry(url, { - method: 'GET', - headers: this.jsonHeaders(token), - }); - - if (!resp.ok) { - const detail = await this.extractK8sError(resp); - this.logger.error( - `List DevWorkspaces failed (${resp.status}): ${detail}`, - ); - throw new Error(detail); - } - - const body = (await resp.json()) as KubeDevWorkspaceList; - const workspaces = (body.items ?? []).map(dw => this.toWorkspace(dw)); - return { workspaces, namespace: ns }; - } - - // ── Get ──────────────────────────────────────────────────────────────── - - async getWorkspace( - namespace: string, - name: string, - ): Promise { - const apiUrl = await this.requireApiUrl(); - const token = await this.resolveToken(); - const url = this.dwUrl(apiUrl, namespace, name); - - const resp = await this.fetchWithRetry(url, { - method: 'GET', - headers: this.jsonHeaders(token), - }); - - if (!resp.ok) { - const detail = await this.extractK8sError(resp); - throw new Error(detail); - } - - return this.toWorkspace((await resp.json()) as KubeDevWorkspace); - } - - // ── Stop ─────────────────────────────────────────────────────────────── - - async stopWorkspace(namespace: string, name: string): Promise { - const apiUrl = await this.requireApiUrl(); - const token = await this.resolveToken(); - const url = this.dwUrl(apiUrl, namespace, name); - - this.logger.info(`Stopping DevWorkspace ${name} in ${namespace}`); - - const resp = await this.fetchWithRetry(url, { - method: 'PATCH', - headers: this.patchHeaders(token), - body: JSON.stringify({ spec: { started: false } }), - }); - - if (!resp.ok) { - const detail = await this.extractK8sError(resp); - this.logger.error(`Stop DevWorkspace failed (${resp.status}): ${detail}`); - throw new Error(detail); - } - } - - // ── Delete ───────────────────────────────────────────────────────────── - - async deleteWorkspace(namespace: string, name: string): Promise { - const apiUrl = await this.requireApiUrl(); - const token = await this.resolveToken(); - const url = this.dwUrl(apiUrl, namespace, name); - - this.logger.info(`Deleting DevWorkspace ${name} in ${namespace}`); - - const resp = await this.fetchWithRetry(url, { - method: 'DELETE', - headers: this.jsonHeaders(token), - }); - - if (!resp.ok && resp.status !== 404) { - const detail = await this.extractK8sError(resp); - this.logger.error( - `Delete DevWorkspace failed (${resp.status}): ${detail}`, - ); - throw new Error(detail); - } - } - - // ═══════════════════════════════════════════════════════════════════════ - // Internal helpers - // ═══════════════════════════════════════════════════════════════════════ - - private dwUrl(apiUrl: string, namespace: string, name?: string): string { - const base = `${apiUrl}${DW_BASE_PATH}/namespaces/${encodeURIComponent(namespace)}/${DW_RESOURCE}`; - return name ? `${base}/${encodeURIComponent(name)}` : base; - } - - private toWorkspace(dw: KubeDevWorkspace): DevSpacesWorkspace { - const project = dw.spec?.template?.projects?.[0]; - return { - name: dw.metadata.name, - namespace: dw.metadata.namespace, - phase: dw.status?.phase ?? 'Unknown', - url: - dw.status?.mainUrl ?? - this.buildDashboardUrl(dw.metadata.namespace, dw.metadata.name), - created_at: dw.metadata.creationTimestamp, - git_repo: project?.git?.remotes?.origin, - }; - } - - private toCreateResponse( - dw: KubeDevWorkspace, - ): DevSpacesCreateWorkspaceResponse { - return { - name: dw.metadata.name, - namespace: dw.metadata.namespace, - phase: dw.status?.phase ?? 'Starting', - message: dw.status?.message, - url: - dw.status?.mainUrl ?? - this.buildDashboardUrl(dw.metadata.namespace, dw.metadata.name), - created_at: dw.metadata.creationTimestamp, - }; - } - - private repoNameFromUrl(gitUrl: string): string { - const last = stripTrailingSlashes(gitUrl).split('/').pop() ?? 'workspace'; - return last.replace(/\.git$/i, ''); - } - - private sanitizeName(raw: string): string { - const sanitized = raw.toLowerCase().replace(/[^a-z0-9-]/g, '-'); - return trimDashes(sanitized).slice(0, MAX_K8S_NAME_LENGTH) || 'workspace'; - } - - // ── Config & auth ────────────────────────────────────────────────────── - - private async getApiUrl(): Promise { - const raw = (await this.adminConfig.get('devSpacesApiUrl')) as - | string - | undefined; - if (raw && typeof raw === 'string') return stripTrailingSlashes(raw); - const fromConfig = this.rootConfig?.getOptionalString('augment.k8s.apiUrl'); - return fromConfig ? stripTrailingSlashes(fromConfig) : undefined; - } - - private async requireApiUrl(): Promise { - const url = await this.getApiUrl(); - if (!url) { - throw new InputError( - 'OpenShift API URL is not configured. Set it in Administration → Dev Spaces.', - ); - } - return url; - } - - /** - * Resolve the Kubernetes namespace for DevWorkspace resources. - * Uses the admin-configured `devSpacesNamespace`, falling back to - * the provided hint or the default `admin-devspaces`. - */ - private async resolveNamespace(hint?: string): Promise { - const configured = (await this.adminConfig.get('devSpacesNamespace')) as - | string - | undefined; - if (configured && typeof configured === 'string' && configured.trim()) { - return configured.trim(); - } - if (hint && hint.endsWith('-devspaces')) { - return hint; - } - return hint ? `${hint}-devspaces` : DEFAULT_DEVSPACES_NAMESPACE; - } - - /** - * Derive a DevSpaces namespace from a Backstage user entity ref. - * Extracts the username from 'user:default/jsmith' and returns 'jsmith-devspaces'. - */ - resolveNamespaceForUser(userRef: string): string { - const match = userRef.match(/^user:[^/]+\/(.+)$/); - const username = match ? match[1] : userRef; - const sanitized = this.sanitizeName(username); - return `${sanitized}-devspaces`; - } - - private async resolveToken(): Promise { - const adminToken = (await this.adminConfig.get('devSpacesToken')) as - | string - | undefined; - - if (adminToken && typeof adminToken === 'string' && adminToken.trim()) { - return adminToken.trim(); - } - - const fromConfig = this.rootConfig?.getOptionalString('augment.k8s.token'); - if (fromConfig?.trim()) return fromConfig.trim(); - - if (this.getAuthToken) { - return this.getAuthToken(); - } - - throw new InputError( - 'Dev Spaces authentication is not configured. Either set an OpenShift token ' + - 'in Administration → Dev Spaces, or ensure the Kagenti provider is active.', - ); - } - - private jsonHeaders(token: string): Record { - return { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }; - } - - private patchHeaders(token: string): Record { - return { - 'Content-Type': 'application/merge-patch+json', - Authorization: `Bearer ${token}`, - }; - } - - private buildDashboardUrl( - namespace: string, - workspaceName: string, - ): string | undefined { - try { - const apiUrl = this.rootConfig?.getOptionalString('augment.k8s.apiUrl'); - if (!apiUrl) return undefined; - const host = new URL(apiUrl).hostname.replace(/^api\./, ''); - return `https://devspaces.apps.${host}/#/ide/${namespace}/${workspaceName}`; - } catch { - return undefined; - } - } - - // ── HTTP helpers ─────────────────────────────────────────────────────── - - private async fetchWithTimeout( - url: string, - init: RequestInit, - ): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - const opts: RequestInit & { dispatcher?: unknown } = { - ...init, - signal: controller.signal, - }; - if (url.startsWith('https://')) { - const { Agent } = await import('undici'); - opts.dispatcher = new Agent({ - connect: { rejectUnauthorized: false }, - }); - } - return await fetch(url, opts); - } finally { - clearTimeout(timeout); - } - } - - private async fetchWithRetry( - url: string, - init: RequestInit, - ): Promise { - let lastError: unknown; - - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - try { - const resp = await this.fetchWithTimeout(url, init); - if ( - !RETRYABLE_STATUS_CODES.has(resp.status) || - attempt === MAX_RETRIES - ) { - return resp; - } - this.logger.warn( - `K8s API returned ${resp.status}, retrying (${attempt + 1}/${MAX_RETRIES})`, - ); - } catch (err) { - lastError = err; - if (attempt === MAX_RETRIES) break; - this.logger.warn( - `K8s request failed, retrying (${attempt + 1}/${MAX_RETRIES}): ${err}`, - ); - } - await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (attempt + 1))); - } - - throw lastError ?? new Error('Kubernetes API request failed after retries'); - } - - private async extractK8sError(resp: Response): Promise { - try { - const body = await resp.text(); - const contentType = resp.headers.get('content-type') ?? ''; - if (contentType.includes('json')) { - const parsed = JSON.parse(body) as Record; - return (parsed.message as string) ?? (parsed.reason as string) ?? body; - } - return body || `HTTP ${resp.status}`; - } catch { - return `HTTP ${resp.status}`; - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/DocumentIngestionService.test.ts b/workspaces/augment/plugins/augment-backend/src/services/DocumentIngestionService.test.ts deleted file mode 100644 index 5923a5701ef..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/DocumentIngestionService.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { LoggerService } from '@backstage/backend-plugin-api'; -import { DocumentIngestionService } from './DocumentIngestionService'; - -function createMockLogger(): Record { - return { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - child: jest.fn().mockReturnThis(), - }; -} - -describe('DocumentIngestionService', () => { - let mockLogger: Record; - - beforeEach(() => { - mockLogger = createMockLogger(); - }); - - it('creates an instance', () => { - const service = new DocumentIngestionService({ - logger: mockLogger as unknown as LoggerService, - }); - expect(service).toBeDefined(); - }); - - describe('fetchFromSources — SSRF protection', () => { - it('blocks localhost URL sources', async () => { - const service = new DocumentIngestionService({ - logger: mockLogger as unknown as LoggerService, - }); - const docs = await service.fetchFromSources([ - { type: 'url', urls: ['https://localhost/secret'] }, - ]); - expect(docs).toHaveLength(0); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('blocked by SSRF protection'), - ); - }); - - it('blocks 127.0.0.1 URL sources', async () => { - const service = new DocumentIngestionService({ - logger: mockLogger as unknown as LoggerService, - }); - const docs = await service.fetchFromSources([ - { type: 'url', urls: ['https://127.0.0.1:8080/admin'] }, - ]); - expect(docs).toHaveLength(0); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('blocked by SSRF protection'), - ); - }); - - it('blocks cloud metadata endpoint (169.254.169.254)', async () => { - const service = new DocumentIngestionService({ - logger: mockLogger as unknown as LoggerService, - }); - const docs = await service.fetchFromSources([ - { type: 'url', urls: ['https://169.254.169.254/latest/meta-data/'] }, - ]); - expect(docs).toHaveLength(0); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('blocked by SSRF protection'), - ); - }); - - it('blocks private network IPs (10.x)', async () => { - const service = new DocumentIngestionService({ - logger: mockLogger as unknown as LoggerService, - }); - const docs = await service.fetchFromSources([ - { type: 'url', urls: ['https://10.0.0.1/internal-api'] }, - ]); - expect(docs).toHaveLength(0); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('blocked by SSRF protection'), - ); - }); - - it('blocks private network IPs (192.168.x)', async () => { - const service = new DocumentIngestionService({ - logger: mockLogger as unknown as LoggerService, - }); - const docs = await service.fetchFromSources([ - { type: 'url', urls: ['https://192.168.1.1/config'] }, - ]); - expect(docs).toHaveLength(0); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('blocked by SSRF protection'), - ); - }); - - it('blocks Google metadata hostname', async () => { - const service = new DocumentIngestionService({ - logger: mockLogger as unknown as LoggerService, - }); - const docs = await service.fetchFromSources([ - { - type: 'url', - urls: ['https://metadata.google.internal/computeMetadata/v1/'], - }, - ]); - expect(docs).toHaveLength(0); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('blocked by SSRF protection'), - ); - }); - }); - - describe('fetchFromSources — path traversal protection', () => { - it('blocks directory sources that traverse outside cwd', async () => { - const service = new DocumentIngestionService({ - logger: mockLogger as unknown as LoggerService, - }); - const docs = await service.fetchFromSources([ - { type: 'directory', path: '../../../etc' }, - ]); - expect(docs).toHaveLength(0); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('resolves outside the working directory'), - ); - }); - }); - - describe('fetchFromSources — empty input', () => { - it('returns empty array for no sources', async () => { - const service = new DocumentIngestionService({ - logger: mockLogger as unknown as LoggerService, - }); - const docs = await service.fetchFromSources([]); - expect(docs).toHaveLength(0); - }); - }); - - describe('fetchFromSources — non-existent directory', () => { - it('warns and returns empty for non-existent directory', async () => { - const service = new DocumentIngestionService({ - logger: mockLogger as unknown as LoggerService, - }); - const docs = await service.fetchFromSources([ - { type: 'directory', path: '/nonexistent-path-abc123' }, - ]); - expect(docs).toHaveLength(0); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('does not exist'), - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/DocumentIngestionService.ts b/workspaces/augment/plugins/augment-backend/src/services/DocumentIngestionService.ts deleted file mode 100644 index 38a33146b66..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/DocumentIngestionService.ts +++ /dev/null @@ -1,432 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { LoggerService } from '@backstage/backend-plugin-api'; -import { - DocumentSource, - DirectorySource, - UrlSource, - GitHubSource, - FetchedDocument, - FileAttributes, -} from '../types'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as crypto from 'crypto'; -import { fetchWithTlsControl } from './utils/http'; -import { isPrivateUrlWithDns } from './utils/SsrfGuard'; -import { - generateTitleFromFileName, - detectContentType, - extractFileNameFromUrl, -} from './utils/documentAttributes'; -import { matchesPatterns } from './utils/GlobMatcher'; -import { toErrorMessage } from './utils'; - -const GITHUB_REPO_PATTERN = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/; - -/** - * Service for fetching documents from various sources - * @internal - */ -export class DocumentIngestionService { - private readonly logger: LoggerService; - private readonly skipTlsVerify: boolean; - - constructor(options: { logger: LoggerService; skipTlsVerify?: boolean }) { - this.logger = options.logger; - this.skipTlsVerify = options.skipTlsVerify ?? false; - } - - private static readonly MAX_REDIRECTS = 5; - - /** - * Fetch a URL with manual redirect handling to re-validate each - * redirect target against the SSRF guard (prevents open-redirect SSRF). - */ - private async fetchUrl( - url: string, - headers: Record = {}, - ): Promise { - let currentUrl = url; - - for (let i = 0; i < DocumentIngestionService.MAX_REDIRECTS; i++) { - const response = await fetchWithTlsControl(currentUrl, { - headers, - skipTlsVerify: this.skipTlsVerify, - redirect: 'manual', - }); - - if (response.status >= 300 && response.status < 400) { - const location = response.headers?.location; - if (!location) { - throw new Error( - `Redirect ${response.status} without Location header`, - ); - } - const redirectUrl = new URL(location, currentUrl).toString(); - const blocked = await isPrivateUrlWithDns(redirectUrl); - if (blocked) { - throw new Error( - `Redirect to ${redirectUrl} blocked by SSRF protection (${blocked})`, - ); - } - currentUrl = redirectUrl; - continue; - } - - return response; - } - - throw new Error( - `Too many redirects (max ${DocumentIngestionService.MAX_REDIRECTS})`, - ); - } - - /** - * Compute a SHA-256 hash of content for change detection. - * Uses first 16 chars of hex digest for reasonable collision resistance. - */ - private computeContentHash(content: string): string { - return crypto - .createHash('sha256') - .update(content, 'utf8') - .digest('hex') - .substring(0, 16); - } - - /** Build a {@link FileAttributes} object for a document. */ - private buildAttributes( - fileName: string, - sourceUrl: string, - modulePath?: string, - ): FileAttributes { - return { - title: generateTitleFromFileName(fileName), - source_url: sourceUrl, - content_type: detectContentType(fileName), - ...(modulePath ? { module: modulePath } : {}), - }; - } - - /** - * Fetch all documents from configured sources. - */ - async fetchFromSources( - sources: DocumentSource[], - ): Promise { - const allDocuments: FetchedDocument[] = []; - - for (const source of sources) { - try { - let documents: FetchedDocument[] = []; - - switch (source.type) { - case 'directory': - documents = await this.fetchFromDirectory(source); - break; - case 'url': - documents = await this.fetchFromUrls(source); - break; - case 'github': - documents = await this.fetchFromGitHub(source); - break; - default: { - const unhandledSource: never = source; - this.logger.warn( - `Unknown source type: ${ - (unhandledSource as DocumentSource).type - }`, - ); - } - } - - this.logger.info( - `Fetched ${documents.length} documents from ${source.type} source`, - ); - allDocuments.push(...documents); - } catch (error) { - this.logger.error( - `Failed to fetch from ${source.type} source: ${toErrorMessage( - error, - )}`, - ); - } - } - - return allDocuments; - } - - private async fetchFromDirectory( - source: DirectorySource, - ): Promise { - const documents: FetchedDocument[] = []; - const patterns = source.patterns || [ - '**/*.md', - '**/*.txt', - '**/*.yaml', - '**/*.json', - ]; - - const cwd = process.cwd(); - const basePath = path.isAbsolute(source.path) - ? source.path - : path.resolve(cwd, source.path); - - if (!path.isAbsolute(source.path)) { - const normalizedBase = path.normalize(basePath); - if ( - !normalizedBase.startsWith(cwd + path.sep) && - normalizedBase !== cwd - ) { - this.logger.warn( - `Directory source path "${source.path}" resolves outside the working directory — skipping`, - ); - return documents; - } - } - - this.logger.info(`Scanning directory: ${basePath}`); - - if (!fs.existsSync(basePath)) { - this.logger.warn(`Directory does not exist: ${basePath}`); - return documents; - } - - const files = await this.findFiles(basePath, patterns); - - for (const filePath of files) { - try { - const content = await fs.promises.readFile(filePath, 'utf-8'); - const relativePath = path.relative(basePath, filePath); - const fileName = path.basename(filePath); - - documents.push({ - sourceId: `directory:${source.path}:${relativePath}`, - fileName, - content, - sourceType: 'directory', - contentHash: this.computeContentHash(content), - attributes: this.buildAttributes( - fileName, - `file://${filePath}`, - path.dirname(relativePath) || 'root', - ), - }); - } catch (error) { - this.logger.warn( - `Failed to read file ${filePath}: ${toErrorMessage(error)}`, - ); - } - } - - return documents; - } - - private async fetchFromUrls(source: UrlSource): Promise { - const documents: FetchedDocument[] = []; - - for (const url of source.urls) { - try { - const blockedReason = await isPrivateUrlWithDns(url); - if (blockedReason) { - this.logger.warn( - `Skipping URL ${url}: blocked by SSRF protection (${blockedReason})`, - ); - continue; - } - - this.logger.debug(`Fetching URL: ${url}`); - - const response = await this.fetchUrl(url, source.headers || {}); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const content = await response.text(); - const fileName = extractFileNameFromUrl(url); - - documents.push({ - sourceId: `url:${url}`, - fileName, - content, - sourceType: 'url', - contentHash: this.computeContentHash(content), - attributes: this.buildAttributes(fileName, url), - }); - } catch (error) { - this.logger.warn( - `Failed to fetch URL ${url}: ${toErrorMessage(error)}`, - ); - } - } - - return documents; - } - - private async fetchFromGitHub( - source: GitHubSource, - ): Promise { - const documents: FetchedDocument[] = []; - const branch = source.branch || 'main'; - const repoPath = source.path || ''; - const patterns = source.patterns || [ - '**/*.md', - '**/*.txt', - '**/*.yaml', - '**/*.json', - ]; - - try { - if (!GITHUB_REPO_PATTERN.test(source.repo)) { - throw new Error( - `Invalid GitHub repo format "${source.repo}". Expected "owner/repo".`, - ); - } - - const apiUrl = `https://api.github.com/repos/${source.repo}/git/trees/${branch}?recursive=1`; - - const headers: Record = { - Accept: 'application/vnd.github.v3+json', - 'User-Agent': 'augment-backstage-plugin', - }; - - if (source.token) { - headers.Authorization = `Bearer ${source.token}`; - } - - const response = await fetch(apiUrl, { - signal: AbortSignal.timeout(30_000), - headers, - }); - - if (!response.ok) { - throw new Error( - `GitHub API error: ${response.status} ${response.statusText}`, - ); - } - - const data = (await response.json()) as { - tree: Array<{ path: string; type: string; url: string }>; - }; - - const files = data.tree.filter(item => { - if (item.type !== 'blob') return false; - if (repoPath && !item.path.startsWith(repoPath)) return false; - return matchesPatterns(item.path, patterns); - }); - - this.logger.info( - `Found ${files.length} matching files in ${source.repo}`, - ); - - for (const file of files) { - try { - const rawUrl = `https://raw.githubusercontent.com/${source.repo}/${branch}/${file.path}`; - - const fileResponse = await fetch(rawUrl, { - signal: AbortSignal.timeout(30_000), - headers: source.token - ? { Authorization: `Bearer ${source.token}` } - : {}, - }); - - if (!fileResponse.ok) { - throw new Error(`HTTP ${fileResponse.status}`); - } - - const content = await fileResponse.text(); - const fileName = path.basename(file.path); - const githubHtmlUrl = `https://github.com/${source.repo}/blob/${branch}/${file.path}`; - - documents.push({ - sourceId: `github:${source.repo}:${file.path}`, - fileName, - content, - sourceType: 'github', - contentHash: this.computeContentHash(content), - attributes: this.buildAttributes( - fileName, - githubHtmlUrl, - path.dirname(file.path) || source.repo, - ), - }); - } catch (error) { - this.logger.warn( - `Failed to fetch ${file.path} from GitHub: ${toErrorMessage( - error, - )}`, - ); - } - } - } catch (error) { - this.logger.error( - `Failed to list files from GitHub repo ${source.repo}: ${toErrorMessage( - error, - )}`, - ); - } - - return documents; - } - - private async findFiles( - basePath: string, - patterns: string[], - ): Promise { - const files: string[] = []; - const normalizedBase = await fs.promises.realpath(basePath); - - const walkDir = async (dir: string): Promise => { - const entries = await fs.promises.readdir(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - let realFullPath: string; - try { - realFullPath = await fs.promises.realpath(fullPath); - } catch { - this.logger.debug('realpath resolution failed, using original path'); - continue; - } - if ( - !realFullPath.startsWith(normalizedBase + path.sep) && - realFullPath !== normalizedBase - ) { - this.logger.debug( - `Skipping "${entry.name}": resolves outside base directory`, - ); - continue; - } - - if (entry.isDirectory()) { - if (!['node_modules', '.git', 'dist', 'build'].includes(entry.name)) { - await walkDir(fullPath); - } - } else if (entry.isFile()) { - const relativePath = path.relative(basePath, fullPath); - if (matchesPatterns(relativePath, patterns)) { - files.push(fullPath); - } - } - } - }; - - await walkDir(basePath); - return files; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/McpTestService.ts b/workspaces/augment/plugins/augment-backend/src/services/McpTestService.ts deleted file mode 100644 index 49fb6b4866d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/McpTestService.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { isPrivateUrlWithDns } from './utils/SsrfGuard'; -import { listMcpServerTools } from './utils/mcpClient'; - -export interface McpTestResult { - success: boolean; - warning?: string; - error?: string; - serverType: string; - tools: Array<{ name: string; description?: string }>; - toolCount: number; -} - -const BLOCKED_HEADERS = new Set([ - 'host', - 'content-type', - 'content-length', - 'accept', - 'transfer-encoding', - 'connection', - 'cookie', -]); - -/** - * Tests MCP server connections using the official @modelcontextprotocol/sdk. - * Handles the full protocol lifecycle: connect, list tools, close. - */ -export class McpTestService { - constructor( - private readonly skipTls: boolean, - private readonly logger: LoggerService, - ) {} - - async testConnection( - url: string, - type?: string, - headers?: Record, - ): Promise { - const ssrfReason = await isPrivateUrlWithDns(url); - if (ssrfReason) { - return { - success: false, - error: - 'URLs pointing to private/internal network addresses are not allowed', - serverType: type || 'streamable-http', - tools: [], - toolCount: 0, - }; - } - - try { - const sanitizedHeaders: Record = {}; - if (headers) { - for (const [key, value] of Object.entries(headers)) { - if ( - typeof value === 'string' && - !BLOCKED_HEADERS.has(key.toLowerCase()) - ) { - sanitizedHeaders[key] = value; - } - } - } - - const tools = await listMcpServerTools(url, { - headers: sanitizedHeaders, - skipTlsVerify: this.skipTls, - clientName: 'augment-test', - }); - - return { - success: true, - serverType: type || 'streamable-http', - tools: tools.map(t => ({ name: t.name, description: t.description })), - toolCount: tools.length, - }; - } catch (err) { - this.logger.debug('MCP test connection failed', { - url, - error: String(err), - }); - return { - success: false, - error: `Connection failed: ${err instanceof Error ? err.message : String(err)}`, - serverType: type || 'streamable-http', - tools: [], - toolCount: 0, - }; - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/ResponsesApiClient.ts b/workspaces/augment/plugins/augment-backend/src/services/ResponsesApiClient.ts deleted file mode 100644 index 1f0b0365f07..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/ResponsesApiClient.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { - ResponsesApiClient, - ResponsesApiError, - LlamaStackApiError, - type ResponsesApiRequestOptions, -} from '../providers/responses-api/client/ResponsesApiClient'; diff --git a/workspaces/augment/plugins/augment-backend/src/services/RuntimeConfigResolver.integration.test.ts b/workspaces/augment/plugins/augment-backend/src/services/RuntimeConfigResolver.integration.test.ts deleted file mode 100644 index a4ed3e13c8e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/RuntimeConfigResolver.integration.test.ts +++ /dev/null @@ -1,488 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Integration test: AdminConfigService → RuntimeConfigResolver → EffectiveConfig - * - * Uses a real in-memory SQLite database to verify the full pipeline: - * UI save (PUT) → AdminConfigService.set() → DB - * → RuntimeConfigResolver.resolve() → EffectiveConfig with override - * → AdminConfigService.delete() → RuntimeConfigResolver.resolve() → YAML baseline - * - * This proves that every admin-configurable key actually flows through - * from the database into the resolved config used at runtime. - */ -import knex, { type Knex } from 'knex'; -import type { DatabaseService } from '@backstage/backend-plugin-api'; -import { AdminConfigService } from './AdminConfigService'; -import { RuntimeConfigResolver } from './RuntimeConfigResolver'; -import { validateAdminConfigValue } from './utils/configValidation'; -import type { ConfigLoader } from '../providers/llamastack/ConfigLoader'; -import type { LlamaStackConfig } from '../types'; -import type { AdminConfigKey } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { isProviderScopedKey } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { createMockLogger } from '../test-utils'; - -const BASELINE_CONFIG: LlamaStackConfig = { - baseUrl: 'https://yaml-server:8321', - model: 'yaml-model/Llama-3-8B', - vectorStoreIds: ['yaml-vs-1'], - vectorStoreName: 'yaml-store', - embeddingModel: 'yaml-embedding', - embeddingDimension: 384, - chunkingStrategy: 'auto' as const, - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - skipTlsVerify: false, - verboseStreamLogging: false, - zdrMode: false, - enableWebSearch: false, - enableCodeInterpreter: false, -}; - -const BASELINE_SYSTEM_PROMPT = 'YAML system prompt'; - -function createMockConfigLoader(): ConfigLoader { - return { - loadLlamaStackConfig: jest.fn().mockReturnValue({ ...BASELINE_CONFIG }), - loadSystemPrompt: jest.fn().mockReturnValue(BASELINE_SYSTEM_PROMPT), - validateRequiredConfig: jest.fn(), - loadSecurityConfig: jest.fn(), - loadDocumentsConfig: jest.fn(), - loadMcpAuthConfigs: jest.fn(), - loadMcpServerConfigs: jest.fn().mockReturnValue([]), - loadBrandingOverrides: jest.fn().mockReturnValue({}), - loadWorkflows: jest.fn(), - loadQuickActions: jest.fn(), - loadPromptGroups: jest.fn(), - loadAgentConfigs: jest.fn().mockReturnValue(undefined), - loadGuardrailShieldIds: jest.fn().mockReturnValue(undefined), - loadPostToolInstructions: jest.fn().mockReturnValue(undefined), - } as unknown as ConfigLoader; -} - -describe('Config Pipeline Integration: UI → DB → Resolver → EffectiveConfig', () => { - let db: Knex; - let adminConfig: AdminConfigService; - let resolver: RuntimeConfigResolver; - const logger = createMockLogger(); - - beforeAll(async () => { - db = knex({ - client: 'better-sqlite3', - connection: ':memory:', - useNullAsDefault: true, - }); - - const mockDatabase: DatabaseService = { - getClient: async () => db, - } as unknown as DatabaseService; - - adminConfig = new AdminConfigService(mockDatabase, logger); - await adminConfig.initialize(); - - resolver = new RuntimeConfigResolver({ - configLoader: createMockConfigLoader(), - adminConfig, - logger, - cacheTtlMs: 0, - }); - }); - - afterAll(async () => { - await db.destroy(); - }); - - afterEach(async () => { - await db('augment_admin_config').del(); - resolver.invalidateCache(); - }); - - /** - * Helper: simulates the full UI flow for a single config key. - * 1. Validates the value (just like PUT /admin/config/:key does) - * 2. Saves via AdminConfigService.set() - * 3. Invalidates cache (just like onConfigChanged does) - * 4. Resolves config and asserts the override is present - * 5. Deletes the key (simulates "Reset to Defaults") - * 6. Resolves config and asserts the baseline is restored - */ - async function testConfigRoundTrip( - key: AdminConfigKey, - uiValue: unknown, - assertOverride: (config: Record) => void, - assertBaseline: (config: Record) => void, - ) { - validateAdminConfigValue(key, uiValue); - if (isProviderScopedKey(key)) { - await adminConfig.setScopedValue( - key, - uiValue, - 'llamastack', - 'user:default/admin', - ); - } else { - await adminConfig.set(key, uiValue, 'user:default/admin'); - } - resolver.invalidateCache(); - - const overridden = await resolver.resolve(); - assertOverride(overridden as unknown as Record); - - if (isProviderScopedKey(key)) { - await adminConfig.deleteScopedValue(key, 'llamastack'); - } else { - await adminConfig.delete(key); - } - resolver.invalidateCache(); - - const reverted = await resolver.resolve(); - assertBaseline(reverted as unknown as Record); - } - - // ------------------------------------------------------------------------- - // Core LlamaStack configs - // ------------------------------------------------------------------------- - - it('model: UI override → revert to YAML', async () => { - await testConfigRoundTrip( - 'model', - 'ui-override-model', - c => expect(c.model).toBe('ui-override-model'), - c => expect(c.model).toBe(BASELINE_CONFIG.model), - ); - }); - - it('baseUrl: UI override → revert to YAML', async () => { - await testConfigRoundTrip( - 'baseUrl', - 'https://ui-server:9999', - c => expect(c.baseUrl).toBe('https://ui-server:9999'), - c => expect(c.baseUrl).toBe(BASELINE_CONFIG.baseUrl), - ); - }); - - it('systemPrompt: UI override → revert to YAML', async () => { - await testConfigRoundTrip( - 'systemPrompt', - 'UI custom prompt', - c => expect(c.systemPrompt).toBe('UI custom prompt'), - c => expect(c.systemPrompt).toBe(BASELINE_SYSTEM_PROMPT), - ); - }); - - it('toolChoice: UI override → revert to YAML', async () => { - await testConfigRoundTrip( - 'toolChoice', - 'required', - c => expect(c.toolChoice).toBe('required'), - c => expect(c.toolChoice).toBeUndefined(), - ); - }); - - it('enableWebSearch: UI override → revert to YAML', async () => { - await testConfigRoundTrip( - 'enableWebSearch', - true, - c => expect(c.enableWebSearch).toBe(true), - c => expect(c.enableWebSearch).toBe(false), - ); - }); - - it('enableCodeInterpreter: UI override → revert to YAML', async () => { - await testConfigRoundTrip( - 'enableCodeInterpreter', - true, - c => expect(c.enableCodeInterpreter).toBe(true), - c => expect(c.enableCodeInterpreter).toBe(false), - ); - }); - - // ------------------------------------------------------------------------- - // Branding & UI-facing configs - // ------------------------------------------------------------------------- - - it('branding: UI override → revert to YAML', async () => { - const brandingOverride = { appName: 'UI App', primaryColor: '#ff0000' }; - await testConfigRoundTrip( - 'branding', - brandingOverride, - c => expect(c.branding).toEqual(brandingOverride), - c => expect(c.branding).toBeUndefined(), - ); - }); - - // ------------------------------------------------------------------------- - // Safety configs (destructive patterns) - // ------------------------------------------------------------------------- - - it('safetyPatterns: UI override → revert to YAML', async () => { - await testConfigRoundTrip( - 'safetyPatterns', - ['DROP TABLE', 'rm -rf'], - c => expect(c.safetyPatterns).toEqual(['DROP TABLE', 'rm -rf']), - c => expect(c.safetyPatterns).toBeUndefined(), - ); - }); - - // ------------------------------------------------------------------------- - // MCP Servers - // ------------------------------------------------------------------------- - - it('mcpServers: UI override → revert to YAML', async () => { - const servers = [ - { - id: 'ui-mcp', - name: 'UI MCP', - url: 'https://mcp.ui.test/sse', - type: 'sse', - }, - ]; - await testConfigRoundTrip( - 'mcpServers', - servers, - c => expect(c.mcpServers).toEqual(servers), - c => expect(c.mcpServers).toBeUndefined(), - ); - }); - - // ------------------------------------------------------------------------- - // Vector Store configs - // ------------------------------------------------------------------------- - - it('activeVectorStoreIds: UI override → revert to YAML', async () => { - await testConfigRoundTrip( - 'activeVectorStoreIds', - ['ui-vs-1', 'ui-vs-2'], - c => expect(c.vectorStoreIds).toEqual(['ui-vs-1', 'ui-vs-2']), - c => expect(c.vectorStoreIds).toEqual(BASELINE_CONFIG.vectorStoreIds), - ); - }); - - it('vectorStoreConfig: UI override → revert to YAML', async () => { - await testConfigRoundTrip( - 'vectorStoreConfig', - { embeddingModel: 'ui-embedding', searchMode: 'hybrid', bm25Weight: 0.4 }, - c => { - expect(c.embeddingModel).toBe('ui-embedding'); - expect(c.searchMode).toBe('hybrid'); - expect(c.bm25Weight).toBe(0.4); - }, - c => { - expect(c.embeddingModel).toBe(BASELINE_CONFIG.embeddingModel); - expect(c.searchMode).toBeUndefined(); - expect(c.bm25Weight).toBeUndefined(); - }, - ); - }); - - // ------------------------------------------------------------------------- - // Safety shield configs (Llama Stack Safety API) - // ------------------------------------------------------------------------- - - it('safetyEnabled: UI toggle on → revert to undefined', async () => { - await testConfigRoundTrip( - 'safetyEnabled', - true, - c => expect(c.safetyEnabled).toBe(true), - c => expect(c.safetyEnabled).toBeUndefined(), - ); - }); - - it('safetyEnabled: UI toggle off explicitly', async () => { - await testConfigRoundTrip( - 'safetyEnabled', - false, - c => expect(c.safetyEnabled).toBe(false), - c => expect(c.safetyEnabled).toBeUndefined(), - ); - }); - - it('inputShields: UI override → revert to undefined', async () => { - await testConfigRoundTrip( - 'inputShields', - ['content_safety', 'prompt_guard'], - c => expect(c.inputShields).toEqual(['content_safety', 'prompt_guard']), - c => expect(c.inputShields).toBeUndefined(), - ); - }); - - it('outputShields: UI override → revert to undefined', async () => { - await testConfigRoundTrip( - 'outputShields', - ['output_filter'], - c => expect(c.outputShields).toEqual(['output_filter']), - c => expect(c.outputShields).toBeUndefined(), - ); - }); - - // ------------------------------------------------------------------------- - // Evaluation configs (Llama Stack Scoring API) - // ------------------------------------------------------------------------- - - it('evaluationEnabled: UI toggle on → revert to undefined', async () => { - await testConfigRoundTrip( - 'evaluationEnabled', - true, - c => expect(c.evaluationEnabled).toBe(true), - c => expect(c.evaluationEnabled).toBeUndefined(), - ); - }); - - it('scoringFunctions: UI override → revert to undefined', async () => { - await testConfigRoundTrip( - 'scoringFunctions', - ['basic::subset_of', 'braintrust::faithfulness'], - c => - expect(c.scoringFunctions).toEqual([ - 'basic::subset_of', - 'braintrust::faithfulness', - ]), - c => expect(c.scoringFunctions).toBeUndefined(), - ); - }); - - it('minScoreThreshold: UI override → revert to undefined', async () => { - await testConfigRoundTrip( - 'minScoreThreshold', - 0.85, - c => expect(c.minScoreThreshold).toBe(0.85), - c => expect(c.minScoreThreshold).toBeUndefined(), - ); - }); - - // ------------------------------------------------------------------------- - // Error-handling configs - // ------------------------------------------------------------------------- - - it('safetyOnError: UI override → revert to undefined', async () => { - await testConfigRoundTrip( - 'safetyOnError', - 'block', - c => expect(c.safetyOnError).toBe('block'), - c => expect(c.safetyOnError).toBeUndefined(), - ); - }); - - it('evaluationOnError: UI override → revert to undefined', async () => { - await testConfigRoundTrip( - 'evaluationOnError', - 'fail', - c => expect(c.evaluationOnError).toBe('fail'), - c => expect(c.evaluationOnError).toBeUndefined(), - ); - }); - - // ------------------------------------------------------------------------- - // MCP Server management: disabledMcpServerIds filters mcpServers - // ------------------------------------------------------------------------- - - it('disabledMcpServerIds: disabling an MCP server excludes it from resolved mcpServers', async () => { - const servers = [ - { id: 'server-a', name: 'A', url: 'https://a.test/sse', type: 'sse' }, - { id: 'server-b', name: 'B', url: 'https://b.test/sse', type: 'sse' }, - ]; - await adminConfig.set('mcpServers', servers, 'admin'); - await adminConfig.set('disabledMcpServerIds', ['server-a'], 'admin'); - resolver.invalidateCache(); - - const config = await resolver.resolve(); - const resolved = config as unknown as Record; - const mcpServers = resolved.mcpServers as Array<{ id: string }>; - expect(mcpServers).toBeDefined(); - expect(mcpServers.map(s => s.id)).not.toContain('server-a'); - expect(mcpServers.map(s => s.id)).toContain('server-b'); - - await adminConfig.delete('disabledMcpServerIds'); - await adminConfig.delete('mcpServers'); - resolver.invalidateCache(); - - const reverted = await resolver.resolve(); - expect(reverted.mcpServers).toBeUndefined(); - }); - - // ------------------------------------------------------------------------- - // Prompt groups: not part of EffectiveConfig — consumed via direct DB read - // in the /prompt-groups route handler. Verified in adminRoutes.test.ts. - // ------------------------------------------------------------------------- - - // ------------------------------------------------------------------------- - // Cache invalidation (simulates what happens after UI save) - // ------------------------------------------------------------------------- - - it('cache invalidation makes new values available immediately', async () => { - await adminConfig.setScopedValue( - 'model', - 'first-model', - 'llamastack', - 'admin', - ); - resolver.invalidateCache(); - const c1 = await resolver.resolve(); - expect(c1.model).toBe('first-model'); - - await adminConfig.setScopedValue( - 'model', - 'second-model', - 'llamastack', - 'admin', - ); - resolver.invalidateCache(); - const c2 = await resolver.resolve(); - expect(c2.model).toBe('second-model'); - }); - - // ------------------------------------------------------------------------- - // Multiple overrides at once - // ------------------------------------------------------------------------- - - it('multiple UI overrides coexist correctly', async () => { - await adminConfig.setScopedValue( - 'model', - 'multi-model', - 'llamastack', - 'admin', - ); - await adminConfig.set('systemPrompt', 'Multi prompt', 'admin'); - await adminConfig.setScopedValue( - 'safetyEnabled', - true, - 'llamastack', - 'admin', - ); - await adminConfig.setScopedValue( - 'evaluationEnabled', - true, - 'llamastack', - 'admin', - ); - await adminConfig.setScopedValue( - 'minScoreThreshold', - 0.9, - 'llamastack', - 'admin', - ); - resolver.invalidateCache(); - - const config = await resolver.resolve(); - expect(config.model).toBe('multi-model'); - expect(config.systemPrompt).toBe('Multi prompt'); - expect(config.safetyEnabled).toBe(true); - expect(config.evaluationEnabled).toBe(true); - expect(config.minScoreThreshold).toBe(0.9); - expect(config.baseUrl).toBe(BASELINE_CONFIG.baseUrl); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/RuntimeConfigResolver.test.ts b/workspaces/augment/plugins/augment-backend/src/services/RuntimeConfigResolver.test.ts deleted file mode 100644 index e16d2542fe6..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/RuntimeConfigResolver.test.ts +++ /dev/null @@ -1,1089 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { RuntimeConfigResolver } from './RuntimeConfigResolver'; -import type { ConfigLoader } from '../providers/llamastack/ConfigLoader'; -import type { AdminConfigService } from './AdminConfigService'; -import type { LlamaStackConfig } from '../types'; -import { createMockLogger } from '../test-utils'; - -function createMockLlamaStackConfig( - overrides?: Partial, -): LlamaStackConfig { - return { - baseUrl: 'https://llama.example.com', - model: 'meta-llama/Llama-3-8B', - vectorStoreIds: ['vs-123'], - vectorStoreName: 'default-store', - embeddingModel: 'all-MiniLM-L6-v2', - embeddingDimension: 384, - chunkingStrategy: 'auto', - maxChunkSizeTokens: 512, - chunkOverlapTokens: 50, - skipTlsVerify: false, - verboseStreamLogging: false, - zdrMode: false, - enableWebSearch: false, - enableCodeInterpreter: false, - ...overrides, - }; -} - -function createMockConfigLoader( - config?: Partial, - systemPrompt?: string, -): ConfigLoader { - return { - loadLlamaStackConfig: jest - .fn() - .mockReturnValue(createMockLlamaStackConfig(config)), - loadSystemPrompt: jest - .fn() - .mockReturnValue(systemPrompt ?? 'You are a helpful assistant.'), - validateRequiredConfig: jest.fn(), - loadSecurityConfig: jest.fn(), - loadDocumentsConfig: jest.fn(), - loadMcpAuthConfigs: jest.fn(), - loadMcpServerConfigs: jest.fn().mockReturnValue([]), - loadBrandingOverrides: jest.fn().mockReturnValue({}), - loadWorkflows: jest.fn(), - loadQuickActions: jest.fn(), - loadPromptGroups: jest.fn(), - loadAgentConfigs: jest.fn().mockReturnValue(undefined), - loadGuardrailShieldIds: jest.fn().mockReturnValue(undefined), - loadPostToolInstructions: jest.fn().mockReturnValue(undefined), - } as unknown as ConfigLoader; -} - -function createMockAdminConfig( - store: Record = {}, -): AdminConfigService { - return { - get: jest.fn(async (key: string) => store[key]), - getScopedValue: jest.fn(async (key: string, providerId: string) => { - const scopedKey = `${providerId}::${key}`; - return store[scopedKey] ?? store[key]; - }), - set: jest.fn(), - delete: jest.fn(), - getEntry: jest.fn(), - listAll: jest.fn(), - initialize: jest.fn(), - } as unknown as AdminConfigService; -} - -describe('RuntimeConfigResolver', () => { - describe('resolve (YAML baseline only)', () => { - it('returns baseline config when no DB overrides exist', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig(); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - - expect(config.model).toBe('meta-llama/Llama-3-8B'); - expect(config.baseUrl).toBe('https://llama.example.com'); - expect(config.systemPrompt).toBe('You are a helpful assistant.'); - expect(config.vectorStoreIds).toEqual(['vs-123']); - expect(config.enableWebSearch).toBe(false); - expect(config.enableCodeInterpreter).toBe(false); - expect(config.embeddingModel).toBe('all-MiniLM-L6-v2'); - expect(config.chunkingStrategy).toBe('auto'); - }); - - it('copies vectorStoreIds to prevent mutation of baseline', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader({ vectorStoreIds: ['a'] }); - const adminConfig = createMockAdminConfig(); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - config.vectorStoreIds.push('b'); - - const config2 = await resolver.resolve(); - expect(config2.vectorStoreIds).toEqual(['a']); - }); - }); - - describe('resolve (DB overrides)', () => { - it('DB model override wins over YAML', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ - model: 'gpt-4o', - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.model).toBe('gpt-4o'); - expect(config.baseUrl).toBe('https://llama.example.com'); - }); - - it('DB systemPrompt override wins over YAML', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ - systemPrompt: 'Custom prompt from admin.', - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.systemPrompt).toBe('Custom prompt from admin.'); - }); - - it('DB baseUrl override wins over YAML', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ - baseUrl: 'https://new-server:8321', - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.baseUrl).toBe('https://new-server:8321'); - }); - - it('DB boolean overrides are applied', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ - enableWebSearch: true, - enableCodeInterpreter: true, - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.enableWebSearch).toBe(true); - expect(config.enableCodeInterpreter).toBe(true); - }); - - it('DB toolChoice string override is applied', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ - toolChoice: 'required', - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.toolChoice).toBe('required'); - }); - - it('DB activeVectorStoreIds override replaces YAML', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader({ - vectorStoreIds: ['original'], - }); - const adminConfig = createMockAdminConfig({ - activeVectorStoreIds: ['new-store-1', 'new-store-2'], - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.vectorStoreIds).toEqual(['new-store-1', 'new-store-2']); - }); - - it('DB vectorStoreConfig overrides individual fields', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ - vectorStoreConfig: { - embeddingModel: 'custom-model', - embeddingDimension: 768, - searchMode: 'hybrid', - bm25Weight: 0.3, - }, - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.embeddingModel).toBe('custom-model'); - expect(config.embeddingDimension).toBe(768); - expect(config.searchMode).toBe('hybrid'); - expect(config.bm25Weight).toBe(0.3); - expect(config.chunkingStrategy).toBe('auto'); - }); - - it('DB safetyPatterns and branding overrides are applied', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ - safetyPatterns: ['drop', 'truncate'], - branding: { appName: 'Custom App' }, - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.safetyPatterns).toEqual(['drop', 'truncate']); - expect(config.branding).toEqual({ appName: 'Custom App' }); - }); - - it('DB mcpServers override is applied', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const servers = [ - { - id: 'mcp-1', - name: 'Test', - url: 'https://mcp.test/sse', - type: 'sse' as const, - }, - ]; - const adminConfig = createMockAdminConfig({ mcpServers: servers }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.mcpServers).toEqual(servers); - }); - - it('merges YAML and DB MCP servers (DB overrides url/name/type, YAML auth preserved)', async () => { - const logger = createMockLogger(); - const yamlServer = { - id: 'yaml-srv', - name: 'YAML Server', - url: 'https://yaml.test', - type: 'streamable-http' as const, - headers: { Authorization: 'Bearer secret' }, - }; - const configLoader = createMockConfigLoader(); - (configLoader.loadMcpServerConfigs as jest.Mock).mockReturnValue([ - yamlServer, - ]); - - const dbServers = [ - { - id: 'yaml-srv', - name: 'Updated Name', - url: 'https://new-url.test', - type: 'sse' as const, - }, - { - id: 'admin-srv', - name: 'Admin Server', - url: 'https://admin.test', - type: 'sse' as const, - }, - ]; - const adminConfig = createMockAdminConfig({ mcpServers: dbServers }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.mcpServers).toHaveLength(2); - - const overridden = config.mcpServers![0]; - expect(overridden.id).toBe('yaml-srv'); - expect(overridden.name).toBe('Updated Name'); - expect(overridden.url).toBe('https://new-url.test'); - expect(overridden.type).toBe('sse'); - // YAML-only auth fields are preserved - expect(overridden.headers).toEqual({ Authorization: 'Bearer secret' }); - - expect(config.mcpServers![1].id).toBe('admin-srv'); - }); - - it('merges requireApproval from DB override onto YAML server', async () => { - const logger = createMockLogger(); - const yamlServer = { - id: 'yaml-srv', - name: 'YAML Server', - url: 'https://yaml.test', - type: 'streamable-http' as const, - }; - const configLoader = createMockConfigLoader(); - (configLoader.loadMcpServerConfigs as jest.Mock).mockReturnValue([ - yamlServer, - ]); - - const dbServers = [ - { - id: 'yaml-srv', - name: 'YAML Server', - url: 'https://yaml.test', - type: 'streamable-http' as const, - requireApproval: 'always' as const, - }, - ]; - const adminConfig = createMockAdminConfig({ mcpServers: dbServers }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.mcpServers).toHaveLength(1); - expect(config.mcpServers![0].requireApproval).toBe('always'); - }); - - it('preserves YAML requireApproval when DB override does not set it', async () => { - const logger = createMockLogger(); - const yamlServer = { - id: 'yaml-srv', - name: 'YAML Server', - url: 'https://yaml.test', - type: 'streamable-http' as const, - requireApproval: 'always' as const, - }; - const configLoader = createMockConfigLoader(); - (configLoader.loadMcpServerConfigs as jest.Mock).mockReturnValue([ - yamlServer, - ]); - - const dbServers = [ - { - id: 'yaml-srv', - name: 'Updated Name', - url: 'https://new.test', - type: 'streamable-http' as const, - }, - ]; - const adminConfig = createMockAdminConfig({ mcpServers: dbServers }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.mcpServers).toHaveLength(1); - expect(config.mcpServers![0].name).toBe('Updated Name'); - expect(config.mcpServers![0].requireApproval).toBe('always'); - }); - - it('filters out disabled YAML servers', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - (configLoader.loadMcpServerConfigs as jest.Mock).mockReturnValue([ - { id: 'srv-a', name: 'A', url: 'https://a.test', type: 'sse' as const }, - { id: 'srv-b', name: 'B', url: 'https://b.test', type: 'sse' as const }, - ]); - - const adminConfig = createMockAdminConfig({ - disabledMcpServerIds: ['srv-a'], - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.mcpServers).toHaveLength(1); - expect(config.mcpServers![0].id).toBe('srv-b'); - }); - - it('DB safetyEnabled override is applied', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ safetyEnabled: true }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.safetyEnabled).toBe(true); - }); - - it('DB safetyEnabled=false override disables safety', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ safetyEnabled: false }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.safetyEnabled).toBe(false); - }); - - it('DB inputShields override is applied', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ - inputShields: ['content_safety', 'prompt_guard'], - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.inputShields).toEqual(['content_safety', 'prompt_guard']); - }); - - it('DB outputShields override is applied', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ - outputShields: ['output_filter'], - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.outputShields).toEqual(['output_filter']); - }); - - it('DB evaluationEnabled override is applied', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ evaluationEnabled: true }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.evaluationEnabled).toBe(true); - }); - - it('DB scoringFunctions override is applied', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ - scoringFunctions: ['basic::subset_of', 'braintrust::faithfulness'], - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.scoringFunctions).toEqual([ - 'basic::subset_of', - 'braintrust::faithfulness', - ]); - }); - - it('DB minScoreThreshold override is applied', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ minScoreThreshold: 0.85 }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.minScoreThreshold).toBe(0.85); - }); - - it('safety/eval overrides are absent when not set in DB', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({}); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.safetyEnabled).toBeUndefined(); - expect(config.inputShields).toBeUndefined(); - expect(config.outputShields).toBeUndefined(); - expect(config.evaluationEnabled).toBeUndefined(); - expect(config.scoringFunctions).toBeUndefined(); - expect(config.minScoreThreshold).toBeUndefined(); - }); - }); - - describe('caching', () => { - it('returns cached config within TTL window', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig(); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 60000, - }); - - const config1 = await resolver.resolve(); - const config2 = await resolver.resolve(); - - expect(config1).toEqual(config2); - expect(configLoader.loadLlamaStackConfig).toHaveBeenCalledTimes(1); - }); - - it('refreshes cache after TTL expires', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig(); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 1, - }); - - const config1 = await resolver.resolve(); - await new Promise(r => setTimeout(r, 5)); - const config2 = await resolver.resolve(); - - expect(config1).not.toBe(config2); - expect(configLoader.loadLlamaStackConfig).toHaveBeenCalledTimes(2); - }); - - it('invalidateCache forces a fresh resolve', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig(); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 60000, - }); - - await resolver.resolve(); - expect(configLoader.loadLlamaStackConfig).toHaveBeenCalledTimes(1); - - resolver.invalidateCache(); - await resolver.resolve(); - expect(configLoader.loadLlamaStackConfig).toHaveBeenCalledTimes(2); - }); - }); - - describe('graceful degradation', () => { - it('returns YAML baseline when AdminConfigService throws', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = { - get: jest.fn().mockRejectedValue(new Error('DB connection lost')), - } as unknown as AdminConfigService; - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - - expect(config.model).toBe('meta-llama/Llama-3-8B'); - expect(config.baseUrl).toBe('https://llama.example.com'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('DB connection lost'), - ); - }); - - it('isolates per-key failures so other keys still apply', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - let callCount = 0; - const keyHandler = async (key: string) => { - callCount++; - if (key === 'model') throw new Error('Corrupt row'); - if (key === 'systemPrompt') return 'From DB'; - return undefined; - }; - const adminConfig = { - get: jest.fn(keyHandler), - getScopedValue: jest.fn(keyHandler), - } as unknown as AdminConfigService; - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - - expect(config.model).toBe('meta-llama/Llama-3-8B'); - expect(config.systemPrompt).toBe('From DB'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('Corrupt row'), - ); - }); - }); - - describe('delete reverts to YAML', () => { - it('returns YAML baseline value when DB key is absent', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader( - { model: 'yaml-model' }, - 'yaml-prompt', - ); - const adminConfig = createMockAdminConfig({}); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.model).toBe('yaml-model'); - expect(config.systemPrompt).toBe('yaml-prompt'); - }); - }); - - describe('concurrency', () => { - it('concurrent resolve() calls share a single in-flight build', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = { - get: jest.fn(async () => { - await new Promise(r => setTimeout(r, 50)); - return undefined; - }), - } as unknown as AdminConfigService; - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 60000, - }); - - const [c1, c2] = await Promise.all([ - resolver.resolve(), - resolver.resolve(), - ]); - - expect(c1).toEqual(c2); - expect(configLoader.loadLlamaStackConfig).toHaveBeenCalledTimes(1); - }); - - it('invalidateCache() during in-flight build prevents stale caching', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - let resolveGate: () => void; - const gate = new Promise(r => { - resolveGate = r; - }); - const adminConfig = { - get: jest.fn(async (key: string) => { - if (key === 'model') { - await gate; - return 'stale-model'; - } - return undefined; - }), - } as unknown as AdminConfigService; - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 60000, - }); - - const p1 = resolver.resolve(); - resolver.invalidateCache(); - resolveGate!(); - await p1; - - // The stale result should NOT have been cached (generation mismatch), - // so the next resolve() must trigger a fresh build. - (adminConfig.get as jest.Mock).mockResolvedValue(undefined); - await resolver.resolve(); - expect(configLoader.loadLlamaStackConfig).toHaveBeenCalledTimes(2); - }); - }); - - describe('cache snapshot isolation', () => { - it('mutating returned config does not corrupt the cache', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader({ - vectorStoreIds: ['original'], - }); - const adminConfig = createMockAdminConfig(); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 60000, - }); - - const config1 = await resolver.resolve(); - config1.vectorStoreIds.push('injected'); - config1.model = 'corrupted'; - - const config2 = await resolver.resolve(); - expect(config2.vectorStoreIds).toEqual(['original']); - expect(config2.model).toBe('meta-llama/Llama-3-8B'); - }); - }); - - describe('type safety', () => { - it('rejects wrong-type DB value for model (number instead of string)', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ model: 42 }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.model).toBe('meta-llama/Llama-3-8B'); - }); - - it('rejects wrong-type DB value for enableWebSearch (string instead of boolean)', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ - enableWebSearch: 'yes', - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.enableWebSearch).toBe(false); - }); - - it('rejects wrong-type DB value for minScoreThreshold (string instead of number)', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ - minScoreThreshold: 'high', - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.minScoreThreshold).toBeUndefined(); - }); - }); - - describe('vectorStoreConfig completeness', () => { - it('all 11 vectorStoreConfig sub-fields override correctly', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig({ - vectorStoreConfig: { - embeddingModel: 'custom-embed', - embeddingDimension: 1024, - searchMode: 'hybrid', - bm25Weight: 0.3, - semanticWeight: 0.7, - chunkingStrategy: 'static', - maxChunkSizeTokens: 256, - chunkOverlapTokens: 25, - fileSearchMaxResults: 10, - fileSearchScoreThreshold: 0.8, - vectorStoreName: 'custom-store', - }, - }); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.embeddingModel).toBe('custom-embed'); - expect(config.embeddingDimension).toBe(1024); - expect(config.searchMode).toBe('hybrid'); - expect(config.bm25Weight).toBe(0.3); - expect(config.semanticWeight).toBe(0.7); - expect(config.chunkingStrategy).toBe('static'); - expect(config.maxChunkSizeTokens).toBe(256); - expect(config.chunkOverlapTokens).toBe(25); - expect(config.fileSearchMaxResults).toBe(10); - expect(config.fileSearchScoreThreshold).toBe(0.8); - expect(config.vectorStoreName).toBe('custom-store'); - }); - }); - - describe('cascading DB failure', () => { - it('DB completely down logs per-key warnings and returns baseline', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const dbError = new Error('ECONNREFUSED'); - const adminConfig = { - get: jest.fn().mockRejectedValue(dbError), - getScopedValue: jest.fn().mockRejectedValue(dbError), - } as unknown as AdminConfigService; - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - - expect(config.model).toBe('meta-llama/Llama-3-8B'); - expect(config.baseUrl).toBe('https://llama.example.com'); - expect(config.vectorStoreIds).toEqual(['vs-123']); - - const warnCalls = logger.warn.mock.calls.filter((call: unknown[]) => - (call[0] as string).includes('ECONNREFUSED'), - ); - expect(warnCalls.length).toBeGreaterThanOrEqual(15); - }); - }); - - describe('production parameters (guardrails, maxToolCalls, temperature, etc.)', () => { - it('passes maxToolCalls from YAML baseline', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader({ maxToolCalls: 25 }); - const adminConfig = createMockAdminConfig(); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.maxToolCalls).toBe(25); - }); - - it('passes maxOutputTokens from YAML baseline', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader({ maxOutputTokens: 4096 }); - const adminConfig = createMockAdminConfig(); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.maxOutputTokens).toBe(4096); - }); - - it('passes temperature from YAML baseline', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader({ temperature: 0.7 }); - const adminConfig = createMockAdminConfig(); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.temperature).toBe(0.7); - }); - - it('passes safetyIdentifier from YAML baseline', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader({ - safetyIdentifier: 'cts-platform', - }); - const adminConfig = createMockAdminConfig(); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.safetyIdentifier).toBe('cts-platform'); - }); - - it('loads guardrails from safety shield IDs', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - (configLoader.loadGuardrailShieldIds as jest.Mock).mockReturnValue([ - 'content_safety', - 'prompt_injection', - ]); - const adminConfig = createMockAdminConfig(); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.guardrails).toEqual(['content_safety', 'prompt_injection']); - }); - - it('omits guardrails when no shields are configured', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - (configLoader.loadGuardrailShieldIds as jest.Mock).mockReturnValue( - undefined, - ); - const adminConfig = createMockAdminConfig(); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.guardrails).toBeUndefined(); - }); - - it('production params default to undefined when not set', async () => { - const logger = createMockLogger(); - const configLoader = createMockConfigLoader(); - const adminConfig = createMockAdminConfig(); - - const resolver = new RuntimeConfigResolver({ - configLoader, - adminConfig, - logger, - cacheTtlMs: 0, - }); - - const config = await resolver.resolve(); - expect(config.maxToolCalls).toBeUndefined(); - expect(config.maxOutputTokens).toBeUndefined(); - expect(config.temperature).toBeUndefined(); - expect(config.safetyIdentifier).toBeUndefined(); - expect(config.guardrails).toBeUndefined(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/RuntimeConfigResolver.ts b/workspaces/augment/plugins/augment-backend/src/services/RuntimeConfigResolver.ts deleted file mode 100644 index cf4c710cdd9..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/RuntimeConfigResolver.ts +++ /dev/null @@ -1,531 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { - AdminConfigKey, - ProviderType, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { isProviderScopedKey } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ConfigLoader } from '../providers/llamastack/ConfigLoader'; -import type { AdminConfigService } from './AdminConfigService'; -import { CONFIG_CACHE_TTL_MS } from '../constants'; -import type { - EffectiveConfig, - ToolChoiceConfig, - MCPServerConfig, - AgentConfig, -} from '../types'; -import { toErrorMessage } from './utils'; - -/** - * Resolves the effective runtime configuration by merging - * the immutable YAML baseline with DB-persisted admin overrides. - * - * Design: - * - YAML is the baseline, loaded once at construction from ConfigLoader. - * - DB overrides are read per-resolve with a short-lived in-memory cache - * (default 5 s TTL) to avoid unnecessary DB pressure under high load. - * - On DB read failure the resolver logs a warning and returns the YAML - * baseline — a database outage never blocks chat. - * - After an admin save, call `invalidateCache()` for immediate effect. - */ -export class RuntimeConfigResolver { - private readonly configLoader: ConfigLoader; - private readonly adminConfig: AdminConfigService; - private readonly logger: LoggerService; - private readonly cacheTtlMs: number; - private readonly providerId: ProviderType; - - private cachedConfig: EffectiveConfig | null = null; - private cacheTimestamp = 0; - private inflightResolve: Promise | null = null; - /** Monotonically increasing counter to detect stale in-flight results */ - private generation = 0; - - constructor(options: { - configLoader: ConfigLoader; - adminConfig: AdminConfigService; - logger: LoggerService; - cacheTtlMs?: number; - providerId?: ProviderType; - }) { - this.configLoader = options.configLoader; - this.adminConfig = options.adminConfig; - this.logger = options.logger; - this.cacheTtlMs = options.cacheTtlMs ?? CONFIG_CACHE_TTL_MS; - this.providerId = options.providerId ?? 'llamastack'; - } - - /** - * Resolve the effective config. Returns cached value if within TTL. - * Concurrent callers share a single in-flight build to avoid - * redundant DB reads under high load. - */ - async resolve(): Promise { - const now = Date.now(); - if (this.cachedConfig && now - this.cacheTimestamp < this.cacheTtlMs) { - return this.snapshot(this.cachedConfig); - } - - if (this.inflightResolve !== null) { - return this.inflightResolve.then(c => this.snapshot(c)); - } - - const startGen = ++this.generation; - - this.inflightResolve = this.buildEffectiveConfig() - .then(config => { - // Only cache if no invalidation happened while we were building - if (this.generation === startGen) { - this.cachedConfig = config; - this.cacheTimestamp = Date.now(); - } - return this.snapshot(config); - }) - .finally(() => { - this.inflightResolve = null; - }); - - return this.inflightResolve; - } - - /** - * Return the last resolved config without triggering a new resolution. - * Returns null if no config has been resolved yet or if the cache has been invalidated. - */ - getCachedConfig(): EffectiveConfig | null { - return this.cachedConfig ? this.snapshot(this.cachedConfig) : null; - } - - /** - * Drop the cached config so the next resolve() re-reads from DB. - * Bumps the generation counter so any in-flight build won't - * cache its (now-stale) result. - */ - invalidateCache(): void { - this.cachedConfig = null; - this.cacheTimestamp = 0; - this.generation++; - } - - private async buildEffectiveConfig(): Promise { - const baseline = this.loadYamlBaseline(); - - try { - return await this.applyDbOverrides(baseline); - } catch (error) { - const msg = toErrorMessage(error); - this.logger.warn( - `Failed to read admin config overrides, using YAML baseline: ${msg}`, - ); - return baseline; - } - } - - /** - * Build an EffectiveConfig from YAML-only (no DB). - * This is synchronous because ConfigLoader reads from in-memory - * Backstage config which was loaded at startup. - */ - private loadYamlBaseline(): EffectiveConfig { - const ls = this.configLoader.loadLlamaStackConfig(); - const systemPrompt = this.configLoader.loadSystemPrompt(); - const yamlMcpServers = this.configLoader.loadMcpServerConfigs(); - const yamlBranding = this.configLoader.loadBrandingOverrides(); - - return { - model: ls.model, - baseUrl: ls.baseUrl, - systemPrompt, - toolChoice: ls.toolChoice, - parallelToolCalls: ls.parallelToolCalls, - textFormat: ls.textFormat, - enableWebSearch: ls.enableWebSearch ?? false, - enableCodeInterpreter: ls.enableCodeInterpreter ?? false, - fileSearchMaxResults: ls.fileSearchMaxResults, - fileSearchScoreThreshold: ls.fileSearchScoreThreshold, - vectorStoreIds: [...ls.vectorStoreIds], - vectorStoreName: ls.vectorStoreName, - embeddingModel: ls.embeddingModel, - embeddingDimension: ls.embeddingDimension ?? 384, - searchMode: ls.searchMode, - bm25Weight: ls.bm25Weight, - semanticWeight: ls.semanticWeight, - chunkingStrategy: ls.chunkingStrategy, - maxChunkSizeTokens: ls.maxChunkSizeTokens, - chunkOverlapTokens: ls.chunkOverlapTokens, - skipTlsVerify: ls.skipTlsVerify ?? false, - zdrMode: ls.zdrMode ?? false, - functions: ls.functions, - token: ls.token, - verboseStreamLogging: ls.verboseStreamLogging ?? false, - mcpServers: yamlMcpServers.length > 0 ? yamlMcpServers : undefined, - branding: Object.keys(yamlBranding).length > 0 ? yamlBranding : undefined, - reasoning: ls.reasoning, - toolScoping: ls.toolScoping, - maxToolCalls: ls.maxToolCalls, - maxOutputTokens: ls.maxOutputTokens, - temperature: ls.temperature, - safetyIdentifier: ls.safetyIdentifier, - truncation: ls.truncation, - postToolInstructions: this.configLoader.loadPostToolInstructions(), - ...this.loadGuardrailBaseline(), - ...this.loadAgentBaseline(), - }; - } - - /** - * Load guardrail shield IDs from YAML safety config. - * Merges inputShields + outputShields into the Responses API `guardrails` field - * for server-side enforcement, in addition to the SafetyService pre/post checks. - */ - private loadGuardrailBaseline(): Pick { - const shields = this.configLoader.loadGuardrailShieldIds(); - if (!shields || shields.length === 0) return {}; - return { guardrails: shields }; - } - - /** - * Load multi-agent config from YAML baseline. - * Returns an empty object if no agents are configured, so the - * spread into EffectiveConfig is a no-op for single-agent mode. - */ - private loadAgentBaseline(): Pick< - EffectiveConfig, - 'agents' | 'defaultAgent' | 'maxAgentTurns' - > { - const agentConfig = this.configLoader.loadAgentConfigs(); - if (!agentConfig) return {}; - return { - agents: agentConfig.agents, - defaultAgent: agentConfig.defaultAgent, - maxAgentTurns: agentConfig.maxAgentTurns, - }; - } - - /** - * Layer DB overrides on top of the baseline. All keys are read - * in parallel for performance; each read has its own error - * isolation via getTyped() so a corrupt key doesn't block others. - */ - private async applyDbOverrides( - baseline: EffectiveConfig, - ): Promise { - const config = { ...baseline }; - - const [ - model, - baseUrl, - systemPrompt, - toolChoice, - enableWebSearch, - enableCodeInterpreter, - vectorStoreIds, - vectorStoreConfig, - safetyPatterns, - dbBranding, - dbMcpServers, - disabledIds, - safetyEnabled, - inputShields, - outputShields, - safetyOnError, - evaluationEnabled, - scoringFunctions, - minScoreThreshold, - evaluationOnError, - dbAgents, - dbDefaultAgent, - dbMaxAgentTurns, - dbMaxToolCalls, - dbMaxOutputTokens, - dbTemperature, - dbSafetyIdentifier, - ] = await Promise.all([ - this.getTyped('model'), - this.getTyped('baseUrl'), - this.getTyped('systemPrompt'), - this.getTyped('toolChoice'), - this.getTyped('enableWebSearch'), - this.getTyped('enableCodeInterpreter'), - this.getTyped('activeVectorStoreIds'), - this.getTyped>('vectorStoreConfig'), - this.getTyped('safetyPatterns'), - this.getTyped< - Partial< - import('@red-hat-developer-hub/backstage-plugin-augment-common').BrandingConfig - > - >('branding'), - this.getTyped('mcpServers'), - this.getTyped('disabledMcpServerIds'), - this.getTyped('safetyEnabled'), - this.getTyped('inputShields'), - this.getTyped('outputShields'), - this.getTyped<'allow' | 'block'>('safetyOnError'), - this.getTyped('evaluationEnabled'), - this.getTyped('scoringFunctions'), - this.getTyped('minScoreThreshold'), - this.getTyped<'skip' | 'fail'>('evaluationOnError'), - this.getTyped>('agents'), - this.getTyped('defaultAgent'), - this.getTyped('maxAgentTurns'), - this.getTyped('maxToolCalls'), - this.getTyped('maxOutputTokens'), - this.getTyped('temperature'), - this.getTyped('safetyIdentifier'), - ]); - - if (typeof model === 'string') config.model = model; - if (typeof baseUrl === 'string') config.baseUrl = baseUrl; - if (typeof systemPrompt === 'string') config.systemPrompt = systemPrompt; - if (toolChoice !== undefined) config.toolChoice = toolChoice; - if (typeof enableWebSearch === 'boolean') - config.enableWebSearch = enableWebSearch; - if (typeof enableCodeInterpreter === 'boolean') - config.enableCodeInterpreter = enableCodeInterpreter; - if (vectorStoreIds !== undefined && Array.isArray(vectorStoreIds)) - config.vectorStoreIds = vectorStoreIds; - if (vectorStoreConfig !== undefined) { - this.applyVectorStoreOverrides(config, vectorStoreConfig); - } - if (safetyPatterns !== undefined) config.safetyPatterns = safetyPatterns; - if (dbBranding !== undefined) { - config.branding = { ...(config.branding ?? {}), ...dbBranding }; - } - if ( - dbMcpServers !== undefined || - (disabledIds !== undefined && Array.isArray(disabledIds)) - ) { - config.mcpServers = this.mergeMcpServers( - config.mcpServers ?? [], - Array.isArray(dbMcpServers) ? dbMcpServers : [], - Array.isArray(disabledIds) ? new Set(disabledIds) : new Set(), - ); - } - if (typeof safetyEnabled === 'boolean') - config.safetyEnabled = safetyEnabled; - if (inputShields !== undefined && Array.isArray(inputShields)) - config.inputShields = inputShields; - if (outputShields !== undefined && Array.isArray(outputShields)) - config.outputShields = outputShields; - if (safetyOnError === 'allow' || safetyOnError === 'block') - config.safetyOnError = safetyOnError; - if (typeof evaluationEnabled === 'boolean') - config.evaluationEnabled = evaluationEnabled; - if (scoringFunctions !== undefined && Array.isArray(scoringFunctions)) - config.scoringFunctions = scoringFunctions; - if (typeof minScoreThreshold === 'number') - config.minScoreThreshold = minScoreThreshold; - if (evaluationOnError === 'skip' || evaluationOnError === 'fail') - config.evaluationOnError = evaluationOnError; - - if (dbAgents !== undefined && typeof dbAgents === 'object') { - // DB agents fully replace YAML agents (not merge). The admin UI - // sends the complete agent roster on save; YAML agents serve only - // as the initial bootstrap. When the admin deletes an agent and - // saves, the omitted agent must not reappear from YAML. - config.agents = dbAgents; - } - if (typeof dbDefaultAgent === 'string') - config.defaultAgent = dbDefaultAgent; - if (typeof dbMaxAgentTurns === 'number') - config.maxAgentTurns = dbMaxAgentTurns; - - if (typeof dbMaxToolCalls === 'number') - config.maxToolCalls = dbMaxToolCalls; - if (typeof dbMaxOutputTokens === 'number') - config.maxOutputTokens = dbMaxOutputTokens; - if (typeof dbTemperature === 'number') config.temperature = dbTemperature; - if (typeof dbSafetyIdentifier === 'string') - config.safetyIdentifier = dbSafetyIdentifier; - // Recompute guardrails from the effective inputShields/outputShields - // so DB safety overrides propagate to the Responses API `guardrails` field. - if (inputShields !== undefined || outputShields !== undefined) { - const effectiveInput = config.inputShields ?? []; - const effectiveOutput = config.outputShields ?? []; - const merged = [...new Set([...effectiveInput, ...effectiveOutput])]; - config.guardrails = merged.length > 0 ? merged : undefined; - } - - return config; - } - - private applyVectorStoreOverrides( - config: EffectiveConfig, - overrides: Record, - ): void { - if (typeof overrides.embeddingModel === 'string') - config.embeddingModel = overrides.embeddingModel; - if (Number.isFinite(overrides.embeddingDimension)) - config.embeddingDimension = overrides.embeddingDimension as number; - if ( - overrides.searchMode === 'semantic' || - overrides.searchMode === 'keyword' || - overrides.searchMode === 'hybrid' - ) - config.searchMode = overrides.searchMode; - if (Number.isFinite(overrides.bm25Weight)) - config.bm25Weight = overrides.bm25Weight as number; - if (Number.isFinite(overrides.semanticWeight)) - config.semanticWeight = overrides.semanticWeight as number; - if ( - overrides.chunkingStrategy === 'auto' || - overrides.chunkingStrategy === 'static' - ) - config.chunkingStrategy = overrides.chunkingStrategy; - if (Number.isFinite(overrides.maxChunkSizeTokens)) - config.maxChunkSizeTokens = overrides.maxChunkSizeTokens as number; - if (Number.isFinite(overrides.chunkOverlapTokens)) - config.chunkOverlapTokens = overrides.chunkOverlapTokens as number; - if (Number.isFinite(overrides.fileSearchMaxResults)) - config.fileSearchMaxResults = overrides.fileSearchMaxResults as number; - if (Number.isFinite(overrides.fileSearchScoreThreshold)) - config.fileSearchScoreThreshold = - overrides.fileSearchScoreThreshold as number; - if (typeof overrides.vectorStoreName === 'string') - config.vectorStoreName = overrides.vectorStoreName; - } - - /** - * Merge YAML and DB MCP servers. - * - * - DB servers whose ID matches a YAML server **override** the YAML - * version's url/name/type/allowedTools/headers/requireApproval while - * preserving YAML-only auth fields (authRef, oauth, serviceAccount) - * unless the DB entry also provides them. - * - DB servers with new IDs are appended as-is. - * - Servers whose ID is in `disabledIds` are excluded. - */ - private mergeMcpServers( - yamlServers: MCPServerConfig[], - dbServers: MCPServerConfig[], - disabledIds: Set = new Set(), - ): MCPServerConfig[] { - const dbById = new Map(dbServers.map(s => [s.id, s])); - - const merged: MCPServerConfig[] = yamlServers - .filter(s => !disabledIds.has(s.id)) - .map(yaml => { - const override = dbById.get(yaml.id); - if (!override) return yaml; - return { - ...yaml, - name: override.name ?? yaml.name, - type: override.type ?? yaml.type, - url: override.url ?? yaml.url, - allowedTools: override.allowedTools ?? yaml.allowedTools, - ...(override.headers !== undefined - ? { headers: override.headers } - : {}), - ...(override.requireApproval !== undefined - ? { requireApproval: override.requireApproval } - : {}), - }; - }); - - const yamlIds = new Set(yamlServers.map(s => s.id)); - for (const db of dbServers) { - if (!yamlIds.has(db.id) && !disabledIds.has(db.id)) { - merged.push(db); - } - } - - return merged; - } - - /** - * Read a single admin config key with per-key error isolation. - * Provider-scoped keys are automatically resolved using the - * provider ID. A corrupt value in one key does not prevent - * other keys from loading. - */ - private async getTyped(key: string): Promise { - try { - const adminKey = key as AdminConfigKey; - const value = isProviderScopedKey(key) - ? await this.adminConfig.getScopedValue(adminKey, this.providerId) - : await this.adminConfig.get(adminKey); - return value as T | undefined; - } catch (error) { - const msg = toErrorMessage(error); - this.logger.warn(`Failed to read admin config key "${key}": ${msg}`); - return undefined; - } - } - - /** - * Deep-clone the config so callers cannot corrupt the cache - * by mutating the returned object, its arrays, or nested objects. - */ - private snapshot(config: EffectiveConfig): EffectiveConfig { - const clone: EffectiveConfig = { - ...config, - vectorStoreIds: config.vectorStoreIds ? [...config.vectorStoreIds] : [], - }; - if (config.safetyPatterns) - clone.safetyPatterns = [...config.safetyPatterns]; - if (config.inputShields) clone.inputShields = [...config.inputShields]; - if (config.outputShields) clone.outputShields = [...config.outputShields]; - if (config.guardrails) clone.guardrails = [...config.guardrails]; - if (config.scoringFunctions) - clone.scoringFunctions = [...config.scoringFunctions]; - if (config.functions) - clone.functions = config.functions.map(f => ({ ...f })); - if (config.mcpServers) - clone.mcpServers = config.mcpServers.map(s => ({ - ...s, - ...(s.headers ? { headers: { ...s.headers } } : {}), - ...(s.allowedTools ? { allowedTools: [...s.allowedTools] } : {}), - })); - if (config.branding) { - clone.branding = { ...config.branding }; - } - if (config.toolScoping) { - clone.toolScoping = { ...config.toolScoping }; - } - if ( - config.toolChoice && - typeof config.toolChoice === 'object' && - 'tools' in config.toolChoice - ) { - clone.toolChoice = { - ...config.toolChoice, - tools: config.toolChoice.tools.map(t => ({ ...t })), - }; - } - if (config.agents) { - clone.agents = Object.fromEntries( - Object.entries(config.agents).map(([k, v]) => [ - k, - { - ...v, - ...(v.mcpServers ? { mcpServers: [...v.mcpServers] } : {}), - ...(v.handoffs ? { handoffs: [...v.handoffs] } : {}), - ...(v.asTools ? { asTools: [...v.asTools] } : {}), - ...(v.functions - ? { functions: v.functions.map(f => ({ ...f })) } - : {}), - ...(v.guardrails ? { guardrails: [...v.guardrails] } : {}), - ...(v.toolGuardrails - ? { toolGuardrails: v.toolGuardrails.map(g => ({ ...g })) } - : {}), - }, - ]), - ); - } - return clone; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/VectorStoreService.ts b/workspaces/augment/plugins/augment-backend/src/services/VectorStoreService.ts deleted file mode 100644 index 521ca386ef4..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/VectorStoreService.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { - VectorStoreService, - DuplicateVectorStoreError, -} from '../providers/responses-api/documents/VectorStoreService'; diff --git a/workspaces/augment/plugins/augment-backend/src/services/WorkflowConfigService.ts b/workspaces/augment/plugins/augment-backend/src/services/WorkflowConfigService.ts deleted file mode 100644 index 16bb647dbce..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/WorkflowConfigService.ts +++ /dev/null @@ -1,438 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { InputError, NotFoundError } from '@backstage/errors'; -import type { - WorkflowDefinition, - WorkflowVersion, - WorkflowTestSuite, - WorkflowEvaluationResult, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { AdminConfigService } from './AdminConfigService'; - -/** - * Manages workflow CRUD operations, versioning, and evaluation storage - * by delegating to the existing AdminConfigService key-value store. - * - * Workflows are stored under the 'workflows' key as Record. - * Published versions are stored under 'workflowVersions' as Record. - */ -export class WorkflowConfigService { - constructor( - private readonly adminConfig: AdminConfigService, - private readonly logger: LoggerService, - ) {} - - // --------------------------------------------------------------------------- - // Workflow CRUD - // --------------------------------------------------------------------------- - - async listWorkflows(): Promise { - const record = await this.getWorkflowsRecord(); - return Object.values(record); - } - - async getWorkflow(id: string): Promise { - const record = await this.getWorkflowsRecord(); - const wf = record[id]; - if (!wf) { - throw new NotFoundError(`Workflow not found: ${id}`); - } - return wf; - } - - async createWorkflow( - workflow: WorkflowDefinition, - user: string, - ): Promise { - const record = await this.getWorkflowsRecord(); - if (record[workflow.id]) { - throw new InputError(`Workflow already exists: ${workflow.id}`); - } - const now = new Date().toISOString(); - const created: WorkflowDefinition = { - ...workflow, - createdAt: now, - updatedAt: now, - createdBy: user, - updatedBy: user, - version: 0, - status: 'draft', - }; - record[workflow.id] = created; - await this.saveWorkflowsRecord(record, user); - this.logger.info(`Created workflow: ${workflow.id} (${workflow.name})`); - return created; - } - - async updateWorkflow( - id: string, - updates: Partial, - user: string, - ): Promise { - const record = await this.getWorkflowsRecord(); - const existing = record[id]; - if (!existing) { - throw new NotFoundError(`Workflow not found: ${id}`); - } - const updated: WorkflowDefinition = { - ...existing, - ...updates, - id, - updatedAt: new Date().toISOString(), - updatedBy: user, - }; - record[id] = updated; - await this.saveWorkflowsRecord(record, user); - return updated; - } - - async deleteWorkflow(id: string, user: string): Promise { - const record = await this.getWorkflowsRecord(); - if (!record[id]) { - throw new NotFoundError(`Workflow not found: ${id}`); - } - delete record[id]; - await this.saveWorkflowsRecord(record, user); - - const versions = await this.getVersionsRecord(); - delete versions[id]; - await this.saveVersionsRecord(versions, user); - - // Clean up governance entries (workflow-level + per-node) - try { - const chatAgents = - ((await this.adminConfig.get('chatAgents')) as Array< - Record - >) ?? []; - const filtered = chatAgents.filter(c => { - const cId = c.agentId as string; - return cId !== id && !cId.startsWith(`wf/${id}/`); - }); - if (filtered.length !== chatAgents.length) { - await this.adminConfig.set('chatAgents', filtered, user); - } - } catch (err) { - this.logger.warn( - `Failed to clean up chatAgents for workflow ${id}: ${err instanceof Error ? err.message : 'unknown'}`, - ); - } - - this.logger.info(`Deleted workflow: ${id}`); - } - - // --------------------------------------------------------------------------- - // Versioning - // --------------------------------------------------------------------------- - - async publishWorkflow( - id: string, - user: string, - changelog?: string, - ): Promise { - const workflow = await this.getWorkflow(id); - this.validateWorkflowForPublish(workflow); - - const versions = await this.getVersionsRecord(); - const existing = versions[id] ?? []; - const nextVersion = - existing.length > 0 ? Math.max(...existing.map(v => v.version)) + 1 : 1; - - const published: WorkflowVersion = { - version: nextVersion, - publishedAt: new Date().toISOString(), - publishedBy: user, - changelog, - definition: { - ...workflow, - version: nextVersion, - status: 'published', - }, - }; - - versions[id] = [...existing, published]; - await this.saveVersionsRecord(versions, user); - - const record = await this.getWorkflowsRecord(); - record[id] = { - ...workflow, - version: nextVersion, - status: 'published', - updatedAt: new Date().toISOString(), - updatedBy: user, - }; - await this.saveWorkflowsRecord(record, user); - - this.logger.info(`Published workflow ${id} as version ${nextVersion}`); - return published; - } - - async getWorkflowVersions(id: string): Promise { - const versions = await this.getVersionsRecord(); - return versions[id] ?? []; - } - - async getWorkflowVersion( - id: string, - version?: number, - ): Promise { - const versions = await this.getWorkflowVersions(id); - if (versions.length === 0) { - throw new NotFoundError(`No published versions for workflow: ${id}`); - } - if (version !== undefined) { - const found = versions.find(v => v.version === version); - if (!found) { - throw new NotFoundError( - `Version ${version} not found for workflow: ${id}`, - ); - } - return found; - } - return versions[versions.length - 1]; - } - - async restoreVersion( - id: string, - version: number, - user: string, - ): Promise { - const snapshot = await this.getWorkflowVersion(id, version); - return this.updateWorkflow( - id, - { - ...snapshot.definition, - status: 'draft', - }, - user, - ); - } - - // --------------------------------------------------------------------------- - // Test Suites - // --------------------------------------------------------------------------- - - async getTestSuites(workflowId: string): Promise { - const record = await this.getTestSuitesRecord(); - return Object.values(record).filter(s => s.workflowId === workflowId); - } - - async saveTestSuite( - suite: WorkflowTestSuite, - user: string, - ): Promise { - const record = await this.getTestSuitesRecord(); - const now = new Date().toISOString(); - record[suite.id] = { - ...suite, - updatedAt: now, - createdAt: suite.createdAt || now, - }; - await this.saveTestSuitesRecord(record, user); - return record[suite.id]; - } - - async deleteTestSuite(id: string, user: string): Promise { - const record = await this.getTestSuitesRecord(); - delete record[id]; - await this.saveTestSuitesRecord(record, user); - } - - // --------------------------------------------------------------------------- - // Evaluation Results - // --------------------------------------------------------------------------- - - async getEvaluations( - workflowId: string, - ): Promise { - const record = await this.getEvaluationsRecord(); - return Object.values(record).filter(e => e.workflowId === workflowId); - } - - async saveEvaluation( - result: WorkflowEvaluationResult, - user: string, - ): Promise { - const record = await this.getEvaluationsRecord(); - record[result.evaluationId] = result; - await this.saveEvaluationsRecord(record, user); - } - - // --------------------------------------------------------------------------- - // Lifecycle Sync -- Align with ChatAgentConfig - // --------------------------------------------------------------------------- - - /** - * When a workflow is published, generate corresponding ChatAgentConfig - * entries from the agent nodes so the existing chat infrastructure - * (agent gallery, model routing, etc.) works seamlessly. - * - * Lifecycle mapping: - * draft -> 'draft' (ChatAgentConfig lifecycle) - * published -> 'published' - * archived -> 'draft' (hidden from gallery) - */ - async syncWorkflowToChatAgents( - workflowId: string, - user: string, - ): Promise { - const workflow = await this.getWorkflow(workflowId); - const agentNodes = workflow.nodes.filter(n => n.type === 'agent'); - if (agentNodes.length === 0) return; - - const existingAgents = - ((await this.adminConfig.get('chatAgents')) as Array< - Record - >) ?? []; - - const workflowAgentIds = new Set(); - - // Workflow-level entry so the agent appears in unified list and My Agents - const wfIdx = existingAgents.findIndex( - a => (a as Record).agentId === workflowId, - ); - const wfEntry: Record = { - agentId: workflowId, - published: workflow.status === 'published', - visible: workflow.status === 'published', - featured: false, - lifecycleStage: workflow.status === 'published' ? 'published' : 'draft', - version: workflow.version ?? 0, - displayName: workflow.name, - description: workflow.description ?? '', - createdBy: user, - framework: 'workflow-builder', - }; - if (wfIdx >= 0) { - existingAgents[wfIdx] = { ...existingAgents[wfIdx], ...wfEntry }; - } else { - existingAgents.push(wfEntry); - } - - for (const node of agentNodes) { - const data = node.data as Record; - const agentId = `wf/${workflowId}/${node.id}`; - workflowAgentIds.add(agentId); - - const isPublished = workflow.status === 'published'; - const lifecycleStage = isPublished ? 'published' : 'draft'; - - const chatAgentEntry: Record = { - agentId, - published: isPublished, - visible: isPublished, - featured: false, - lifecycleStage, - version: workflow.version ?? 0, - displayName: (data.name as string) || node.id, - description: (data.handoffDescription as string) || '', - createdBy: user, - }; - - const idx = existingAgents.findIndex( - a => (a as Record).agentId === agentId, - ); - if (idx >= 0) { - existingAgents[idx] = { ...existingAgents[idx], ...chatAgentEntry }; - } else { - existingAgents.push(chatAgentEntry); - } - } - - await this.adminConfig.set('chatAgents', existingAgents, user); - this.logger.info( - `Synced ${workflowAgentIds.size} workflow agents to chatAgents for workflow ${workflowId}`, - ); - } - - // --------------------------------------------------------------------------- - // Internal helpers - // --------------------------------------------------------------------------- - - private async getWorkflowsRecord(): Promise< - Record - > { - const raw = await this.adminConfig.get('workflows'); - return (raw as Record) ?? {}; - } - - private async saveWorkflowsRecord( - record: Record, - user: string, - ): Promise { - await this.adminConfig.set('workflows', record, user); - } - - private async getVersionsRecord(): Promise< - Record - > { - const raw = await this.adminConfig.get('workflowVersions'); - return (raw as Record) ?? {}; - } - - private async saveVersionsRecord( - record: Record, - user: string, - ): Promise { - await this.adminConfig.set('workflowVersions', record, user); - } - - private async getTestSuitesRecord(): Promise< - Record - > { - const raw = await this.adminConfig.get('workflowTestSuites'); - return (raw as Record) ?? {}; - } - - private async saveTestSuitesRecord( - record: Record, - user: string, - ): Promise { - await this.adminConfig.set('workflowTestSuites', record, user); - } - - private async getEvaluationsRecord(): Promise< - Record - > { - const raw = await this.adminConfig.get('workflowEvaluations'); - return (raw as Record) ?? {}; - } - - private async saveEvaluationsRecord( - record: Record, - user: string, - ): Promise { - await this.adminConfig.set('workflowEvaluations', record, user); - } - - private validateWorkflowForPublish(workflow: WorkflowDefinition): void { - if (!workflow.nodes || workflow.nodes.length === 0) { - throw new InputError('Cannot publish workflow with no nodes'); - } - const hasStart = workflow.nodes.some(n => n.type === 'start'); - if (!hasStart) { - throw new InputError('Workflow must have a Start node'); - } - const hasAgent = workflow.nodes.some(n => n.type === 'agent'); - if (!hasAgent) { - throw new InputError('Workflow must have at least one Agent node'); - } - if (!workflow.name || workflow.name.trim().length === 0) { - throw new InputError('Workflow must have a name'); - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/WorkflowMigration.ts b/workspaces/augment/plugins/augment-backend/src/services/WorkflowMigration.ts deleted file mode 100644 index 2e2dd87b1f2..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/WorkflowMigration.ts +++ /dev/null @@ -1,266 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { randomUUID as uuid } from 'node:crypto'; -import type { - WorkflowDefinition, - WorkflowNode, - WorkflowEdge, - AgentNodeData, - ToolNodeData, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { AgentConfig } from '../types/config'; - -const HORIZONTAL_SPACING = 300; -const VERTICAL_SPACING = 200; - -/** - * Converts existing AgentConfig records (the legacy flat agent - * configuration format) into a WorkflowDefinition with auto-laid-out - * nodes and edges derived from handoff/asTools relationships. - * - * Layout algorithm: - * - Start node at top center - * - Default agent directly below start - * - Handoff targets laid out horizontally at the next row - * - Tool/guardrail nodes placed around their agents - */ -export function migrateAgentConfigsToWorkflow( - agentsConfig: Record, - defaultAgentKey: string, - workflowName?: string, -): WorkflowDefinition { - const workflowId = uuid(); - const now = new Date().toISOString(); - const nodes: WorkflowNode[] = []; - const edges: WorkflowEdge[] = []; - - const keys = Object.keys(agentsConfig); - if (keys.length === 0) { - return { - id: workflowId, - name: workflowName || 'Migrated Workflow', - description: 'Auto-migrated from agent configuration', - version: 0, - createdAt: now, - updatedAt: now, - status: 'draft', - nodes: [createStartNode()], - edges: [], - settings: { maxTurns: 10, tracingEnabled: true }, - }; - } - - // Create start node - const startNode = createStartNode(); - nodes.push(startNode); - - // Determine entry agent - const entryKey = keys.includes(defaultAgentKey) ? defaultAgentKey : keys[0]; - - // Compute layout levels using BFS from entry agent - const levels = computeLevels(agentsConfig, entryKey); - - // Create agent nodes - const agentNodeIds = new Map(); - for (const [key, config] of Object.entries(agentsConfig)) { - const level = levels.get(key) ?? 1; - const sameLevel = [...levels.entries()] - .filter(([, l]) => l === level) - .map(([k]) => k); - const posInLevel = sameLevel.indexOf(key); - const totalInLevel = sameLevel.length; - const xOffset = (posInLevel - (totalInLevel - 1) / 2) * HORIZONTAL_SPACING; - - const nodeId = `agent-${key}`; - agentNodeIds.set(key, nodeId); - - const agentData: AgentNodeData = { - agentKey: key, - name: config.name ?? key, - instructions: config.instructions ?? '', - handoffDescription: config.handoffDescription, - model: config.model, - mcpServers: config.mcpServers, - enableRAG: config.enableRAG, - vectorStoreIds: config.vectorStoreIds, - enableWebSearch: config.enableWebSearch, - enableCodeInterpreter: config.enableCodeInterpreter, - temperature: config.temperature, - maxOutputTokens: config.maxOutputTokens, - maxToolCalls: config.maxToolCalls, - guardrails: config.guardrails, - toolUseBehavior: config.toolUseBehavior as string | undefined, - resetToolChoice: config.resetToolChoice, - handoffInputFilter: config.handoffInputFilter, - handoffInputSchema: config.handoffInputSchema, - nestHandoffHistory: config.nestHandoffHistory, - truncation: config.truncation, - enabled: config.enabled, - }; - - nodes.push({ - id: nodeId, - type: 'agent', - position: { x: 250 + xOffset, y: 100 + level * VERTICAL_SPACING }, - data: agentData, - label: config.name ?? key, - }); - } - - // Connect start to entry agent - const entryNodeId = agentNodeIds.get(entryKey)!; - edges.push({ - id: `edge-start-${entryNodeId}`, - source: startNode.id, - target: entryNodeId, - type: 'sequence', - }); - - // Create handoff edges - for (const [key, config] of Object.entries(agentsConfig)) { - const sourceId = agentNodeIds.get(key)!; - - if (config.handoffs) { - for (const target of config.handoffs) { - const targetId = agentNodeIds.get(target); - if (targetId && targetId !== sourceId) { - edges.push({ - id: `edge-handoff-${key}-${target}`, - source: sourceId, - target: targetId, - type: 'handoff', - label: `handoff`, - }); - } - } - } - - if (config.asTools) { - for (const target of config.asTools) { - const targetId = agentNodeIds.get(target); - if (targetId && targetId !== sourceId) { - edges.push({ - id: `edge-astool-${key}-${target}`, - source: sourceId, - target: targetId, - type: 'sequence', - label: 'as tool', - }); - } - } - } - - // Create tool nodes for MCP servers - if (config.mcpServers && config.mcpServers.length > 0) { - for (let i = 0; i < config.mcpServers.length; i++) { - const serverId = config.mcpServers[i]; - const toolNodeId = `tool-${key}-${serverId}`; - const toolData: ToolNodeData = { - kind: 'mcp_server', - label: serverId, - mcpServerId: serverId, - }; - - const agentNode = nodes.find(n => n.id === sourceId)!; - nodes.push({ - id: toolNodeId, - type: 'tool', - position: { - x: agentNode.position.x + 220 + i * 160, - y: agentNode.position.y + 20, - }, - data: toolData, - label: serverId, - }); - - edges.push({ - id: `edge-tool-${key}-${serverId}`, - source: sourceId, - target: toolNodeId, - type: 'tool_binding', - }); - } - } - } - - return { - id: workflowId, - name: - workflowName || `Migrated: ${agentsConfig[entryKey]?.name ?? entryKey}`, - description: `Auto-migrated from ${keys.length} agent configuration(s)`, - version: 0, - createdAt: now, - updatedAt: now, - status: 'draft', - nodes, - edges, - settings: { - maxTurns: 10, - defaultModel: agentsConfig[entryKey]?.model, - tracingEnabled: true, - conversationPersistence: true, - }, - }; -} - -function createStartNode(): WorkflowNode { - return { - id: 'start-1', - type: 'start', - position: { x: 250, y: 30 }, - data: { inputDescription: 'User message' }, - label: 'Start', - }; -} - -/** - * BFS to assign depth levels to agents based on handoff/asTools graph. - */ -function computeLevels( - agentsConfig: Record, - entryKey: string, -): Map { - const levels = new Map(); - const queue: Array<{ key: string; level: number }> = [ - { key: entryKey, level: 0 }, - ]; - levels.set(entryKey, 0); - - while (queue.length > 0) { - const { key, level } = queue.shift()!; - const config = agentsConfig[key]; - if (!config) continue; - - const targets = [...(config.handoffs ?? []), ...(config.asTools ?? [])]; - - for (const target of targets) { - if (!levels.has(target) && agentsConfig[target]) { - levels.set(target, level + 1); - queue.push({ key: target, level: level + 1 }); - } - } - } - - // Assign unconnected agents at level 1 - for (const key of Object.keys(agentsConfig)) { - if (!levels.has(key)) { - levels.set(key, 1); - } - } - - return levels; -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/index.ts b/workspaces/augment/plugins/augment-backend/src/services/index.ts deleted file mode 100644 index 1746aa00f8b..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// Provider-agnostic services (shared across all providers) -export { DocumentIngestionService } from './DocumentIngestionService'; -export { ChatSessionService } from './ChatSessionService'; -export type { ChatSession } from './ChatSessionService'; - -// Shared cross-provider services -export { - ResponsesApiClient, - ResponsesApiError, - LlamaStackApiError, - type ResponsesApiRequestOptions, -} from './ResponsesApiClient'; -export { - VectorStoreService, - DuplicateVectorStoreError, -} from './VectorStoreService'; -export { - buildMetaPrompt, - extractToolContext, - PROMPT_ENGINEER_INSTRUCTIONS, - type MetaPromptResult, - type ToolCapabilityInfo, - type PromptCapabilities, -} from './promptGeneration'; - -// Agent approval workflow -export { AgentApprovalWorkflowService } from './AgentApprovalWorkflowService'; -export type { AgentApprovalConfig } from './AgentApprovalWorkflowService'; - -// Utilities -export { detectFileFormat } from './utils/file-utils'; diff --git a/workspaces/augment/plugins/augment-backend/src/services/promptGeneration.ts b/workspaces/augment/plugins/augment-backend/src/services/promptGeneration.ts deleted file mode 100644 index d459942ad73..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/promptGeneration.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { - buildMetaPrompt, - extractToolContext, - PROMPT_ENGINEER_INSTRUCTIONS, - type MetaPromptResult, - type ToolCapabilityInfo, - type PromptCapabilities, -} from '../providers/responses-api/chat/promptGeneration'; diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/HttpEmbedder.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/HttpEmbedder.ts deleted file mode 100644 index 0d43d639150..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/HttpEmbedder.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { EmbeddingProvider } from './types'; - -/** - * Embedding provider that calls an HTTP embedding service. - * 1:1 port of Python ToolScope's HttpEmbeddingProvider. - * - * Contract: - * POST {endpoint} - * Body: {"texts": ["...", "..."], "model": "..."} (model optional) - * Response: {"vectors": [[...], [...]]} - */ -export class HttpEmbeddingProvider implements EmbeddingProvider { - private readonly endpoint: string; - private readonly model?: string; - private readonly headers?: Record; - private readonly timeoutMs: number; - - constructor(opts: { - endpoint: string; - model?: string; - headers?: Record; - timeoutMs?: number; - }) { - this.endpoint = opts.endpoint; - this.model = opts.model; - this.headers = opts.headers; - this.timeoutMs = opts.timeoutMs ?? 30_000; - } - - embedTexts(_texts: string[]): number[][] { - /* istanbul ignore next -- async in sync wrapper */ - throw new Error( - 'HttpEmbeddingProvider.embedTexts() is async-only. ' + - 'Use embedTextsAsync() or call via an async wrapper.', - ); - } - - async embedTextsAsync(texts: string[]): Promise { - const payload: Record = { texts }; - if (this.model) payload.model = this.model; - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.timeoutMs); - - try { - const resp = await fetch(this.endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(this.headers ?? {}), - }, - body: JSON.stringify(payload), - signal: controller.signal, - }); - - if (!resp.ok) { - throw new Error( - `HTTP embedder request failed: ${resp.status} ${resp.statusText}`, - ); - } - - const data = (await resp.json()) as { vectors?: number[][] }; - if (!Array.isArray(data.vectors) || data.vectors.length === 0) { - throw new Error( - "HTTP embedder returned no vectors (expected JSON key 'vectors').", - ); - } - return data.vectors; - } finally { - clearTimeout(timer); - } - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/InMemoryToolIndex.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/InMemoryToolIndex.ts deleted file mode 100644 index dafa33f5611..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/InMemoryToolIndex.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ToolDescriptor } from './types'; - -interface IndexEntry { - tool: ToolDescriptor; - vector: number[]; -} - -function cosineSimilarity(a: number[], b: number[]): number { - if (a.length !== b.length || a.length === 0) return 0; - - let dot = 0; - let normA = 0; - let normB = 0; - for (let i = 0; i < a.length; i++) { - dot += a[i] * b[i]; - normA += a[i] * a[i]; - normB += b[i] * b[i]; - } - - const denom = Math.sqrt(normA) * Math.sqrt(normB); - if (denom === 0) return 0; - const result = dot / denom; - return Number.isFinite(result) ? result : 0; -} - -/** - * In-memory brute-force vector index for tool embeddings. - * - * Stores { tool, vector } tuples and searches via cosine similarity. - * Same approach as ToolScope's MemoryBackend -- sufficient for <200 tools - * where full index structures are overkill. - */ -export class InMemoryToolIndex { - private entries: IndexEntry[] = []; - - upsert(tools: ToolDescriptor[], vectors: number[][]): void { - if (tools.length !== vectors.length) { - throw new Error( - `upsert: tools.length (${tools.length}) must equal vectors.length (${vectors.length})`, - ); - } - for (let i = 0; i < tools.length; i++) { - const tool = tools[i]; - const vector = vectors[i]; - - const existing = this.entries.findIndex( - e => e.tool.serverId === tool.serverId && e.tool.name === tool.name, - ); - if (existing >= 0) { - this.entries[existing] = { tool, vector }; - } else { - this.entries.push({ tool, vector }); - } - } - } - - search( - queryVector: number[], - k: number, - ): Array<{ tool: ToolDescriptor; score: number }> { - const scored = this.entries.map(entry => ({ - tool: entry.tool, - score: cosineSimilarity(queryVector, entry.vector), - })); - - scored.sort((a, b) => b.score - a.score); - return scored.slice(0, k); - } - - /** - * Remove tools that are no longer present. - * Keeps only entries whose (serverId, name) pair appears in the keep set. - */ - removeStale(currentTools: ToolDescriptor[]): number { - const keepKeys = new Set(currentTools.map(t => `${t.serverId}\0${t.name}`)); - const before = this.entries.length; - this.entries = this.entries.filter(e => - keepKeys.has(`${e.tool.serverId}\0${e.tool.name}`), - ); - return before - this.entries.length; - } - - clear(): void { - this.entries = []; - } - - size(): number { - return this.entries.length; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/TfIdfEmbedder.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/TfIdfEmbedder.ts deleted file mode 100644 index 9f0d26690a0..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/TfIdfEmbedder.ts +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { EmbeddingProvider } from './types'; - -export type { EmbeddingProvider }; - -/** - * Tokenize text into lowercase terms, splitting on non-alphanumeric characters. - * Filters out single-character tokens and common stop words. - */ -const STOP_WORDS = new Set([ - 'a', - 'an', - 'the', - 'is', - 'are', - 'was', - 'were', - 'be', - 'been', - 'being', - 'have', - 'has', - 'had', - 'do', - 'does', - 'did', - 'will', - 'would', - 'could', - 'should', - 'may', - 'might', - 'shall', - 'can', - 'to', - 'of', - 'in', - 'for', - 'on', - 'with', - 'at', - 'by', - 'from', - 'as', - 'into', - 'about', - 'that', - 'this', - 'it', - 'its', - 'or', - 'and', - 'but', - 'if', - 'not', - 'no', - 'so', - 'up', - 'out', -]); - -/** - * Split camelCase and PascalCase boundaries before lowercasing. - * e.g. "createIssue" -> "create Issue" -> "create issue" - * "getContainerLogs" -> "get Container Logs" -> "get container logs" - */ -function splitCamelCase(text: string): string { - return text.replaceAll(/([a-z0-9])([A-Z])/g, '$1 $2'); -} - -function tokenize(text: string): string[] { - return splitCamelCase(text) - .toLowerCase() - .split(/[^a-z0-9]+/) - .filter(t => t.length > 1 && !STOP_WORDS.has(t)); -} - -/** - * In-process TF-IDF embedding provider. - * - * Builds a vocabulary from the corpus of tool descriptions and computes - * TF-IDF vectors. Vocabulary is rebuilt when the corpus changes. - * Deterministic, synchronous, zero external dependencies. - * - * Follows ToolScope's EmbeddingProvider protocol. - */ -export class TfIdfEmbedder implements EmbeddingProvider { - private vocabulary: string[] = []; - private vocabIndex: Map = new Map(); - private idfValues: number[] = []; - - /** - * Build the vocabulary and IDF values from a corpus of texts. - * Must be called before embed() to ensure vectors are meaningful. - */ - fit(corpus: string[]): void { - const docFreq = new Map(); - const allTerms = new Set(); - - for (const text of corpus) { - const uniqueTerms = new Set(tokenize(text)); - for (const term of uniqueTerms) { - allTerms.add(term); - docFreq.set(term, (docFreq.get(term) ?? 0) + 1); - } - } - - this.vocabulary = Array.from(allTerms).sort((a, b) => a.localeCompare(b)); - this.vocabIndex = new Map(this.vocabulary.map((t, i) => [t, i])); - - const n = corpus.length || 1; - this.idfValues = this.vocabulary.map(term => { - const df = docFreq.get(term) ?? 0; - return Math.log((n + 1) / (df + 1)) + 1; - }); - } - - /** - * Embed one or more texts into TF-IDF vectors. - * Each vector has length = vocabulary size. - */ - embedTexts(texts: string[]): number[][] { - const dim = this.vocabulary.length; - if (dim === 0) { - return texts.map(() => []); - } - - return texts.map(text => { - const tokens = tokenize(text); - const tf = new Map(); - for (const token of tokens) { - tf.set(token, (tf.get(token) ?? 0) + 1); - } - - const vec = new Array(dim).fill(0); - const maxTf = Math.max(1, ...tf.values()); - - for (const [term, count] of tf) { - const idx = this.vocabIndex.get(term); - if (idx !== undefined) { - vec[idx] = (0.5 + (0.5 * count) / maxTf) * this.idfValues[idx]; - } - } - - // L2-normalize - let norm = 0; - for (let i = 0; i < dim; i++) { - norm += vec[i] * vec[i]; - } - norm = Math.sqrt(norm); - if (norm > 0) { - for (let i = 0; i < dim; i++) { - vec[i] /= norm; - } - } - - return vec; - }); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/ToolIndex.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/ToolIndex.ts deleted file mode 100644 index 6124ca6f363..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/ToolIndex.ts +++ /dev/null @@ -1,525 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - CanonicalTool, - EmbeddingProvider, - ToolIndexBackend, - ToolNormalizer, - ToolFingerprint, - Json, -} from './types'; -import { EmbeddingCache } from './cache'; -import type { ToolTextConfig } from './text'; -import { DEFAULT_TOOL_TEXT_CONFIG, renderToolText } from './text'; -import type { Reranker, RerankingConfig } from './rerankers'; -import { makeReranker } from './rerankers'; -import type { StickySessionConfig } from './session'; -import { DEFAULT_SESSION_CONFIG, SessionCache, cosine } from './session'; -import type { ToolScopeTrace, TraceSink } from './observability'; -import { createTrace, Stopwatch } from './observability'; -import { AutoToolNormalizer } from './normalize'; -import { MemoryBackend } from './backends/memory'; - -// ---- Messages-to-query helpers ---- - -function messagesToQueryText(messages: unknown): string { - if (typeof messages === 'string') return messages; - if (Array.isArray(messages)) { - return messages - .map(m => { - if (typeof m === 'object' && m !== null && !Array.isArray(m)) { - const obj = m as Record; - return `${obj.role ?? ''}: ${obj.content ?? ''}`; - } - return String(m); - }) - .join('\n'); - } - return typeof messages === 'object' && messages !== null - ? JSON.stringify(messages) - : String(messages); -} - -// ---- Tag helpers ---- - -function tagSet(tags?: readonly string[]): Set | null { - if (!tags || tags.length === 0) return null; - const s = new Set(); - for (const t of tags) { - const trimmed = typeof t === 'string' ? t.trim() : ''; - if (trimmed) s.add(trimmed); - } - return s.size > 0 ? s : null; -} - -function passesTagFilters( - tool: CanonicalTool, - allow: Set | null, - deny: Set | null, -): boolean { - const tset = new Set(tool.tags || []); - if (deny !== null) { - for (const tag of tset) { - if (deny.has(tag)) return false; - } - } - if (allow !== null) { - let hasAny = false; - for (const tag of tset) { - if (allow.has(tag)) { - hasAny = true; - break; - } - } - if (!hasAny) return false; - } - return true; -} - -// ---- Filter options ---- - -export interface FilterOptions { - k?: number; - filter?: Json; - reranking?: RerankingConfig; - allowTags?: readonly string[]; - denyTags?: readonly string[]; - sessionId?: string; - turnId?: string; - traceSink?: TraceSink; -} - -/** - * Central ToolIndex. 1:1 port of Python ToolScope's ToolIndex. - * - * Long-lived: register tools once, filter per prompt. - * Normalizes -> embeds (cached) -> upserts -> searches -> returns original tools. - */ -export class ToolIndex { - readonly backend: ToolIndexBackend; - readonly embedder: EmbeddingProvider; - readonly normalizer: ToolNormalizer; - readonly cache: EmbeddingCache; - readonly namespace: string | null; - readonly toolTextConfig: ToolTextConfig; - readonly reranker: Reranker | null; - readonly rerankingConfig: RerankingConfig | null; - readonly sessionCfg: StickySessionConfig; - private sessionCache: SessionCache | null = null; - - constructor(opts: { - backend: ToolIndexBackend; - embedder: EmbeddingProvider; - normalizer: ToolNormalizer; - cache: EmbeddingCache; - namespace?: string | null; - toolTextConfig?: ToolTextConfig; - reranker?: Reranker | null; - rerankingConfig?: RerankingConfig | null; - sessionCfg?: StickySessionConfig; - }) { - this.backend = opts.backend; - this.embedder = opts.embedder; - this.normalizer = opts.normalizer; - this.cache = opts.cache; - this.namespace = opts.namespace ?? null; - this.toolTextConfig = opts.toolTextConfig ?? DEFAULT_TOOL_TEXT_CONFIG; - this.reranker = opts.reranker ?? null; - this.rerankingConfig = opts.rerankingConfig ?? null; - this.sessionCfg = opts.sessionCfg ?? DEFAULT_SESSION_CONFIG; - } - - private ensureSessionCache(): void { - if (!this.sessionCache && this.sessionCfg.enabled) { - this.sessionCache = new SessionCache(this.sessionCfg); - } - } - - /** - * Normalize tools, embed them, and upsert into backend. - * - * When the embedder has a fit() method (e.g. TF-IDF), vocabulary is rebuilt - * from the full corpus on every call. This invalidates the embedding cache, - * so all tools are re-embedded. For fixed-dimension embedders (e.g. HTTP), - * cached vectors are reused when fingerprints haven't changed. - */ - upsertTools(tools: unknown[]): void { - const canonical = this.normalizer.normalize(tools); - if (canonical.length === 0) return; - - const allTexts = canonical.map(t => renderToolText(t, this.toolTextConfig)); - - if (this.embedder.fit) { - this.embedder.fit(allTexts); - this.cache.clear(); - } - - const toEmbed: Array<{ idx: number; tool: CanonicalTool }> = []; - const toEmbedTexts: string[] = []; - - for (let i = 0; i < canonical.length; i++) { - if (this.cache.get(canonical[i].fingerprint) === undefined) { - toEmbed.push({ idx: i, tool: canonical[i] }); - toEmbedTexts.push(allTexts[i]); - } - } - - if (toEmbed.length > 0) { - const vecs = this.embedder.embedTexts(toEmbedTexts); - for (let j = 0; j < toEmbed.length; j++) { - this.cache.put(toEmbed[j].tool.fingerprint, vecs[j]); - } - } - - const ids: string[] = []; - const vectors: number[][] = []; - const payloads: CanonicalTool[] = []; - - for (const t of canonical) { - const vec = this.cache.get(t.fingerprint); - if (!vec) { - throw new Error('Missing vector in cache after embedding step.'); - } - ids.push(t.fingerprint.value); - vectors.push(vec); - payloads.push(t); - } - - const dim = vectors[0].length; - this.backend.ensureNamespace(this.namespace, dim); - this.backend.upsert(ids, vectors, payloads, this.namespace); - } - - deleteTools(fingerprints: ToolFingerprint[]): void { - const ids = fingerprints.map(fp => fp.value); - if (ids.length > 0) { - this.backend.delete(ids, this.namespace); - } - } - - filter(messages: unknown, options?: FilterOptions): unknown[] { - const [tools] = this.filterWithTrace(messages, options); - return tools; - } - - filterWithTrace( - messages: unknown, - options?: FilterOptions, - ): [unknown[], ToolScopeTrace] { - const k = options?.k ?? 12; - const allowTagsInput = options?.allowTags; - const denyTagsInput = options?.denyTags; - const sessionId = options?.sessionId; - const turnId = options?.turnId; - const traceSink = options?.traceSink; - - const trace = createTrace(sessionId, turnId); - const totalSw = new Stopwatch(); - - const queryText = messagesToQueryText(messages); - const qSw = new Stopwatch(); - const qvec = this.embedder.embedTexts([queryText])[0]; - trace.msEmbedQuery = qSw.ms(); - - const allow = tagSet(allowTagsInput); - const deny = tagSet(denyTagsInput); - if (allow) trace.allowTags = [...allow].sort((a, b) => a.localeCompare(b)); - if (deny) trace.denyTags = [...deny].sort((a, b) => a.localeCompare(b)); - - // ---- Session-aware state lookup ---- - let state: - | { lastQueryVec: number[]; lastSelectedIds: string[] } - | undefined; - let refreshMode = false; - - if (sessionId && this.sessionCfg.enabled) { - this.ensureSessionCache(); - state = this.sessionCache!.get(sessionId); - if (state) { - const sim = cosine(qvec, state.lastQueryVec); - trace.querySimilarityToPrev = sim; - - if (sim >= this.sessionCfg.similarityThresholdReuse) { - trace.mode = 'reuse'; - let prevTools = this.backend.get( - state.lastSelectedIds, - this.namespace, - ); - - const fSw = new Stopwatch(); - if (allow !== null || deny !== null) { - prevTools = prevTools.filter(t => passesTagFilters(t, allow, deny)); - } - trace.msFilter += fSw.ms(); - - if (prevTools.length > 0) { - prevTools = prevTools.slice(0, k); - trace.returnedTools = prevTools.length; - trace.candidatesAfterFilters = prevTools.length; - - const selectedIds = prevTools.map(t => t.fingerprint.value); - this.sessionCache!.put(sessionId, qvec, selectedIds); - - const toolsOut = this.normalizer.denormalize(prevTools); - trace.approxCharsAfter = toolsOut.reduce( - (sum, t) => - sum + - (typeof t === 'object' && t !== null - ? JSON.stringify(t) - : String(t) - ).length, - 0, - ); - trace.msTotal = totalSw.ms(); - traceSink?.emit(trace); - return [toolsOut, trace]; - } - } - - refreshMode = sim >= this.sessionCfg.similarityThresholdRefresh; - trace.mode = refreshMode ? 'refresh' : 'fresh'; - } - } - - // ---- Determine initial retrieval pool ---- - const activeCfg = options?.reranking ?? this.rerankingConfig; - let activeReranker = this.reranker; - if (options?.reranking) { - activeReranker = makeReranker(options.reranking); - } - - let poolSize = k; - if (activeCfg && activeReranker) { - poolSize = Math.max(poolSize, activeCfg.poolSize); - } - if (allow !== null || deny !== null) { - poolSize = Math.max(poolSize, Math.min(200, k * 8)); - } - if (refreshMode) { - poolSize = Math.max(poolSize, Math.min(200, k * 8)); - } - trace.retrievedCandidates = poolSize; - - // ---- Backend search ---- - const backendFilter: Record = { - ...(options?.filter ?? {}), - }; - if (allow) - backendFilter.allow_tags = [...allow].sort((a, b) => a.localeCompare(b)); - if (deny) - backendFilter.deny_tags = [...deny].sort((a, b) => a.localeCompare(b)); - - const sSw = new Stopwatch(); - const hits = this.backend.search( - qvec, - poolSize, - this.namespace, - Object.keys(backendFilter).length > 0 ? backendFilter : undefined, - ); - trace.msSearch = sSw.ms(); - - if (hits.length === 0) { - trace.msTotal = totalSw.ms(); - if (sessionId && this.sessionCfg.enabled) { - this.ensureSessionCache(); - this.sessionCache!.put(sessionId, qvec, []); - } - traceSink?.emit(trace); - return [[], trace]; - } - - const hitIds = hits.map(([id]) => id); - const hitScore = new Map(hits.map(([id, sc]) => [id, sc])); - - // ---- Fetch ---- - const fSw = new Stopwatch(); - const fetched = this.backend.get(hitIds, this.namespace); - trace.msFetch = fSw.ms(); - - // ---- Order by vector rank ---- - const byId = new Map(fetched.map(t => [t.fingerprint.value, t])); - let candidates: CanonicalTool[] = hitIds - .map(id => byId.get(id)) - .filter((t): t is CanonicalTool => t !== undefined); - if (candidates.length === 0 && fetched.length > 0) { - candidates = [...fetched]; - } - - trace.candidates = candidates.slice(0, 50).map(t => ({ - toolId: t.fingerprint.value, - toolName: t.name, - vectorScore: hitScore.get(t.fingerprint.value), - passedFilters: true, - filterReasons: [], - })); - - // ---- Apply allow/deny tag filters ---- - const filtSw = new Stopwatch(); - if (allow !== null || deny !== null) { - const kept: CanonicalTool[] = []; - const keptSet = new Set(); - - for (const t of candidates) { - if (passesTagFilters(t, allow, deny)) { - kept.push(t); - keptSet.add(t.fingerprint.value); - } - } - - for (const cs of trace.candidates) { - if (!keptSet.has(cs.toolId)) { - cs.passedFilters = false; - const reasons: string[] = []; - if (deny) reasons.push('deny_tags'); - if (allow) reasons.push('allow_tags'); - cs.filterReasons = reasons; - } - } - - candidates = kept; - } - trace.msFilter = filtSw.ms(); - trace.candidatesAfterFilters = candidates.length; - - // ---- Stickiness (refresh mode) ---- - if (state && refreshMode && candidates.length > 0) { - const prevSet = new Set(state.lastSelectedIds); - - candidates = [...candidates].sort((a, b) => { - const aBoost = prevSet.has(a.fingerprint.value) - ? this.sessionCfg.stickyBoost - : 0; - const bBoost = prevSet.has(b.fingerprint.value) - ? this.sessionCfg.stickyBoost - : 0; - return bBoost - aBoost; - }); - - if (this.sessionCfg.stickyKeep > 0 && state.lastSelectedIds.length > 0) { - let prevTools = this.backend.get(state.lastSelectedIds, this.namespace); - if (allow !== null || deny !== null) { - prevTools = prevTools.filter(t => passesTagFilters(t, allow, deny)); - } - - const existing = new Set(candidates.map(t => t.fingerprint.value)); - let added = 0; - for (const pt of prevTools) { - if (!existing.has(pt.fingerprint.value)) { - candidates.unshift(pt); - existing.add(pt.fingerprint.value); - added++; - } - if (added >= this.sessionCfg.stickyKeep) break; - } - } - } - - // ---- Optional reranking ---- - if (activeCfg && activeReranker && candidates.length > 0) { - const rSw = new Stopwatch(); - - const qText = queryText.slice(0, activeCfg.maxQueryChars); - const docs = candidates.map(t => - renderToolText(t, this.toolTextConfig).slice(0, activeCfg.maxDocChars), - ); - const scores = activeReranker.score(qText, docs); - - trace.msRerank = rSw.ms(); - trace.candidatesReranked = candidates.length; - - const scored = candidates.map((t, i) => ({ tool: t, score: scores[i] })); - scored.sort((a, b) => b.score - a.score); - candidates = scored.slice(0, k).map(s => s.tool); - - const scoreMap = new Map( - scored.map(s => [s.tool.fingerprint.value, s.score]), - ); - for (const cs of trace.candidates) { - const rs = scoreMap.get(cs.toolId); - if (rs !== undefined) cs.rerankScore = rs; - } - } else { - candidates = candidates.slice(0, k); - } - - trace.returnedTools = candidates.length; - - // ---- Persist session state ---- - if (sessionId && this.sessionCfg.enabled) { - this.ensureSessionCache(); - const selectedIds = candidates.map(t => t.fingerprint.value); - this.sessionCache!.put(sessionId, qvec, selectedIds); - } - - const toolsOut = this.normalizer.denormalize(candidates); - trace.approxCharsAfter = toolsOut.reduce( - (sum, t) => - sum + - (typeof t === 'object' && t !== null ? JSON.stringify(t) : String(t)) - .length, - 0, - ); - - trace.msTotal = totalSw.ms(); - traceSink?.emit(trace); - - return [toolsOut, trace]; - } -} - -// ---- Factory ---- - -export interface MakeIndexOptions { - tools: unknown[]; - backend?: ToolIndexBackend; - embedder?: EmbeddingProvider; - normalizer?: ToolNormalizer; - namespace?: string | null; - cache?: EmbeddingCache; - toolTextConfig?: ToolTextConfig; - reranking?: RerankingConfig; - sessionCfg?: StickySessionConfig; -} - -/** - * Convenience constructor: creates a ToolIndex and immediately upserts tools. - * 1:1 port of Python ToolScope's make_index(). - */ -export function makeIndex(opts: MakeIndexOptions): ToolIndex { - if (!opts.embedder) { - throw new Error( - 'No embedder configured. Provide an embedder to makeIndex().', - ); - } - - const resolvedReranker = makeReranker(opts.reranking); - - const idx = new ToolIndex({ - backend: opts.backend ?? new MemoryBackend(), - embedder: opts.embedder, - normalizer: opts.normalizer ?? new AutoToolNormalizer(), - cache: opts.cache ?? new EmbeddingCache(), - namespace: opts.namespace, - toolTextConfig: opts.toolTextConfig, - reranker: resolvedReranker, - rerankingConfig: opts.reranking, - sessionCfg: opts.sessionCfg, - }); - - idx.upsertTools(opts.tools); - return idx; -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/ToolScopeFilterService.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/ToolScopeFilterService.ts deleted file mode 100644 index 792a035316f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/ToolScopeFilterService.ts +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createHash } from 'crypto'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { - ToolDescriptor, - EmbeddingProvider, - ToolScopeService, - ToolScopeResult, - CanonicalTool, - ToolNormalizer, -} from './types'; -import { ToolIndex } from './ToolIndex'; -import { MemoryBackend } from './backends/memory'; -import { EmbeddingCache } from './cache'; -import { TfIdfEmbedder } from './TfIdfEmbedder'; -import { fingerprintTool } from './fingerprint'; - -/** - * Normalizer that converts ToolDescriptor (augment format) to CanonicalTool. - * Uses serverId in the fingerprint extra field so tools with the same name on - * different servers remain distinct. - */ -class ToolDescriptorNormalizer implements ToolNormalizer { - normalize(tools: unknown[]): CanonicalTool[] { - return (tools as ToolDescriptor[]).map(t => ({ - name: t.name, - description: t.description, - inputSchema: {}, - tags: [], - fingerprint: fingerprintTool( - t.name, - t.description, - {}, - { - serverId: t.serverId, - }, - ), - payload: t, - })); - } - - denormalize(canonical: CanonicalTool[]): unknown[] { - return canonical.map(ct => ct.payload as ToolDescriptor); - } -} - -function descriptorFingerprint(tool: ToolDescriptor): string { - return createHash('sha256') - .update(`${tool.serverId}\0${tool.name}\0${tool.description}`) - .digest('hex'); -} - -/** - * Orchestrates tool embedding, indexing, and per-query filtering. - * - * Implements the ToolScopeService interface consumed by providers. - * Internally delegates to ToolIndex (1:1 Python ToolScope port). - * Completely provider-agnostic -- no imports from providers/, types/config, - * or Llama Stack. - */ -export class ToolScopeFilterService implements ToolScopeService { - private toolIndex: ToolIndex; - private readonly embedder: EmbeddingProvider; - private readonly logger: LoggerService; - private fingerprints = new Map(); - - constructor(logger: LoggerService, embedder?: EmbeddingProvider) { - this.logger = logger; - this.embedder = embedder ?? new TfIdfEmbedder(); - this.toolIndex = this.createToolIndex(); - } - - private createToolIndex(): ToolIndex { - return new ToolIndex({ - backend: new MemoryBackend(), - embedder: this.embedder, - normalizer: new ToolDescriptorNormalizer(), - cache: new EmbeddingCache(), - }); - } - - private toolKey(tool: ToolDescriptor): string { - return `${tool.serverId}\0${tool.name}`; - } - - /** - * Update the index with the current set of tools. - * Uses fingerprinting to detect changes; when any tool changes, - * recreates the ToolIndex and upserts all tools to ensure vocabulary - * and vector consistency. - */ - updateIndex(tools: ToolDescriptor[]): void { - const currentKeys = new Set(); - const changedTools: ToolDescriptor[] = []; - - for (const tool of tools) { - const key = this.toolKey(tool); - currentKeys.add(key); - const fp = descriptorFingerprint(tool); - if (this.fingerprints.get(key) !== fp) { - changedTools.push(tool); - this.fingerprints.set(key, fp); - } - } - - let removed = 0; - for (const key of this.fingerprints.keys()) { - if (!currentKeys.has(key)) { - this.fingerprints.delete(key); - removed++; - } - } - - if (changedTools.length > 0 || removed > 0) { - this.toolIndex = this.createToolIndex(); - this.toolIndex.upsertTools(tools); - } - - const serverIds = new Set(tools.map(t => t.serverId)); - this.logger.info( - `[ToolScope] Index updated: ${tools.length} tools across ${serverIds.size} servers (${changedTools.length} changed, ${removed} removed)`, - ); - } - - /** - * Filter tools by semantic relevance to the user query. - * Returns per-server allowed tool lists, intersected with admin constraints. - */ - filterTools( - query: string, - k: number, - adminAllowedTools?: Map, - minScore = 0, - ): ToolScopeResult { - const [filtered, trace] = this.toolIndex.filterWithTrace(query, { k }); - - const scoreByName = new Map(); - for (const c of trace.candidates) { - if (c.vectorScore !== undefined) { - scoreByName.set(c.toolName, c.vectorScore); - } - } - - const scopedTools = new Map(); - const scores: Array<{ serverId: string; name: string; score: number }> = []; - - for (const raw of filtered) { - const t = raw as ToolDescriptor; - const score = scoreByName.get(t.name) ?? 0; - - if (minScore > 0 && score < minScore) continue; - - const existing = scopedTools.get(t.serverId) ?? []; - existing.push(t.name); - scopedTools.set(t.serverId, existing); - scores.push({ serverId: t.serverId, name: t.name, score }); - } - - scores.sort((a, b) => b.score - a.score); - - if (adminAllowedTools) { - for (const [serverId, toolNames] of scopedTools) { - const adminList = adminAllowedTools.get(serverId); - if (adminList) { - const adminSet = new Set(adminList); - scopedTools.set( - serverId, - toolNames.filter(name => adminSet.has(name)), - ); - } - } - } - - return { scopedTools, scores, durationMs: trace.msTotal }; - } -} - -/** - * Create a ToolScopeService with the default stack: - * TF-IDF embedder + ToolIndex with MemoryBackend. - */ -export function createToolScopeService( - logger: LoggerService, - options?: { embedder?: EmbeddingProvider }, -): ToolScopeService { - return new ToolScopeFilterService(logger, options?.embedder); -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/HttpEmbedder.test.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/HttpEmbedder.test.ts deleted file mode 100644 index d6d3cafb3b7..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/HttpEmbedder.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { HttpEmbeddingProvider } from '../HttpEmbedder'; - -const mockFetch = jest.fn(); -(globalThis as any).fetch = mockFetch; - -describe('HttpEmbeddingProvider', () => { - let provider: HttpEmbeddingProvider; - - beforeEach(() => { - mockFetch.mockReset(); - provider = new HttpEmbeddingProvider({ - endpoint: 'https://embed.example.com/v1/embed', - model: 'test-model', - timeoutMs: 5000, - }); - }); - - it('embedTexts throws with async guidance', () => { - expect(() => provider.embedTexts(['hello'])).toThrow('async-only'); - }); - - describe('embedTextsAsync', () => { - it('calls the endpoint and returns vectors', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - vectors: [ - [1, 0], - [0, 1], - ], - }), - }); - - const result = await provider.embedTextsAsync(['hello', 'world']); - expect(result).toEqual([ - [1, 0], - [0, 1], - ]); - expect(mockFetch).toHaveBeenCalledWith( - 'https://embed.example.com/v1/embed', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ - texts: ['hello', 'world'], - model: 'test-model', - }), - }), - ); - }); - - it('throws on non-OK response', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - }); - - await expect(provider.embedTextsAsync(['test'])).rejects.toThrow( - '500 Internal Server Error', - ); - }); - - it('throws when response has no vectors', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ data: [] }), - }); - - await expect(provider.embedTextsAsync(['test'])).rejects.toThrow( - 'no vectors', - ); - }); - - it('sends custom headers', async () => { - const customProvider = new HttpEmbeddingProvider({ - endpoint: 'https://embed.example.com/v1/embed', - headers: { Authorization: 'Bearer token123' }, - }); - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ vectors: [[1]] }), - }); - - await customProvider.embedTextsAsync(['test']); - - const callHeaders = mockFetch.mock.calls[0][1]?.headers; - expect(callHeaders?.Authorization).toBe('Bearer token123'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/InMemoryToolIndex.test.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/InMemoryToolIndex.test.ts deleted file mode 100644 index da6d4025e69..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/InMemoryToolIndex.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { InMemoryToolIndex } from '../InMemoryToolIndex'; -import type { ToolDescriptor } from '../types'; - -function tool( - serverId: string, - name: string, - description = '', -): ToolDescriptor { - return { serverId, name, description }; -} - -describe('InMemoryToolIndex', () => { - let index: InMemoryToolIndex; - - beforeEach(() => { - index = new InMemoryToolIndex(); - }); - - describe('upsert and size', () => { - it('starts empty', () => { - expect(index.size()).toBe(0); - }); - - it('adds tools', () => { - index.upsert( - [tool('s1', 'tool1'), tool('s1', 'tool2')], - [ - [1, 0], - [0, 1], - ], - ); - expect(index.size()).toBe(2); - }); - - it('updates existing tools by serverId+name', () => { - index.upsert([tool('s1', 'tool1')], [[1, 0]]); - index.upsert([tool('s1', 'tool1', 'updated')], [[0, 1]]); - expect(index.size()).toBe(1); - - const results = index.search([0, 1], 1); - expect(results[0].tool.description).toBe('updated'); - expect(results[0].score).toBeCloseTo(1.0, 5); - }); - - it('throws when tools and vectors have different lengths', () => { - expect(() => - index.upsert([tool('s1', 'a'), tool('s1', 'b')], [[1, 0]]), - ).toThrow('tools.length (2) must equal vectors.length (1)'); - }); - - it('handles empty upsert', () => { - index.upsert([], []); - expect(index.size()).toBe(0); - }); - }); - - describe('search', () => { - it('returns results sorted by cosine similarity descending', () => { - const t1 = tool('s1', 'exact_match'); - const t2 = tool('s1', 'partial_match'); - const t3 = tool('s2', 'no_match'); - - index.upsert( - [t1, t2, t3], - [ - [1, 0, 0], - [0.7, 0.7, 0], - [0, 0, 1], - ], - ); - - const results = index.search([1, 0, 0], 3); - expect(results).toHaveLength(3); - expect(results[0].tool.name).toBe('exact_match'); - expect(results[1].tool.name).toBe('partial_match'); - expect(results[2].tool.name).toBe('no_match'); - expect(results[0].score).toBeGreaterThan(results[1].score); - expect(results[1].score).toBeGreaterThan(results[2].score); - }); - - it('returns at most k results', () => { - index.upsert( - [tool('s1', 'a'), tool('s1', 'b'), tool('s1', 'c')], - [ - [1, 0, 0], - [0, 1, 0], - [0, 0, 1], - ], - ); - - const results = index.search([1, 0, 0], 2); - expect(results).toHaveLength(2); - }); - - it('returns all results when k > index size', () => { - index.upsert([tool('s1', 'only')], [[1, 0]]); - const results = index.search([1, 0], 10); - expect(results).toHaveLength(1); - }); - - it('returns empty array when index is empty', () => { - const results = index.search([1, 0], 5); - expect(results).toEqual([]); - }); - - it('returns 0 score for orthogonal vectors', () => { - index.upsert([tool('s1', 'orthogonal')], [[1, 0]]); - const results = index.search([0, 1], 1); - expect(results[0].score).toBeCloseTo(0, 5); - }); - - it('returns 1.0 score for identical normalized vectors', () => { - const norm = 1 / Math.sqrt(2); - index.upsert([tool('s1', 'identical')], [[norm, norm]]); - const results = index.search([norm, norm], 1); - expect(results[0].score).toBeCloseTo(1.0, 5); - }); - - it('returns empty array when k is 0', () => { - index.upsert([tool('s1', 'a')], [[1, 0]]); - const results = index.search([1, 0], 0); - expect(results).toEqual([]); - }); - - it('handles mismatched query vector dimensions gracefully', () => { - index.upsert([tool('s1', 'a')], [[1, 0, 0]]); - const results = index.search([1, 0], 1); - expect(results[0].score).toBe(0); - }); - - it('returns 0 score when vectors contain NaN', () => { - index.upsert([tool('s1', 'nan_tool')], [[NaN, 1]]); - const results = index.search([1, 0], 1); - expect(results[0].score).toBe(0); - }); - }); - - describe('removeStale', () => { - it('removes tools not in the current set', () => { - const t1 = tool('s1', 'keep'); - const t2 = tool('s1', 'remove'); - index.upsert( - [t1, t2], - [ - [1, 0], - [0, 1], - ], - ); - - const removed = index.removeStale([t1]); - expect(removed).toBe(1); - expect(index.size()).toBe(1); - }); - - it('keeps all tools when all are current', () => { - const tools = [tool('s1', 'a'), tool('s2', 'b')]; - index.upsert(tools, [ - [1, 0], - [0, 1], - ]); - - const removed = index.removeStale(tools); - expect(removed).toBe(0); - expect(index.size()).toBe(2); - }); - - it('removes all when current set is empty', () => { - index.upsert([tool('s1', 'a')], [[1, 0]]); - const removed = index.removeStale([]); - expect(removed).toBe(1); - expect(index.size()).toBe(0); - }); - - it('selectively removes across multiple servers', () => { - const t1 = tool('s1', 'keep'); - const t2 = tool('s1', 'remove'); - const t3 = tool('s2', 'keep'); - const t4 = tool('s2', 'remove'); - index.upsert( - [t1, t2, t3, t4], - [ - [1, 0], - [0, 1], - [1, 1], - [0, 0], - ], - ); - - const removed = index.removeStale([t1, t3]); - expect(removed).toBe(2); - expect(index.size()).toBe(2); - }); - }); - - describe('clear', () => { - it('removes all entries', () => { - index.upsert([tool('s1', 'a')], [[1]]); - index.clear(); - expect(index.size()).toBe(0); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/MemoryBackend.test.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/MemoryBackend.test.ts deleted file mode 100644 index 3550eccf162..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/MemoryBackend.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { MemoryBackend } from '../backends/memory'; -import type { CanonicalTool } from '../types'; - -function tool(name: string, tags: string[] = []): CanonicalTool { - return { - name, - description: `Description of ${name}`, - inputSchema: {}, - fingerprint: { value: `fp_${name}` }, - tags, - }; -} - -describe('MemoryBackend', () => { - let backend: MemoryBackend; - - beforeEach(() => { - backend = new MemoryBackend(); - }); - - describe('ensureNamespace', () => { - it('creates a new namespace', () => { - expect(() => backend.ensureNamespace(null, 3)).not.toThrow(); - }); - - it('allows same dimension on repeat call', () => { - backend.ensureNamespace('ns1', 3); - expect(() => backend.ensureNamespace('ns1', 3)).not.toThrow(); - }); - - it('throws on dimension mismatch', () => { - backend.ensureNamespace('ns1', 3); - expect(() => backend.ensureNamespace('ns1', 5)).toThrow('dim 3, got 5'); - }); - }); - - describe('upsert and get', () => { - it('inserts and retrieves tools', () => { - const t = tool('a'); - backend.upsert(['fp_a'], [[1, 0, 0]], [t]); - - const result = backend.get(['fp_a']); - expect(result).toHaveLength(1); - expect(result[0].name).toBe('a'); - }); - - it('updates existing tools', () => { - const t1 = tool('a'); - const t2 = { ...t1, description: 'updated' }; - backend.upsert(['fp_a'], [[1, 0]], [t1]); - backend.upsert(['fp_a'], [[0, 1]], [t2]); - - const result = backend.get(['fp_a']); - expect(result).toHaveLength(1); - expect(result[0].description).toBe('updated'); - }); - - it('handles empty upsert', () => { - backend.upsert([], [], []); - expect(backend.get([])).toEqual([]); - }); - - it('throws on dimension mismatch within upsert', () => { - backend.ensureNamespace(null, 2); - expect(() => backend.upsert(['fp_a'], [[1, 0, 0]], [tool('a')])).toThrow( - 'dim 2, got 3', - ); - }); - - it('skips unknown ids in get', () => { - backend.upsert(['fp_a'], [[1, 0]], [tool('a')]); - const result = backend.get(['fp_a', 'fp_b']); - expect(result).toHaveLength(1); - }); - }); - - describe('delete', () => { - it('removes records by id', () => { - backend.upsert( - ['fp_a', 'fp_b'], - [ - [1, 0], - [0, 1], - ], - [tool('a'), tool('b')], - ); - backend.delete(['fp_a']); - - expect(backend.get(['fp_a'])).toEqual([]); - expect(backend.get(['fp_b'])).toHaveLength(1); - }); - - it('handles non-existent namespace gracefully', () => { - expect(() => backend.delete(['fp_a'], 'nonexistent')).not.toThrow(); - }); - }); - - describe('search', () => { - it('returns top-k results by cosine similarity', () => { - const tools = [tool('exact'), tool('partial'), tool('none')]; - backend.upsert( - ['fp_exact', 'fp_partial', 'fp_none'], - [ - [1, 0, 0], - [0.7, 0.7, 0], - [0, 0, 1], - ], - tools, - ); - - const results = backend.search([1, 0, 0], 3); - expect(results).toHaveLength(3); - expect(results[0][0]).toBe('fp_exact'); - expect(results[0][1]).toBeCloseTo(1.0, 5); - expect(results[1][0]).toBe('fp_partial'); - }); - - it('returns at most k results', () => { - backend.upsert( - ['a', 'b', 'c'], - [ - [1, 0], - [0, 1], - [1, 1], - ], - [tool('a'), tool('b'), tool('c')], - ); - const results = backend.search([1, 0], 2); - expect(results).toHaveLength(2); - }); - - it('returns empty for non-existent namespace', () => { - expect(backend.search([1, 0], 5, 'nonexistent')).toEqual([]); - }); - }); - - describe('tag filtering in search', () => { - beforeEach(() => { - backend.upsert( - ['fp_jira', 'fp_k8s', 'fp_both'], - [ - [1, 0], - [0, 1], - [1, 1], - ], - [ - tool('jira_tool', ['jira']), - tool('k8s_tool', ['kubernetes']), - tool('both_tool', ['jira', 'kubernetes']), - ], - ); - }); - - it('filters by allow_tags', () => { - const results = backend.search([1, 1], 10, null, { - allow_tags: ['jira'], - }); - expect(results.every(([id]) => id !== 'fp_k8s')).toBe(true); - }); - - it('filters by deny_tags', () => { - const results = backend.search([1, 1], 10, null, { - deny_tags: ['kubernetes'], - }); - expect(results.every(([id]) => id !== 'fp_k8s')).toBe(true); - expect(results.every(([id]) => id !== 'fp_both')).toBe(true); - }); - - it('combines allow and deny', () => { - const results = backend.search([1, 1], 10, null, { - allow_tags: ['jira'], - deny_tags: ['kubernetes'], - }); - expect(results).toHaveLength(1); - expect(results[0][0]).toBe('fp_jira'); - }); - }); - - describe('namespaces', () => { - it('isolates data between namespaces', () => { - backend.upsert(['a'], [[1, 0]], [tool('a')], 'ns1'); - backend.upsert(['b'], [[0, 1]], [tool('b')], 'ns2'); - - expect(backend.get(['a'], 'ns1')).toHaveLength(1); - expect(backend.get(['a'], 'ns2')).toHaveLength(0); - expect(backend.get(['b'], 'ns2')).toHaveLength(1); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/TfIdfEmbedder.test.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/TfIdfEmbedder.test.ts deleted file mode 100644 index 03aa30a879c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/TfIdfEmbedder.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { TfIdfEmbedder } from '../TfIdfEmbedder'; - -describe('TfIdfEmbedder', () => { - let embedder: TfIdfEmbedder; - - beforeEach(() => { - embedder = new TfIdfEmbedder(); - }); - - describe('embed without fit', () => { - it('returns empty vectors when vocabulary is empty', () => { - const result = embedder.embedTexts(['hello world']); - expect(result).toEqual([[]]); - }); - - it('returns empty array for empty input', () => { - const result = embedder.embedTexts([]); - expect(result).toEqual([]); - }); - }); - - describe('fit with empty corpus', () => { - it('produces empty vectors after fitting empty corpus', () => { - embedder.fit([]); - const result = embedder.embedTexts(['hello world']); - expect(result).toEqual([[]]); - }); - }); - - describe('fit and embed', () => { - it('produces consistent vectors for the same input', () => { - const corpus = ['list pods in kubernetes', 'create github issue']; - embedder.fit(corpus); - - const v1 = embedder.embedTexts(['list pods in kubernetes']); - const v2 = embedder.embedTexts(['list pods in kubernetes']); - - expect(v1).toEqual(v2); - }); - - it('produces different vectors for different inputs', () => { - const corpus = [ - 'list pods in kubernetes cluster', - 'create issue on github repository', - ]; - embedder.fit(corpus); - - const [v1] = embedder.embedTexts(['list pods in kubernetes cluster']); - const [v2] = embedder.embedTexts(['create issue on github repository']); - - expect(v1).not.toEqual(v2); - }); - - it('produces L2-normalized vectors', () => { - const corpus = ['deploy application', 'scale deployment replicas']; - embedder.fit(corpus); - - const [vec] = embedder.embedTexts(['deploy application']); - const norm = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0)); - expect(norm).toBeCloseTo(1.0, 5); - }); - - it('handles empty string input', () => { - embedder.fit(['hello world', 'foo bar']); - const [vec] = embedder.embedTexts(['']); - expect(vec.every(v => v === 0)).toBe(true); - }); - - it('handles single-word descriptions', () => { - embedder.fit(['deploy', 'search']); - const [v1] = embedder.embedTexts(['deploy']); - const [v2] = embedder.embedTexts(['search']); - expect(v1.length).toBe(v2.length); - expect(v1.length).toBeGreaterThan(0); - }); - - it('rebuilds vocabulary on refit', () => { - embedder.fit(['alpha bravo']); - const [v1] = embedder.embedTexts(['alpha bravo']); - const dim1 = v1.length; - - embedder.fit(['charlie delta echo']); - const [v2] = embedder.embedTexts(['charlie delta echo']); - const dim2 = v2.length; - - expect(dim1).not.toBe(dim2); - }); - - it('embeds multiple texts at once', () => { - embedder.fit(['pods list', 'create issue', 'deploy app']); - const results = embedder.embedTexts(['pods list', 'create issue']); - expect(results).toHaveLength(2); - expect(results[0].length).toBe(results[1].length); - }); - - it('filters stop words and short tokens', () => { - embedder.fit(['the quick brown fox', 'a lazy dog']); - const [v1] = embedder.embedTexts(['the is a']); - expect(v1.every(v => v === 0)).toBe(true); - }); - - it('similar queries produce similar vectors', () => { - const corpus = [ - 'list pods kubernetes cluster', - 'create github issue tracker', - 'deploy application server', - ]; - embedder.fit(corpus); - - const [qVec] = embedder.embedTexts(['pods kubernetes']); - const [podVec] = embedder.embedTexts(['list pods kubernetes cluster']); - const [issueVec] = embedder.embedTexts(['create github issue tracker']); - - const dotSimilar = qVec.reduce((s, v, i) => s + v * podVec[i], 0); - const dotDissimilar = qVec.reduce((s, v, i) => s + v * issueVec[i], 0); - - expect(dotSimilar).toBeGreaterThan(dotDissimilar); - }); - }); - - describe('camelCase splitting', () => { - it('splits camelCase tool names into separate tokens', () => { - const corpus = ['createIssue on GitHub', 'listPods in cluster']; - embedder.fit(corpus); - - const [vec] = embedder.embedTexts(['create issue']); - const hasNonZero = vec.some(v => v !== 0); - expect(hasNonZero).toBe(true); - }); - - it('matches camelCase tool name from natural language query', () => { - const corpus = [ - 'createIssue Create a new issue', - 'getContainerLogs Get container logs', - 'listDeployments List all deployments', - ]; - embedder.fit(corpus); - - const [qVec] = embedder.embedTexts(['create issue']); - const [createVec] = embedder.embedTexts([ - 'createIssue Create a new issue', - ]); - const [logsVec] = embedder.embedTexts([ - 'getContainerLogs Get container logs', - ]); - - const dotCreate = qVec.reduce((s, v, i) => s + v * createVec[i], 0); - const dotLogs = qVec.reduce((s, v, i) => s + v * logsVec[i], 0); - - expect(dotCreate).toBeGreaterThan(dotLogs); - }); - - it('handles PascalCase names', () => { - embedder.fit(['ListAllPods in namespace']); - const [vec] = embedder.embedTexts(['list pods']); - const hasNonZero = vec.some(v => v !== 0); - expect(hasNonZero).toBe(true); - }); - - it('handles snake_case names (unchanged behavior)', () => { - embedder.fit(['list_pods in namespace']); - const [vec] = embedder.embedTexts(['list pods']); - const hasNonZero = vec.some(v => v !== 0); - expect(hasNonZero).toBe(true); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/ToolIndex.test.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/ToolIndex.test.ts deleted file mode 100644 index df900fc9ad0..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/ToolIndex.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ToolIndex, makeIndex } from '../ToolIndex'; -import { MemoryBackend } from '../backends/memory'; -import { TfIdfEmbedder } from '../TfIdfEmbedder'; -import { McpToolNormalizer } from '../normalize'; -import { EmbeddingCache } from '../cache'; -import type { ToolScopeTrace, TraceSink } from '../observability'; -import type { StickySessionConfig } from '../session'; - -function mcpTool(name: string, description: string, tags: string[] = []) { - return { - name, - description, - inputSchema: {}, - ...(tags.length > 0 ? { tags } : {}), - }; -} - -function createTestIndex(opts?: { sessionCfg?: StickySessionConfig }) { - const embedder = new TfIdfEmbedder(); - return new ToolIndex({ - backend: new MemoryBackend(), - embedder, - normalizer: new McpToolNormalizer(), - cache: new EmbeddingCache(), - sessionCfg: opts?.sessionCfg, - }); -} - -describe('ToolIndex', () => { - describe('upsertTools', () => { - it('upserts MCP tools and makes them searchable', () => { - const idx = createTestIndex(); - idx.upsertTools([ - mcpTool('list_pods', 'List all kubernetes pods'), - mcpTool('create_issue', 'Create a GitHub issue'), - ]); - - const result = idx.filter('list pods', { k: 1 }); - expect(result).toHaveLength(1); - const tool = result[0] as Record; - expect(tool.name).toBe('list_pods'); - }); - - it('caches embeddings for non-fit embedders', () => { - const embedder: any = { - embedTexts: jest.fn((texts: string[]) => texts.map(() => [1, 0, 0])), - }; - - const idx = new ToolIndex({ - backend: new MemoryBackend(), - embedder, - normalizer: new McpToolNormalizer(), - cache: new EmbeddingCache(), - }); - - const tools = [mcpTool('test', 'description')]; - idx.upsertTools(tools); - idx.upsertTools(tools); - - expect(embedder.embedTexts).toHaveBeenCalledTimes(1); - }); - - it('re-embeds all tools when fit() is present (TF-IDF)', () => { - const embedder = new TfIdfEmbedder(); - const spy = jest.spyOn(embedder, 'embedTexts'); - const idx = new ToolIndex({ - backend: new MemoryBackend(), - embedder, - normalizer: new McpToolNormalizer(), - cache: new EmbeddingCache(), - }); - - idx.upsertTools([mcpTool('test', 'description')]); - idx.upsertTools([mcpTool('test', 'description')]); - - expect(spy).toHaveBeenCalledTimes(2); - }); - }); - - describe('filter', () => { - it('returns original tool specs unchanged', () => { - const idx = createTestIndex(); - const original = mcpTool('list_pods', 'List kubernetes pods'); - idx.upsertTools([original]); - - const [result] = idx.filter('list pods', { k: 1 }) as any[]; - expect(result).toBe(original); - }); - - it('accepts string messages', () => { - const idx = createTestIndex(); - idx.upsertTools([mcpTool('test', 'test tool')]); - const result = idx.filter('test', { k: 1 }); - expect(result).toHaveLength(1); - }); - - it('accepts OpenAI-style message arrays', () => { - const idx = createTestIndex(); - idx.upsertTools([mcpTool('test', 'test tool')]); - const result = idx.filter([{ role: 'user', content: 'test' }], { k: 1 }); - expect(result).toHaveLength(1); - }); - }); - - describe('filterWithTrace', () => { - it('returns trace with timing information', () => { - const idx = createTestIndex(); - idx.upsertTools([ - mcpTool('list_pods', 'List all kubernetes pods'), - mcpTool('create_issue', 'Create a GitHub issue'), - ]); - - const [tools, trace] = idx.filterWithTrace('list pods', { k: 1 }); - expect(tools).toHaveLength(1); - expect(trace.msTotal).toBeGreaterThanOrEqual(0); - expect(trace.msEmbedQuery).toBeGreaterThanOrEqual(0); - expect(trace.msSearch).toBeGreaterThanOrEqual(0); - expect(trace.returnedTools).toBe(1); - }); - - it('emits trace to TraceSink', () => { - const traces: ToolScopeTrace[] = []; - const sink: TraceSink = { emit: t => traces.push(t) }; - - const idx = createTestIndex(); - idx.upsertTools([mcpTool('test', 'test')]); - idx.filterWithTrace('test', { k: 1, traceSink: sink }); - - expect(traces).toHaveLength(1); - expect(traces[0].returnedTools).toBe(1); - }); - - it('records candidate scores', () => { - const idx = createTestIndex(); - idx.upsertTools([ - mcpTool('list_pods', 'List pods'), - mcpTool('create_issue', 'Create issue'), - ]); - - const [, trace] = idx.filterWithTrace('list pods', { k: 2 }); - expect(trace.candidates.length).toBeGreaterThan(0); - expect(trace.candidates[0].vectorScore).toBeDefined(); - }); - }); - - describe('tag filtering', () => { - it('filters by allow_tags', () => { - const idx = createTestIndex(); - idx.upsertTools([ - mcpTool('jira_tool', 'Create Jira issue', ['jira']), - mcpTool('k8s_tool', 'List pods', ['kubernetes']), - ]); - - const result = idx.filter('tool', { - k: 10, - allowTags: ['jira'], - }); - expect(result).toHaveLength(1); - expect((result[0] as any).name).toBe('jira_tool'); - }); - - it('filters by deny_tags', () => { - const idx = createTestIndex(); - idx.upsertTools([ - mcpTool('safe_tool', 'Safe tool', ['safe']), - mcpTool('dangerous_tool', 'Dangerous tool', ['dangerous']), - ]); - - const result = idx.filter('tool', { - k: 10, - denyTags: ['dangerous'], - }); - expect(result).toHaveLength(1); - expect((result[0] as any).name).toBe('safe_tool'); - }); - }); - - describe('sticky sessions', () => { - const sessionCfg: StickySessionConfig = { - enabled: true, - similarityThresholdReuse: 0.92, - similarityThresholdRefresh: 0.8, - stickyKeep: 2, - stickyBoost: 0.03, - ttlSeconds: 3600, - maxSessions: 100, - }; - - it('reuses previous tools for highly similar queries', () => { - const idx = createTestIndex({ sessionCfg }); - idx.upsertTools([ - mcpTool('list_pods', 'List kubernetes pods'), - mcpTool('create_issue', 'Create GitHub issue'), - mcpTool('deploy_app', 'Deploy application'), - ]); - - const [tools1, trace1] = idx.filterWithTrace('list kubernetes pods', { - k: 1, - sessionId: 'test-session', - }); - expect(tools1).toHaveLength(1); - expect(trace1.mode).toBe('fresh'); - - const [tools2, trace2] = idx.filterWithTrace('list kubernetes pods', { - k: 1, - sessionId: 'test-session', - }); - expect(trace2.mode).toBe('reuse'); - expect((tools2[0] as any).name).toBe((tools1[0] as any).name); - }); - - it('uses fresh mode for different queries', () => { - const idx = createTestIndex({ sessionCfg }); - idx.upsertTools([ - mcpTool('list_pods', 'List kubernetes pods'), - mcpTool('create_issue', 'Create GitHub issue'), - ]); - - idx.filterWithTrace('list kubernetes pods', { - k: 1, - sessionId: 'test-session', - }); - - const [, trace2] = idx.filterWithTrace( - 'completely unrelated topic about cooking recipes', - { - k: 1, - sessionId: 'test-session', - }, - ); - expect(trace2.mode).not.toBe('reuse'); - }); - }); - - describe('makeIndex factory', () => { - it('creates an index with tools immediately upserted', () => { - const idx = makeIndex({ - tools: [mcpTool('test', 'test tool')], - embedder: new TfIdfEmbedder(), - }); - - const result = idx.filter('test', { k: 1 }); - expect(result).toHaveLength(1); - }); - - it('uses AutoToolNormalizer by default', () => { - const idx = makeIndex({ - tools: [ - { name: 'mcp_tool', description: 'an mcp tool', inputSchema: {} }, - ], - embedder: new TfIdfEmbedder(), - }); - - const result = idx.filter('mcp tool', { k: 1 }); - expect(result).toHaveLength(1); - }); - }); - - describe('deleteTools', () => { - it('removes tools from the index', () => { - const idx = createTestIndex(); - const tools = [mcpTool('test', 'test tool')]; - idx.upsertTools(tools); - - const [, trace] = idx.filterWithTrace('test', { k: 1 }); - expect(trace.returnedTools).toBe(1); - - const canonical = idx.normalizer.normalize(tools); - idx.deleteTools(canonical.map(t => t.fingerprint)); - - const [, trace2] = idx.filterWithTrace('test', { k: 1 }); - expect(trace2.returnedTools).toBe(0); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/ToolScopeFilterService.test.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/ToolScopeFilterService.test.ts deleted file mode 100644 index 45f5f264295..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/ToolScopeFilterService.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { - ToolScopeFilterService, - createToolScopeService, -} from '../ToolScopeFilterService'; -import type { ToolDescriptor } from '../types'; -import { createMockLogger } from '../../../test-utils/mocks'; - -function tool( - serverId: string, - name: string, - description: string, -): ToolDescriptor { - return { serverId, name, description }; -} - -describe('ToolScopeFilterService', () => { - let mockLogger: ReturnType; - let service: ToolScopeFilterService; - - beforeEach(() => { - jest.clearAllMocks(); - mockLogger = createMockLogger(); - service = new ToolScopeFilterService( - mockLogger as unknown as LoggerService, - ); - }); - - describe('updateIndex', () => { - it('logs index update summary', () => { - const tools = [ - tool('ocp', 'list_pods', 'List all pods in a namespace'), - tool('ocp', 'get_logs', 'Get container logs'), - tool('github', 'create_issue', 'Create a GitHub issue'), - ]; - - service.updateIndex(tools); - - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('3 tools across 2 servers'), - ); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('3 changed'), - ); - }); - - it('detects unchanged tools via fingerprinting', () => { - const tools = [tool('ocp', 'list_pods', 'List all pods in a namespace')]; - - service.updateIndex(tools); - service.updateIndex(tools); - - const secondCall = (mockLogger.info as jest.Mock).mock.calls.find( - (c: string[]) => typeof c[0] === 'string' && c[0].includes('0 changed'), - ); - expect(secondCall).toBeTruthy(); - }); - - it('detects changed tools when descriptions update', () => { - service.updateIndex([tool('ocp', 'list_pods', 'List all pods')]); - - service.updateIndex([ - tool('ocp', 'list_pods', 'List all pods in all namespaces'), - ]); - - const calls = (mockLogger.info as jest.Mock).mock.calls; - const secondUpdate = calls.find( - (c: string[]) => - typeof c[0] === 'string' && - c[0].includes('1 changed') && - c[0].includes('0 removed'), - ); - expect(secondUpdate).toBeTruthy(); - }); - - it('detects removed tools', () => { - service.updateIndex([ - tool('ocp', 'list_pods', 'List all pods'), - tool('ocp', 'get_logs', 'Get container logs'), - ]); - - service.updateIndex([tool('ocp', 'list_pods', 'List all pods')]); - - const calls = (mockLogger.info as jest.Mock).mock.calls; - const hasRemoved = calls.some( - (c: string[]) => typeof c[0] === 'string' && c[0].includes('1 removed'), - ); - expect(hasRemoved).toBe(true); - }); - }); - - describe('filterTools', () => { - const tools = [ - tool('ocp', 'list_pods', 'List all kubernetes pods in the cluster'), - tool('ocp', 'get_logs', 'Get container logs from a running pod'), - tool('ocp', 'scale_deployment', 'Scale deployment replicas up or down'), - tool('github', 'create_issue', 'Create a new issue on GitHub'), - tool('github', 'search_repos', 'Search GitHub repositories'), - tool('aws', 'list_instances', 'List EC2 instances in AWS account'), - ]; - - beforeEach(() => { - service.updateIndex(tools); - }); - - it('returns top-K tools grouped by serverId', () => { - const result = service.filterTools('list pods', 3); - expect(result.scopedTools.size).toBeGreaterThan(0); - - let totalTools = 0; - for (const names of result.scopedTools.values()) { - totalTools += names.length; - } - expect(totalTools).toBeLessThanOrEqual(3); - }); - - it('returns scores sorted descending', () => { - const result = service.filterTools('kubernetes pods', 3); - for (let i = 1; i < result.scores.length; i++) { - expect(result.scores[i - 1].score).toBeGreaterThanOrEqual( - result.scores[i].score, - ); - } - }); - - it('reports duration in milliseconds', () => { - const result = service.filterTools('create issue', 3); - expect(result.durationMs).toBeGreaterThanOrEqual(0); - }); - - it('intersects with admin allowedTools (never expands)', () => { - const adminAllowed = new Map([['ocp', ['list_pods', 'get_logs']]]); - - const result = service.filterTools( - 'pods logs deployment', - 6, - adminAllowed, - ); - - const ocpTools = result.scopedTools.get('ocp') ?? []; - expect(ocpTools).not.toContain('scale_deployment'); - for (const name of ocpTools) { - expect(['list_pods', 'get_logs']).toContain(name); - } - }); - - it('returns all servers when no admin restriction', () => { - const result = service.filterTools('list pods instances', 6); - const servers = Array.from(result.scopedTools.keys()); - expect(servers.length).toBeGreaterThanOrEqual(1); - }); - - it('does not restrict servers absent from adminAllowedTools', () => { - const adminAllowed = new Map([['ocp', ['list_pods']]]); - const result = service.filterTools( - 'create github issue', - 6, - adminAllowed, - ); - const githubTools = result.scopedTools.get('github') ?? []; - expect(githubTools.length).toBeGreaterThan(0); - }); - - it('filters out all tools when admin allows empty array for server', () => { - const adminAllowed = new Map([['ocp', [] as string[]]]); - const result = service.filterTools('list pods', 6, adminAllowed); - const ocpTools = result.scopedTools.get('ocp') ?? []; - expect(ocpTools).toEqual([]); - }); - - it('handles empty query gracefully', () => { - const result = service.filterTools('', 3); - expect(result.scopedTools).toBeDefined(); - expect(result.scores).toBeDefined(); - expect(result.durationMs).toBeGreaterThanOrEqual(0); - }); - }); - - describe('minScore filtering', () => { - const tools = [ - tool('ocp', 'list_pods', 'List all kubernetes pods in the cluster'), - tool('ocp', 'get_logs', 'Get container logs from a running pod'), - tool('github', 'create_issue', 'Create a new issue on GitHub'), - tool('aws', 'list_instances', 'List EC2 instances in AWS'), - ]; - - beforeEach(() => { - service.updateIndex(tools); - }); - - it('filters out low-scoring tools when minScore is set', () => { - const withoutMin = service.filterTools('list pods', 10); - const withMin = service.filterTools('list pods', 10, undefined, 0.1); - - let countWithout = 0; - for (const names of withoutMin.scopedTools.values()) { - countWithout += names.length; - } - let countWith = 0; - for (const names of withMin.scopedTools.values()) { - countWith += names.length; - } - - expect(countWith).toBeLessThanOrEqual(countWithout); - }); - - it('returns no tools when minScore is impossibly high', () => { - const result = service.filterTools('list pods', 10, undefined, 0.99); - - let total = 0; - for (const names of result.scopedTools.values()) { - total += names.length; - } - expect(total).toBe(0); - }); - - it('returns all top-K when minScore is 0', () => { - const result = service.filterTools('list pods', 3, undefined, 0); - expect(result.scores.length).toBe(3); - }); - }); - - describe('vocabulary consistency after incremental updates', () => { - it('existing tools remain searchable after adding a new tool', () => { - const initial = [ - tool('ocp', 'list_pods', 'List all kubernetes pods'), - tool('ocp', 'get_logs', 'Get container logs'), - ]; - service.updateIndex(initial); - - const beforeAdd = service.filterTools('list pods', 2); - const beforePods = beforeAdd.scores.find(s => s.name === 'list_pods'); - expect(beforePods).toBeDefined(); - expect(beforePods!.score).toBeGreaterThan(0); - - service.updateIndex([ - ...initial, - tool('github', 'create_issue', 'Create a new issue on GitHub'), - ]); - - const afterAdd = service.filterTools('list pods', 3); - const afterPods = afterAdd.scores.find(s => s.name === 'list_pods'); - expect(afterPods).toBeDefined(); - expect(afterPods!.score).toBeGreaterThan(0); - }); - - it('existing tools remain searchable after modifying a tool description', () => { - const initial = [ - tool('ocp', 'list_pods', 'List all kubernetes pods'), - tool('ocp', 'get_logs', 'Get container logs'), - tool('github', 'create_issue', 'Create a GitHub issue'), - ]; - service.updateIndex(initial); - - service.updateIndex([ - tool('ocp', 'list_pods', 'List all kubernetes pods'), - tool('ocp', 'get_logs', 'Retrieve logs from containers and pods'), - tool('github', 'create_issue', 'Create a GitHub issue'), - ]); - - const result = service.filterTools('list pods', 3); - const podsTool = result.scores.find(s => s.name === 'list_pods'); - expect(podsTool).toBeDefined(); - expect(podsTool!.score).toBeGreaterThan(0); - }); - }); - - describe('createToolScopeService factory', () => { - it('creates a working service', () => { - const svc = createToolScopeService( - mockLogger as unknown as LoggerService, - ); - svc.updateIndex([tool('s1', 'tool1', 'A helpful tool')]); - const result = svc.filterTools('helpful', 1); - expect(result.scopedTools.size).toBe(1); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/cache.test.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/cache.test.ts deleted file mode 100644 index 96b824b3250..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/cache.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { EmbeddingCache } from '../cache'; - -describe('EmbeddingCache', () => { - let cache: EmbeddingCache; - - beforeEach(() => { - cache = new EmbeddingCache(); - }); - - it('starts empty', () => { - expect(cache.size).toBe(0); - }); - - it('stores and retrieves vectors by fingerprint', () => { - cache.put({ value: 'fp1' }, [1, 2, 3]); - expect(cache.get({ value: 'fp1' })).toEqual([1, 2, 3]); - }); - - it('returns undefined for unknown fingerprint', () => { - expect(cache.get({ value: 'unknown' })).toBeUndefined(); - }); - - it('overwrites existing vector', () => { - cache.put({ value: 'fp1' }, [1, 0]); - cache.put({ value: 'fp1' }, [0, 1]); - expect(cache.get({ value: 'fp1' })).toEqual([0, 1]); - expect(cache.size).toBe(1); - }); - - it('tracks size correctly', () => { - cache.put({ value: 'a' }, [1]); - cache.put({ value: 'b' }, [2]); - expect(cache.size).toBe(2); - }); - - it('clears all entries', () => { - cache.put({ value: 'a' }, [1]); - cache.put({ value: 'b' }, [2]); - cache.clear(); - expect(cache.size).toBe(0); - expect(cache.get({ value: 'a' })).toBeUndefined(); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/fingerprint.test.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/fingerprint.test.ts deleted file mode 100644 index 674b6556fb4..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/fingerprint.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { stableJson, fingerprintTool } from '../fingerprint'; - -describe('stableJson', () => { - it('sorts keys alphabetically', () => { - expect(stableJson({ b: 1, a: 2 })).toBe('{"a":2,"b":1}'); - }); - - it('sorts nested keys recursively', () => { - expect(stableJson({ z: { b: 1, a: 2 }, a: 0 })).toBe( - '{"a":0,"z":{"a":2,"b":1}}', - ); - }); - - it('handles arrays without reordering', () => { - expect(stableJson({ a: [3, 1, 2] })).toBe('{"a":[3,1,2]}'); - }); - - it('handles null values', () => { - expect(stableJson({ a: null })).toBe('{"a":null}'); - }); - - it('handles empty object', () => { - expect(stableJson({})).toBe('{}'); - }); -}); - -describe('fingerprintTool', () => { - it('produces deterministic hex hash', () => { - const fp1 = fingerprintTool('test', 'desc', {}); - const fp2 = fingerprintTool('test', 'desc', {}); - expect(fp1.value).toBe(fp2.value); - expect(fp1.value).toMatch(/^[a-f0-9]{64}$/); - }); - - it('changes when name changes', () => { - const fp1 = fingerprintTool('tool_a', 'desc', {}); - const fp2 = fingerprintTool('tool_b', 'desc', {}); - expect(fp1.value).not.toBe(fp2.value); - }); - - it('changes when description changes', () => { - const fp1 = fingerprintTool('tool', 'desc_a', {}); - const fp2 = fingerprintTool('tool', 'desc_b', {}); - expect(fp1.value).not.toBe(fp2.value); - }); - - it('changes when inputSchema changes', () => { - const fp1 = fingerprintTool('tool', 'desc', { type: 'object' }); - const fp2 = fingerprintTool('tool', 'desc', { type: 'string' }); - expect(fp1.value).not.toBe(fp2.value); - }); - - it('includes extra in fingerprint', () => { - const fp1 = fingerprintTool('tool', 'desc', {}, { version: '1' } as any); - const fp2 = fingerprintTool('tool', 'desc', {}, { version: '2' } as any); - expect(fp1.value).not.toBe(fp2.value); - }); - - it('defaults extra to empty object', () => { - const fp1 = fingerprintTool('tool', 'desc', {}); - const fp2 = fingerprintTool('tool', 'desc', {}, {}); - expect(fp1.value).toBe(fp2.value); - }); - - it('is insensitive to key insertion order in schema', () => { - const fp1 = fingerprintTool('tool', 'desc', { a: 1, b: 2 }); - const fp2 = fingerprintTool('tool', 'desc', { b: 2, a: 1 }); - expect(fp1.value).toBe(fp2.value); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/normalize.test.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/normalize.test.ts deleted file mode 100644 index 94ade309e06..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/normalize.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - OpenAIToolNormalizer, - McpToolNormalizer, - AutoToolNormalizer, -} from '../normalize'; - -describe('OpenAIToolNormalizer', () => { - const norm = new OpenAIToolNormalizer(); - - it('normalizes an OpenAI function tool dict', () => { - const tool = { - type: 'function', - function: { - name: 'get_weather', - description: 'Get weather for a city', - parameters: { - type: 'object', - properties: { city: { type: 'string' } }, - }, - }, - }; - - const [ct] = norm.normalize([tool]); - expect(ct.name).toBe('get_weather'); - expect(ct.description).toBe('Get weather for a city'); - expect(ct.inputSchema).toEqual(tool.function.parameters); - expect(ct.fingerprint.value).toMatch(/^[a-f0-9]{64}$/); - expect(ct.payload).toBe(tool); - }); - - it('extracts tags from toolscope_tags', () => { - const tool = { - type: 'function', - function: { name: 'test', description: '', parameters: {} }, - toolscope_tags: ['weather', 'api'], - }; - const [ct] = norm.normalize([tool]); - expect(ct.tags).toEqual(['weather', 'api']); - }); - - it('extracts tags from annotations.tags', () => { - const tool = { - type: 'function', - function: { name: 'test', description: '', parameters: {} }, - annotations: { tags: ['internal'] }, - }; - const [ct] = norm.normalize([tool]); - expect(ct.tags).toEqual(['internal']); - }); - - it('throws on non-OpenAI dict', () => { - expect(() => norm.normalize([{ name: 'test' }])).toThrow(TypeError); - }); - - it('round-trips via denormalize', () => { - const tool = { - type: 'function', - function: { name: 'test', description: '', parameters: {} }, - }; - const canonical = norm.normalize([tool]); - const [out] = norm.denormalize(canonical); - expect(out).toBe(tool); - }); - - it('denormalize throws when payload is missing', () => { - expect(() => - norm.denormalize([ - { - name: 'x', - description: '', - inputSchema: {}, - fingerprint: { value: 'a' }, - tags: [], - }, - ]), - ).toThrow('payload missing'); - }); -}); - -describe('McpToolNormalizer', () => { - const norm = new McpToolNormalizer(); - - it('normalizes an MCP tool dict', () => { - const tool = { - name: 'list_pods', - description: 'List pods', - inputSchema: { type: 'object' }, - }; - const [ct] = norm.normalize([tool]); - expect(ct.name).toBe('list_pods'); - expect(ct.description).toBe('List pods'); - expect(ct.payload).toBe(tool); - }); - - it('extracts tags from MCP dict', () => { - const tool = { - name: 'test', - inputSchema: {}, - tags: ['kubernetes'], - }; - const [ct] = norm.normalize([tool]); - expect(ct.tags).toEqual(['kubernetes']); - }); - - it('handles MCP object-style tools', () => { - const tool = { name: 'test', inputSchema: {}, description: 'desc' }; - const [ct] = norm.normalize([tool]); - expect(ct.name).toBe('test'); - expect(ct.description).toBe('desc'); - }); - - it('throws on unrecognized format', () => { - expect(() => norm.normalize([42])).toThrow(TypeError); - }); -}); - -describe('AutoToolNormalizer', () => { - const norm = new AutoToolNormalizer(); - - it('auto-detects OpenAI format', () => { - const tool = { - type: 'function', - function: { name: 'test', description: 'desc', parameters: {} }, - }; - const [ct] = norm.normalize([tool]); - expect(ct.name).toBe('test'); - }); - - it('auto-detects MCP format', () => { - const tool = { name: 'test', description: 'desc', inputSchema: {} }; - const [ct] = norm.normalize([tool]); - expect(ct.name).toBe('test'); - }); - - it('normalizes mixed formats', () => { - const openai = { - type: 'function', - function: { name: 'openai_tool', description: '', parameters: {} }, - }; - const mcp = { name: 'mcp_tool', description: '', inputSchema: {} }; - const results = norm.normalize([openai, mcp]); - expect(results).toHaveLength(2); - expect(results[0].name).toBe('openai_tool'); - expect(results[1].name).toBe('mcp_tool'); - }); - - it('throws on unrecognized format', () => { - expect(() => norm.normalize([{ random: 'data' }])).toThrow(TypeError); - }); - - it('round-trips mixed formats', () => { - const openai = { - type: 'function', - function: { name: 'a', description: '', parameters: {} }, - }; - const mcp = { name: 'b', description: '', inputSchema: {} }; - const canonical = norm.normalize([openai, mcp]); - const out = norm.denormalize(canonical); - expect(out[0]).toBe(openai); - expect(out[1]).toBe(mcp); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/observability.test.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/observability.test.ts deleted file mode 100644 index 145c202f384..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/observability.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createTrace, Stopwatch } from '../observability'; -import type { TraceSink, ToolScopeTrace } from '../observability'; - -describe('createTrace', () => { - it('creates a trace with default values', () => { - const trace = createTrace(); - expect(trace.mode).toBe('fresh'); - expect(trace.returnedTools).toBe(0); - expect(trace.msTotal).toBe(0); - expect(trace.candidates).toEqual([]); - }); - - it('sets sessionId and turnId', () => { - const trace = createTrace('session-1', 'turn-2'); - expect(trace.sessionId).toBe('session-1'); - expect(trace.turnId).toBe('turn-2'); - }); -}); - -describe('Stopwatch', () => { - it('measures elapsed time in ms', () => { - const sw = new Stopwatch(); - const elapsed = sw.ms(); - expect(elapsed).toBeGreaterThanOrEqual(0); - expect(elapsed).toBeLessThan(1000); - }); -}); - -describe('TraceSink', () => { - it('receives emitted traces', () => { - const traces: ToolScopeTrace[] = []; - const sink: TraceSink = { emit: t => traces.push(t) }; - - const trace = createTrace(); - trace.returnedTools = 5; - sink.emit(trace); - - expect(traces).toHaveLength(1); - expect(traces[0].returnedTools).toBe(5); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/session.test.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/session.test.ts deleted file mode 100644 index d43d1cb4ddf..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/session.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { cosine, SessionCache, DEFAULT_SESSION_CONFIG } from '../session'; - -describe('cosine', () => { - it('returns 1 for identical vectors', () => { - expect(cosine([1, 0], [1, 0])).toBeCloseTo(1.0, 5); - }); - - it('returns 0 for orthogonal vectors', () => { - expect(cosine([1, 0], [0, 1])).toBeCloseTo(0, 5); - }); - - it('returns -1 for opposite vectors', () => { - expect(cosine([1, 0], [-1, 0])).toBeCloseTo(-1.0, 5); - }); - - it('handles non-unit vectors', () => { - expect(cosine([3, 0], [3, 0])).toBeCloseTo(1.0, 5); - }); -}); - -describe('SessionCache', () => { - it('stores and retrieves session state', () => { - const cache = new SessionCache({ - ...DEFAULT_SESSION_CONFIG, - enabled: true, - }); - - cache.put('s1', [1, 0], ['fp1', 'fp2']); - const state = cache.get('s1'); - expect(state).toBeDefined(); - expect(state!.lastQueryVec).toEqual([1, 0]); - expect(state!.lastSelectedIds).toEqual(['fp1', 'fp2']); - }); - - it('returns undefined for unknown session', () => { - const cache = new SessionCache({ - ...DEFAULT_SESSION_CONFIG, - enabled: true, - }); - expect(cache.get('unknown')).toBeUndefined(); - }); - - it('expires sessions after TTL', () => { - const cache = new SessionCache({ - ...DEFAULT_SESSION_CONFIG, - enabled: true, - ttlSeconds: 0.001, - }); - - cache.put('s1', [1, 0], ['fp1']); - - const now = Date.now(); - jest.spyOn(Date, 'now').mockReturnValue(now + 10_000); - expect(cache.get('s1')).toBeUndefined(); - jest.restoreAllMocks(); - }); - - it('evicts oldest sessions when maxSessions exceeded', () => { - const cache = new SessionCache({ - ...DEFAULT_SESSION_CONFIG, - enabled: true, - maxSessions: 3, - }); - - cache.put('s1', [1], ['a']); - cache.put('s2', [2], ['b']); - cache.put('s3', [3], ['c']); - cache.put('s4', [4], ['d']); - - expect(cache.size).toBeLessThanOrEqual(3); - }); - - it('updates existing session', () => { - const cache = new SessionCache({ - ...DEFAULT_SESSION_CONFIG, - enabled: true, - }); - - cache.put('s1', [1, 0], ['fp1']); - cache.put('s1', [0, 1], ['fp2']); - const state = cache.get('s1'); - expect(state!.lastQueryVec).toEqual([0, 1]); - expect(state!.lastSelectedIds).toEqual(['fp2']); - expect(cache.size).toBe(1); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/text.test.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/text.test.ts deleted file mode 100644 index ede56ed3ebf..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/__tests__/text.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - renderToolText, - DEFAULT_TOOL_TEXT_CONFIG, - lowercase, - collapseWhitespace, - normalizeUnicode, - stripControlChars, -} from '../text'; -import type { CanonicalTool } from '../types'; - -function makeTool(overrides?: Partial): CanonicalTool { - return { - name: 'create_issue', - description: 'Create a new issue', - inputSchema: { type: 'object', properties: { title: { type: 'string' } } }, - fingerprint: { value: 'abc' }, - tags: ['jira', 'project'], - ...overrides, - }; -} - -describe('renderToolText', () => { - it('renders name + description by default', () => { - const text = renderToolText(makeTool()); - expect(text).toBe('create_issue\nCreate a new issue'); - }); - - it('includes schema when configured', () => { - const text = renderToolText(makeTool(), { - ...DEFAULT_TOOL_TEXT_CONFIG, - fields: ['name', 'description', 'schema'], - }); - expect(text).toContain('create_issue'); - expect(text).toContain('"type":"object"'); - }); - - it('includes tags when configured', () => { - const text = renderToolText(makeTool(), { - ...DEFAULT_TOOL_TEXT_CONFIG, - fields: ['name', 'tags'], - }); - expect(text).toBe('create_issue\njira project'); - }); - - it('truncates text to configured length', () => { - const text = renderToolText(makeTool(), { - ...DEFAULT_TOOL_TEXT_CONFIG, - truncate: 10, - }); - expect(text.length).toBe(10); - }); - - it('does not truncate when truncate is null', () => { - const longDesc = 'a'.repeat(500); - const text = renderToolText(makeTool({ description: longDesc }), { - ...DEFAULT_TOOL_TEXT_CONFIG, - truncate: null, - }); - expect(text.length).toBeGreaterThan(256); - }); - - it('applies preprocessors in order', () => { - const text = renderToolText(makeTool(), { - ...DEFAULT_TOOL_TEXT_CONFIG, - preprocessors: [lowercase()], - }); - expect(text).toBe('create_issue\ncreate a new issue'); - }); - - it('skips empty parts', () => { - const text = renderToolText(makeTool({ description: '' }), { - ...DEFAULT_TOOL_TEXT_CONFIG, - fields: ['name', 'description'], - }); - expect(text).toBe('create_issue'); - }); - - it('throws on unsupported field', () => { - expect(() => - renderToolText(makeTool(), { - ...DEFAULT_TOOL_TEXT_CONFIG, - fields: ['unknown' as any], - }), - ).toThrow('Unsupported field'); - }); -}); - -describe('preprocessors', () => { - it('lowercase converts to lowercase', () => { - expect(lowercase()('Hello World')).toBe('hello world'); - }); - - it('collapseWhitespace normalizes whitespace', () => { - expect(collapseWhitespace()(' hello world ')).toBe('hello world'); - }); - - it('normalizeUnicode normalizes unicode', () => { - const fn = normalizeUnicode('NFKC'); - expect(fn('\u00e9')).toBe('\u00e9'); - }); - - it('stripControlChars removes control characters but keeps newlines', () => { - const fn = stripControlChars(); - expect(fn('hello\x00world\ntest\ttab')).toBe('helloworld\ntest\ttab'); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/backends/memory.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/backends/memory.ts deleted file mode 100644 index d6c74f9244f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/backends/memory.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { CanonicalTool, Json, ToolIndexBackend } from '../types'; - -const DEFAULT_NS = '__default__'; - -function norm(v: number[]): number { - let sum = 0; - for (let i = 0; i < v.length; i++) sum += v[i] * v[i]; - return Math.sqrt(sum) || 1.0; -} - -function cosine(q: number[], qNorm: number, v: number[]): number { - let dot = 0; - for (let i = 0; i < q.length; i++) dot += q[i] * v[i]; - const result = dot / (qNorm * norm(v)); - return Number.isFinite(result) ? result : 0; -} - -function passesTagFilter( - tool: CanonicalTool, - allow: Set | null, - deny: Set | null, -): boolean { - const tset = new Set(tool.tags || []); - if (deny !== null) { - for (const tag of tset) { - if (deny.has(tag)) return false; - } - } - if (allow !== null) { - let hasAny = false; - for (const tag of tset) { - if (allow.has(tag)) { - hasAny = true; - break; - } - } - if (!hasAny) return false; - } - return true; -} - -/** - * In-memory vector backend. 1:1 port of Python ToolScope's MemoryBackend. - * - * - Zero dependencies - * - Namespace support - * - Cosine similarity search with tag filtering - * - Intended for defaults, tests, and small-to-medium tool catalogs - */ -export class MemoryBackend implements ToolIndexBackend { - private readonly _vectors = new Map>(); - private readonly _payloads = new Map>(); - private readonly _dims = new Map(); - - ensureNamespace(namespace: string | null, dim: number): void { - const ns = namespace ?? DEFAULT_NS; - if (!this._vectors.has(ns)) { - this._vectors.set(ns, new Map()); - this._payloads.set(ns, new Map()); - this._dims.set(ns, dim); - } else if (this._dims.get(ns) !== dim) { - throw new Error( - `Namespace '${ns}' already exists with dim ${this._dims.get(ns)}, got ${dim}`, - ); - } - } - - upsert( - ids: string[], - vectors: number[][], - payloads: CanonicalTool[], - namespace?: string | null, - ): void { - if (vectors.length === 0) return; - const ns = namespace ?? DEFAULT_NS; - - this.ensureNamespace(ns, vectors[0].length); - - const vMap = this._vectors.get(ns)!; - const pMap = this._payloads.get(ns)!; - const dim = this._dims.get(ns)!; - - for (let i = 0; i < ids.length; i++) { - if (vectors[i].length !== dim) { - throw new Error('Vector dimension mismatch'); - } - vMap.set(ids[i], vectors[i]); - pMap.set(ids[i], payloads[i]); - } - } - - delete(ids: string[], namespace?: string | null): void { - const ns = namespace ?? DEFAULT_NS; - const vMap = this._vectors.get(ns); - const pMap = this._payloads.get(ns); - if (!vMap || !pMap) return; - - for (const id of ids) { - vMap.delete(id); - pMap.delete(id); - } - } - - search( - queryVector: number[], - k: number, - namespace?: string | null, - filter?: Json, - ): Array<[string, number]> { - const ns = namespace ?? DEFAULT_NS; - const vMap = this._vectors.get(ns); - const pMap = this._payloads.get(ns); - if (!vMap || !pMap) return []; - - let allow: Set | null = null; - let deny: Set | null = null; - if (filter) { - const at = filter.allow_tags; - const dt = filter.deny_tags; - if (Array.isArray(at)) allow = new Set(at as string[]); - if (Array.isArray(dt)) deny = new Set(dt as string[]); - } - - const qn = norm(queryVector); - const scored: Array<[string, number]> = []; - - for (const [id, vec] of vMap) { - const payload = pMap.get(id); - if (!payload) continue; - if (filter && !passesTagFilter(payload, allow, deny)) continue; - scored.push([id, cosine(queryVector, qn, vec)]); - } - - scored.sort((a, b) => b[1] - a[1]); - return scored.slice(0, k); - } - - get(ids: string[], namespace?: string | null): CanonicalTool[] { - const ns = namespace ?? DEFAULT_NS; - const pMap = this._payloads.get(ns); - if (!pMap) return []; - return ids - .map(id => pMap.get(id)) - .filter((p): p is CanonicalTool => p !== undefined); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/cache.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/cache.ts deleted file mode 100644 index 9b4ec08ba35..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/cache.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ToolFingerprint } from './types'; - -/** - * Cache embeddings by tool fingerprint. Keeps stateless API fast. - * 1:1 port of Python ToolScope's EmbeddingCache. - */ -export class EmbeddingCache { - private readonly vectors = new Map(); - - get(fp: ToolFingerprint): number[] | undefined { - return this.vectors.get(fp.value); - } - - put(fp: ToolFingerprint, vec: number[]): void { - this.vectors.set(fp.value, vec); - } - - get size(): number { - return this.vectors.size; - } - - clear(): void { - this.vectors.clear(); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/embeddingConfig.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/embeddingConfig.ts deleted file mode 100644 index 5ddba6f7873..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/embeddingConfig.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { EmbeddingProvider } from './types'; -import { TfIdfEmbedder } from './TfIdfEmbedder'; -import { HttpEmbeddingProvider } from './HttpEmbedder'; - -/** - * Provider-agnostic embedding configuration. - * 1:1 port of Python ToolScope's EmbeddingConfig. - */ -export interface EmbeddingConfig { - /** - * Provider identifier: - * - "tfidf" (TypeScript-only, in-process) - * - "http" / "rest" (remote HTTP API) - */ - readonly provider: string; - - /** Provider-specific model name (passed to HTTP endpoint). */ - readonly model?: string; - - /** HTTP endpoint URL (required for "http" provider). */ - readonly endpoint?: string; - readonly headers?: Record; - readonly timeoutMs?: number; - - readonly extra?: Record; -} - -export class EmbeddingNotConfiguredError extends Error { - constructor(message: string) { - super(message); - this.name = 'EmbeddingNotConfiguredError'; - } -} - -/** - * Resolve an EmbeddingProvider from EmbeddingConfig. - * Only imports provider deps when that provider is selected. - */ -export function makeEmbedder(config?: EmbeddingConfig): EmbeddingProvider { - if (!config) { - throw new EmbeddingNotConfiguredError( - 'No embedding configured. Provide either:\n' + - ' - embedder (custom EmbeddingProvider), or\n' + - ' - embedding config with provider "tfidf" or "http"', - ); - } - - const prov = config.provider.trim().toLowerCase(); - - if (prov === 'tfidf' || prov === 'tf-idf') { - return new TfIdfEmbedder(); - } - - if (prov === 'http' || prov === 'rest') { - if (!config.endpoint) { - throw new EmbeddingNotConfiguredError( - 'HTTP embedding provider requires endpoint in config.', - ); - } - return new HttpEmbeddingProvider({ - endpoint: config.endpoint, - model: config.model, - headers: config.headers, - timeoutMs: config.timeoutMs, - }); - } - - throw new EmbeddingNotConfiguredError( - `Unknown embedding provider: "${config.provider}"`, - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/filter.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/filter.ts deleted file mode 100644 index 70ff7a1e4b4..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/filter.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - EmbeddingProvider, - ToolIndexBackend, - ToolNormalizer, -} from './types'; -import type { ToolTextConfig } from './text'; -import type { RerankingConfig } from './rerankers'; -import type { TraceSink, ToolScopeTrace } from './observability'; -import type { EmbeddingConfig } from './embeddingConfig'; -import { makeEmbedder } from './embeddingConfig'; -import { makeIndex } from './ToolIndex'; -import { AutoToolNormalizer } from './normalize'; - -/** - * Options for one-shot filter functions. - */ -export interface FilterToolsOptions { - k?: number; - normalizer?: ToolNormalizer; - backend?: ToolIndexBackend; - namespace?: string | null; - embedder?: EmbeddingProvider; - embedding?: EmbeddingConfig; - toolTextConfig?: ToolTextConfig; - reranking?: RerankingConfig; - allowTags?: readonly string[]; - denyTags?: readonly string[]; - traceSink?: TraceSink; -} - -/** - * One-shot filter: create an ephemeral index, upsert tools, filter by query. - * 1:1 port of Python ToolScope's filter_tools(). - */ -export function filterTools( - messages: unknown, - tools: unknown[], - options?: FilterToolsOptions, -): unknown[] { - const [result] = filterToolsWithTrace(messages, tools, options); - return result; -} - -/** - * One-shot filter with trace: same as filterTools but returns the trace. - * 1:1 port of Python ToolScope's filter_tools_with_trace(). - */ -export function filterToolsWithTrace( - messages: unknown, - tools: unknown[], - options?: FilterToolsOptions, -): [unknown[], ToolScopeTrace] { - if (options?.embedder && options?.embedding) { - throw new Error("Specify either 'embedder' or 'embedding', not both."); - } - - const embedder = options?.embedder ?? makeEmbedder(options?.embedding); - const norm = options?.normalizer ?? new AutoToolNormalizer(); - - const idx = makeIndex({ - tools, - normalizer: norm, - backend: options?.backend, - embedder, - namespace: options?.namespace, - toolTextConfig: options?.toolTextConfig, - reranking: options?.reranking, - }); - - return idx.filterWithTrace(messages, { - k: options?.k ?? 12, - reranking: options?.reranking, - allowTags: options?.allowTags, - denyTags: options?.denyTags, - traceSink: options?.traceSink, - }); -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/fingerprint.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/fingerprint.ts deleted file mode 100644 index 77d98e7acc0..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/fingerprint.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createHash } from 'node:crypto'; -import type { Json, ToolFingerprint } from './types'; - -/** - * Produce deterministic JSON for hashing. Keys are sorted recursively - * to ensure identical output regardless of insertion order. - * Matches Python ToolScope's stable_json(obj) output exactly for - * cross-language fingerprint compatibility. - */ -export function stableJson(obj: unknown): string { - return JSON.stringify(obj, (_key, value) => { - if (value !== null && typeof value === 'object' && !Array.isArray(value)) { - const sorted: Record = {}; - for (const k of Object.keys(value as Record).sort( - (a, b) => a.localeCompare(b), - )) { - sorted[k] = (value as Record)[k]; - } - return sorted; - } - return value; - }); -} - -/** - * Compute a stable fingerprint for a tool definition. - * The fingerprint changes when the tool meaningfully changes. - * Uses the same field layout as Python ToolScope for cross-language compat. - */ -export function fingerprintTool( - name: string, - description: string, - inputSchema: Json, - extra?: Json, -): ToolFingerprint { - const base = { - name, - description, - input_schema: inputSchema, - extra: extra ?? {}, - }; - const hash = createHash('sha256').update(stableJson(base)).digest('hex'); - return { value: hash }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/index.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/index.ts deleted file mode 100644 index 1e7cdd2fb54..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/index.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ---- Main API (1:1 with Python ToolScope __init__.py) ---- - -export { - filterTools as filter, - filterToolsWithTrace as filterWithTrace, -} from './filter'; -export { makeIndex as index } from './ToolIndex'; -export { ToolIndex } from './ToolIndex'; -export type { FilterOptions, MakeIndexOptions } from './ToolIndex'; - -// ---- Core types ---- - -export type { - Json, - CanonicalTool, - ToolFingerprint, - EmbeddingProvider, - ToolNormalizer, - ToolIndexBackend, -} from './types'; - -// ---- Fingerprinting ---- - -export { stableJson, fingerprintTool } from './fingerprint'; - -// ---- Configs ---- - -export type { EmbeddingConfig } from './embeddingConfig'; -export { EmbeddingNotConfiguredError, makeEmbedder } from './embeddingConfig'; -export type { RerankingConfig, Reranker } from './rerankers'; -export { makeReranker } from './rerankers'; -export type { StickySessionConfig, SessionState } from './session'; -export { SessionCache, DEFAULT_SESSION_CONFIG } from './session'; -export type { ToolTextConfig, ToolTextField, ToolPreprocessor } from './text'; -export { - DEFAULT_TOOL_TEXT_CONFIG, - renderToolText, - lowercase, - collapseWhitespace, - normalizeUnicode, - stripControlChars, -} from './text'; - -// ---- Normalizers ---- - -export { - AutoToolNormalizer, - McpToolNormalizer, - OpenAIToolNormalizer, -} from './normalize'; - -// ---- Backends ---- - -export { MemoryBackend } from './backends/memory'; - -// ---- Embedders ---- - -export { TfIdfEmbedder } from './TfIdfEmbedder'; -export { HttpEmbeddingProvider } from './HttpEmbedder'; - -// ---- Observability ---- - -export type { ToolScopeTrace, ToolScore, TraceSink } from './observability'; -export { createTrace, Stopwatch } from './observability'; - -// ---- Cache ---- - -export { EmbeddingCache } from './cache'; - -// ---- Augment integration exports ---- - -export type { - ToolDescriptor, - ToolScopeService, - ToolScopeResult, -} from './types'; -export { InMemoryToolIndex } from './InMemoryToolIndex'; -export { - ToolScopeFilterService, - createToolScopeService, -} from './ToolScopeFilterService'; diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/normalize.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/normalize.ts deleted file mode 100644 index 70507abc89c..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/normalize.ts +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { CanonicalTool, Json, ToolNormalizer } from './types'; -import { fingerprintTool } from './fingerprint'; - -/** Shared denormalize implementation for all normalizers that store payload. */ -function denormalizeFromPayload(canonical: CanonicalTool[]): unknown[] { - return canonical.map(ct => { - if (ct.payload === undefined) { - throw new Error('CanonicalTool.payload missing; cannot round-trip.'); - } - return ct.payload; - }); -} - -// ---- Tag extraction helpers ---- - -function extractTagsFromMapping(m: Record): readonly string[] { - let tags: unknown = undefined; - - if (Array.isArray(m.toolscope_tags)) { - tags = m.toolscope_tags; - } else if (Array.isArray(m.tags)) { - tags = m.tags; - } else { - const ann = m.annotations; - if ( - ann !== null && - typeof ann === 'object' && - !Array.isArray(ann) && - Array.isArray((ann as Record).tags) - ) { - tags = (ann as Record).tags; - } - } - - if (!Array.isArray(tags)) return []; - - return tags - .filter((t): t is string => typeof t === 'string' && t.trim().length > 0) - .map(t => t.trim()); -} - -// ---- Detection helpers ---- - -function isOpenAIToolDict(t: unknown): t is Record { - if (typeof t !== 'object' || t === null || Array.isArray(t)) return false; - const obj = t as Record; - if (obj.type !== 'function') return false; - const fn = obj.function; - if (typeof fn !== 'object' || fn === null || Array.isArray(fn)) return false; - const fnObj = fn as Record; - return ( - (typeof fnObj.name === 'string' || fnObj.name === undefined) && - 'parameters' in fnObj - ); -} - -function isMcpToolDict(t: unknown): t is Record { - if (typeof t !== 'object' || t === null || Array.isArray(t)) return false; - const obj = t as Record; - return ( - typeof obj.name === 'string' && - 'inputSchema' in obj && - typeof obj.inputSchema === 'object' - ); -} - -function getAttr(obj: unknown, name: string): unknown { - if (typeof obj !== 'object' || obj === null) return undefined; - return (obj as Record)[name]; -} - -function isMcpToolObject(t: unknown): boolean { - return ( - getAttr(t, 'name') !== undefined && getAttr(t, 'inputSchema') !== undefined - ); -} - -// ---- Normalizers ---- - -/** - * Normalizer for OpenAI function tool dicts: - * {type:"function", function:{name, description, parameters}} - */ -export class OpenAIToolNormalizer implements ToolNormalizer { - normalize(tools: unknown[]): CanonicalTool[] { - return tools.map(t => { - if (!isOpenAIToolDict(t)) { - throw new TypeError( - 'OpenAIToolNormalizer expects OpenAI-like tool dicts.', - ); - } - - const fn = t.function as Record; - const name = String(fn.name ?? ''); - const desc = String(fn.description ?? ''); - const params = (fn.parameters ?? {}) as Json; - const tags = extractTagsFromMapping(t); - const fp = fingerprintTool(name, desc, params); - - return { - name, - description: desc, - inputSchema: params, - tags, - fingerprint: fp, - payload: t, - }; - }); - } - - denormalize(canonical: CanonicalTool[]): unknown[] { - return denormalizeFromPayload(canonical); - } -} - -/** - * Normalizer for MCP tool descriptors (dicts or objects). - * Dict shape: {name, description, inputSchema, ...} - * Object shape: tool.name, tool.description, tool.inputSchema - */ -export class McpToolNormalizer implements ToolNormalizer { - normalize(tools: unknown[]): CanonicalTool[] { - return tools.map(t => { - if (isMcpToolDict(t)) { - const name = String(t.name ?? ''); - const desc = String(t.description ?? ''); - const schema = (t.inputSchema ?? {}) as Json; - const tags = extractTagsFromMapping(t); - const fp = fingerprintTool(name, desc, schema); - - return { - name, - description: desc, - inputSchema: schema, - tags, - fingerprint: fp, - payload: t, - }; - } - - if (isMcpToolObject(t)) { - const name = String(getAttr(t, 'name') ?? ''); - const desc = String(getAttr(t, 'description') ?? ''); - const schema = (getAttr(t, 'inputSchema') ?? {}) as Json; - - const rawTags = getAttr(t, 'tags'); - const tags = Array.isArray(rawTags) - ? rawTags - .filter( - (x): x is string => - typeof x === 'string' && x.trim().length > 0, - ) - .map(x => x.trim()) - : []; - - const fp = fingerprintTool(name, desc, schema); - return { - name, - description: desc, - inputSchema: schema, - tags, - fingerprint: fp, - payload: t, - }; - } - - throw new TypeError( - 'McpToolNormalizer expects MCP tool dicts or objects with .name/.inputSchema.', - ); - }); - } - - denormalize(canonical: CanonicalTool[]): unknown[] { - return denormalizeFromPayload(canonical); - } -} - -/** - * Auto-detect tool schema per tool and normalize accordingly. - * Supports OpenAI function tool dicts, MCP tool dicts, and MCP tool objects. - */ -export class AutoToolNormalizer implements ToolNormalizer { - private readonly openai = new OpenAIToolNormalizer(); - private readonly mcp = new McpToolNormalizer(); - - normalize(tools: unknown[]): CanonicalTool[] { - return tools.map(t => { - if (isOpenAIToolDict(t)) { - return this.openai.normalize([t])[0]; - } - if (isMcpToolDict(t) || isMcpToolObject(t)) { - return this.mcp.normalize([t])[0]; - } - throw new TypeError( - 'AutoToolNormalizer could not detect tool schema. ' + - 'Supported: OpenAI tool dicts, MCP tool dicts, MCP tool objects.', - ); - }); - } - - denormalize(canonical: CanonicalTool[]): unknown[] { - return denormalizeFromPayload(canonical); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/observability.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/observability.ts deleted file mode 100644 index dc35ed93fb3..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/observability.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Per-tool scoring details in a trace. - */ -export interface ToolScore { - toolId: string; - toolName: string; - vectorScore?: number; - rerankScore?: number; - passedFilters: boolean; - filterReasons: readonly string[]; -} - -/** - * Full trace of a single filter operation. - * 1:1 port of Python ToolScope's ToolScopeTrace. - */ -export interface ToolScopeTrace { - // Identity - sessionId?: string; - turnId?: string; - - // High-level mode - mode: 'fresh' | 'reuse' | 'refresh'; - querySimilarityToPrev?: number; - - // Counts - inputToolCount?: number; - indexedToolCount?: number; - retrievedCandidates: number; - candidatesAfterFilters: number; - candidatesReranked: number; - returnedTools: number; - - // Tag filters - allowTags: readonly string[]; - denyTags: readonly string[]; - - // Token-ish hints (rough but useful) - approxCharsBefore?: number; - approxCharsAfter?: number; - - // Timing (ms) - msEmbedQuery: number; - msSearch: number; - msFetch: number; - msFilter: number; - msRerank: number; - msTotal: number; - - // Why selected (top candidates + returned) - candidates: ToolScore[]; - - // Free-form metadata for callers - extra: Record; -} - -/** Creates a fresh trace with default values. */ -export function createTrace( - sessionId?: string, - turnId?: string, -): ToolScopeTrace { - return { - sessionId, - turnId, - mode: 'fresh', - retrievedCandidates: 0, - candidatesAfterFilters: 0, - candidatesReranked: 0, - returnedTools: 0, - allowTags: [], - denyTags: [], - msEmbedQuery: 0, - msSearch: 0, - msFetch: 0, - msFilter: 0, - msRerank: 0, - msTotal: 0, - candidates: [], - extra: {}, - }; -} - -/** - * Receives completed traces. Implement to: - * - log JSON - * - send to metrics/OTel - * - write to a file - */ -export interface TraceSink { - emit(trace: ToolScopeTrace): void; -} - -/** - * Precision timer for per-phase measurement. - */ -export class Stopwatch { - private readonly t0: number; - - constructor() { - // eslint-disable-next-line no-restricted-globals - this.t0 = performance.now(); - } - - ms(): number { - // eslint-disable-next-line no-restricted-globals - return performance.now() - this.t0; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/rerankers.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/rerankers.ts deleted file mode 100644 index c6b6744cd66..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/rerankers.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Scores (query, doc) pairs. Higher score = better. - */ -export interface Reranker { - score(query: string, docs: string[]): number[]; -} - -/** - * Provider-agnostic reranking configuration. - * 1:1 port of Python ToolScope's RerankingConfig. - */ -export interface RerankingConfig { - readonly provider: string; - readonly model: string; - - /** Retrieve top-N from vector DB, rerank, return top-k. */ - readonly poolSize: number; - readonly allowDownload: boolean; - - /** Truncation before reranker scoring. */ - readonly maxQueryChars: number; - readonly maxDocChars: number; - - readonly extra?: Record; -} - -export const DEFAULT_RERANKING_CONFIG: Partial = { - poolSize: 50, - allowDownload: false, - maxQueryChars: 512, - maxDocChars: 256, -}; - -/** - * Resolve a Reranker from config. Currently no built-in neural implementations - * in the TypeScript port (would require external ML dependencies). - * Returns null if config is undefined. - */ -export function makeReranker(config?: RerankingConfig): Reranker | null { - if (!config) return null; - - throw new Error( - `No built-in reranker for provider "${config.provider}" in TypeScript. ` + - 'Provide a custom Reranker implementation via the reranker parameter.', - ); -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/session.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/session.ts deleted file mode 100644 index c1e9f809fba..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/session.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Cosine similarity between two vectors. - * Used for comparing query embeddings between turns. - */ -export function cosine(a: number[], b: number[]): number { - let dot = 0; - let na = 0; - let nb = 0; - for (let i = 0; i < a.length; i++) { - dot += a[i] * b[i]; - na += a[i] * a[i]; - nb += b[i] * b[i]; - } - const denom = (Math.sqrt(na) || 1) * (Math.sqrt(nb) || 1); - return dot / denom; -} - -/** - * Controls session-aware caching and toolset stickiness. - * 1:1 port of Python ToolScope's StickySessionConfig. - */ -export interface StickySessionConfig { - readonly enabled: boolean; - - /** If cosine(query, lastQuery) >= this, reuse last selected tool ids. */ - readonly similarityThresholdReuse: number; - - /** If cosine >= this but below reuse threshold, refresh with sticky bias. */ - readonly similarityThresholdRefresh: number; - - /** When refreshing, ensure up to this many previously-selected tools are kept. */ - readonly stickyKeep: number; - - /** Score bonus for previously-selected tools in refresh mode. */ - readonly stickyBoost: number; - - /** Expire session state after inactivity (seconds). */ - readonly ttlSeconds: number; - - /** Max concurrent sessions (memory bound). */ - readonly maxSessions: number; -} - -export const DEFAULT_SESSION_CONFIG: StickySessionConfig = { - enabled: false, - similarityThresholdReuse: 0.92, - similarityThresholdRefresh: 0.8, - stickyKeep: 4, - stickyBoost: 0.03, - ttlSeconds: 3600, - maxSessions: 1000, -}; - -export interface SessionState { - lastSeenTs: number; - lastQueryVec: number[]; - lastSelectedIds: string[]; -} - -/** - * In-memory session cache with TTL expiry and LRU-style eviction. - * 1:1 port of Python ToolScope's SessionCache. - */ -export class SessionCache { - private readonly cfg: StickySessionConfig; - private readonly states = new Map(); - - constructor(cfg: StickySessionConfig) { - this.cfg = cfg; - } - - get(sessionId: string): SessionState | undefined { - const st = this.states.get(sessionId); - if (!st) return undefined; - - if (Date.now() / 1000 - st.lastSeenTs > this.cfg.ttlSeconds) { - this.states.delete(sessionId); - return undefined; - } - return st; - } - - put(sessionId: string, queryVec: number[], selectedIds: string[]): void { - this.states.set(sessionId, { - lastSeenTs: Date.now() / 1000, - lastQueryVec: queryVec, - lastSelectedIds: [...selectedIds], - }); - - if (this.states.size > this.cfg.maxSessions) { - const entries = [...this.states.entries()]; - entries.sort((a, b) => a[1].lastSeenTs - b[1].lastSeenTs); - const evictCount = Math.max(1, Math.floor(this.states.size / 10)); - for (let i = 0; i < evictCount; i++) { - this.states.delete(entries[i][0]); - } - } - } - - get size(): number { - return this.states.size; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/text.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/text.ts deleted file mode 100644 index e148c4f647d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/text.ts +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { CanonicalTool } from './types'; - -/** String-to-string transform applied to tool text before embedding. */ -export type ToolPreprocessor = (text: string) => string; - -/** Supported field names for ToolTextConfig. */ -export type ToolTextField = 'name' | 'description' | 'schema' | 'tags'; - -/** - * Controls how a tool is converted to text for embedding. - * 1:1 port of Python ToolScope's ToolTextConfig. - */ -export interface ToolTextConfig { - /** - * Which parts of CanonicalTool to include in the embedding text. - * Default: ['name', 'description'] - */ - readonly fields: readonly ToolTextField[]; - - /** - * Max number of characters for the produced text (after assembling, - * before preprocessors). null = no truncation. - * Default: 256 - */ - readonly truncate: number | null; - - /** - * Optional sequence of string->string transforms applied in order. - * Default: [] (no preprocessing) - */ - readonly preprocessors: readonly ToolPreprocessor[]; -} - -/** Default config matching Python ToolScope battle-tested defaults. */ -export const DEFAULT_TOOL_TEXT_CONFIG: ToolTextConfig = { - fields: ['name', 'description'], - truncate: 256, - preprocessors: [], -}; - -/** - * Render a CanonicalTool into text for embedding, using the given config. - */ -export function renderToolText( - tool: CanonicalTool, - config: ToolTextConfig = DEFAULT_TOOL_TEXT_CONFIG, -): string { - const parts: string[] = []; - - for (const field of config.fields) { - switch (field) { - case 'name': - parts.push(tool.name || ''); - break; - case 'description': - parts.push(tool.description || ''); - break; - case 'schema': - parts.push(tool.inputSchema ? JSON.stringify(tool.inputSchema) : ''); - break; - case 'tags': - parts.push((tool.tags || []).join(' ')); - break; - default: - throw new Error( - `Unsupported field in ToolTextConfig.fields: "${field}"`, - ); - } - } - - let text = parts.filter(p => p.length > 0).join('\n'); - - if (config.truncate !== null && config.truncate >= 0) { - text = text.slice(0, config.truncate); - } - - for (const op of config.preprocessors) { - text = op(text); - } - - return text; -} - -// ---- Built-in preprocessors (user opt-in) ---- - -export function lowercase(): ToolPreprocessor { - return (s: string) => s.toLowerCase(); -} - -export function normalizeUnicode( - form: 'NFC' | 'NFD' | 'NFKC' | 'NFKD' = 'NFKC', -): ToolPreprocessor { - return (s: string) => s.normalize(form); -} - -const WS_RE = /\s+/g; - -export function collapseWhitespace(): ToolPreprocessor { - return (s: string) => s.replace(WS_RE, ' ').trim(); -} - -// eslint-disable-next-line no-control-regex -const CONTROL_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x80-\x9f]/g; - -export function stripControlChars(): ToolPreprocessor { - return (s: string) => s.replace(CONTROL_RE, ''); -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/toolscope/types.ts b/workspaces/augment/plugins/augment-backend/src/services/toolscope/types.ts deleted file mode 100644 index cc4eb4360d1..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/toolscope/types.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ---- JSON utility type ---- - -export type Json = Record; - -// ---- Core value types (mirrors Python ToolScope core/types.py) ---- - -/** - * Stable identifier for a specific version of a tool definition. - * Used as primary key in vector backends. - * Changes when the tool meaningfully changes (name, description, schema, tags). - */ -export interface ToolFingerprint { - readonly value: string; -} - -/** - * Canonical internal representation of a tool. - * payload: the original tool spec provided by the user (e.g. OpenAI tool dict, - * MCP tool descriptor). Returned verbatim to the model after filtering. - */ -export interface CanonicalTool { - readonly name: string; - readonly description: string; - readonly inputSchema: Json; - readonly fingerprint: ToolFingerprint; - readonly tags: readonly string[]; - readonly payload?: unknown; -} - -// ---- Protocols (mirrors Python ToolScope core/types.py) ---- - -/** Produces dense vectors from text. */ -export interface EmbeddingProvider { - embedTexts(texts: string[]): number[][]; - /** Build vocabulary / warm up the model from a corpus. TF-IDF requires this. */ - fit?(corpus: string[]): void; -} - -/** Converts tool specs to/from CanonicalTool. */ -export interface ToolNormalizer { - normalize(tools: unknown[]): CanonicalTool[]; - denormalize(canonical: CanonicalTool[]): unknown[]; -} - -/** - * Vector index backend interface. - * Mirrors common vector DB APIs (Milvus, Qdrant, Pinecone). - * All backends MUST treat ToolFingerprint.value as the primary key. - */ -export interface ToolIndexBackend { - /** Create collection/namespace if needed. May be a no-op for simple backends. */ - ensureNamespace(namespace: string | null, dim: number): void; - - /** Insert or update records. */ - upsert( - ids: string[], - vectors: number[][], - payloads: CanonicalTool[], - namespace?: string | null, - ): void; - - /** Remove records by id. */ - delete(ids: string[], namespace?: string | null): void; - - /** Return top-k (id, score) pairs. Score must be higher-is-better. */ - search( - queryVector: number[], - k: number, - namespace?: string | null, - filter?: Json, - ): Array<[string, number]>; - - /** Fetch CanonicalTool records by id. */ - get(ids: string[], namespace?: string | null): CanonicalTool[]; -} - -// ---- Augment integration types (not in Python ToolScope) ---- - -/** A tool with its server context -- provider-agnostic. */ -export interface ToolDescriptor { - /** Unique identifier of the MCP server that exposes this tool */ - serverId: string; - /** Tool name as registered in the MCP server */ - name: string; - /** Human-readable description used for semantic embedding */ - description: string; -} - -/** Result of a tool scoping operation, including per-tool scores. */ -export interface ToolScopeResult { - /** serverId -> list of allowed tool names */ - scopedTools: Map; - /** All scored results from the index search, sorted descending */ - scores: Array<{ serverId: string; name: string; score: number }>; - /** How long the filterTools call took in ms */ - durationMs: number; -} - -/** The scoping service interface consumed by providers. */ -export interface ToolScopeService { - updateIndex(tools: ToolDescriptor[]): void; - filterTools( - query: string, - k: number, - adminAllowedTools?: Map, - minScore?: number, - ): ToolScopeResult; -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/GlobMatcher.test.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/GlobMatcher.test.ts deleted file mode 100644 index 6802115cb7d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/GlobMatcher.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { matchesPatterns } from './GlobMatcher'; - -describe('matchesPatterns', () => { - describe('simple wildcards (*.ts)', () => { - it('matches *.ts for file in current directory', () => { - expect(matchesPatterns('foo.ts', ['*.ts'])).toBe(true); - expect(matchesPatterns('index.ts', ['*.ts'])).toBe(true); - }); - - it('does not match *.ts for file in subdirectory', () => { - expect(matchesPatterns('src/foo.ts', ['*.ts'])).toBe(false); - expect(matchesPatterns('a/b/index.ts', ['*.ts'])).toBe(false); - }); - - it('does not match *.ts for different extension', () => { - expect(matchesPatterns('foo.js', ['*.ts'])).toBe(false); - expect(matchesPatterns('foo.md', ['*.ts'])).toBe(false); - }); - }); - - describe('double-star patterns (**/*.ts)', () => { - it('matches **/*.ts for any path ending in .ts', () => { - expect(matchesPatterns('src/foo.ts', ['**/*.ts'])).toBe(true); - expect(matchesPatterns('a/b/c/index.ts', ['**/*.ts'])).toBe(true); - expect(matchesPatterns('foo.ts', ['**/*.ts'])).toBe(true); - }); - - it('does not match **/*.ts for non-.ts files', () => { - expect(matchesPatterns('src/foo.js', ['**/*.ts'])).toBe(false); - expect(matchesPatterns('src/foo.md', ['**/*.ts'])).toBe(false); - }); - }); - - describe('directory patterns (src/**)', () => { - it('matches src/** for paths under src', () => { - expect(matchesPatterns('src/index.ts', ['src/**'])).toBe(true); - expect(matchesPatterns('src/utils/helper.ts', ['src/**'])).toBe(true); - expect(matchesPatterns('src/a/b/c/file.ts', ['src/**'])).toBe(true); - }); - - it('does not match src/** for paths outside src', () => { - expect(matchesPatterns('lib/index.ts', ['src/**'])).toBe(false); - expect(matchesPatterns('index.ts', ['src/**'])).toBe(false); - }); - }); - - describe('returns false when no patterns match', () => { - it('returns false when path matches none of the patterns', () => { - expect(matchesPatterns('foo.txt', ['*.ts', '*.js'])).toBe(false); - expect(matchesPatterns('docs/readme.md', ['src/**', '*.ts'])).toBe(false); - }); - }); - - describe('handles empty patterns array', () => { - it('returns false for empty patterns', () => { - expect(matchesPatterns('foo.ts', [])).toBe(false); - expect(matchesPatterns('any/path/file.txt', [])).toBe(false); - }); - }); - - describe('pattern matching behavior', () => { - it('returns true when any pattern matches', () => { - expect(matchesPatterns('foo.ts', ['*.js', '*.ts', '*.md'])).toBe(true); - expect(matchesPatterns('src/bar.ts', ['*.ts', '**/*.ts'])).toBe(true); - }); - - it('normalizes backslashes to forward slashes', () => { - expect(matchesPatterns('src\\foo\\bar.ts', ['**/*.ts'])).toBe(true); - }); - - it('matches ? wildcard for single character', () => { - expect(matchesPatterns('a.ts', ['?.ts'])).toBe(true); - expect(matchesPatterns('ab.ts', ['?.ts'])).toBe(false); - }); - - it('matches * for any characters in filename', () => { - expect(matchesPatterns('my-component.tsx', ['*.tsx'])).toBe(true); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/GlobMatcher.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/GlobMatcher.ts deleted file mode 100644 index c45574e8dc2..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/GlobMatcher.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Check if a file path matches any of the provided glob patterns. - * Supports `**`, `*`, and `?` wildcards. - */ -export function matchesPatterns(filePath: string, patterns: string[]): boolean { - for (const pattern of patterns) { - if (matchPattern(filePath, pattern)) { - return true; - } - } - return false; -} - -function matchPattern(filePath: string, pattern: string): boolean { - const normalizedPath = filePath.replace(/\\/g, '/'); - const normalizedPattern = pattern.replace(/\\/g, '/'); - - // Pattern: **/*.ext - const doubleStarExtMatch = normalizedPattern.match(/^\*\*\/\*\.(\w+)$/); - if (doubleStarExtMatch) { - const ext = doubleStarExtMatch[1]; - return normalizedPath.endsWith(`.${ext}`); - } - - // Pattern: *.ext - const singleStarExtMatch = normalizedPattern.match(/^\*\.(\w+)$/); - if (singleStarExtMatch) { - const ext = singleStarExtMatch[1]; - return normalizedPath.endsWith(`.${ext}`) && !normalizedPath.includes('/'); - } - - let regex = normalizedPattern - .replace(/\./g, '\\.') - .replace(/\*\*/g, '{{DOUBLE_STAR}}') - .replace(/\*/g, '[^/]*') - .replace(/{{DOUBLE_STAR}}/g, '.*') - .replace(/\?/g, '.'); - - if (!normalizedPattern.startsWith('**/')) { - regex = `(.*\\/)?${regex}`; - } - - const regexPattern = new RegExp(`^${regex}$`); - return regexPattern.test(normalizedPath); -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/SsrfGuard.test.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/SsrfGuard.test.ts deleted file mode 100644 index 79afbdfba9b..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/SsrfGuard.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { isPrivateUrl } from './SsrfGuard'; - -describe('SsrfGuard', () => { - describe('isPrivateUrl', () => { - describe('public URLs', () => { - it('returns null for https://example.com', () => { - expect(isPrivateUrl('https://example.com')).toBeNull(); - }); - - it('returns null for https://api.github.com/repos', () => { - expect(isPrivateUrl('https://api.github.com/repos')).toBeNull(); - }); - - it('returns null for other public domains', () => { - expect(isPrivateUrl('https://www.google.com')).toBeNull(); - expect(isPrivateUrl('https://registry.npmjs.org')).toBeNull(); - expect(isPrivateUrl('https://example.org/path')).toBeNull(); - }); - }); - - describe('localhost', () => { - it('returns reason for https://localhost:8080', () => { - expect(isPrivateUrl('https://localhost:8080')).toBe('localhost'); - }); - - it('returns reason for https://127.0.0.1:3000', () => { - expect(isPrivateUrl('https://127.0.0.1:3000')).toBe('loopback'); - }); - - it('returns reason for https://localhost/', () => { - expect(isPrivateUrl('https://localhost/')).toBe('localhost'); - }); - }); - - describe('private IPs (RFC-1918)', () => { - it('returns reason for 10.0.0.1', () => { - expect(isPrivateUrl('https://10.0.0.1')).toBe('RFC-1918'); - }); - - it('returns reason for 172.16.0.1', () => { - expect(isPrivateUrl('https://172.16.0.1:8080')).toBe('RFC-1918'); - }); - - it('returns reason for 172.31.255.255', () => { - expect(isPrivateUrl('https://172.31.255.255')).toBe('RFC-1918'); - }); - - it('returns reason for 192.168.1.1', () => { - expect(isPrivateUrl('https://192.168.1.1')).toBe('RFC-1918'); - }); - - it('returns reason for 192.168.0.0', () => { - expect(isPrivateUrl('https://192.168.0.0')).toBe('RFC-1918'); - }); - }); - - describe('link-local (169.254.x.x)', () => { - it('returns reason for 169.254.1.1', () => { - expect(isPrivateUrl('https://169.254.1.1')).toBe('link-local'); - }); - - it('returns reason for 169.254.0.0', () => { - expect(isPrivateUrl('https://169.254.0.0')).toBe('link-local'); - }); - }); - - describe('cloud metadata endpoint (169.254.169.254)', () => { - it('returns reason for 169.254.169.254 (matches link-local pattern first)', () => { - // Implementation matches 169.254.* as link-local before cloud metadata check - expect(isPrivateUrl('https://169.254.169.254/latest/meta-data/')).toBe( - 'link-local', - ); - }); - }); - - describe('IPv6 loopback', () => { - it('returns reason for ::1', () => { - expect(isPrivateUrl('https://[::1]:3000')).toBe('IPv6 loopback'); - }); - - it('returns reason for ::1 without brackets in hostname extraction', () => { - expect(isPrivateUrl('https://[::1]/')).toBe('IPv6 loopback'); - }); - }); - - describe('blocked hostnames', () => { - it('returns reason for metadata.google.internal', () => { - expect( - isPrivateUrl('https://metadata.google.internal/computeMetadata/v1/'), - ).toBe('blocked host: metadata.google.internal'); - }); - - it('returns reason for metadata.goog', () => { - expect(isPrivateUrl('https://metadata.goog/')).toBe( - 'blocked host: metadata.goog', - ); - }); - }); - - describe('malformed URLs', () => { - it('returns reason for invalid URL', () => { - expect(isPrivateUrl('not-a-valid-url')).toBe('invalid URL'); - }); - - it('returns reason for empty string', () => { - expect(isPrivateUrl('')).toBe('invalid URL'); - }); - - it('returns reason for URL with invalid characters', () => { - expect(isPrivateUrl('https://[invalid')).toBe('invalid URL'); - }); - - it('returns reason for URL with missing protocol', () => { - expect(isPrivateUrl('://example.com')).toBe('invalid URL'); - }); - }); - - describe('non-HTTP protocols', () => { - it('blocks ftp:// protocol', () => { - expect(isPrivateUrl('ftp://example.com')).toBe( - 'blocked protocol: ftp:', - ); - }); - - it('blocks file:// protocol', () => { - expect(isPrivateUrl('file:///etc/passwd')).toBe( - 'blocked protocol: file:', - ); - }); - - it('blocks data: protocol', () => { - expect(isPrivateUrl('data:text/html,

test

')).toBe( - 'blocked protocol: data:', - ); - }); - }); - - describe('IPv4-mapped IPv6', () => { - it('returns reason for ::ffff:127.0.0.1', () => { - expect(isPrivateUrl('https://[::ffff:127.0.0.1]')).toBe( - 'IPv4-mapped IPv6', - ); - }); - - it('returns reason for ::ffff:10.0.0.1', () => { - expect(isPrivateUrl('https://[::ffff:10.0.0.1]')).toBe( - 'IPv4-mapped IPv6', - ); - }); - - it('returns reason for ::ffff:192.168.1.1', () => { - expect(isPrivateUrl('https://[::ffff:192.168.1.1]')).toBe( - 'IPv4-mapped IPv6', - ); - }); - }); - - describe('unspecified and IPv6 ULA/link-local', () => { - it('returns reason for 0.0.0.0', () => { - expect(isPrivateUrl('https://0.0.0.0')).toBe('unspecified'); - }); - - it('returns reason for IPv6 ULA (fc00::)', () => { - expect(isPrivateUrl('https://[fc00::1]')).toBe('IPv6 ULA'); - }); - - it('returns reason for IPv6 link-local (fe80::)', () => { - expect(isPrivateUrl('https://[fe80::1]')).toBe('IPv6 link-local'); - }); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/SsrfGuard.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/SsrfGuard.ts deleted file mode 100644 index 11c2d02983f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/SsrfGuard.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Private/internal IP ranges and cloud metadata endpoints that should never - * be fetched as document sources to prevent SSRF. - */ -const BLOCKED_HOSTNAMES = new Set([ - 'metadata.google.internal', - 'metadata.goog', -]); - -/** AWS/cloud metadata endpoint - intentionally blocked for SSRF protection */ -const CLOUD_METADATA_IP = ['169', '254', '169', '254'].join('.'); - -const PRIVATE_IP_PATTERNS: Array<{ re: RegExp; label: string }> = [ - { re: /^127\./, label: 'loopback' }, - { re: /^10\./, label: 'RFC-1918' }, - { re: /^172\.(1[6-9]|2\d|3[01])\./, label: 'RFC-1918' }, - { re: /^192\.168\./, label: 'RFC-1918' }, - { re: /^169\.254\./, label: 'link-local' }, - { re: /^0\./, label: 'unspecified' }, - { re: /^::1$/, label: 'IPv6 loopback' }, - { re: /^fc[0-9a-f]{2}:/i, label: 'IPv6 ULA' }, - { re: /^fe80:/i, label: 'IPv6 link-local' }, - { re: /^::ffff:/i, label: 'IPv4-mapped IPv6' }, -]; - -/** - * Returns a human-readable reason string if the URL targets a private, - * loopback, or cloud-metadata address. Returns `null` when the URL is safe. - * - * Performs both hostname string checks and DNS resolution to prevent - * SSRF bypass via hostnames that resolve to private/link-local IPs. - */ -const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']); - -function isPrivateIp(ip: string): string | null { - const cleaned = ip.replace(/^\[|\]$/g, ''); - if (cleaned === 'localhost') return 'localhost'; - if (BLOCKED_HOSTNAMES.has(cleaned)) return `blocked host: ${cleaned}`; - for (const { re, label } of PRIVATE_IP_PATTERNS) { - if (re.test(cleaned)) return label; - } - if (cleaned === CLOUD_METADATA_IP) return 'cloud metadata endpoint'; - return null; -} - -export function isPrivateUrl(url: string): string | null { - let parsed: URL; - try { - parsed = new URL(url); - } catch { - return 'invalid URL'; - } - - if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) { - return `blocked protocol: ${parsed.protocol}`; - } - - const hostname = parsed.hostname.replace(/^\[|\]$/g, ''); - - const hostnameCheck = isPrivateIp(hostname); - if (hostnameCheck) return hostnameCheck; - - return null; -} - -/** - * Async variant that also resolves DNS to catch hostnames pointing - * to private IPs (e.g. attacker-controlled DNS rebinding). - */ -export async function isPrivateUrlWithDns(url: string): Promise { - const syncResult = isPrivateUrl(url); - if (syncResult) return syncResult; - - const { hostname } = new URL(url); - try { - const dns = await import('dns'); - const { resolve4, resolve6 } = dns.promises; - - const [v4addrs, v6addrs] = await Promise.allSettled([ - resolve4(hostname), - resolve6(hostname), - ]); - - const allAddrs = [ - ...(v4addrs.status === 'fulfilled' ? v4addrs.value : []), - ...(v6addrs.status === 'fulfilled' ? v6addrs.value : []), - ]; - - for (const addr of allAddrs) { - const reason = isPrivateIp(addr); - if (reason) return `${reason} (resolved from ${hostname})`; - } - } catch { - // DNS resolution failed — allow the request; the actual fetch will fail - } - - return null; -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/configValidation.test.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/configValidation.test.ts deleted file mode 100644 index b6d67ec5952..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/configValidation.test.ts +++ /dev/null @@ -1,753 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { InputError } from '@backstage/errors'; -import { validateAdminConfigValue } from './configValidation'; - -describe('validateAdminConfigValue', () => { - describe('baseUrl', () => { - it('accepts valid HTTP URLs', () => { - expect(() => - validateAdminConfigValue('baseUrl', 'http://llama.example.com:8321'), - ).not.toThrow(); - expect(() => - validateAdminConfigValue('baseUrl', 'https://llama.example.com'), - ).not.toThrow(); - expect(() => - validateAdminConfigValue('baseUrl', 'https://server:443/v1'), - ).not.toThrow(); - }); - - it.each([ - ['empty string', ''], - ['non-string (number)', 123], - ['non-string (null)', null], - ['non-HTTP protocols', 'sftp://server.com'], - ['malformed URLs', 'not-a-url'], - ])('rejects %s', (_, value) => { - expect(() => validateAdminConfigValue('baseUrl', value)).toThrow( - InputError, - ); - }); - - it('rejects private/internal addresses (SSRF)', () => { - expect(() => - validateAdminConfigValue('baseUrl', 'https://localhost:8321'), - ).toThrow(/private\/internal/); - expect(() => - validateAdminConfigValue('baseUrl', 'https://10.0.0.1:8321'), - ).toThrow(/private\/internal/); - }); - }); - - describe('model', () => { - it('accepts valid model names', () => { - expect(() => - validateAdminConfigValue('model', 'meta-llama/Llama-3-8B'), - ).not.toThrow(); - expect(() => validateAdminConfigValue('model', 'gpt-4o')).not.toThrow(); - }); - - it.each([ - ['empty string', ''], - ['strings over 200 chars', 'x'.repeat(201)], - ['non-string values', 42], - ])('rejects %s', (_, value) => { - expect(() => validateAdminConfigValue('model', value)).toThrow( - InputError, - ); - }); - }); - - describe('systemPrompt', () => { - it('accepts valid prompts', () => { - expect(() => - validateAdminConfigValue('systemPrompt', 'You are a helpful AI.'), - ).not.toThrow(); - expect(() => validateAdminConfigValue('systemPrompt', '')).not.toThrow(); - }); - - it('rejects non-string values', () => { - expect(() => validateAdminConfigValue('systemPrompt', 123)).toThrow( - InputError, - ); - }); - - it('rejects prompts over 50000 chars', () => { - expect(() => - validateAdminConfigValue('systemPrompt', 'x'.repeat(50001)), - ).toThrow(InputError); - }); - }); - - describe('toolChoice', () => { - it('accepts valid string values', () => { - expect(() => - validateAdminConfigValue('toolChoice', 'auto'), - ).not.toThrow(); - expect(() => - validateAdminConfigValue('toolChoice', 'required'), - ).not.toThrow(); - expect(() => - validateAdminConfigValue('toolChoice', 'none'), - ).not.toThrow(); - }); - - it('accepts valid function object', () => { - expect(() => - validateAdminConfigValue('toolChoice', { - type: 'function', - name: 'get_weather', - }), - ).not.toThrow(); - }); - - it('accepts valid allowed_tools object', () => { - expect(() => - validateAdminConfigValue('toolChoice', { - type: 'allowed_tools', - tools: [{ type: 'web_search' }], - }), - ).not.toThrow(); - }); - - it('rejects invalid string values', () => { - expect(() => validateAdminConfigValue('toolChoice', 'invalid')).toThrow( - InputError, - ); - }); - - it('rejects non-string non-object values', () => { - expect(() => validateAdminConfigValue('toolChoice', 123)).toThrow( - InputError, - ); - }); - }); - - describe('enableWebSearch', () => { - it('accepts boolean values', () => { - expect(() => - validateAdminConfigValue('enableWebSearch', true), - ).not.toThrow(); - expect(() => - validateAdminConfigValue('enableWebSearch', false), - ).not.toThrow(); - }); - - it('rejects non-boolean values', () => { - expect(() => validateAdminConfigValue('enableWebSearch', 'true')).toThrow( - InputError, - ); - expect(() => validateAdminConfigValue('enableWebSearch', 1)).toThrow( - InputError, - ); - }); - }); - - describe('enableCodeInterpreter', () => { - it('accepts boolean values', () => { - expect(() => - validateAdminConfigValue('enableCodeInterpreter', true), - ).not.toThrow(); - }); - - it('rejects non-boolean values', () => { - expect(() => - validateAdminConfigValue('enableCodeInterpreter', 'yes'), - ).toThrow(InputError); - }); - }); - - describe('branding', () => { - it('accepts valid objects', () => { - expect(() => - validateAdminConfigValue('branding', { appName: 'Test' }), - ).not.toThrow(); - }); - - it('accepts a fully-populated valid branding object', () => { - expect(() => - validateAdminConfigValue('branding', { - appName: 'My App', - tagline: 'Hello', - primaryColor: '#1e40af', - secondaryColor: '#475569', - successColor: '#10b981', - warningColor: '#f59e0b', - errorColor: '#ef4444', - infoColor: '#0ea5e9', - logoUrl: 'https://example.com/logo.png', - faviconUrl: 'https://example.com/favicon.ico', - themePreset: 'default', - }), - ).not.toThrow(); - }); - - it.each([ - ['null', null], - ['arrays', []], - ['non-object values', 'string'], - ])('rejects %s', (_, value) => { - expect(() => validateAdminConfigValue('branding', value)).toThrow( - InputError, - ); - }); - - it('rejects invalid hex color', () => { - expect(() => - validateAdminConfigValue('branding', { primaryColor: 'red' }), - ).toThrow(/valid hex color/); - }); - - it('rejects 3-digit hex color', () => { - expect(() => - validateAdminConfigValue('branding', { primaryColor: '#abc' }), - ).toThrow(/valid hex color/); - }); - - it('rejects non-string color', () => { - expect(() => - validateAdminConfigValue('branding', { primaryColor: 123 }), - ).toThrow(/valid hex color/); - }); - - it('rejects unknown keys', () => { - expect(() => - validateAdminConfigValue('branding', { bogusField: true }), - ).toThrow(/unknown key.*bogusField/); - }); - - describe('logoUrl / faviconUrl validation', () => { - it('accepts valid https URLs', () => { - expect(() => - validateAdminConfigValue('branding', { - logoUrl: 'https://example.com/logo.png', - }), - ).not.toThrow(); - }); - - it('accepts valid http URLs', () => { - expect(() => - validateAdminConfigValue('branding', { - faviconUrl: 'http://localhost:3000/favicon.ico', - }), - ).not.toThrow(); - }); - - it('rejects javascript: URLs', () => { - expect(() => - validateAdminConfigValue('branding', { - // eslint-disable-next-line no-script-url - logoUrl: 'javascript:alert(1)', - }), - ).toThrow(/protocol/); - }); - - it('rejects data: URLs', () => { - expect(() => - validateAdminConfigValue('branding', { - faviconUrl: 'data:text/html,

xss

', - }), - ).toThrow(/protocol/); - }); - - it('rejects invalid URLs', () => { - expect(() => - validateAdminConfigValue('branding', { logoUrl: 'not-a-url' }), - ).toThrow(/valid URL/); - }); - - it('accepts empty string (clears the value)', () => { - expect(() => - validateAdminConfigValue('branding', { logoUrl: '' }), - ).not.toThrow(); - }); - }); - }); - - describe('safetyPatterns', () => { - it('accepts valid string arrays', () => { - expect(() => - validateAdminConfigValue('safetyPatterns', ['delete', 'drop']), - ).not.toThrow(); - expect(() => - validateAdminConfigValue('safetyPatterns', []), - ).not.toThrow(); - }); - - it('rejects non-array values', () => { - expect(() => - validateAdminConfigValue('safetyPatterns', 'delete'), - ).toThrow(InputError); - }); - - it('rejects arrays with non-string elements', () => { - expect(() => - validateAdminConfigValue('safetyPatterns', ['valid', 123]), - ).toThrow(InputError); - }); - }); - - describe('vectorStoreConfig', () => { - it('accepts a valid config object', () => { - expect(() => - validateAdminConfigValue('vectorStoreConfig', { - embeddingModel: 'all-MiniLM-L6-v2', - embeddingDimension: 384, - searchMode: 'hybrid', - bm25Weight: 0.3, - chunkingStrategy: 'auto', - }), - ).not.toThrow(); - }); - - it('accepts an empty object', () => { - expect(() => - validateAdminConfigValue('vectorStoreConfig', {}), - ).not.toThrow(); - }); - - it.each([ - ['null', null], - ['arrays', []], - ['invalid searchMode', { searchMode: 'invalid' }], - ['invalid chunkingStrategy', { chunkingStrategy: 'invalid' }], - ['non-number embeddingDimension', { embeddingDimension: '384' }], - ])('rejects %s', (_, value) => { - expect(() => - validateAdminConfigValue('vectorStoreConfig', value), - ).toThrow(InputError); - }); - }); - - describe('activeVectorStoreIds', () => { - it('accepts a valid string array', () => { - expect(() => - validateAdminConfigValue('activeVectorStoreIds', ['vs-1', 'vs-2']), - ).not.toThrow(); - }); - - it('accepts an empty array', () => { - expect(() => - validateAdminConfigValue('activeVectorStoreIds', []), - ).not.toThrow(); - }); - - it('rejects non-array', () => { - expect(() => - validateAdminConfigValue('activeVectorStoreIds', 'vs-1'), - ).toThrow(InputError); - }); - - it('rejects empty strings in array', () => { - expect(() => - validateAdminConfigValue('activeVectorStoreIds', ['vs-1', '']), - ).toThrow(InputError); - }); - - it('rejects non-string elements', () => { - expect(() => - validateAdminConfigValue('activeVectorStoreIds', ['vs-1', 42]), - ).toThrow(InputError); - }); - }); - - describe('mcpServers', () => { - it('accepts a valid MCP server array', () => { - expect(() => - validateAdminConfigValue('mcpServers', [ - { - id: 'mcp-1', - name: 'Test MCP', - url: 'https://mcp.example.com/sse', - type: 'sse', - }, - ]), - ).not.toThrow(); - }); - - it('accepts MCP server with requireApproval "always"', () => { - expect(() => - validateAdminConfigValue('mcpServers', [ - { - id: 'mcp-1', - name: 'Test MCP', - url: 'https://mcp.example.com/sse', - requireApproval: 'always', - }, - ]), - ).not.toThrow(); - }); - - it('accepts MCP server with requireApproval "never"', () => { - expect(() => - validateAdminConfigValue('mcpServers', [ - { - id: 'mcp-1', - name: 'Test MCP', - url: 'https://mcp.example.com/sse', - requireApproval: 'never', - }, - ]), - ).not.toThrow(); - }); - - it('accepts MCP server without requireApproval (defaults to never)', () => { - expect(() => - validateAdminConfigValue('mcpServers', [ - { - id: 'mcp-1', - name: 'Test MCP', - url: 'https://mcp.example.com/sse', - }, - ]), - ).not.toThrow(); - }); - - it('accepts an empty array', () => { - expect(() => validateAdminConfigValue('mcpServers', [])).not.toThrow(); - }); - - it.each([ - ['non-array values', 'not-array'], - ['servers missing id', [{ name: 'Test', url: 'https://mcp.test' }]], - ['servers missing name', [{ id: 'mcp-1', url: 'https://mcp.test' }]], - [ - 'servers with invalid URL', - [{ id: 'mcp-1', name: 'Test', url: 'not-a-url' }], - ], - [ - 'servers with non-http URL', - [{ id: 'mcp-1', name: 'Test', url: 'ftp://mcp.test' }], - ], - [ - 'duplicate server IDs', - [ - { id: 'mcp-1', name: 'A', url: 'https://a.test' }, - { id: 'mcp-1', name: 'B', url: 'https://b.test' }, - ], - ], - [ - 'servers with invalid requireApproval', - [ - { - id: 'mcp-1', - name: 'Test', - url: 'https://mcp.test', - requireApproval: 'sometimes', - }, - ], - ], - ])('rejects %s', (_, value) => { - expect(() => validateAdminConfigValue('mcpServers', value)).toThrow( - InputError, - ); - }); - - it('accepts servers with overlapping allowedTools (proxy handles namespacing)', () => { - expect(() => - validateAdminConfigValue('mcpServers', [ - { - id: 'aap-mcp', - name: 'AAP', - url: 'https://aap.test', - allowedTools: ['projects_list', 'job_templates_list'], - }, - { - id: 'ocp-mcp', - name: 'OCP', - url: 'https://ocp.test', - allowedTools: ['projects_list', 'pods_list'], - }, - ]), - ).not.toThrow(); - }); - }); - - describe('safetyEnabled', () => { - it('accepts a boolean', () => { - expect(() => - validateAdminConfigValue('safetyEnabled', true), - ).not.toThrow(); - expect(() => - validateAdminConfigValue('safetyEnabled', false), - ).not.toThrow(); - }); - - it('rejects non-boolean', () => { - expect(() => validateAdminConfigValue('safetyEnabled', 'yes')).toThrow( - InputError, - ); - }); - }); - - describe('inputShields', () => { - it('accepts an array of non-empty strings', () => { - expect(() => - validateAdminConfigValue('inputShields', ['content_safety']), - ).not.toThrow(); - }); - - it('rejects non-array', () => { - expect(() => validateAdminConfigValue('inputShields', 'shield')).toThrow( - InputError, - ); - }); - - it('rejects empty strings in array', () => { - expect(() => validateAdminConfigValue('inputShields', [''])).toThrow( - InputError, - ); - }); - }); - - describe('outputShields', () => { - it('accepts an array of non-empty strings', () => { - expect(() => - validateAdminConfigValue('outputShields', ['content_safety']), - ).not.toThrow(); - }); - - it('rejects non-array', () => { - expect(() => validateAdminConfigValue('outputShields', 42)).toThrow( - InputError, - ); - }); - }); - - describe('evaluationEnabled', () => { - it('accepts a boolean', () => { - expect(() => - validateAdminConfigValue('evaluationEnabled', true), - ).not.toThrow(); - }); - - it('rejects non-boolean', () => { - expect(() => validateAdminConfigValue('evaluationEnabled', 1)).toThrow( - InputError, - ); - }); - }); - - describe('scoringFunctions', () => { - it('accepts an array of non-empty strings', () => { - expect(() => - validateAdminConfigValue('scoringFunctions', [ - 'basic::subset_of', - 'braintrust::faithfulness', - ]), - ).not.toThrow(); - }); - - it('rejects non-array', () => { - expect(() => validateAdminConfigValue('scoringFunctions', 'fn')).toThrow( - InputError, - ); - }); - }); - - describe('minScoreThreshold', () => { - it('accepts a number between 0 and 1', () => { - expect(() => - validateAdminConfigValue('minScoreThreshold', 0.7), - ).not.toThrow(); - expect(() => - validateAdminConfigValue('minScoreThreshold', 0), - ).not.toThrow(); - expect(() => - validateAdminConfigValue('minScoreThreshold', 1), - ).not.toThrow(); - }); - - it('rejects non-number', () => { - expect(() => - validateAdminConfigValue('minScoreThreshold', '0.7'), - ).toThrow(InputError); - }); - - it('rejects out-of-range values', () => { - expect(() => validateAdminConfigValue('minScoreThreshold', -0.1)).toThrow( - InputError, - ); - expect(() => validateAdminConfigValue('minScoreThreshold', 1.1)).toThrow( - InputError, - ); - }); - }); - - describe('promptGroups', () => { - it('accepts a valid prompt groups array', () => { - expect(() => - validateAdminConfigValue('promptGroups', [ - { - id: 'lane-1', - title: 'Getting Started', - cards: [{ title: 'Card 1', prompt: 'Hello' }], - }, - ]), - ).not.toThrow(); - }); - - it('accepts an empty array', () => { - expect(() => validateAdminConfigValue('promptGroups', [])).not.toThrow(); - }); - - it.each([ - ['non-array', 'not array'], - ['groups without id', [{ title: 'Lane', cards: [] }]], - ['groups without title', [{ id: 'l1', cards: [] }]], - ['groups without cards array', [{ id: 'l1', title: 'Lane' }]], - [ - 'cards without title', - [{ id: 'l1', title: 'Lane', cards: [{ prompt: 'Go' }] }], - ], - [ - 'cards without prompt', - [{ id: 'l1', title: 'Lane', cards: [{ title: 'Card' }] }], - ], - ])('rejects %s', (_, value) => { - expect(() => validateAdminConfigValue('promptGroups', value)).toThrow( - InputError, - ); - }); - }); - - describe('agents', () => { - it('accepts a valid agents object', () => { - expect(() => - validateAdminConfigValue('agents', { - triage: { - name: 'Triage', - instructions: 'Route users.', - handoffs: ['billing'], - }, - billing: { - name: 'Billing', - instructions: 'Handle billing.', - mcpServers: ['ocp-mcp'], - enableRAG: true, - }, - }), - ).not.toThrow(); - }); - - it('accepts an empty agents object', () => { - expect(() => validateAdminConfigValue('agents', {})).not.toThrow(); - }); - - it('rejects non-object value', () => { - expect(() => validateAdminConfigValue('agents', 'not-object')).toThrow( - InputError, - ); - }); - - it('rejects array value', () => { - expect(() => validateAdminConfigValue('agents', [])).toThrow(InputError); - }); - - it('rejects agent without name', () => { - expect(() => - validateAdminConfigValue('agents', { - a: { instructions: 'Do things.' }, - }), - ).toThrow(InputError); - }); - - it('rejects agent with empty name', () => { - expect(() => - validateAdminConfigValue('agents', { - a: { name: ' ', instructions: 'Do things.' }, - }), - ).toThrow(InputError); - }); - - it('rejects agent without instructions', () => { - expect(() => - validateAdminConfigValue('agents', { - a: { name: 'Agent A' }, - }), - ).toThrow(InputError); - }); - - it('rejects non-array handoffs', () => { - expect(() => - validateAdminConfigValue('agents', { - a: { name: 'A', instructions: 'Do.', handoffs: 'not-array' }, - }), - ).toThrow(InputError); - }); - - it('rejects non-string elements in handoffs', () => { - expect(() => - validateAdminConfigValue('agents', { - a: { name: 'A', instructions: 'Do.', handoffs: [123] }, - }), - ).toThrow(InputError); - }); - - it('rejects non-boolean enableRAG', () => { - expect(() => - validateAdminConfigValue('agents', { - a: { name: 'A', instructions: 'Do.', enableRAG: 'yes' }, - }), - ).toThrow(InputError); - }); - - it('rejects handoff to nonexistent agent', () => { - expect(() => - validateAdminConfigValue('agents', { - router: { - name: 'Router', - instructions: 'Route.', - handoffs: ['missing-agent'], - }, - }), - ).toThrow(/handoff to "missing-agent" which does not exist/); - }); - - it('rejects asTools reference to nonexistent agent', () => { - expect(() => - validateAdminConfigValue('agents', { - manager: { - name: 'Manager', - instructions: 'Manage.', - asTools: ['nonexistent'], - }, - }), - ).toThrow(/asTools reference to "nonexistent" which does not exist/); - }); - - it('accepts valid handoff and asTools references', () => { - expect(() => - validateAdminConfigValue('agents', { - router: { - name: 'Router', - instructions: 'Route.', - handoffs: ['specialist'], - }, - specialist: { - name: 'Specialist', - instructions: 'Help.', - }, - manager: { - name: 'Manager', - instructions: 'Orchestrate.', - asTools: ['specialist'], - }, - }), - ).not.toThrow(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/configValidation.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/configValidation.ts deleted file mode 100644 index d2e37f8fe2e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/configValidation.ts +++ /dev/null @@ -1,671 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { InputError } from '@backstage/errors'; -import type { AdminConfigKey } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { - MAX_BRANDING_FIELD_LENGTH, - MAX_CONFIG_VALUE_SIZE, - MAX_MODEL_LENGTH, - MAX_SYSTEM_PROMPT_LENGTH, -} from '../../constants'; -import { isPrivateUrl } from './SsrfGuard'; - -function assertSizeLimit(value: unknown, key: string): void { - const serialized = JSON.stringify(value); - if (serialized && serialized.length > MAX_CONFIG_VALUE_SIZE) { - throw new InputError( - `${key} value is too large (${serialized.length} chars, max ${MAX_CONFIG_VALUE_SIZE})`, - ); - } -} - -function validatePromptGroups(value: unknown): void { - if (!Array.isArray(value)) { - throw new InputError('promptGroups must be an array'); - } - for (const group of value) { - if (!group || typeof group !== 'object' || Array.isArray(group)) { - throw new InputError('Each prompt group must be an object'); - } - const g = group as Record; - if (typeof g.id !== 'string' || g.id.trim().length === 0) { - throw new InputError('Each prompt group must have a non-empty id'); - } - if (typeof g.title !== 'string' || g.title.trim().length === 0) { - throw new InputError('Each prompt group must have a non-empty title'); - } - if (!Array.isArray(g.cards)) { - throw new InputError(`Prompt group "${g.id}" must have a cards array`); - } - for (const card of g.cards as unknown[]) { - if (!card || typeof card !== 'object' || Array.isArray(card)) { - throw new InputError( - `Each card in prompt group "${g.id}" must be an object`, - ); - } - const c = card as Record; - if (typeof c.title !== 'string' || c.title.trim().length === 0) { - throw new InputError( - `Each card in prompt group "${g.id}" must have a non-empty title`, - ); - } - if (typeof c.prompt !== 'string') { - throw new InputError( - `Each card in prompt group "${g.id}" must have a prompt string`, - ); - } - } - } - assertSizeLimit(value, 'promptGroups'); -} - -function validateAgents(value: unknown): void { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new InputError('agents must be a non-null object'); - } - const agents = value as Record; - for (const [key, agentVal] of Object.entries(agents)) { - if ( - typeof agentVal !== 'object' || - agentVal === null || - Array.isArray(agentVal) - ) { - throw new InputError(`Agent "${key}" must be a non-null object`); - } - const agent = agentVal as Record; - if (typeof agent.name !== 'string' || agent.name.trim().length === 0) { - throw new InputError(`Agent "${key}" must have a non-empty name`); - } - if ( - typeof agent.instructions !== 'string' || - agent.instructions.trim().length === 0 - ) { - throw new InputError(`Agent "${key}" must have non-empty instructions`); - } - const arrayFields = [ - 'handoffs', - 'asTools', - 'mcpServers', - 'vectorStoreIds', - ] as const; - for (const field of arrayFields) { - if (agent[field] !== undefined) { - if (!Array.isArray(agent[field])) { - throw new InputError(`Agent "${key}".${field} must be an array`); - } - for (const item of agent[field] as unknown[]) { - if (typeof item !== 'string') { - throw new InputError( - `Agent "${key}".${field} must contain only strings`, - ); - } - } - } - } - const boolFields = [ - 'enableRAG', - 'enableWebSearch', - 'enableCodeInterpreter', - ] as const; - for (const field of boolFields) { - if (agent[field] !== undefined && typeof agent[field] !== 'boolean') { - throw new InputError(`Agent "${key}".${field} must be a boolean`); - } - } - } - - // Graph-level validation: handoff/asTools targets must reference - // agents that exist within the same save payload. - const keys = new Set(Object.keys(agents)); - for (const [key, agentVal] of Object.entries(agents)) { - const agent = agentVal as Record; - - if (Array.isArray(agent.handoffs)) { - for (const target of agent.handoffs as string[]) { - if (target === key) { - throw new InputError( - `Agent "${key}" has a handoff to itself — this would cause an infinite loop`, - ); - } - if (!keys.has(target)) { - throw new InputError( - `Agent "${key}" has handoff to "${target}" which does not exist ` + - `in the saved agents. Available: [${[...keys].join(', ')}]`, - ); - } - } - } - - if (Array.isArray(agent.asTools)) { - for (const target of agent.asTools as string[]) { - if (target === key) { - throw new InputError( - `Agent "${key}" has an asTools reference to itself — this would cause an infinite loop`, - ); - } - if (!keys.has(target)) { - throw new InputError( - `Agent "${key}" has asTools reference to "${target}" which does ` + - `not exist in the saved agents. Available: [${[...keys].join(', ')}]`, - ); - } - } - } - } - - // Detect circular handoff chains (A → B → C → A). - // Only checks handoffs (transfer of control), not asTools (function calls - // that return results and don't transfer control permanently). - const handoffGraph: Record = {}; - for (const [key, agentVal] of Object.entries(agents)) { - const agent = agentVal as Record; - handoffGraph[key] = Array.isArray(agent.handoffs) - ? (agent.handoffs as string[]).filter(t => keys.has(t)) - : []; - } - const visited = new Set(); - const inStack = new Set(); - function dfs(node: string, path: string[]): void { - if (inStack.has(node)) { - const cycleStart = path.indexOf(node); - const cycle = path.slice(cycleStart).concat(node); - throw new InputError( - `Circular handoff chain detected: ${cycle.join(' → ')}`, - ); - } - if (visited.has(node)) return; - visited.add(node); - inStack.add(node); - for (const target of handoffGraph[node] ?? []) { - dfs(target, [...path, node]); - } - inStack.delete(node); - } - for (const key of keys) { - dfs(key, []); - } - - assertSizeLimit(value, 'agents'); -} - -function validateWorkflows(value: unknown): void { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new InputError( - 'workflows must be a non-null object (Record)', - ); - } - const workflows = value as Record; - for (const [key, wfVal] of Object.entries(workflows)) { - if (typeof wfVal !== 'object' || wfVal === null || Array.isArray(wfVal)) { - throw new InputError(`Workflow "${key}" must be a non-null object`); - } - const wf = wfVal as Record; - if (typeof wf.id !== 'string' || wf.id.trim().length === 0) { - throw new InputError(`Workflow "${key}" must have a non-empty id`); - } - if (wf.id !== key) { - throw new InputError( - `Workflow key "${key}" must match its id field "${wf.id}"`, - ); - } - if (typeof wf.name !== 'string' || wf.name.trim().length === 0) { - throw new InputError(`Workflow "${key}" must have a non-empty name`); - } - if (!Array.isArray(wf.nodes)) { - throw new InputError(`Workflow "${key}" must have a nodes array`); - } - if (!Array.isArray(wf.edges)) { - throw new InputError(`Workflow "${key}" must have an edges array`); - } - } - assertSizeLimit(value, 'workflows'); -} - -const VALIDATORS: Partial void>> = { - agents: validateAgents, - workflows: validateWorkflows, - promptGroups: validatePromptGroups, - - baseUrl: value => { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new InputError('baseUrl must be a non-empty string'); - } - try { - const url = new URL(value); - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - throw new InputError('baseUrl must start with http:// or https://'); - } - } catch (err) { - if (err instanceof InputError) throw err; - throw new InputError(`baseUrl must be a valid URL: ${value}`); - } - const ssrfReason = isPrivateUrl(value); - if (ssrfReason) { - throw new InputError( - `baseUrl must not point to a private/internal address (${ssrfReason})`, - ); - } - }, - - model: value => { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new InputError('model must be a non-empty string'); - } - if (value.length > MAX_MODEL_LENGTH) { - throw new InputError( - `model must be at most ${MAX_MODEL_LENGTH} characters`, - ); - } - }, - - systemPrompt: value => { - if (typeof value !== 'string') { - throw new InputError('systemPrompt must be a string'); - } - if (value.length > MAX_SYSTEM_PROMPT_LENGTH) { - throw new InputError( - `systemPrompt must be at most ${MAX_SYSTEM_PROMPT_LENGTH} characters`, - ); - } - }, - - toolChoice: value => { - if (typeof value === 'string') { - if (!['auto', 'required', 'none'].includes(value)) { - throw new InputError( - 'toolChoice must be "auto", "required", "none", or a {type, name} object', - ); - } - return; - } - if (typeof value === 'object' && value !== null) { - const obj = value as Record; - if (obj.type === 'function' && typeof obj.name === 'string') { - return; - } - if (obj.type === 'allowed_tools' && Array.isArray(obj.tools)) { - return; - } - } - throw new InputError( - 'toolChoice must be "auto", "required", "none", or a valid tool choice object', - ); - }, - - enableWebSearch: value => { - if (typeof value !== 'boolean') { - throw new InputError('enableWebSearch must be a boolean'); - } - }, - - enableCodeInterpreter: value => { - if (typeof value !== 'boolean') { - throw new InputError('enableCodeInterpreter must be a boolean'); - } - }, - - branding: value => { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new InputError('branding must be a non-null object'); - } - const b = value as Record; - - const HEX = /^#[0-9a-fA-F]{6}$/; - const colorFields = [ - 'primaryColor', - 'secondaryColor', - 'successColor', - 'warningColor', - 'errorColor', - 'infoColor', - ]; - for (const field of colorFields) { - if (b[field] !== undefined) { - if (typeof b[field] !== 'string' || !HEX.test(b[field] as string)) { - throw new InputError( - `branding.${field} must be a valid hex color (e.g. #1e40af)`, - ); - } - } - } - - const stringFields = [ - 'appName', - 'tagline', - 'inputPlaceholder', - 'logoUrl', - 'faviconUrl', - 'themePreset', - ]; - for (const field of stringFields) { - if (b[field] !== undefined) { - if (typeof b[field] !== 'string') { - throw new InputError(`branding.${field} must be a string`); - } - if ((b[field] as string).length > MAX_BRANDING_FIELD_LENGTH) { - throw new InputError( - `branding.${field} must be at most ${MAX_BRANDING_FIELD_LENGTH} characters`, - ); - } - } - } - - const urlFields = ['logoUrl', 'faviconUrl'] as const; - for (const field of urlFields) { - if (b[field] !== undefined && typeof b[field] === 'string') { - const url = b[field] as string; - if (url.length > 0) { - try { - const parsed = new URL(url); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - throw new InputError( - `branding.${field} must use http:// or https:// protocol`, - ); - } - } catch (err) { - if (err instanceof InputError) throw err; - throw new InputError( - `branding.${field} must be a valid URL: ${url}`, - ); - } - } - } - } - - const allowedKeys = new Set([...colorFields, ...stringFields]); - for (const key of Object.keys(b)) { - if (!allowedKeys.has(key)) { - throw new InputError(`branding contains unknown key: "${key}"`); - } - } - - assertSizeLimit(value, 'branding'); - }, - - safetyPatterns: value => { - if (!Array.isArray(value)) { - throw new InputError('safetyPatterns must be an array of strings'); - } - for (const item of value) { - if (typeof item !== 'string') { - throw new InputError('Each safety pattern must be a string'); - } - } - }, - - safetyEnabled: value => { - if (typeof value !== 'boolean') { - throw new InputError('safetyEnabled must be a boolean'); - } - }, - - inputShields: value => { - if (!Array.isArray(value)) { - throw new InputError('inputShields must be an array of strings'); - } - for (const item of value) { - if (typeof item !== 'string' || item.trim().length === 0) { - throw new InputError('Each input shield must be a non-empty string'); - } - } - }, - - outputShields: value => { - if (!Array.isArray(value)) { - throw new InputError('outputShields must be an array of strings'); - } - for (const item of value) { - if (typeof item !== 'string' || item.trim().length === 0) { - throw new InputError('Each output shield must be a non-empty string'); - } - } - }, - - evaluationEnabled: value => { - if (typeof value !== 'boolean') { - throw new InputError('evaluationEnabled must be a boolean'); - } - }, - - scoringFunctions: value => { - if (!Array.isArray(value)) { - throw new InputError('scoringFunctions must be an array of strings'); - } - for (const item of value) { - if (typeof item !== 'string' || item.trim().length === 0) { - throw new InputError( - 'Each scoring function must be a non-empty string', - ); - } - } - }, - - minScoreThreshold: value => { - if (typeof value !== 'number') { - throw new InputError('minScoreThreshold must be a number'); - } - if (value < 0 || value > 1) { - throw new InputError('minScoreThreshold must be between 0 and 1'); - } - }, - - safetyOnError: value => { - if (value !== 'allow' && value !== 'block') { - throw new InputError('safetyOnError must be "allow" or "block"'); - } - }, - - evaluationOnError: value => { - if (value !== 'skip' && value !== 'fail') { - throw new InputError('evaluationOnError must be "skip" or "fail"'); - } - }, - - vectorStoreConfig: value => { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new InputError('vectorStoreConfig must be a non-null object'); - } - const v = value as Record; - if (v.embeddingModel !== undefined) { - if (typeof v.embeddingModel !== 'string') { - throw new InputError( - 'vectorStoreConfig.embeddingModel must be a string', - ); - } - if (v.embeddingModel.trim().length === 0) { - throw new InputError( - 'vectorStoreConfig.embeddingModel must be a non-empty string', - ); - } - } - if (v.embeddingDimension !== undefined) { - if ( - typeof v.embeddingDimension !== 'number' || - !Number.isFinite(v.embeddingDimension) - ) { - throw new InputError( - 'vectorStoreConfig.embeddingDimension must be a finite number', - ); - } - if (v.embeddingDimension <= 0) { - throw new InputError( - 'vectorStoreConfig.embeddingDimension must be a positive number', - ); - } - } - if ( - v.searchMode !== undefined && - v.searchMode !== 'semantic' && - v.searchMode !== 'keyword' && - v.searchMode !== 'hybrid' - ) { - throw new InputError( - 'vectorStoreConfig.searchMode must be "semantic", "keyword", or "hybrid"', - ); - } - if ( - v.chunkingStrategy !== undefined && - v.chunkingStrategy !== 'auto' && - v.chunkingStrategy !== 'static' - ) { - throw new InputError( - 'vectorStoreConfig.chunkingStrategy must be "auto" or "static"', - ); - } - const numericFields: Array<{ - name: string; - min?: number; - max?: number; - }> = [ - { name: 'bm25Weight', min: 0, max: 1 }, - { name: 'semanticWeight', min: 0, max: 1 }, - { name: 'maxChunkSizeTokens', min: 1 }, - { name: 'chunkOverlapTokens', min: 0 }, - { name: 'fileSearchMaxResults', min: 1 }, - { name: 'fileSearchScoreThreshold', min: 0, max: 1 }, - ]; - for (const { name, min, max } of numericFields) { - if (v[name] !== undefined) { - if (typeof v[name] !== 'number') { - throw new InputError(`vectorStoreConfig.${name} must be a number`); - } - const num = v[name] as number; - if (min !== undefined && num < min) { - throw new InputError( - `vectorStoreConfig.${name} must be at least ${min}`, - ); - } - if (max !== undefined && num > max) { - throw new InputError( - `vectorStoreConfig.${name} must be at most ${max}`, - ); - } - } - } - if (v.vectorStoreName !== undefined) { - if (typeof v.vectorStoreName !== 'string') { - throw new InputError( - 'vectorStoreConfig.vectorStoreName must be a string', - ); - } - if (v.vectorStoreName.trim().length === 0) { - throw new InputError( - 'vectorStoreConfig.vectorStoreName must be a non-empty string', - ); - } - } - }, - - activeVectorStoreIds: value => { - if (!Array.isArray(value)) { - throw new InputError('activeVectorStoreIds must be an array of strings'); - } - for (const item of value) { - if (typeof item !== 'string' || item.trim().length === 0) { - throw new InputError( - 'Each activeVectorStoreIds entry must be a non-empty string', - ); - } - } - }, - - disabledMcpServerIds: value => { - if (!Array.isArray(value)) { - throw new InputError('disabledMcpServerIds must be an array'); - } - for (const item of value) { - if (typeof item !== 'string' || item.trim().length === 0) { - throw new InputError( - 'Each disabledMcpServerIds entry must be a non-empty string', - ); - } - } - const unique = new Set(value); - if (unique.size !== value.length) { - throw new InputError('disabledMcpServerIds must not contain duplicates'); - } - }, - - mcpServers: value => { - if (!Array.isArray(value)) { - throw new InputError('mcpServers must be an array'); - } - const seenIds = new Set(); - for (const server of value) { - if (!server || typeof server !== 'object') { - throw new InputError('Each MCP server must be an object'); - } - const s = server as Record; - if (typeof s.id !== 'string' || s.id.trim().length === 0) { - throw new InputError('Each MCP server must have a non-empty id'); - } - if (seenIds.has(s.id)) { - throw new InputError(`Duplicate MCP server id: "${s.id}"`); - } - seenIds.add(s.id); - if (typeof s.name !== 'string' || s.name.trim().length === 0) { - throw new InputError('Each MCP server must have a non-empty name'); - } - if (typeof s.url !== 'string' || s.url.trim().length === 0) { - throw new InputError('Each MCP server must have a non-empty url'); - } - try { - const parsed = new URL(s.url); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - throw new InputError( - `MCP server "${s.id}" url must start with http:// or https://`, - ); - } - } catch (err) { - if (err instanceof InputError) throw err; - throw new InputError( - `MCP server "${s.id}" url must be a valid URL: ${s.url}`, - ); - } - const ssrfReason = isPrivateUrl(s.url as string); - if (ssrfReason) { - throw new InputError( - `MCP server "${s.id}" url must not point to a private/internal address (${ssrfReason})`, - ); - } - if ( - s.requireApproval !== undefined && - s.requireApproval !== 'always' && - s.requireApproval !== 'never' - ) { - throw new InputError( - `MCP server "${s.id}" requireApproval must be "always" or "never"`, - ); - } - } - - assertSizeLimit(value, 'mcpServers'); - }, -}; - -/** - * Validates values before they are persisted via AdminConfigService. - * Returns normally on success, throws InputError on failure. - */ -export function validateAdminConfigValue( - key: AdminConfigKey, - value: unknown, -): void { - const validator = VALIDATORS[key]; - if (validator) { - validator(value); - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/documentAttributes.test.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/documentAttributes.test.ts deleted file mode 100644 index a6ab96648e4..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/documentAttributes.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - generateTitleFromFileName, - detectContentType, - extractFileNameFromUrl, - generateDefaultAttributes, -} from './documentAttributes'; - -describe('generateTitleFromFileName', () => { - it('removes extension and converts separators to spaces', () => { - expect(generateTitleFromFileName('my-file.ts')).toBe('my file'); - expect(generateTitleFromFileName('readme_markdown.md')).toBe( - 'readme markdown', - ); - }); - - it('handles various file extensions', () => { - expect(generateTitleFromFileName('index.ts')).toBe('index'); - expect(generateTitleFromFileName('config.json')).toBe('config'); - expect(generateTitleFromFileName('script.py')).toBe('script'); - expect(generateTitleFromFileName('documentation.md')).toBe('documentation'); - }); - - it('collapses multiple spaces', () => { - expect(generateTitleFromFileName('my---file_name.ts')).toBe('my file name'); - }); - - it('trims leading and trailing whitespace', () => { - expect(generateTitleFromFileName(' foo_bar .ts')).toBe('foo bar'); - }); - - it('returns original filename when result would be empty', () => { - expect(generateTitleFromFileName('.hidden')).toBe('.hidden'); - }); - - it('handles empty string', () => { - expect(generateTitleFromFileName('')).toBe(''); - }); - - it('handles filename with no extension', () => { - expect(generateTitleFromFileName('Makefile')).toBe('Makefile'); - }); -}); - -describe('detectContentType', () => { - it('detects markdown', () => { - expect(detectContentType('readme.md')).toBe('markdown'); - }); - - it('detects pdf', () => { - expect(detectContentType('doc.pdf')).toBe('pdf-documentation'); - }); - - it('detects text', () => { - expect(detectContentType('notes.txt')).toBe('text'); - }); - - it('detects yaml', () => { - expect(detectContentType('config.yaml')).toBe('yaml'); - expect(detectContentType('config.yml')).toBe('yaml'); - }); - - it('detects json', () => { - expect(detectContentType('package.json')).toBe('json'); - }); - - it('detects html', () => { - expect(detectContentType('page.html')).toBe('html'); - expect(detectContentType('page.htm')).toBe('html'); - }); - - it('returns text for unknown extensions', () => { - expect(detectContentType('script.ts')).toBe('text'); - expect(detectContentType('script.py')).toBe('text'); - expect(detectContentType('file.xyz')).toBe('text'); - }); - - it('handles no extension', () => { - expect(detectContentType('Makefile')).toBe('text'); - }); - - it('handles empty string', () => { - expect(detectContentType('')).toBe('text'); - }); - - it('is case insensitive', () => { - expect(detectContentType('readme.MD')).toBe('markdown'); - expect(detectContentType('config.JSON')).toBe('json'); - }); -}); - -describe('extractFileNameFromUrl', () => { - it('extracts filename from simple URL', () => { - expect(extractFileNameFromUrl('https://example.com/docs/readme.md')).toBe( - 'readme.md', - ); - }); - - it('extracts filename from URL with query params', () => { - expect( - extractFileNameFromUrl( - 'https://example.com/file.json?version=1&token=abc', - ), - ).toBe('file.json'); - }); - - it('extracts filename from URL with hash', () => { - expect(extractFileNameFromUrl('https://example.com/doc.pdf#page=5')).toBe( - 'doc.pdf', - ); - }); - - it('returns document.txt when pathname has no filename', () => { - expect(extractFileNameFromUrl('https://example.com/')).toBe('document.txt'); - }); - - it('returns document.txt for invalid URL', () => { - expect(extractFileNameFromUrl('not-a-valid-url')).toBe('document.txt'); - }); - - it('handles deep paths', () => { - expect( - extractFileNameFromUrl( - 'https://github.com/owner/repo/blob/main/src/index.ts', - ), - ).toBe('index.ts'); - }); - - it('handles file extension in path', () => { - expect( - extractFileNameFromUrl('https://example.com/a/b/c/config.yaml'), - ).toBe('config.yaml'); - }); -}); - -describe('generateDefaultAttributes', () => { - it('generates attributes from FetchedDocument', () => { - const doc = { - sourceId: 'https://example.com/readme.md', - fileName: 'readme.md', - content: '# Hello', - sourceType: 'url' as const, - }; - const attrs = generateDefaultAttributes(doc); - expect(attrs.title).toBe('readme'); - expect(attrs.source_url).toBe('https://example.com/readme.md'); - expect(attrs.content_type).toBe('markdown'); - }); - - it('uses sourceType when detected content type is text', () => { - const doc = { - sourceId: 'file:///local/path', - fileName: 'notes.txt', - content: 'notes', - sourceType: 'directory' as const, - }; - const attrs = generateDefaultAttributes(doc); - expect(attrs.content_type).toBe('directory'); - }); - - it('falls back to fileName when title would be empty', () => { - const doc = { - sourceId: 'https://example.com/', - fileName: '.gitignore', - content: '', - sourceType: 'url' as const, - }; - const attrs = generateDefaultAttributes(doc); - expect(attrs.title).toBe('.gitignore'); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/documentAttributes.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/documentAttributes.ts deleted file mode 100644 index 6eb539bea21..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/documentAttributes.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as path from 'path'; - -import type { FetchedDocument, FileAttributes } from '../../types'; - -/** - * Generate a human-readable title from a filename. - * Removes extension and converts separators to spaces. - */ -export function generateTitleFromFileName(fileName: string): string { - return ( - fileName - .replace(/\.[^/.]+$/, '') - .replace(/[-_]/g, ' ') - .replace(/\s+/g, ' ') - .trim() || fileName - ); -} - -/** - * Detect content type based on file extension. - */ -export function detectContentType(fileName: string): string { - const ext = path.extname(fileName).toLowerCase(); - switch (ext) { - case '.md': - return 'markdown'; - case '.pdf': - return 'pdf-documentation'; - case '.txt': - return 'text'; - case '.yaml': - case '.yml': - return 'yaml'; - case '.json': - return 'json'; - case '.html': - case '.htm': - return 'html'; - default: - return 'text'; - } -} - -/** - * Generate default attributes for a document based on its source metadata. - * Used when FetchedDocument doesn't have explicit attributes. - */ -export function generateDefaultAttributes( - doc: FetchedDocument, -): FileAttributes { - const title = generateTitleFromFileName(doc.fileName) || doc.fileName; - const detectedContentType = detectContentType(doc.fileName); - const contentType = - detectedContentType === 'text' ? doc.sourceType : detectedContentType; - - return { - title, - source_url: doc.sourceId, - content_type: contentType, - }; -} - -/** - * Extract filename from a URL, falling back to `'document.txt'`. - */ -export function extractFileNameFromUrl(url: string): string { - try { - const urlObj = new URL(url); - const pathname = urlObj.pathname; - const fileName = path.basename(pathname); - return fileName || 'document.txt'; - } catch { - // Path parsing failed; return safe default filename - return 'document.txt'; - } -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/errorSanitizer.test.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/errorSanitizer.test.ts deleted file mode 100644 index b6c51f2090e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/errorSanitizer.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { sanitizeErrorMessage } from './errorSanitizer'; - -describe('sanitizeErrorMessage', () => { - it('extracts detail and status from Llama Stack API error', () => { - const raw = - 'Llama Stack API error: 400 Bad Request - {"detail":"Invalid model"}'; - const result = sanitizeErrorMessage(raw); - expect(result).toEqual({ - message: 'Invalid model', - inferredStatus: 400, - }); - }); - - it('extracts detail and status from Responses API error', () => { - const raw = - 'Responses API error: 500 Streaming request failed - {"detail":"Internal server error"}'; - const result = sanitizeErrorMessage(raw); - expect(result).toEqual({ - message: 'Internal server error', - inferredStatus: 500, - }); - }); - - it('extracts 422 status from Responses API validation error', () => { - const raw = - 'Responses API error: 422 Unprocessable Entity - {"detail":"Unsupported tool type"}'; - const result = sanitizeErrorMessage(raw); - expect(result).toEqual({ - message: 'Unsupported tool type', - inferredStatus: 422, - }); - }); - - it('falls back to truncated body when detail is not a string', () => { - const raw = - 'Responses API error: 400 Bad Request - {"detail":{"nested":"value"}}'; - const result = sanitizeErrorMessage(raw); - expect(result.inferredStatus).toBe(400); - expect(result.message).toContain('"detail"'); - }); - - it('falls back to raw body when JSON parsing fails', () => { - const raw = - 'Llama Stack API error: 500 Internal Server Error - not valid json'; - const result = sanitizeErrorMessage(raw); - expect(result).toEqual({ - message: 'not valid json', - inferredStatus: 500, - }); - }); - - it('truncates long error bodies', () => { - const longBody = 'x'.repeat(500); - const raw = `Responses API error: 500 Error - ${longBody}`; - const result = sanitizeErrorMessage(raw); - expect(result.inferredStatus).toBe(500); - expect(result.message.length).toBeLessThan(400); - expect(result.message).toMatch(/\.\.\.$/); - }); - - it('returns raw message for unrecognized error formats', () => { - const raw = 'Something unexpected happened'; - const result = sanitizeErrorMessage(raw); - expect(result).toEqual({ message: 'Something unexpected happened' }); - }); -}); diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/errorSanitizer.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/errorSanitizer.ts deleted file mode 100644 index 3ceb17d4782..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/errorSanitizer.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Extract a user-safe message and HTTP status code from a raw error string. - * - * Handles: - * - Legacy "Llama Stack API error: ..." format - * - "Responses API error: ..." format from ResponsesApiError - * - "Kagenti API error: ..." format from KagentiApiClient - * - Kagenti stream errors with status codes - * - * Strips upstream server detail/stack traces to avoid leaking internals. - */ -export function sanitizeErrorMessage(raw: string): { - message: string; - inferredStatus?: number; -} { - // Bounded quantifiers to avoid catastrophic backtracking (S5852) - const llamaRegex = - /(?:Llama Stack|Responses) API error:\s*(\d{3})\s*[^\n-]{0,200}-\s*([\s\S]{0,10000})/; - const llamaMatch = llamaRegex.exec(raw); - if (llamaMatch) { - return extractFromMatch(llamaMatch); - } - - const kagentiRegex = - /Kagenti (?:API|stream request failed).*?status\s+(\d{3})(?:\s*-\s*([\s\S]{0,10000}))?/; - const kagentiMatch = kagentiRegex.exec(raw); - if (kagentiMatch) { - return extractFromMatch(kagentiMatch); - } - - const MAX_GENERIC_LENGTH = 200; - if (raw.length > MAX_GENERIC_LENGTH) { - return { message: `${raw.substring(0, MAX_GENERIC_LENGTH)}...` }; - } - return { message: raw }; -} - -function extractFromMatch(match: RegExpExecArray): { - message: string; - inferredStatus?: number; -} { - const status = Number.parseInt(match[1], 10); - const body = (match[2] ?? '').trim(); - if (!body) { - return { - message: `Request failed with status ${status}`, - inferredStatus: status, - }; - } - try { - const parsed = JSON.parse(body); - if (typeof parsed.detail === 'string') { - return { message: parsed.detail, inferredStatus: status }; - } - if (typeof parsed.message === 'string') { - return { message: parsed.message, inferredStatus: status }; - } - } catch { - // Body may not be JSON; fall through to truncation - } - const truncated = body.length > 300 ? `${body.substring(0, 300)}...` : body; - return { message: truncated, inferredStatus: status }; -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/errors.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/errors.ts deleted file mode 100644 index cb990cb1816..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/errors.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Safely extract an error message from an unknown caught value. - * - * @param error - The caught value (may be Error, string, or anything). - * @param fallback - Message returned when no useful string can be extracted. - */ -export function toErrorMessage( - error: unknown, - fallback = 'Unknown error', -): string { - if (error instanceof Error) { - return error.message; - } - if (typeof error === 'string') { - return error; - } - if (error !== null && error !== undefined) { - return String(error); - } - return fallback; -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/file-utils.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/file-utils.ts deleted file mode 100644 index d0ea9c04e1b..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/file-utils.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { FileFormat } from '../../types'; - -/** - * Extract file extension from filename (lowercase, without dot). - * Uses path.extname semantics: 'file.tar.gz' → 'gz'. - */ -function getFileExtension(fileName: string): string { - const dotIdx = fileName.lastIndexOf('.'); - if (dotIdx < 0 || dotIdx === fileName.length - 1) return ''; - return fileName.slice(dotIdx + 1).toLowerCase(); -} - -/** - * File format mapping for O(1) lookup - */ -const FILE_FORMATS: Record = { - yaml: FileFormat.YAML, - yml: FileFormat.YAML, - json: FileFormat.JSON, - md: FileFormat.MARKDOWN, - markdown: FileFormat.MARKDOWN, - pdf: FileFormat.PDF, - txt: FileFormat.TEXT, -}; - -/** - * Detect file format from filename extension - */ -export function detectFileFormat(fileName: string): FileFormat { - const ext = getFileExtension(fileName); - return FILE_FORMATS[ext] || FileFormat.TEXT; -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/http.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/http.ts deleted file mode 100644 index 1aed6f51382..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/http.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { DEFAULT_HTTP_TIMEOUT_MS } from '../../constants'; -import { createTlsSkipFetch } from './mcpClient'; - -/** - * Response-like interface returned by fetchWithTlsControl. - * Compatible with both GET (DocumentIngestionService) and POST (McpAuthService) patterns. - */ -export interface TlsResponse { - ok: boolean; - status: number; - headers?: Record; - text: () => Promise; - json: () => Promise; -} - -export interface FetchWithTlsControlOptions { - method?: string; - headers?: Record; - body?: string; - skipTlsVerify?: boolean; - timeoutMs?: number; - redirect?: RequestInit['redirect']; -} - -function toTlsResponse(r: Response): TlsResponse { - const responseHeaders: Record = {}; - if (r.headers && typeof r.headers.forEach === 'function') { - r.headers.forEach((v, k) => { - responseHeaders[k] = v; - }); - } - return { - ok: r.ok, - status: r.status, - headers: responseHeaders, - text: () => r.text(), - json: () => r.json() as Promise, - }; -} - -/** - * Shared TLS-aware HTTP(S) fetch that returns a Response-like object. - * Consolidates the identical `fetchWithTlsControl` implementations - * previously duplicated across DocumentIngestionService, McpAuthService, - * and StatusService. - * - * When `skipTlsVerify` is true and the URL is HTTPS, uses an undici - * dispatcher that disables certificate verification (for self-signed - * certs in dev/enterprise environments). Otherwise delegates to - * native `fetch` with full TLS verification. - */ -export function fetchWithTlsControl( - url: string, - options: FetchWithTlsControlOptions = {}, -): Promise { - const { - method = 'GET', - headers = {}, - body, - skipTlsVerify = false, - timeoutMs = DEFAULT_HTTP_TIMEOUT_MS, - redirect, - } = options; - - const fetchFn = - skipTlsVerify && new URL(url).protocol === 'https:' - ? createTlsSkipFetch() - : fetch; - - const init: RequestInit = { method, headers }; - if (body) init.body = body; - if (redirect) init.redirect = redirect; - if (timeoutMs !== null && timeoutMs !== undefined && timeoutMs > 0) { - init.signal = AbortSignal.timeout(timeoutMs); - } - - return fetchFn(url, init).then(toTlsResponse); -} diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/index.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/index.ts deleted file mode 100644 index de07acf3a02..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { detectFileFormat } from './file-utils'; -export { toErrorMessage } from './errors'; -export { - connectToMcpServer, - listMcpServerTools, - createTlsSkipFetch, -} from './mcpClient'; -export type { - McpConnectionOptions, - McpToolInfo, - McpConnectionResult, -} from './mcpClient'; -export { fetchWithTlsControl } from './http'; -export type { TlsResponse, FetchWithTlsControlOptions } from './http'; diff --git a/workspaces/augment/plugins/augment-backend/src/services/utils/mcpClient.ts b/workspaces/augment/plugins/augment-backend/src/services/utils/mcpClient.ts deleted file mode 100644 index 9f46b898373..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/services/utils/mcpClient.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; - -/** - * Creates a custom fetch function that skips TLS certificate verification. - * Uses Node.js built-in undici Agent to scope the TLS override to this - * fetch instance only, keeping the rest of the process secure. - * - * Only called when `augment.llamaStack.skipTlsVerify` is explicitly set - * to `true` in app-config.yaml (for self-signed certificates in - * dev/enterprise environments). - */ -export function createTlsSkipFetch(): typeof globalThis.fetch { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { Agent } = require('undici') as { - Agent: new (opts: Record) => unknown; - }; - const verifyTls = false; // NOSONAR — intentional: only invoked when skipTlsVerify config is true - const dispatcher = new Agent({ - connect: { rejectUnauthorized: verifyTls }, - }); - return ((input: RequestInfo | URL, init?: RequestInit) => { - return globalThis.fetch(input, { - ...init, - dispatcher, - } as RequestInit); - }) as typeof globalThis.fetch; -} - -export interface McpConnectionOptions { - headers?: Record; - skipTlsVerify?: boolean; - clientName?: string; -} - -export interface McpToolInfo { - name: string; - description?: string; - inputSchema?: Record; -} - -export interface McpConnectionResult { - client: Client; - tools: McpToolInfo[]; -} - -/** - * Connect to an MCP server using the official SDK and list its tools. - * The caller is responsible for closing the client when done. - */ -export async function connectToMcpServer( - url: string, - opts: McpConnectionOptions = {}, -): Promise { - const requestInit: RequestInit = {}; - if (opts.headers && Object.keys(opts.headers).length > 0) { - requestInit.headers = opts.headers; - } - - const transport = new StreamableHTTPClientTransport(new URL(url), { - requestInit, - fetch: opts.skipTlsVerify ? createTlsSkipFetch() : undefined, - }); - - const client = new Client({ - name: opts.clientName ?? 'augment', - version: '1.0.0', - }); - - await client.connect(transport); - const { tools } = await client.listTools(); - - return { - client, - tools: tools.map(t => ({ - name: t.name, - description: t.description, - inputSchema: t.inputSchema as Record | undefined, - })), - }; -} - -/** - * Connect to an MCP server, list its tools, and immediately close the connection. - * Used for health checks and test connections where we don't need to keep the client open. - */ -export async function listMcpServerTools( - url: string, - opts: McpConnectionOptions = {}, -): Promise { - const { client, tools } = await connectToMcpServer(url, opts); - try { - await client.close(); - } catch { - // Best-effort close - } - return tools; -} diff --git a/workspaces/augment/plugins/augment-backend/src/test-utils/index.ts b/workspaces/augment/plugins/augment-backend/src/test-utils/index.ts deleted file mode 100644 index e731566ce5a..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/test-utils/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { createMockLogger, createMockResponse } from './mocks'; diff --git a/workspaces/augment/plugins/augment-backend/src/test-utils/mocks.ts b/workspaces/augment/plugins/augment-backend/src/test-utils/mocks.ts deleted file mode 100644 index ee84abca64b..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/test-utils/mocks.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { Response } from 'express'; -import type { ResponsesApiClient } from '../providers/llamastack/ResponsesApiClient'; - -/** - * Creates a typed mock LoggerService for tests. - * Eliminates the need for `as unknown as LoggerService` casts. - */ -export function createMockLogger(): jest.Mocked { - return { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - child: jest.fn().mockReturnThis(), - } as jest.Mocked; -} - -/** - * Creates a mock ResponsesApiClient with `request` and `streamRequest` stubs. - */ -export function createMockClient(): jest.Mocked< - Pick -> { - const request = jest.fn(); - return { - request, - requestWithRetry: request, - streamRequest: jest.fn(), - }; -} - -/** - * Creates a typed mock express Response for tests. - * Provides the minimal methods commonly used in route handlers. - * Use when testing route handlers that call res.status().json(). - */ -export function createMockResponse(): jest.Mocked { - return { - status: jest.fn().mockReturnThis(), - json: jest.fn().mockReturnThis(), - send: jest.fn().mockReturnThis(), - } as unknown as jest.Mocked; -} diff --git a/workspaces/augment/plugins/augment-backend/src/types/chat.ts b/workspaces/augment/plugins/augment-backend/src/types/chat.ts deleted file mode 100644 index 42e4a91181d..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/types/chat.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - ChatMessage, - ChatResponse, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -/** - * Request for chat endpoint - * @public - */ -export interface ChatRequest { - messages: ChatMessage[]; - enableRAG?: boolean; - /** Previous response ID for conversation branching (Responses API native feature) */ - previousResponseId?: string; - /** Conversation ID for linking responses to a Llama Stack conversation */ - conversationId?: string; - /** Session ID — when provided, the backend manages conversation_id lookup/creation */ - sessionId?: string; - /** Model or agent identifier (e.g. "namespace/agentName" for Kagenti) */ - model?: string; -} - -export type { ChatResponse }; diff --git a/workspaces/augment/plugins/augment-backend/src/types/config.ts b/workspaces/augment/plugins/augment-backend/src/types/config.ts deleted file mode 100644 index 57775f936a5..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/types/config.ts +++ /dev/null @@ -1,703 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { BrandingConfig } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { MCPServerConfig } from './security'; - -/** - * Effective configuration resolved at request time by merging - * YAML baseline with admin DB overrides. All chat-time config - * consumers should read from this interface, not from cached - * service fields. - * - * @public - */ -export interface EffectiveConfig { - /** LLM model identifier */ - model: string; - /** Llama Stack server URL */ - baseUrl: string; - /** System prompt injected as instructions */ - systemPrompt: string; - /** Tool choice strategy */ - toolChoice?: ToolChoiceConfig; - /** Allow the model to invoke multiple tools in one turn */ - parallelToolCalls?: boolean; - /** Structured output format */ - textFormat?: { - type: 'json_schema'; - json_schema: { - name: string; - schema: Record; - strict?: boolean; - }; - }; - /** Enable built-in web search tool */ - enableWebSearch: boolean; - /** Enable built-in code interpreter tool */ - enableCodeInterpreter: boolean; - /** Max chunks returned by file_search */ - fileSearchMaxResults?: number; - /** Minimum relevance score threshold for file_search */ - fileSearchScoreThreshold?: number; - /** Active vector store IDs for RAG */ - vectorStoreIds: string[]; - /** Vector store name for auto-creation */ - vectorStoreName: string; - /** Embedding model */ - embeddingModel: string; - /** Embedding dimension */ - embeddingDimension: number; - /** Search mode */ - searchMode?: 'semantic' | 'keyword' | 'hybrid'; - /** BM25 weight for hybrid search */ - bm25Weight?: number; - /** Semantic weight for hybrid search */ - semanticWeight?: number; - /** Chunking strategy */ - chunkingStrategy: 'auto' | 'static'; - /** Max chunk size in tokens */ - maxChunkSizeTokens: number; - /** Chunk overlap in tokens */ - chunkOverlapTokens: number; - /** Skip TLS verification */ - skipTlsVerify: boolean; - /** Zero Data Retention mode */ - zdrMode: boolean; - /** Custom function definitions */ - functions?: FunctionDefinition[]; - /** API authentication token */ - token?: string; - /** Verbose stream logging */ - verboseStreamLogging: boolean; - /** Destructive action patterns for safety */ - safetyPatterns?: string[]; - /** Branding overrides */ - branding?: Partial; - /** Additional MCP servers added via admin UI (merged with YAML) */ - mcpServers?: MCPServerConfig[]; - /** Override safety enabled/disabled from admin panel */ - safetyEnabled?: boolean; - /** Override input shield IDs from admin panel */ - inputShields?: string[]; - /** Override output shield IDs from admin panel */ - outputShields?: string[]; - /** Override evaluation enabled/disabled from admin panel */ - evaluationEnabled?: boolean; - /** Override scoring function IDs from admin panel */ - scoringFunctions?: string[]; - /** Override minimum score threshold from admin panel */ - minScoreThreshold?: number; - /** Override safety onError behavior from admin panel ('allow' = fail-open, 'block' = fail-closed) */ - safetyOnError?: 'allow' | 'block'; - /** Override evaluation onError behavior from admin panel ('skip' = ignore errors, 'fail' = report errors) */ - evaluationOnError?: 'skip' | 'fail'; - /** - * Reasoning configuration for models that support thinking (Gemini 2.5+, o1/o3, etc.) - * Maps to the OpenAI Responses API `reasoning` parameter. - * Llama Stack translates this to provider-specific params (e.g. Gemini thinking_level). - */ - reasoning?: ReasoningConfig; - /** Semantic tool scoping configuration */ - toolScoping?: ToolScopingConfig; - - // =========================================================================== - // Responses API Production Parameters - // =========================================================================== - - /** - * Guardrail shield IDs to apply during response generation. - * Passed directly to the Responses API `guardrails` field for - * server-side enforcement alongside local SafetyService checks. - * Shield IDs must be registered on the Llama Stack server. - */ - guardrails?: string[]; - - /** - * Maximum number of total built-in tool calls per response. - * Maps to the Responses API `max_tool_calls` parameter. - * Prevents runaway tool loops (e.g., infinite file_search). - */ - maxToolCalls?: number; - - /** - * Upper bound for the number of output tokens per response. - * Maps to the Responses API `max_output_tokens` parameter. - * Controls cost and prevents excessively long outputs. - */ - maxOutputTokens?: number; - - /** - * Sampling temperature (0.0–2.0). Lower values are more - * deterministic, higher values more creative. - * Maps to the Responses API `temperature` parameter. - */ - temperature?: number; - - /** - * Stable identifier for safety monitoring and abuse detection. - * Maps to the Responses API `safety_identifier` parameter. - * Typically set to the organization or deployment name. - */ - safetyIdentifier?: string; - - /** - * Maximum number of inference iterations for the Responses API. - * Maps to the Responses API `max_infer_iters` parameter. - * Server-side iteration limit complementing the client-side `maxAgentTurns`. - */ - maxInferIters?: number; - - /** - * Instructions appended to approval continuations to guide - * how the model formats tool output. Configurable via - * `augment.postToolInstructions` in YAML. - */ - postToolInstructions?: string; - - /** - * Context window truncation strategy for the Responses API. - * - 'auto': Server truncates old conversation context to fit the model's window - * - 'disabled': No truncation (default); requests that exceed context will fail - * Maps to the Responses API `truncation` parameter. - */ - truncation?: 'auto' | 'disabled'; - - // =========================================================================== - // Multi-Agent Configuration - // =========================================================================== - - /** - * Multi-agent definitions keyed by agent identifier. - * When present, the orchestrator uses multi-agent routing instead of single-agent chat. - * Each key becomes the agent identifier used in handoff function names (e.g. `transfer_to_{key}`). - */ - agents?: Record; - /** Key of the default (entry/triage) agent. Must match a key in `agents`. */ - defaultAgent?: string; - /** Maximum number of internal turns the multi-agent runner may execute per user request (default: 10) */ - maxAgentTurns?: number; - - /** - * Llama Stack prompt reference for server-managed prompt templates. - * When set, the `prompt` field replaces `instructions` in the `/v1/responses` request. - */ - promptRef?: { - id: string; - version?: number; - variables?: Record; - }; -} - -/** - * Reasoning configuration for the OpenAI Responses API. - * Controls how much thinking a model performs before responding. - * @public - */ -export interface ReasoningConfig { - /** How much reasoning effort the model should apply */ - effort?: 'low' | 'medium' | 'high'; - /** - * Controls reasoning summary output in streaming responses. - * - 'auto': Server decides (default) - * - 'concise': Brief summaries - * - 'detailed': Full reasoning summaries - * - 'none': No summaries - */ - summary?: 'auto' | 'concise' | 'detailed' | 'none'; -} - -/** - * Allowed tool specification for filtering available tools - * @public - */ -export type AllowedToolSpec = - | { type: 'file_search' } - | { type: 'web_search' } - | { type: 'code_interpreter' } - | { type: 'mcp'; server_label: string } - | { type: 'function'; name: string }; - -/** - * Tool choice configuration for Responses API - * Controls how the model selects which tools to use - * @public - */ -export type ToolChoiceConfig = - | 'auto' // Model decides when to call tools (default) - | 'required' // Model must call at least one tool - | 'none' // Model cannot call any tools - | { type: 'function'; name: string } // Force a specific function - | { - type: 'allowed_tools'; - mode?: 'auto' | 'required'; - tools: AllowedToolSpec[]; - }; // Restrict to specific tools - -/** - * Configuration for semantic tool scoping (ToolScope). - * Provider-agnostic: controls whether per-query tool filtering - * is active and how many tools to select. - * @public - */ -export interface ToolScopingConfig { - /** Enable semantic tool scoping (default: false) */ - enabled: boolean; - /** Max tools selected per chat turn. */ - maxToolsPerTurn: number; - /** Min total tool count before scoping activates (default: 15) */ - activationThreshold: number; - /** Minimum cosine similarity score for a tool to be included (default: 0.1) */ - minScore: number; -} - -/** - * Configuration for Llama Stack integration - * Uses Llama Stack's OpenAI-compatible APIs with Responses API for RAG - * @public - */ -export interface LlamaStackConfig { - /** Base URL for the Llama Stack server */ - baseUrl: string; - /** IDs of vector stores to search (supports multiple for automatic multi-store RAG) */ - vectorStoreIds: string[]; - /** Name for auto-created vector store (required if vectorStoreIds not provided) */ - vectorStoreName: string; - /** Embedding model for vector store (e.g., 'sentence-transformers/all-MiniLM-L6-v2') */ - embeddingModel: string; - /** Embedding dimension (e.g., 384 for all-MiniLM-L6-v2, 768 for text-embedding-004) */ - embeddingDimension?: number; - /** - * Vector store search mode for RAG retrieval - * - 'semantic': Pure embedding-based search (default) - * - 'keyword': Pure BM25 keyword search - * - 'hybrid': Combines semantic and keyword search for better results - */ - searchMode?: 'semantic' | 'keyword' | 'hybrid'; - /** - * Weight for BM25 keyword search in hybrid mode (0.0 - 1.0) - * Only used when searchMode is 'hybrid' - * Default: 0.5 - */ - bm25Weight?: number; - /** - * Weight for semantic/embedding search in hybrid mode (0.0 - 1.0) - * Only used when searchMode is 'hybrid' - * Default: 0.5 - */ - semanticWeight?: number; - /** LLM model to use for Responses API (e.g., 'gemini/gemini-2.5-flash') */ - model: string; - /** Optional API token for authentication */ - token?: string; - /** Chunking strategy for file uploads: 'auto' or 'static' */ - chunkingStrategy: 'auto' | 'static'; - /** Max chunk size in tokens (for static chunking) */ - maxChunkSizeTokens: number; - /** Chunk overlap in tokens (for static chunking) */ - chunkOverlapTokens: number; - /** Skip TLS certificate verification (for self-signed certs in dev/enterprise) */ - skipTlsVerify?: boolean; - /** Enable verbose logging for streaming events (default: false) */ - verboseStreamLogging?: boolean; - /** - * Tool choice configuration - controls how the model selects tools - * - 'auto': Model decides when to call tools (default) - * - 'required': Model must call at least one tool - * - 'none': Model cannot call any tools - * - `\{ type: 'function', name: '...' \}`: Force a specific function - */ - toolChoice?: ToolChoiceConfig; - /** - * Whether to allow parallel tool calls - * - true: Model can call multiple tools in a single turn (default) - * - false: Model can only call one tool at a time - */ - parallelToolCalls?: boolean; - /** - * Structured output format configuration - * When set, the model will return output conforming to the JSON schema - */ - textFormat?: { - type: 'json_schema'; - json_schema: { - name: string; - schema: Record; - strict?: boolean; - }; - }; - /** - * Custom function definitions for the model to call - * These are added to the tools array alongside file_search and MCP tools - */ - functions?: FunctionDefinition[]; - /** - * Enable Zero Data Retention (ZDR) mode - * When enabled: - * - Responses are not stored (store: false) - * - Encrypted reasoning tokens are returned for stateless operation - * - Pass encrypted tokens back in subsequent requests - */ - zdrMode?: boolean; - /** - * Maximum number of chunks returned by file_search per query (1-50). - * Lower values reduce input tokens; higher values provide more context. - * Llama Stack default is 10 when not specified. - */ - fileSearchMaxResults?: number; - /** - * Minimum relevance score for file_search results (0.0-1.0). - * Chunks scoring below this threshold are dropped before being sent to the model. - * 0.0 means no filtering (default), 0.3-0.5 is a reasonable starting point. - */ - fileSearchScoreThreshold?: number; - /** - * Enable the built-in web_search tool - * When enabled, the model can search the web for real-time information - */ - enableWebSearch?: boolean; - /** - * Enable the built-in code_interpreter tool - * When enabled, the model can execute Python code for data analysis - */ - enableCodeInterpreter?: boolean; - /** - * Reasoning / thinking configuration. - * Enables and controls the model's internal chain-of-thought. - * - effort: 'low' | 'medium' | 'high' — maps to Gemini thinking_level or OpenAI reasoning effort - * - summary: 'auto' | 'concise' | 'detailed' | 'none' — controls reasoning summary output - */ - reasoning?: ReasoningConfig; - /** - * Semantic tool scoping configuration. - * When enabled, tools are filtered per-query by semantic relevance. - */ - toolScoping?: ToolScopingConfig; - - /** - * Maximum number of total built-in tool calls per response. - * Maps to the Responses API `max_tool_calls` parameter. - */ - maxToolCalls?: number; - - /** - * Upper bound for output tokens per response. - * Maps to the Responses API `max_output_tokens` parameter. - */ - maxOutputTokens?: number; - - /** - * Sampling temperature (0.0–2.0). - * Maps to the Responses API `temperature` parameter. - */ - temperature?: number; - - /** - * Stable identifier for safety monitoring and abuse detection. - * Maps to the Responses API `safety_identifier` parameter. - */ - safetyIdentifier?: string; - - /** - * Context window truncation strategy. - * - 'auto': Server truncates old context to fit the model's window - * - 'disabled': No truncation (default) - */ - truncation?: 'auto' | 'disabled'; -} - -/** - * Function definition for Responses API - * Defines a custom function the model can call - * @public - */ -export interface FunctionDefinition { - /** Function name (e.g., 'search_catalog') */ - name: string; - /** Description of what the function does and when to use it */ - description: string; - /** JSON Schema defining the function's input parameters */ - parameters: Record; - /** Whether to enforce strict mode for schema compliance (default: true) */ - strict?: boolean; -} - -/** - * Handoff input filter strategy. - * Controls how conversation history is transformed when handing off. - * Follows the OpenAI Agents SDK `HandoffInputFilter` pattern. - * @public - */ -export type HandoffInputFilter = 'none' | 'removeToolCalls' | 'summaryOnly'; - -/** - * Tool use behavior after tool results are returned. - * Follows the OpenAI Agents SDK `ToolUseBehavior` pattern. - * @public - */ -export type ToolUseBehavior = - | 'run_llm_again' - | 'stop_on_first_tool' - | { stopAtToolNames: string[] }; - -/** - * JSON Schema definition for structured agent output. - * @public - */ -export interface OutputSchema { - /** Schema name for the Responses API */ - name: string; - /** JSON Schema object defining the expected output structure */ - schema: Record; - /** Whether to enforce strict schema compliance (default: true) */ - strict?: boolean; -} - -/** - * Per-tool guardrail rule applied before or after tool execution. - * Follows the OpenAI Agents SDK `defineToolInputGuardrail` / `defineToolOutputGuardrail` pattern. - * Since LlamaStack handles MCP tool execution server-side, these guardrails are - * pattern-based validation rules applied at the orchestration layer. - * @public - */ -export interface ToolGuardrailRule { - /** Tool name pattern to match (glob-style, e.g. '*_delete', 'pods_*') */ - toolPattern: string; - /** Whether this is an input or output guardrail */ - phase: 'input' | 'output'; - /** - * Action when the guardrail matches: - * - 'block': reject the tool call with a message - * - 'warn': log a warning but allow execution - * - 'require_approval': force HITL approval even if tool doesn't normally require it - */ - action: 'block' | 'warn' | 'require_approval'; - /** Human-readable message explaining why this guardrail exists */ - message: string; - /** - * Optional argument/output pattern to match against (regex). - * When set, the guardrail only fires if the tool arguments/output match this pattern. - */ - contentPattern?: string; -} - -/** - * Structured lifecycle event emitted by the ADK orchestration layer. - * Follows the OpenAI Agents SDK `RunHooks` pattern for observability. - * @public - */ -export type AgentLifecycleEvent = - | { type: 'agent.start'; agentKey: string; agentName: string; turn: number } - | { - type: 'agent.end'; - agentKey: string; - agentName: string; - turn: number; - result: string; - } - | { - type: 'agent.handoff'; - fromAgent: string; - toAgent: string; - fromKey: string; - toKey: string; - reason?: string; - } - | { - type: 'agent.tool_start'; - agentKey: string; - toolName: string; - turn: number; - } - | { - type: 'agent.tool_end'; - agentKey: string; - toolName: string; - turn: number; - success: boolean; - }; - -/** - * Callback for receiving lifecycle events from the runner. - * @public - */ -export type LifecycleEventCallback = (event: AgentLifecycleEvent) => void; - -/** - * Deep-clone an agent config and apply partial overrides. - * Matches the OpenAI Agents SDK `Agent.clone()` pattern. - * @public - */ -export function cloneAgentConfig( - base: AgentConfig, - overrides: Partial = {}, -): AgentConfig { - const cloned = JSON.parse(JSON.stringify(base)) as AgentConfig; - return { ...cloned, ...overrides }; -} - -/** - * Configuration for a single agent in a multi-agent system. - * Follows the OpenAI Agents SDK pattern: each agent has its own instructions, - * tools, and can hand off to other agents via function calls. - * @public - */ -export interface AgentConfig { - /** Display name of the agent */ - name: string; - /** System prompt / instructions for this agent */ - instructions: string; - /** Description shown to other agents when this agent is a handoff target */ - handoffDescription?: string; - /** Override the LLM model for this agent (falls back to global model) */ - model?: string; - /** Subset of MCP server IDs this agent can use (from the global mcpServers list) */ - mcpServers?: string[]; - /** Agent keys this agent can hand off to (generates `transfer_to_{key}` functions) */ - handoffs?: string[]; - /** Agent keys this agent can call as tools (generates `call_{key}` functions) */ - asTools?: string[]; - /** Whether this agent has access to RAG / file_search */ - enableRAG?: boolean; - /** Override global vector store IDs for this agent's RAG queries (falls back to global) */ - vectorStoreIds?: string[]; - /** Whether this agent has access to web search */ - enableWebSearch?: boolean; - /** Whether this agent has access to code interpreter */ - enableCodeInterpreter?: boolean; - /** Custom function definitions specific to this agent */ - functions?: FunctionDefinition[]; - /** Override tool choice strategy for this agent */ - toolChoice?: ToolChoiceConfig; - /** Override reasoning configuration for this agent */ - reasoning?: ReasoningConfig; - /** - * @deprecated No longer used. Each agent's `instructions` is the complete prompt - * sent to the LLM, matching the OpenAI Agents SDK model. Kept for backward - * compatibility with existing saved configs (silently ignored at runtime). - */ - inheritSystemPrompt?: boolean; - /** - * JSON Schema for handoff metadata the model can pass when routing to this agent. - * When set, the transfer_to_ tool accepts structured parameters (reason, priority, etc.). - * Follows the OpenAI Agents SDK `inputType` pattern. - */ - handoffInputSchema?: Record; - /** - * How conversation history is transformed when another agent hands off to this one. - * - 'none' (default): pass all history as-is - * - 'removeToolCalls': strip function_call / function_call_output items - * - 'summaryOnly': only pass the handoff output (minimal context) - * Follows the OpenAI Agents SDK `HandoffInputFilter` pattern. - */ - handoffInputFilter?: HandoffInputFilter; - /** - * What happens after tool calls return results. - * - 'run_llm_again' (default): re-invoke the model with tool results - * - 'stop_on_first_tool': return tool output directly as the response - * Follows the OpenAI Agents SDK `ToolUseBehavior` pattern. - */ - toolUseBehavior?: ToolUseBehavior; - /** - * JSON Schema for this agent's structured output. - * When set, the agent's final output is validated against this schema. - * Maps to the Responses API `text.format` parameter. - */ - outputSchema?: OutputSchema; - /** - * Runtime condition controlling whether this agent's handoffs are available. - * When false, other agents cannot hand off to this agent. - * Default: true. - * Follows the OpenAI Agents SDK `isEnabled` pattern. - */ - enabled?: boolean; - /** - * Per-tool guardrail rules for this agent. - * Applied at the orchestration layer to validate tool inputs/outputs - * beyond the global SafetyService shields. - * Follows the OpenAI Agents SDK `defineToolInputGuardrail` / `defineToolOutputGuardrail` pattern. - */ - toolGuardrails?: ToolGuardrailRule[]; - - /** - * Override server-side guardrail shield IDs for this agent. - * Passed to the Responses API `guardrails` field. - */ - guardrails?: string[]; - - /** - * Override max tool calls per response for this agent. - * Prevents runaway tool loops for agents with many MCP tools. - */ - maxToolCalls?: number; - - /** - * Override max output tokens for this agent. - * Useful to constrain router agents to short outputs or - * allow analyst agents longer responses. - */ - maxOutputTokens?: number; - - /** - * Override sampling temperature for this agent. - * Lower for routers (deterministic), higher for creative agents. - */ - temperature?: number; - - /** - * After the agent uses a tool, reset `toolChoice` to `undefined` (i.e. `auto`) - * to prevent the model from being forced into infinite tool-calling loops. - * Default: true. Matches the OpenAI Agents SDK `reset_tool_choice` pattern. - */ - resetToolChoice?: boolean; - - /** - * On handoff, wrap the full conversation history into a single structured - * input item with XML-style tags, reducing context size for the target agent. - * Complements `handoffInputFilter` with a nesting strategy. - * Matches the OpenAI Agents SDK `nest_handoff_history` pattern. - */ - nestHandoffHistory?: boolean; - - /** - * Llama Stack prompt reference for server-managed prompt templates. - * When set, the `prompt` field is sent to `/v1/responses` instead of `instructions`. - * Enables server-side versioning, variable substitution, and A/B testing. - */ - promptRef?: { - id: string; - version?: number; - variables?: Record; - }; - - /** - * Override context window truncation strategy for this agent. - * - 'auto': Server truncates old context to fit the model's window - * - 'disabled': No truncation (default) - */ - truncation?: 'auto' | 'disabled'; - - /** - * How this agent is published to the chat gallery. - * - 'router': Published as a user-facing entry point. Typically has handoffs to specialists. - * - 'specialist': Hidden from gallery. Only reachable via handoffs/asTools from other agents. - * - 'standalone': Published independently. No team affiliation, directly selectable by users. - * Default: auto-derived from topology -- agents with outgoing handoffs/asTools default to 'router', - * agents that are targets of another agent's handoffs/asTools default to 'specialist', - * agents with no connections default to 'standalone'. - */ - publishAs?: 'router' | 'specialist' | 'standalone'; -} diff --git a/workspaces/augment/plugins/augment-backend/src/types/conversation.ts b/workspaces/augment/plugins/augment-backend/src/types/conversation.ts deleted file mode 100644 index 38dff82f8a4..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/types/conversation.ts +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ResponseUsage } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ResponsesApiOutputEvent } from './responsesApi'; - -/** - * A tool call awaiting user approval - * @public - */ -export interface PendingToolApproval { - /** Unique ID for this approval request */ - approvalId: string; - /** The response ID from Llama Stack (for resuming) */ - responseId: string; - /** Tool call details */ - toolCall: { - /** Tool call ID from the API */ - callId: string; - /** Tool name */ - name: string; - /** MCP server label */ - serverLabel: string; - /** Arguments as JSON string */ - arguments: string; - /** Parsed arguments for display */ - parsedArguments: Record; - }; - /** Timestamp when approval was requested */ - requestedAt: string; - /** Optional custom message for the approval dialog */ - confirmationMessage?: string; - /** Severity level for UI styling */ - severity: 'info' | 'warning' | 'critical'; -} - -/** - * User's response to a tool approval request - * @public - */ -export interface ToolApprovalResponse { - /** The approval ID being responded to */ - approvalId: string; - /** User's decision */ - approved: boolean; - /** Optional modified arguments (if user edited them) */ - modifiedArguments?: string; - /** Optional reason for rejection */ - rejectionReason?: string; -} - -/** - * Result after processing an approval response - * @public - */ -export interface ToolApprovalResult { - /** Whether the tool was executed */ - executed: boolean; - /** Tool output if executed */ - output?: string; - /** Error if execution failed */ - error?: string; - /** Whether chat should continue after this */ - continueChat: boolean; -} - -/** - * @internal - */ -export interface ResponsesApiResponse { - id: string; - object: 'response'; - created_at: number; - model: string; - status: 'completed' | 'failed' | 'cancelled' | 'in_progress'; - output: ResponsesApiOutputEvent[]; - usage?: ResponseUsage; - /** Arbitrary metadata attached to the response */ - metadata?: Record; - /** Previous response ID for conversation chaining */ - previous_response_id?: string; - /** Reasoning configuration used, if any */ - reasoning?: { - effort?: 'low' | 'medium' | 'high'; - summary?: 'auto' | 'concise' | 'detailed'; - encrypted_content?: string; - }; - /** Context window truncation strategy used */ - truncation?: 'auto' | 'disabled'; - /** Temperature used for generation */ - temperature?: number; - /** Top-p used for generation */ - top_p?: number; - /** Maximum output tokens configured */ - max_output_tokens?: number; -} - -/** - * A stored response from Llama Stack (conversation entry) - * Mapped from GET /v1/responses - * @public - */ -export interface StoredResponse { - /** Unique response ID - use as previousResponseId to continue conversation */ - id: string; - /** Model used for this response */ - model: string; - /** Status of the response */ - status: 'completed' | 'failed' | 'in_progress'; - /** Unix timestamp when created */ - created_at: number; - /** Output items from the response */ - output: Array<{ - type: string; - id?: string; - role?: string; - content?: Array<{ - type: string; - text: string; - }>; - }>; -} - -/** - * List of stored responses (paginated) - * Response from GET /v1/responses - * @public - */ -export interface StoredResponseList { - /** The responses */ - data: StoredResponse[]; - /** Whether there are more results */ - has_more: boolean; - /** First ID in the list */ - first_id?: string; - /** Last ID in the list */ - last_id?: string; -} - -/** - * Input item from a response (conversation context) - * Response from GET /v1/responses/:id/input_items - * @public - */ -export interface ResponseInputItem { - /** Item type: message, function_call_output, etc. */ - type: string; - /** Item ID */ - id?: string; - /** Role for message types */ - role?: 'user' | 'assistant' | 'system'; - /** Content for message types */ - content?: string | Array<{ type: string; text?: string }>; - /** Status */ - status?: string; - /** Call ID for function outputs */ - call_id?: string; - /** Output for function outputs */ - output?: string; -} - -/** - * List of input items for a response - * @public - */ -export interface ResponseInputItemList { - data: ResponseInputItem[]; - has_more: boolean; - first_id?: string; - last_id?: string; -} diff --git a/workspaces/augment/plugins/augment-backend/src/types/documents.ts b/workspaces/augment/plugins/augment-backend/src/types/documents.ts deleted file mode 100644 index ce8db65a23e..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/types/documents.ts +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { DocumentInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -/** - * Directory source - local file system - * @public - */ -export interface DirectorySource { - type: 'directory'; - /** Path to the directory (relative to Backstage root or absolute) */ - path: string; - /** Glob patterns to match files (e.g., '**\/*.md') */ - patterns?: string[]; -} - -/** - * URL source - fetch documents from URLs - * @public - */ -export interface UrlSource { - type: 'url'; - /** List of URLs to fetch documents from */ - urls: string[]; - /** Optional headers for authentication */ - headers?: Record; -} - -/** - * GitHub repository source - * @public - */ -export interface GitHubSource { - type: 'github'; - /** Repository in format 'owner/repo' */ - repo: string; - /** Branch to fetch from (default: main) */ - branch?: string; - /** Path within the repository (default: root) */ - path?: string; - /** Glob patterns to match files */ - patterns?: string[]; - /** GitHub token for private repos */ - token?: string; -} - -/** - * Union type for all document sources - * @public - */ -export type DocumentSource = DirectorySource | UrlSource | GitHubSource; - -/** - * Document ingestion configuration - * @public - */ -export interface DocumentsConfig { - /** Sync mode: 'full' syncs all (add new, remove deleted), 'append' only adds new */ - syncMode: 'full' | 'append'; - /** How often to sync documents (cron expression or duration like '1h', '30m') */ - syncSchedule?: string; - /** Document sources to ingest */ - sources: DocumentSource[]; -} - -/** - * File attributes for RAG citation metadata - * Attached to files during upload and returned in search results - * @public - */ -export interface FileAttributes { - /** Document title for display in citations */ - title?: string; - /** Source URL for clickable citations (e.g., GitHub URL, documentation link) */ - source_url?: string; - /** Content type classification (e.g., 'markdown', 'pdf-documentation', 'workshop-content') */ - content_type?: string; - /** Module or category grouping */ - module?: string; - /** Allow custom attributes */ - [key: string]: string | undefined; -} - -/** - * A fetched document ready for upload - * @internal - */ -export interface FetchedDocument { - /** Unique identifier for tracking (used for sync) */ - sourceId: string; - /** File name for the document */ - fileName: string; - /** Content of the document */ - content: string; - /** Source type this came from */ - sourceType: 'directory' | 'url' | 'github'; - /** Content hash for change detection (computed during fetch) */ - contentHash?: string; - /** Metadata attributes for RAG citations */ - attributes?: FileAttributes; -} - -/** - * Request to upload documents - * @public - */ -export interface UploadDocumentsRequest { - files: Array<{ - fileName: string; - content: string; - }>; -} - -/** - * Response from upload documents endpoint - * @public - */ -export interface UploadDocumentsResponse { - success: boolean; - uploaded: DocumentInfo[]; - failed: Array<{ - fileName: string; - error: string; - }>; -} diff --git a/workspaces/augment/plugins/augment-backend/src/types/evaluation.ts b/workspaces/augment/plugins/augment-backend/src/types/evaluation.ts deleted file mode 100644 index 4432e72435f..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/types/evaluation.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - ChatResponse, - EvaluationResult, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -/** - * Configuration for response evaluation - * @public - */ -export interface EvaluationConfig { - /** Enable automatic response scoring */ - enabled: boolean; - /** Scoring function IDs to use */ - scoringFunctions?: string[]; - /** Minimum score threshold (0-1) - below this, add warning to response */ - minScoreThreshold?: number; - /** - * Behavior when evaluation fails due to an error. - * - 'skip': Skip evaluation on error and return the response as-is - * - 'fail': Return a failed evaluation result to flag the issue - * Default: 'skip' (evaluation is optional, don't block responses) - */ - onError?: 'skip' | 'fail'; -} - -/** - * Scoring function information from Llama Stack - * @public - */ -export interface ScoringFunctionInfo { - identifier: string; - provider_id: string; - provider_resource_id: string; - type: string; - description?: string; - params?: Record; -} - -/** - * Individual score result - * @public - */ -export interface ScoreResult { - score_type: string; - value: number; - metadata?: Record; -} - -/** - * Response from scoring API - * @internal - */ -export interface ScoringResponse { - results: Record< - string, - { - score_rows: Array<{ - /** Per-function nested scores (e.g. accuracy sub-metrics) */ - scores?: Record; - /** Direct score value (used by basic::subset_of and similar) */ - score?: number; - }>; - /** Aggregated results (e.g. accuracy summary) */ - aggregated_results?: Record>; - } - >; -} - -/** - * Extended chat response with evaluation information - * @public - */ -export interface EvaluatedChatResponse extends ChatResponse { - /** Evaluation result if scoring is enabled */ - evaluation?: EvaluationResult; -} diff --git a/workspaces/augment/plugins/augment-backend/src/types/index.ts b/workspaces/augment/plugins/augment-backend/src/types/index.ts deleted file mode 100644 index dad8ba0bed6..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/types/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// Re-export shared types from common for backward compatibility. -// Consumers that import from the backend package continue to see these types. -export { FileFormat } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -export type { - ChatMessage, - ToolCallInfo, - RAGSource, - EvaluationResult, - DocumentInfo, - ProviderStatus, - VectorStoreStatus, - MCPServerStatus, - SecurityMode, - AugmentStatus, - VectorStoreInfo, - WorkflowStep, - Workflow, - QuickAction, - PromptCard, - PromptGroup, - ConversationSummary, - ResponseUsage, - InputTokensDetails, - OutputTokensDetails, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -// Domain-specific type modules -export * from './config'; -export * from './documents'; -export * from './responsesApi'; -export * from './security'; -export * from './safety'; -export * from './evaluation'; -export * from './conversation'; -export * from './chat'; diff --git a/workspaces/augment/plugins/augment-backend/src/types/responsesApi.ts b/workspaces/augment/plugins/augment-backend/src/types/responsesApi.ts deleted file mode 100644 index 9f1c54020c4..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/types/responsesApi.ts +++ /dev/null @@ -1,359 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * @internal - */ -export interface LlamaStackFileResponse { - id: string; - object: string; - bytes: number; - created_at: number; - expires_at?: number; - filename: string; - purpose: string; -} - -/** - * @internal - */ -export interface LlamaStackVectorStoreFileResponse { - id: string; - object: string; - status: 'completed' | 'in_progress' | 'failed' | 'cancelled'; - created_at: number; - last_error?: { code: string; message: string } | null; - usage_bytes: number; - vector_store_id: string; -} - -/** - * @internal - */ -export interface LlamaStackVectorStoreResponse { - id: string; - object: string; - name: string; - status: string; - created_at?: number; - metadata?: Record; - usage_bytes?: number; - last_active_at?: number; - file_counts: { - total: number; - completed: number; - in_progress: number; - failed: number; - cancelled: number; - }; -} - -/** - * Responses API output event types - * @internal - */ -export interface ResponsesApiFileSearchResult { - type: 'file_search_call'; - id: string; - status: string; - queries: string[]; - results: Array<{ - file_id: string; - filename: string; - score: number; - text: string; - attributes: Record; - }>; -} - -/** - * MCP tool call result from Responses API - * @internal - */ -export interface ResponsesApiMcpCall { - type: 'mcp_call'; - id: string; - name: string; - arguments: string; - server_label: string; - error?: string; - output?: string; -} - -/** - * @internal - */ -export interface ResponsesApiMessage { - type: 'message'; - id: string; - role: 'assistant'; - status: 'completed' | 'failed' | 'in_progress'; - content: Array<{ - type: 'output_text'; - text: string; - /** Annotations such as file citations or URL citations */ - annotations?: ResponsesApiAnnotation[]; - }>; -} - -/** - * Annotation on output text content, e.g. file citation or URL citation. - * @internal - */ -export interface ResponsesApiAnnotation { - type: 'file_citation' | 'url_citation' | 'file_path'; - /** Start index in the output text */ - start_index?: number; - /** End index in the output text */ - end_index?: number; - file_citation?: { - file_id: string; - filename?: string; - quote?: string; - }; - url_citation?: { - url: string; - title?: string; - }; - file_path?: { - file_id: string; - }; - [key: string]: unknown; -} - -/** - * Function call output from Responses API. - * Returned when the model invokes a custom function tool (e.g. handoff, agent-as-tool). - * @internal - */ -export interface ResponsesApiFunctionCall { - type: 'function_call'; - id: string; - call_id: string; - name: string; - arguments: string; - status?: 'completed' | 'failed' | 'in_progress'; -} - -/** - * Function call output item returned in the response output array. - * Carries the result of a previously invoked function_call. - * @internal - */ -export interface ResponsesApiFunctionCallOutput { - type: 'function_call_output'; - id?: string; - call_id: string; - output: string; - status?: string; -} - -/** - * Reasoning/thinking output item from the Responses API. - * Produced when the model uses chain-of-thought reasoning. - * @internal - */ -export interface ResponsesApiReasoningItem { - type: 'reasoning'; - id: string; - summary?: Array<{ type: 'summary_text'; text: string }>; - /** Encrypted reasoning content (ZDR mode) */ - encrypted_content?: string; - status?: 'completed' | 'in_progress'; -} - -/** - * @internal - */ -export type ResponsesApiOutputEvent = - | ResponsesApiFileSearchResult - | ResponsesApiMcpCall - | ResponsesApiMessage - | ResponsesApiFunctionCall - | ResponsesApiFunctionCallOutput - | ResponsesApiReasoningItem; - -/** - * Content block for multi-modal function call output. - * Matches OpenAI's ResponseInputContent — currently only text is used - * by the multi-agent runner, but the type supports future expansion. - * @internal - */ -export interface ResponseInputContent { - type: 'input_text' | 'input_image' | 'input_file'; - text?: string; - [key: string]: unknown; -} - -/** - * Input item for sending function call results back to the Responses API. - * Used by the multi-agent runner to acknowledge handoffs and return - * agent-as-tool results. - * - * `output` supports both a plain string and an array of content blocks - * per the OpenAI Responses API spec. Current usage is string-only; - * the wider type enables future multi-modal agent-as-tool patterns. - * @internal - */ -export interface FunctionCallOutputItem { - type: 'function_call_output'; - call_id: string; - output: string | ResponseInputContent[]; -} - -/** - * Message input item for sending user/system/assistant messages via input array. - * @internal - */ -export interface MessageInputItem { - type: 'message'; - role: 'user' | 'system' | 'developer' | 'assistant'; - content: string | ResponseInputContent[]; -} - -/** - * Item reference for including a previous response item by ID. - * @internal - */ -export interface ItemReferenceInputItem { - type: 'item_reference'; - id: string; -} - -/** - * Union of input item types that can be sent as array elements in the - * Responses API `input` field. - * - * Includes the actively-used FunctionCallOutputItem as well as - * message, item_reference, and function_call types for completeness. - * @internal - */ -export type ResponsesApiInputItem = - | FunctionCallOutputItem - | MessageInputItem - | ItemReferenceInputItem; - -/** - * File search tool format for Responses API - * @internal - */ -export interface ResponsesApiFileSearchTool { - type: 'file_search'; - vector_store_ids: string[]; - /** Maximum number of chunks returned by file_search (1-50, Llama Stack default: 10) */ - max_num_results?: number; - /** Ranking/filtering options for search results */ - ranking_options?: { - /** Ranker strategy: 'weighted', 'rrf', or 'neural' */ - ranker?: string; - /** Minimum relevance score (0.0-1.0); chunks below this are dropped */ - score_threshold?: number; - }; -} - -/** - * MCP tool format for Responses API - * @internal - */ -export interface ResponsesApiMcpTool { - type: 'mcp'; - server_url: string; - server_label: string; - /** HITL approval requirement - maps directly to Llama Stack API */ - require_approval: - | 'never' - | 'always' - | { always?: string[]; never?: string[] }; - headers?: Record; - /** Restrict which tools from this server are exposed to the model. - * Llama Stack accepts plain string[] or AllowedToolsFilter { tool_names }. */ - allowed_tools?: string[]; -} - -/** - * Function tool format for Responses API - * Defines a custom function the model can call - * @internal - */ -export interface ResponsesApiFunctionTool { - type: 'function'; - name: string; - description: string; - parameters: Record; - strict?: boolean; -} - -/** - * Web search tool format for Responses API - * Built-in tool for searching the web - * @internal - */ -export interface ResponsesApiWebSearchTool { - type: 'web_search'; - /** Location context for search results */ - user_location?: { - type: 'approximate'; - city?: string; - region?: string; - country?: string; - timezone?: string; - }; - /** Limit on number of search results */ - search_context_size?: 'low' | 'medium' | 'high'; -} - -/** - * Code interpreter tool format for Responses API - * Built-in tool for executing Python code - * @internal - */ -export interface ResponsesApiCodeInterpreterTool { - type: 'code_interpreter'; - /** Container to run the code in */ - container?: { - type: 'auto'; - file_ids?: string[]; - }; -} - -/** - * Image generation tool format for Responses API - * Built-in tool for generating images - * @internal - */ -export interface ResponsesApiImageGenerationTool { - type: 'image_generation'; - /** Model to use for image generation */ - model?: string; - /** Quality setting */ - quality?: 'low' | 'medium' | 'high' | 'auto'; - /** Image size */ - size?: '1024x1024' | '1024x1536' | '1536x1024' | 'auto'; - /** Whether to produce a partial image at each step (streaming) */ - partial_images?: number; -} - -/** - * Union type for all Responses API tools - * @internal - */ -export type ResponsesApiTool = - | ResponsesApiFileSearchTool - | ResponsesApiMcpTool - | ResponsesApiFunctionTool - | ResponsesApiWebSearchTool - | ResponsesApiCodeInterpreterTool - | ResponsesApiImageGenerationTool; diff --git a/workspaces/augment/plugins/augment-backend/src/types/safety.ts b/workspaces/augment/plugins/augment-backend/src/types/safety.ts deleted file mode 100644 index d747267b273..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/types/safety.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ChatResponse } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -/** - * Shield registration configuration - * @public - */ -export interface ShieldRegistration { - /** Unique identifier for the shield */ - shieldId: string; - /** Provider ID (e.g., 'llama-guard', 'prompt-guard') */ - providerId: string; - /** Provider-specific shield/model ID */ - providerShieldId: string; -} - -/** - * Safety configuration for guardrails - * @public - */ -export interface SafetyConfig { - /** Enable safety checks */ - enabled: boolean; - /** Shield IDs to use for input validation */ - inputShields?: string[]; - /** Shield IDs to use for output filtering */ - outputShields?: string[]; - /** Shields to register on Llama Stack if not already present */ - registerShields?: ShieldRegistration[]; - /** - * Behavior when a safety check fails due to an error (not a violation). - * - 'allow': Allow the message through on error (fail-open, less secure) - * - 'block': Block the message on error (fail-closed, more secure) - * Default: 'block' (secure by default) - */ - onError?: 'allow' | 'block'; -} - -/** - * Shield information from Llama Stack - * @public - */ -export interface ShieldInfo { - identifier: string; - provider_id: string; - provider_resource_id: string; - type: string; - params?: Record; -} - -/** - * Safety violation detected by a shield - * @public - */ -export interface SafetyViolation { - violation_level: 'info' | 'warn' | 'error'; - user_message?: string; - metadata?: Record; -} - -/** - * Result from running a safety shield - * @internal - */ -export interface RunShieldResponse { - violation?: SafetyViolation; -} - -/** - * Extended chat response with safety information - * @public - */ -export interface SafetyChatResponse extends ChatResponse { - /** Whether the response was filtered by safety shields */ - filtered?: boolean; - /** Reason for filtering (if filtered) */ - filterReason?: string; - /** Whether this action requires user confirmation */ - requiresConfirmation?: boolean; - /** Description of pending action requiring confirmation */ - pendingAction?: string; -} diff --git a/workspaces/augment/plugins/augment-backend/src/types/security.ts b/workspaces/augment/plugins/augment-backend/src/types/security.ts deleted file mode 100644 index 153a89d5245..00000000000 --- a/workspaces/augment/plugins/augment-backend/src/types/security.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { SecurityMode } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -/** - * Approval filter for fine-grained HITL control - * Specifies which tools always or never require approval - * @public - */ -export interface ApprovalFilter { - /** Tool names that always require approval */ - always?: string[]; - /** Tool names that never require approval (auto-execute) */ - never?: string[]; -} - -/** - * OAuth configuration shared between per-server and global security modes. - * @public - */ -export interface OAuthClientConfig { - /** OAuth token endpoint URL (e.g., Keycloak token endpoint) */ - tokenUrl: string; - /** OAuth client ID (service account client) */ - clientId: string; - /** OAuth client secret */ - clientSecret: string; - /** OAuth scopes to request (default: ['openid']) */ - scopes?: string[]; -} - -/** - * Security configuration for Augment - * - * Controls authentication and authorization at multiple levels: - * - Plugin access (Backstage RBAC) - * - MCP server authentication (OAuth) - * - * @public - */ -export interface SecurityConfig { - /** - * Security mode: - * - 'none': No access control - anyone can use Augment - * - 'plugin-only': Keycloak group controls plugin access, MCP servers are open (default) - * - 'full': Keycloak auth for both plugin AND MCP server authentication - */ - mode: SecurityMode; - - /** - * Custom message shown when access is denied - */ - accessDeniedMessage?: string; - - /** - * OAuth configuration for MCP server authentication - * Only used in 'full' mode - */ - mcpOAuth?: OAuthClientConfig; -} - -/** - * Kubernetes ServiceAccount configuration for MCP server authentication - * @public - */ -export interface MCPServerServiceAccountConfig { - /** ServiceAccount name in the cluster */ - name: string; - /** Namespace of the ServiceAccount (default: current namespace or 'default') */ - namespace?: string; -} - -/** - * Named authentication configuration (for reuse across MCP servers) - * @public - */ -export type MCPAuthConfig = - | ({ type: 'oauth' } & OAuthClientConfig) - | ({ type: 'serviceAccount' } & MCPServerServiceAccountConfig); - -/** - * MCP Server configuration - * @public - */ -export interface MCPServerConfig { - /** Unique identifier for the server */ - id: string; - /** Display name for the server */ - name: string; - /** Server type - only streamable-http and sse work with Responses API */ - type: 'streamable-http' | 'sse'; - /** URL for HTTP/SSE servers */ - url: string; - /** Optional static headers for authentication */ - headers?: Record; - /** Reference to a named auth config from augment.mcpAuth */ - authRef?: string; - /** OAuth configuration for dynamic token authentication */ - oauth?: OAuthClientConfig; - /** Kubernetes ServiceAccount configuration */ - serviceAccount?: MCPServerServiceAccountConfig; - /** - * Human-in-the-loop approval requirement - * - 'always': All tools require user approval before execution - * - 'never': All tools execute automatically (default) - * - ApprovalFilter: Fine-grained control per tool name - */ - requireApproval?: 'always' | 'never' | ApprovalFilter; - /** - * Restrict which tools from this server are exposed to the model. - * When set, only the named tools are sent — all others are ignored. - * This dramatically reduces token usage for servers with many tools. - */ - allowedTools?: string[]; -} diff --git a/workspaces/augment/plugins/augment-common/.eslintrc.js b/workspaces/augment/plugins/augment-common/.eslintrc.js deleted file mode 100644 index b17a00e31ad..00000000000 --- a/workspaces/augment/plugins/augment-common/.eslintrc.js +++ /dev/null @@ -1,2 +0,0 @@ -module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); - diff --git a/workspaces/augment/plugins/augment-common/CHANGELOG.md b/workspaces/augment/plugins/augment-common/CHANGELOG.md deleted file mode 100644 index c28810779c1..00000000000 --- a/workspaces/augment/plugins/augment-common/CHANGELOG.md +++ /dev/null @@ -1,16 +0,0 @@ -# @red-hat-developer-hub/backstage-plugin-augment-common - -## 0.3.0 - -### Minor Changes - -- aa44c81: Bump minor after the release-2.1 cut so main patch versions do not collide with the maintenance line on npm. - -## 0.2.0 - -### Minor Changes - -- b418448: Add agent registry governance: unified agent listings with governanceRegistered flag, register-for-governance API, strict lifecycle transitions on publish/unpublish, and Registry UI with unregistered agent warnings and bulk actions. -- afaed59: Initial release of the Augment AI assistant plugin for Backstage. Provides a chat interface backed by Llama Stack with RAG, tool calling, multi-agent orchestration, and configurable safety guardrails. -- 3e18b17: Agent lifecycle hardening: add 5-stage promotion pipeline (draft/review/staging/production/retired), ownership-gated operations, rejection tracking, role-aware UI, admin polling dashboards, cascading delete, and SonataFlow agent-approval workflow. -- 717a651: Add SonataFlow agent approval workflow integration with dual-mode support (immediate or workflow-based transitions), request-unpublish and withdraw endpoints. diff --git a/workspaces/augment/plugins/augment-common/docs/BACKSTAGE_RBAC_SETUP.md b/workspaces/augment/plugins/augment-common/docs/BACKSTAGE_RBAC_SETUP.md deleted file mode 100644 index 736fd4d0cd1..00000000000 --- a/workspaces/augment/plugins/augment-common/docs/BACKSTAGE_RBAC_SETUP.md +++ /dev/null @@ -1,411 +0,0 @@ -# Backstage RBAC Configuration for Augment - -This guide explains how to configure Backstage RBAC to control access to the Augment plugin based on Keycloak groups. - -## Prerequisites - -- Keycloak configured as per [Keycloak Setup Guide](./KEYCLOAK_SETUP.md) -- Backstage with RBAC plugin installed (included in RHDH) -- `@backstage/plugin-permission-backend` configured - -## Overview - -Backstage RBAC allows you to: - -1. Map Keycloak groups to Backstage roles -2. Assign permissions to roles -3. Control access to Augment based on group membership - -## Step 1: Configure Keycloak Authentication - -Add OIDC authentication to your `app-config.yaml`: - -```yaml -auth: - environment: development - providers: - oidc: - development: - metadataUrl: https://YOUR_KEYCLOAK_URL/realms/demo/.well-known/openid-configuration - clientId: backstage - clientSecret: ${KEYCLOAK_CLIENT_SECRET} - prompt: auto - signIn: - resolvers: - - resolver: emailMatchingUserEntityProfileEmail -``` - -### Environment Variable - -Set the client secret as an environment variable: - -```bash -export KEYCLOAK_CLIENT_SECRET="your-backstage-client-secret" -``` - -## Step 2: Configure Catalog User/Group Sync - -To have users and groups available for RBAC, you can either: - -### Option A: Use Keycloak Entity Provider (Recommended) - -```yaml -catalog: - providers: - keycloakOrg: - default: - baseUrl: https://YOUR_KEYCLOAK_URL - loginRealm: master - realm: demo - clientId: backstage - clientSecret: ${KEYCLOAK_CLIENT_SECRET} - schedule: - frequency: { minutes: 30 } - timeout: { minutes: 3 } -``` - -### Option B: Define Entities Manually - -Create `users-and-groups.yaml`: - -```yaml ---- -apiVersion: backstage.io/v1alpha1 -kind: Group -metadata: - name: augment-users - namespace: default - description: Users with access to Augment -spec: - type: team - children: [] ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: developer - namespace: default -spec: - profile: - displayName: Developer User - email: developer@example.com - memberOf: - - augment-users ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: user1 - namespace: default -spec: - profile: - displayName: User One - email: user1@example.com - memberOf: [] -``` - -Add to catalog locations: - -```yaml -catalog: - locations: - - type: file - target: ./users-and-groups.yaml - rules: - - allow: [User, Group] -``` - -## Step 3: Enable Permission Framework - -Configure the permission backend in `app-config.yaml`: - -```yaml -permission: - enabled: true -``` - -## Step 4: Configure RBAC Policies - -### Option A: Inline Policies (Simple) - -For simple setups, define policies inline: - -```yaml -permission: - enabled: true - rbac: - policies: - # Map Keycloak group to Backstage role - - g, group:default/augment-users, role:default/augment-user - - # Grant Augment access to the role - - p, role:default/augment-user, augment.access, read, allow - - # Optional: Admin role with full access - - g, group:default/augment-admins, role:default/augment-admin - - p, role:default/augment-admin, augment.access, read, allow -``` - -### Option B: CSV File (Recommended for Production) - -For larger deployments, use a CSV file: - -**app-config.yaml:** - -```yaml -permission: - enabled: true - rbac: - policies-csv-file: /path/to/rbac-policy.csv - admin: - users: - - name: user:default/admin -``` - -**rbac-policy.csv:** - -```csv -# Group to Role mappings -g, group:default/augment-users, role:default/augment-user -g, group:default/augment-admins, role:default/augment-admin -g, group:default/rhdh-team, role:default/augment-user - -# Permission grants -p, role:default/augment-user, augment.access, read, allow -p, role:default/augment-admin, augment.access, read, allow - -# Default deny for users not in any group (implicit) -``` - -## Step 5: Understand the Permission - -Augment uses a single permission: - -| Permission | Resource Type | Action | Description | -| ---------------- | ------------- | ------ | ------------------------------------ | -| `augment.access` | (none) | read | Controls access to the entire plugin | - -This is an "all or nothing" permission: - -- If ALLOW → User has full access to Augment -- If DENY → User cannot access any Augment features - -## Step 6: Policy Syntax Reference - -### Group Mappings (g) - -```csv -g, , -``` - -Examples: - -```csv -# User to role -g, user:default/alice, role:default/augment-user - -# Group to role -g, group:default/augment-users, role:default/augment-user - -# Nested roles (role inheritance) -g, role:default/augment-admin, role:default/augment-user -``` - -### Permission Policies (p) - -```csv -p, , , , -``` - -Examples: - -```csv -# Allow read access -p, role:default/augment-user, augment.access, read, allow - -# Deny access (explicit) -p, role:default/restricted-user, augment.access, read, deny -``` - -## Step 7: Verify Configuration - -### 7.1 Check User Groups in Backstage - -1. Navigate to Backstage catalog -2. Search for `kind:user` -3. Click on a user and verify their group memberships - -### 7.2 Test Permission Evaluation - -Use the Backstage permission API to test: - -```bash -# With Backstage running, test permission for a user token -curl -X POST http://localhost:7007/api/permission/authorize \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer " \ - -d '{ - "items": [ - { - "permission": { - "type": "basic", - "name": "augment.access", - "attributes": { "action": "read" } - } - } - ] - }' -``` - -Expected responses: - -- Allowed user: `{"items":[{"result":"ALLOW"}]}` -- Denied user: `{"items":[{"result":"DENY"}]}` - -### 7.3 View Permission Logs - -Look for permission evaluation logs: - -```bash -# In Backstage logs -grep "permission-evaluation" /path/to/backstage.log -``` - -Example log output: - -``` -permission info permission.permission-evaluation - meta={"userEntityRef":"user:default/developer","permissionName":"augment.access","result":"ALLOW"} -``` - -## Complete Configuration Example - -Here's a complete `app-config.yaml` section: - -```yaml -auth: - environment: development - providers: - oidc: - development: - metadataUrl: https://keycloak.example.com/realms/demo/.well-known/openid-configuration - clientId: backstage - clientSecret: ${KEYCLOAK_CLIENT_SECRET} - prompt: auto - signIn: - resolvers: - - resolver: emailMatchingUserEntityProfileEmail - -catalog: - providers: - keycloakOrg: - default: - baseUrl: https://keycloak.example.com - loginRealm: master - realm: demo - clientId: backstage - clientSecret: ${KEYCLOAK_CLIENT_SECRET} - -permission: - enabled: true - rbac: - policies: - # Augment access control - - g, group:default/augment-users, role:default/augment-user - - p, role:default/augment-user, augment.access, read, allow - - # Catalog read access for all authenticated users - - p, role:default/augment-user, catalog.entity.read, read, allow -``` - -## How It Works at Runtime - -1. **User logs in** via Keycloak OIDC -2. **Backstage receives JWT** with groups claim -3. **User entity resolved** via email matching -4. **User's groups determined** from entity or JWT claims -5. **Permission check requested** for `augment.access` -6. **RBAC policy evaluated**: - - Is user in `group:default/augment-users`? - - If yes → user has `role:default/augment-user` - - Does role have `augment.access` permission? - - If yes → ALLOW - - Otherwise → DENY - -## Frontend Behavior - -The Augment frontend uses a `ConditionalPermissionWrapper` that respects the security mode: - -```tsx -// In 'none' mode - no permission check, content renders directly -// In 'plugin-only' or 'full' mode - uses RequirePermission - - - {/* Main content */} - -``` - -The wrapper fetches the `securityMode` from the backend `/status` endpoint: - -- **`none` mode**: Content renders directly, no permission check -- **`plugin-only` / `full` mode**: Uses `RequirePermission` to enforce RBAC - -Denied users see: "You do not have the necessary permissions to access Augment" - -## Backend Behavior - -All Augment API endpoints are protected: - -```typescript -// Middleware in router.ts -const requirePluginAccess: express.RequestHandler = async (req, res, next) => { - const decision = await permissions.authorize( - [{ permission: augmentAccessPermission }], - { credentials }, - ); - - if (decision.result === AuthorizeResult.DENY) { - res.status(403).json({ error: 'Access denied' }); - return; - } - next(); -}; -``` - -- Denied users receive: `403 Forbidden` -- Response includes: `{"error":"You don't have permission to access Augment"}` - -## Troubleshooting - -### User Gets Access Denied Despite Being in Group - -1. **Check entity sync**: Is the user's group membership in Backstage catalog? - - ```bash - curl http://localhost:7007/api/catalog/entities?filter=kind=user,metadata.name=developer - ``` - -2. **Check group name format**: Must match `group:default/GROUP_NAME` - -3. **Check policy syntax**: Ensure no typos in CSV file - -### Permission Not Registered - -If you see "Unknown permission" errors: - -1. Verify augment-backend is loaded -2. Check backend logs for permission registration: - ``` - Registered augment.access permission for plugin-level access control - ``` - -### Keycloak Groups Not Syncing - -1. Verify Group Mapper is configured in Keycloak -2. Check if groups appear in JWT token -3. Verify `keycloakOrg` provider is configured and running - -## Next Steps - -- [Configure MCP Server](./MCP_SERVER_SETUP.md) -- [Validation Guide](./VALIDATION.md) diff --git a/workspaces/augment/plugins/augment-common/docs/CONFIG_REFERENCE.md b/workspaces/augment/plugins/augment-common/docs/CONFIG_REFERENCE.md deleted file mode 100644 index 53b9bb93de7..00000000000 --- a/workspaces/augment/plugins/augment-common/docs/CONFIG_REFERENCE.md +++ /dev/null @@ -1,200 +0,0 @@ -# Configuration Reference - -This document describes every configuration surface for the Augment plugin: what YAML requires, what the admin UI can override, the precedence rules, and the default values applied when fields are omitted. - -## Minimum Required YAML - -The plugin will **not load** without these two fields. In RHDH, Backstage validates the ConfigMap against the plugin's config schema at startup and rejects the plugin if they are missing. - -```yaml -augment: - llamaStack: - baseUrl: 'https://your-llama-stack-server.com' - model: 'meta-llama/Llama-3.3-70B-Instruct' -``` - -Everything else is optional and has code-level defaults. - -## Configuration Precedence - -The plugin resolves configuration at runtime using a two-layer model: - -``` -EffectiveConfig = YAML baseline + DB admin overrides -``` - -1. **YAML** (`app-config.yaml` / ConfigMap) is loaded at startup into memory by Backstage. The plugin reads from this in-memory config — it does not re-read the file. -2. **DB** (admin UI saves to the `augment_admin_config` database table) overrides YAML per-key. If a DB value exists for a key, it wins. If no DB value exists, the YAML value (or its code-level default) is used. -3. **Cache**: The merged result is cached for 5 seconds. After an admin save, the cache is immediately invalidated. -4. **Fallback**: If the DB is unreachable, the system falls back to YAML-only and logs a warning. Chat is never blocked by a database outage. - -### Special Merge Behaviors - -Most keys are simple "DB replaces YAML" overrides, but three keys have different logic: - -| Key | Merge Rule | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `branding` | Shallow merge: `{ ...yamlBranding, ...dbBranding }`. DB fields win, but YAML-only fields survive. | -| `mcpServers` | Smart merge: DB servers with matching IDs override YAML properties (url, name, type) while preserving YAML-only auth fields. New DB servers are appended. Servers in `disabledMcpServerIds` are removed. | -| `agents` | Full replacement: DB agents completely replace YAML agents. The admin UI sends the entire roster on save. If an agent is removed in the UI, it does not reappear from YAML. | - -### Reset to Defaults - -The admin UI "Reset to Defaults" action deletes the DB entry for a key via `DELETE /admin/config/:key`. The next config resolution finds no DB value and falls back to the current YAML value. - -### YAML Changes Require a Restart - -The plugin does not watch for YAML file changes. Any change to `app-config.yaml` or the ConfigMap requires a backend restart (rolling update in RHDH on OpenShift) to take effect. - -### "Invisible YAML" Caveat - -Once a DB override exists for a key, subsequent YAML changes to that key are silently ignored. The DB value continues to win until an admin either updates it or resets to defaults. - -## Code-Level Defaults - -When YAML fields are omitted, these defaults are applied by `ConfigLoader` and `RuntimeConfigResolver`: - -| Field | Default Value | Constant | -| ----------------------- | ------------------------------------------------------------------------------- | ----------------------------- | -| `systemPrompt` | `''` (empty string — falls back to `'You are a helpful assistant.'` at runtime) | `DEFAULT_SYSTEM_PROMPT` | -| `vectorStoreName` | `'augment-knowledge-base'` | `DEFAULT_VECTOR_STORE_NAME` | -| `embeddingModel` | `'sentence-transformers/all-MiniLM-L6-v2'` | `DEFAULT_EMBEDDING_MODEL` | -| `embeddingDimension` | `384` | `DEFAULT_EMBEDDING_DIMENSION` | -| `chunkingStrategy` | `'auto'` | — | -| `maxChunkSizeTokens` | `512` | `DEFAULT_CHUNK_SIZE` | -| `chunkOverlapTokens` | `50` | `DEFAULT_CHUNK_OVERLAP` | -| `skipTlsVerify` | `false` | — | -| `verboseStreamLogging` | `false` | — | -| `enableWebSearch` | `false` | — | -| `enableCodeInterpreter` | `false` | — | -| `zdrMode` | `false` | — | -| `security.mode` | `'plugin-only'` | — | -| `vectorStoreIds` | `[]` (auto-creates a vector store using `vectorStoreName`) | — | -| `mcpServers` | `[]` (none configured) | — | -| `agents` | `undefined` (single-agent mode — no multi-agent orchestration) | — | -| `documents` | `null` (no document ingestion) | — | -| `toolChoice` | `undefined` (model decides, equivalent to `'auto'`) | — | -| `branding` | Built-in defaults from `DEFAULT_BRANDING` | — | - -## What the Admin UI Can Configure - -The admin panel stores overrides in the `augment_admin_config` database table. These 29 keys can be changed at runtime without restarting: - -### LLM / Inference - -| Admin Key | Description | -| ----------------------- | ------------------------------------------------------------------------ | -| `model` | Override the LLM model identifier | -| `baseUrl` | Override the Llama Stack server URL | -| `systemPrompt` | Override the global system prompt | -| `toolChoice` | Tool selection strategy (`auto`, `required`, `none`, or a specific tool) | -| `enableWebSearch` | Toggle the built-in web search tool | -| `enableCodeInterpreter` | Toggle the built-in code interpreter | -| `temperature` | Sampling temperature | -| `maxOutputTokens` | Maximum output tokens per response | -| `maxToolCalls` | Cap on tool calls per response (prevents runaway loops) | - -### Multi-Agent - -| Admin Key | Description | -| --------------- | --------------------------------------------------------- | -| `agents` | Full agent roster — replaces YAML agents entirely on save | -| `defaultAgent` | Which agent handles the first turn of a conversation | -| `maxAgentTurns` | Maximum handoff turns before stopping | - -### RAG / Vector Store - -| Admin Key | Description | -| ---------------------- | ----------------------------------------------------------- | -| `activeVectorStoreIds` | Which vector store IDs to search for RAG | -| `vectorStoreConfig` | Embedding model, chunk sizes, search mode, score thresholds | - -### MCP Servers - -| Admin Key | Description | -| ---------------------- | ----------------------------------------- | -| `mcpServers` | Add or override MCP server configurations | -| `disabledMcpServerIds` | Disable specific MCP servers by ID | - -### Safety - -| Admin Key | Description | -| ------------------ | ------------------------------------------------------------------------- | -| `safetyEnabled` | Toggle safety checks on/off | -| `inputShields` | Shield IDs applied to user input | -| `outputShields` | Shield IDs applied to AI output | -| `safetyOnError` | `'allow'` (fail-open) or `'block'` (fail-closed) when a shield call fails | -| `safetyIdentifier` | Safety identifier override | - -### Evaluation - -| Admin Key | Description | -| ------------------- | ---------------------------------------------- | -| `evaluationEnabled` | Toggle response quality evaluation | -| `scoringFunctions` | Scoring function IDs for evaluation | -| `minScoreThreshold` | Minimum acceptable score (0-1) | -| `evaluationOnError` | `'skip'` or `'fail'` when a scoring call fails | - -### UI / Branding - -| Admin Key | Description | -| ---------------- | ----------------------------------------------- | -| `branding` | Colors, logo URL, tagline, theme preset | -| `promptGroups` | Welcome screen prompt cards (grouped) | -| `safetyPatterns` | Regex patterns for destructive action detection | - -### Provider - -| Admin Key | Description | -| ---------------- | ------------------------ | -| `activeProvider` | Which AI provider to use | - -## What Is YAML-Only - -These settings **cannot** be changed from the admin UI. They require a YAML change and a restart. - -| YAML Key | Reason | -| ------------------------------ | ------------------------------------------------------------- | -| `security.mode` | Security-critical; read once at startup | -| `security.adminUsers` | Controls who has admin access | -| `security.mcpOAuth` | Contains OAuth secrets | -| `security.accessDeniedMessage` | Frontend-visibility setting | -| `documents.*` | All ingestion config (sources, sync mode, schedule, patterns) | -| `token` | Secret: Llama Stack API authentication | -| `skipTlsVerify` | Security-critical TLS setting | -| `zdrMode` | Zero Data Retention compliance setting | -| `parallelToolCalls` | Advanced tuning | -| `textFormat` | Advanced structured output schema | -| `functions` | Global custom function definitions | -| `verboseStreamLogging` | Operational debugging toggle | -| `quickPrompts` | Read from YAML per request (no DB layer) | -| `workflows` | Read from YAML per request (no DB layer) | -| `mcpAuth` | Shared OAuth credentials for MCP servers | -| `toolExecutionMode` | `'direct'` vs `'backend'` MCP execution | -| `serverCapabilities` | Override auto-detected Llama Stack server capabilities | -| `provider` | Provider type (only `'llamastack'` supported) | - -## Admin API Endpoints - -| Endpoint | Method | Description | -| ------------------------- | ------ | ----------------------------------------------------------------------- | -| `/admin/config` | GET | List all admin config entries stored in DB | -| `/admin/config/:key` | GET | Get a single config entry (returns `source: 'database'` or `'default'`) | -| `/admin/config/:key` | PUT | Set/update a config value (`{ "value": }`) | -| `/admin/config/:key` | DELETE | Reset to defaults (deletes the DB entry) | -| `/admin/effective-config` | GET | Get the merged effective config (YAML + DB) | - -## Database Storage - -Admin overrides are stored in the `augment_admin_config` table: - -| Column | Type | Description | -| -------------- | ----------- | ----------------------------------- | -| `config_key` | string (PK) | The admin config key | -| `config_value` | text | JSON-serialized value | -| `updated_at` | timestamp | Last update time | -| `updated_by` | string | User entity ref who made the change | - -Provider-scoped keys (e.g., `model`, `baseUrl`) are stored as `{providerId}::{key}` (e.g., `llamastack::model`). - -In RHDH, each plugin gets its own PostgreSQL database automatically. The `augment_admin_config` table persists across pod restarts and OCI image updates. diff --git a/workspaces/augment/plugins/augment-common/docs/KEYCLOAK_SETUP.md b/workspaces/augment/plugins/augment-common/docs/KEYCLOAK_SETUP.md deleted file mode 100644 index 0aa2ce15df4..00000000000 --- a/workspaces/augment/plugins/augment-common/docs/KEYCLOAK_SETUP.md +++ /dev/null @@ -1,345 +0,0 @@ -# Keycloak Configuration for Augment - -This guide provides step-by-step instructions to configure Keycloak for Augment authentication and authorization. - -## Prerequisites - -- Keycloak server running (version 20+ recommended) -- Admin access to Keycloak -- Network connectivity between Backstage and Keycloak - -## Overview - -We will configure: - -1. A Keycloak realm for your organization -2. A client for Backstage OIDC authentication -3. A client for MCP server token validation -4. Groups for access control -5. Users for testing - -## Step 1: Create or Select a Realm - -If you don't have a realm, create one: - -1. Log into Keycloak Admin Console -2. Click **Create Realm** -3. Name: `demo` (or your preferred name) -4. Click **Create** - -## Step 2: Create the Backstage Client - -This client handles user authentication for Backstage. - -### 2.1 Create the Client - -1. Go to **Clients** → **Create client** -2. Configure: - -| Setting | Value | -| ----------- | -------------- | -| Client type | OpenID Connect | -| Client ID | `backstage` | -| Name | Backstage OIDC | - -3. Click **Next** - -### 2.2 Configure Client Settings - -| Setting | Value | -| --------------------- | ------------ | -| Client authentication | ON | -| Authorization | OFF | -| Standard flow | ON (checked) | -| Direct access grants | ON (checked) | - -Click **Next**, then **Save** - -### 2.3 Configure Access Settings - -Go to the **Settings** tab: - -| Setting | Value | -| ------------------------------- | ------------------------- | -| Root URL | `http://localhost:7007` | -| Home URL | `http://localhost:7007` | -| Valid redirect URIs | `http://localhost:7007/*` | -| Valid post logout redirect URIs | `http://localhost:7007/*` | -| Web origins | `http://localhost:7007` | - -> **Note**: Replace `localhost:7007` with your Backstage URL in production. - -### 2.4 Get Client Secret - -1. Go to **Credentials** tab -2. Copy the **Client secret** - you'll need this for Backstage configuration - -### 2.5 Add Group Mapper - -To include group memberships in the JWT token: - -1. Go to **Client scopes** tab -2. Click on `backstage-dedicated` -3. Go to **Mappers** tab -4. Click **Configure a new mapper** -5. Select **Group Membership** -6. Configure: - -| Setting | Value | -| ------------------- | -------- | -| Name | `groups` | -| Token Claim Name | `groups` | -| Full group path | OFF | -| Add to ID token | ON | -| Add to access token | ON | -| Add to userinfo | ON | - -7. Click **Save** - -## Step 3: Create the MCP Server Client - -This client is used for MCP server token validation. - -### 3.1 Create the Client - -1. Go to **Clients** → **Create client** -2. Configure: - -| Setting | Value | -| ----------- | -------------- | -| Client type | OpenID Connect | -| Client ID | `mcp-server` | -| Name | MCP Server | - -3. Click **Next** - -### 3.2 Configure Client Settings - -| Setting | Value | -| ---------------------- | ----- | -| Client authentication | ON | -| Authorization | OFF | -| Standard flow | OFF | -| Direct access grants | OFF | -| Service accounts roles | ON | - -Click **Save** - -### 3.3 Get Client Secret - -1. Go to **Credentials** tab -2. Copy the **Client secret** - needed for MCP server configuration - -### 3.4 Add Audience Mapper - -To ensure tokens have the correct audience for MCP: - -1. Go to **Client scopes** tab for the `backstage` client -2. Click on `backstage-dedicated` -3. Go to **Mappers** tab -4. Click **Add mapper** → **By configuration** -5. Select **Audience** -6. Configure: - -| Setting | Value | -| ------------------------ | --------------------- | -| Name | `mcp-server-audience` | -| Included Client Audience | `mcp-server` | -| Add to ID token | OFF | -| Add to access token | ON | - -7. Click **Save** - -## Step 4: Create Groups - -Create groups to control access to Augment. - -### 4.1 Create augment-users Group - -1. Go to **Groups** → **Create group** -2. Name: `augment-users` -3. Click **Create** - -### 4.2 Create Additional Groups (Optional) - -For more granular control, create additional groups: - -- `augment-admins` - Full access -- `augment-readonly` - Read-only access -- `rhdh-team` - General RHDH team members - -## Step 5: Create Test Users - -### 5.1 Create Admin User (developer) - -1. Go to **Users** → **Add user** -2. Configure: - -| Setting | Value | -| -------------- | ----------------------- | -| Username | `developer` | -| Email | `developer@example.com` | -| Email verified | ON | -| First name | Developer | -| Last name | User | - -3. Click **Create** - -4. Go to **Credentials** tab: - - Click **Set password** - - Password: `developer123` - - Temporary: OFF - - Click **Save** - -5. Go to **Groups** tab: - - Click **Join Group** - - Select `augment-users` - - Click **Join** - -### 5.2 Create Restricted User (user1) - -1. Go to **Users** → **Add user** -2. Configure: - -| Setting | Value | -| -------------- | ------------------- | -| Username | `user1` | -| Email | `user1@example.com` | -| Email verified | ON | -| First name | User | -| Last name | One | - -3. Click **Create** - -4. Go to **Credentials** tab: - - Click **Set password** - - Password: `user1password` - - Temporary: OFF - - Click **Save** - -> **Note**: Do NOT add `user1` to `augment-users` group. This user will be denied access. - -## Step 6: Verify Configuration - -### 6.1 Test Token Generation - -Use the Keycloak token endpoint to verify the configuration: - -```bash -# Get a token for developer user -curl -X POST \ - "https://YOUR_KEYCLOAK_URL/realms/demo/protocol/openid-connect/token" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=password" \ - -d "client_id=backstage" \ - -d "client_secret=YOUR_CLIENT_SECRET" \ - -d "username=developer" \ - -d "password=developer123" \ - -d "scope=openid profile email" -``` - -### 6.2 Decode and Verify JWT - -Decode the access token at [jwt.io](https://jwt.io) and verify: - -1. **Groups claim exists**: - -```json -{ - "groups": ["augment-users"] -} -``` - -2. **Audience includes mcp-server**: - -```json -{ - "aud": ["mcp-server", "backstage"] -} -``` - -3. **User info is present**: - -```json -{ - "preferred_username": "developer", - "email": "developer@example.com" -} -``` - -### 6.3 Test Restricted User - -Get a token for `user1` and verify it does NOT have the `augment-users` group: - -```bash -curl -X POST \ - "https://YOUR_KEYCLOAK_URL/realms/demo/protocol/openid-connect/token" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=password" \ - -d "client_id=backstage" \ - -d "client_secret=YOUR_CLIENT_SECRET" \ - -d "username=user1" \ - -d "password=user1password" \ - -d "scope=openid profile email" -``` - -The token should either have no `groups` claim or an empty array. - -## Summary of Created Resources - -| Resource Type | Name | Purpose | -| ------------- | --------------------- | ----------------------------- | -| Realm | `demo` | Organization realm | -| Client | `backstage` | Backstage OIDC authentication | -| Client | `mcp-server` | MCP server token validation | -| Group | `augment-users` | Access to Augment | -| User | `developer` | Test user with access | -| User | `user1` | Test user without access | -| Mapper | `groups` | Include groups in JWT | -| Mapper | `mcp-server-audience` | Add audience claim | - -## Configuration Values for Next Steps - -Save these values for Backstage and MCP configuration: - -```yaml -# Values from Keycloak setup -keycloak: - realm: demo - url: https://YOUR_KEYCLOAK_URL - backstage_client_id: backstage - backstage_client_secret: YOUR_BACKSTAGE_CLIENT_SECRET - mcp_client_id: mcp-server - mcp_client_secret: YOUR_MCP_CLIENT_SECRET -``` - -## Next Steps - -- [Configure Backstage RBAC](./BACKSTAGE_RBAC_SETUP.md) -- [Configure MCP Server](./MCP_SERVER_SETUP.md) - -## Troubleshooting - -### Groups Not Appearing in Token - -1. Verify the Group Mapper is configured on the `backstage-dedicated` scope -2. Check that `Add to access token` is enabled -3. Verify the user is actually in the group - -### Audience Claim Missing - -1. Verify the Audience Mapper is configured -2. Check that `mcp-server` client exists -3. Verify `Add to access token` is enabled - -### Token Validation Fails - -1. Check the Keycloak realm URL is correct -2. Verify the client secret matches -3. Check for clock skew between servers - -### CORS Errors - -1. Verify `Web origins` is set correctly on the client -2. Add your Backstage URL to allowed origins -3. Check for trailing slashes in URLs diff --git a/workspaces/augment/plugins/augment-common/docs/MCP_SERVER_SETUP.md b/workspaces/augment/plugins/augment-common/docs/MCP_SERVER_SETUP.md deleted file mode 100644 index 0d04e089462..00000000000 --- a/workspaces/augment/plugins/augment-common/docs/MCP_SERVER_SETUP.md +++ /dev/null @@ -1,516 +0,0 @@ -# MCP Server Security Configuration - -This guide explains how to configure the Kubernetes MCP Server for secure operation with Keycloak authentication. - -## Prerequisites - -- Keycloak configured as per [Keycloak Setup Guide](./KEYCLOAK_SETUP.md) -- Kubernetes MCP Server installed -- Network connectivity between MCP server and Keycloak - -## Overview - -The MCP server provides several security controls: - -1. **OAuth/JWT Validation** - Validates user tokens from Keycloak -2. **Tool Restrictions** - Limits which tools are available -3. **Resource Blocking** - Blocks access to specific Kubernetes resources -4. **Read-Only Mode** - Exposes only non-destructive operations - -## Configuration Methods - -### Method 1: TOML Configuration File - -Create a `config.toml` file: - -```toml -# MCP Server Security Configuration - -# Enable OAuth authentication -require_oauth = true - -# Keycloak authorization URL (used for token validation) -authorization_url = "https://YOUR_KEYCLOAK_URL/realms/demo" - -# Expected audience in JWT tokens -oauth_audience = "mcp-server" - -# Disable dynamic client registration (security hardening) -disable_dynamic_client_registration = true - -# Tool restrictions -read_only = false # Set to true for read-only mode -disable_destructive = true # Hide destructive operations - -# Explicit tool control -enabled_tools = [] # Empty = all allowed (except disabled) -disabled_tools = [ - "delete_resource", # Block resource deletion - "apply_resource", # Block resource creation/updates - "patch_resource" # Block resource patching -] - -# Block access to sensitive resources -[[denied_resources]] -group = "" -version = "v1" -kind = "Secret" - -[[denied_resources]] -group = "" -version = "v1" -kind = "ConfigMap" - -[[denied_resources]] -group = "rbac.authorization.k8s.io" -version = "v1" -kind = "ClusterRole" - -[[denied_resources]] -group = "rbac.authorization.k8s.io" -version = "v1" -kind = "ClusterRoleBinding" - -# Server settings -port = "8080" -log_level = 2 -``` - -### Method 2: Environment Variables - -```bash -# OAuth settings -export MCP_REQUIRE_OAUTH=true -export MCP_AUTHORIZATION_URL="https://YOUR_KEYCLOAK_URL/realms/demo" -export MCP_OAUTH_AUDIENCE="mcp-server" - -# Tool restrictions -export MCP_READ_ONLY=false -export MCP_DISABLE_DESTRUCTIVE=true -``` - -### Method 3: Command Line Flags - -```bash -./kubernetes-mcp-server \ - --require-oauth \ - --authorization-url "https://YOUR_KEYCLOAK_URL/realms/demo" \ - --oauth-audience "mcp-server" \ - --disable-destructive \ - --config config.toml -``` - -## Security Modes - -### Mode 1: Full Access (Not Recommended for Production) - -All tools enabled, no restrictions: - -```toml -require_oauth = true -authorization_url = "https://YOUR_KEYCLOAK_URL/realms/demo" -oauth_audience = "mcp-server" - -read_only = false -disable_destructive = false -disabled_tools = [] -denied_resources = [] -``` - -### Mode 2: Read-Only (Recommended) - -Only read operations allowed: - -```toml -require_oauth = true -authorization_url = "https://YOUR_KEYCLOAK_URL/realms/demo" -oauth_audience = "mcp-server" - -# Only expose read-only tools -read_only = true - -# Block sensitive resources -[[denied_resources]] -group = "" -version = "v1" -kind = "Secret" -``` - -### Mode 3: Restricted Write (Balanced) - -Allow some writes, block dangerous operations: - -```toml -require_oauth = true -authorization_url = "https://YOUR_KEYCLOAK_URL/realms/demo" -oauth_audience = "mcp-server" - -read_only = false -disable_destructive = true - -# Explicitly disable dangerous tools -disabled_tools = [ - "delete_resource", - "exec_command" -] - -# Block sensitive resources -[[denied_resources]] -group = "" -version = "v1" -kind = "Secret" - -[[denied_resources]] -group = "rbac.authorization.k8s.io" -version = "v1" -kind = "ClusterRole" -``` - -## Tool Categories - -The MCP server annotates tools with hints: - -| Annotation | Description | Example Tools | -| ---------------------- | -------------------------- | ---------------------------------- | -| `readOnlyHint=true` | Safe read operations | `list_resources`, `get_resource` | -| `destructiveHint=true` | Dangerous write operations | `delete_resource` | -| (none) | Normal write operations | `apply_resource`, `patch_resource` | - -### Read-Only Tools (Safe) - -When `read_only = true`, only these are exposed: - -- `list_resources` - List Kubernetes resources -- `get_resource` - Get a specific resource -- `list_api_resources` - List available API types -- `get_events` - Get cluster events -- `get_pod_logs` - Get pod logs -- `get_cluster_info` - Get cluster information - -### Destructive Tools (Dangerous) - -When `disable_destructive = true`, these are hidden: - -- `delete_resource` - Delete any resource -- `delete_pod` - Force delete pods -- `drain_node` - Drain a node -- `cordon_node` - Cordon a node - -### Standard Write Tools - -Available unless explicitly disabled: - -- `apply_resource` - Create or update resources -- `patch_resource` - Patch resources -- `scale_deployment` - Scale deployments/statefulsets - -## Resource Blocking - -Block access to specific Kubernetes resource types: - -```toml -# Block all Secrets -[[denied_resources]] -group = "" -version = "v1" -kind = "Secret" - -# Block ConfigMaps -[[denied_resources]] -group = "" -version = "v1" -kind = "ConfigMap" - -# Block RBAC resources -[[denied_resources]] -group = "rbac.authorization.k8s.io" -version = "v1" -kind = "ClusterRole" - -[[denied_resources]] -group = "rbac.authorization.k8s.io" -version = "v1" -kind = "ClusterRoleBinding" - -[[denied_resources]] -group = "rbac.authorization.k8s.io" -version = "v1" -kind = "Role" - -[[denied_resources]] -group = "rbac.authorization.k8s.io" -version = "v1" -kind = "RoleBinding" - -# Block ServiceAccounts -[[denied_resources]] -group = "" -version = "v1" -kind = "ServiceAccount" -``` - -## Token Exchange (STS) Configuration - -For advanced scenarios requiring token exchange: - -```toml -require_oauth = true -authorization_url = "https://YOUR_KEYCLOAK_URL/realms/demo" -oauth_audience = "mcp-server" - -# STS token exchange settings -sts_client_id = "mcp-server" -sts_client_secret = "YOUR_MCP_CLIENT_SECRET" -sts_audience = "openshift" -sts_scopes = ["openid", "profile"] -``` - -> **Note**: Token exchange currently doesn't enable per-user Kubernetes RBAC. See [Security Architecture](./SECURITY.md) for details. - -## Augment Backend Configuration - -Configure Augment to use the secured MCP server: - -**app-config.yaml:** - -```yaml -augment: - # Security mode determines how MCP servers are authenticated - # See docs/SECURITY_MODES.md for details - security: - mode: 'plugin-only' # MCP server uses its own service account - # mode: 'full' # Augment sends OAuth tokens to MCP server - - mcpServers: - - id: kubernetes - name: Kubernetes MCP - type: streamable-http # or 'sse' - url: http://kubernetes-mcp-server:8080/mcp - requireApproval: 'never' -``` - -### How Authentication Works by Security Mode - -| Mode | MCP Server Auth | Description | -| ------------- | ------------------ | --------------------------------------- | -| `none` | No auth headers | MCP server uses its own service account | -| `plugin-only` | No auth headers | MCP server uses its own service account | -| `full` | OAuth Bearer token | Augment fetches token from Keycloak | - -For `full` mode, configure the OAuth settings: - -```yaml -augment: - security: - mode: 'full' - mcpOAuth: - tokenUrl: 'https://keycloak.example.com/realms/demo/protocol/openid-connect/token' - clientId: 'augment-backend' - clientSecret: ${MCP_OAUTH_CLIENT_SECRET} -``` - -## Kubernetes Deployment - -### ServiceAccount and RBAC - -Create a ServiceAccount for the MCP server: - -```yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: mcp-server - namespace: mcp-system ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: mcp-server-role -rules: - # Read-only access (safe) - - apiGroups: [''] - resources: - ['pods', 'services', 'namespaces', 'configmaps', 'persistentvolumeclaims'] - verbs: ['get', 'list', 'watch'] - - apiGroups: ['apps'] - resources: ['deployments', 'statefulsets', 'daemonsets', 'replicasets'] - verbs: ['get', 'list', 'watch'] - - apiGroups: [''] - resources: ['events'] - verbs: ['get', 'list', 'watch'] - - apiGroups: [''] - resources: ['pods/log'] - verbs: ['get'] - - # Write access (if needed) - # - apiGroups: ["apps"] - # resources: ["deployments", "statefulsets"] - # verbs: ["patch", "update"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: mcp-server-binding -subjects: - - kind: ServiceAccount - name: mcp-server - namespace: mcp-system -roleRef: - kind: ClusterRole - name: mcp-server-role - apiGroup: rbac.authorization.k8s.io -``` - -### Deployment - -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: kubernetes-mcp-server - namespace: mcp-system -spec: - replicas: 1 - selector: - matchLabels: - app: kubernetes-mcp-server - template: - metadata: - labels: - app: kubernetes-mcp-server - spec: - serviceAccountName: mcp-server - containers: - - name: mcp-server - image: quay.io/strimzi-ci/kubernetes-mcp-server:latest - ports: - - containerPort: 8080 - env: - - name: MCP_REQUIRE_OAUTH - value: 'true' - - name: MCP_AUTHORIZATION_URL - value: 'https://YOUR_KEYCLOAK_URL/realms/demo' - - name: MCP_OAUTH_AUDIENCE - value: 'mcp-server' - - name: MCP_DISABLE_DESTRUCTIVE - value: 'true' - volumeMounts: - - name: config - mountPath: /etc/mcp-server - volumes: - - name: config - configMap: - name: mcp-server-config ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: mcp-server-config - namespace: mcp-system -data: - config.toml: | - require_oauth = true - authorization_url = "https://YOUR_KEYCLOAK_URL/realms/demo" - oauth_audience = "mcp-server" - read_only = true - - [[denied_resources]] - group = "" - version = "v1" - kind = "Secret" ---- -apiVersion: v1 -kind: Service -metadata: - name: kubernetes-mcp-server - namespace: mcp-system -spec: - selector: - app: kubernetes-mcp-server - ports: - - port: 8080 - targetPort: 8080 -``` - -## Verification - -### Test Token Validation - -```bash -# Get a user token from Keycloak -TOKEN=$(curl -s -X POST \ - "https://YOUR_KEYCLOAK_URL/realms/demo/protocol/openid-connect/token" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=password" \ - -d "client_id=backstage" \ - -d "client_secret=YOUR_SECRET" \ - -d "username=developer" \ - -d "password=developer123" | jq -r '.access_token') - -# Test MCP server with valid token -curl -H "Authorization: Bearer $TOKEN" \ - http://localhost:8080/sse -``` - -Expected: Connection established (for valid token) - -### Test Without Token - -```bash -curl http://localhost:8080/sse -``` - -Expected: `401 Unauthorized` - -### Test Invalid Token - -```bash -curl -H "Authorization: Bearer invalid-token" \ - http://localhost:8080/sse -``` - -Expected: `401 Unauthorized` - -### Check Available Tools - -With valid token, check which tools are available: - -```bash -# The tool list is returned in the MCP protocol initialization -# You can see this in Augment's chat interface -``` - -## Security Best Practices - -1. **Always enable OAuth** in production -2. **Use read_only mode** unless writes are required -3. **Block sensitive resources** (Secrets, RBAC, ServiceAccounts) -4. **Use least-privilege RBAC** for the MCP ServiceAccount -5. **Enable TLS** for production deployments -6. **Monitor access logs** for unusual patterns -7. **Rotate credentials** regularly - -## Troubleshooting - -### Token Validation Fails - -1. Check Keycloak URL is reachable from MCP server -2. Verify audience claim in token matches `oauth_audience` -3. Check token hasn't expired -4. Verify JWKS endpoint is accessible - -### Tools Not Available - -1. Check `read_only` and `disable_destructive` settings -2. Verify `enabled_tools` / `disabled_tools` configuration -3. Check MCP server logs for tool registration - -### Resource Access Denied - -1. Check `denied_resources` configuration -2. Verify ServiceAccount RBAC permissions -3. Check Kubernetes audit logs - -## Next Steps - -- [Validation Guide](./VALIDATION.md) - Test your complete setup diff --git a/workspaces/augment/plugins/augment-common/docs/README.md b/workspaces/augment/plugins/augment-common/docs/README.md deleted file mode 100644 index c0098d2a6dd..00000000000 --- a/workspaces/augment/plugins/augment-common/docs/README.md +++ /dev/null @@ -1,169 +0,0 @@ -# Augment Security Documentation - -This directory contains comprehensive documentation and **automated setup scripts** for configuring security for the Augment plugin with Keycloak. - -## ⚠️ Choose Your Security Mode First - -| Mode | Setup Required | Status | -| ----------------- | ------------------------------------------ | ----------------------- | -| **`none`** | Nothing - just set `security.mode: 'none'` | ✅ Tested | -| **`plugin-only`** | Keycloak + Backstage RBAC | ✅ Tested (Recommended) | -| **`full`** | Keycloak + RBAC + MCP OAuth | 🚧 Experimental | - -## Quick Start for `plugin-only` Mode (Recommended) - -```bash -cd docs/scripts -chmod +x *.sh - -# 1. Configure Keycloak -export KEYCLOAK_URL="https://keycloak.example.com" -export KEYCLOAK_ADMIN_PASSWORD="admin-password" -./01-keycloak-setup.sh - -# 2. Generate Backstage configuration -source keycloak-config.env -./02-backstage-config.sh - -# 3. Validate -./validate-quick.sh -``` - -> **Note**: Scripts 03 and 04 are for `full` mode (MCP OAuth) which is experimental. - -See [scripts/README.md](./scripts/README.md) for detailed script documentation. - -## Documentation Index - -### For `none` and `plugin-only` Modes ✅ - -| Document | Description | -| ---------------------------------------------------- | ----------------------------------------------- | -| [**SECURITY_MODES.md**](./SECURITY_MODES.md) | **⭐ 3 Security Modes Explained (start here!)** | -| [**scripts/**](./scripts/) | Automated setup scripts | -| [KEYCLOAK_SETUP.md](./KEYCLOAK_SETUP.md) | Complete Keycloak configuration guide | -| [BACKSTAGE_RBAC_SETUP.md](./BACKSTAGE_RBAC_SETUP.md) | Backstage permission and RBAC configuration | -| [VALIDATION.md](./VALIDATION.md) | Step-by-step validation and testing guide | - -### For `full` Mode 🚧 (Experimental) - -| Document | Description | -| -------------------------------------------- | ------------------------------ | -| [MCP_SERVER_SETUP.md](./MCP_SERVER_SETUP.md) | MCP server OAuth configuration | -| [SECURITY.md](./SECURITY.md) | Security architecture overview | - -## Tested Versions - -| Component | Tested Versions | -| --------------------- | ---------------- | -| Keycloak | 22.x, 24.x, 25.x | -| Backstage | 1.35.x+ | -| RHDH | 1.3+ | -| kubernetes-mcp-server | 0.5.x+ | -| Kubernetes | 1.28+ | -| OpenShift | 4.14+ | - -## Manual Setup - -1. **Read the Architecture**: Start with [SECURITY.md](./SECURITY.md) to understand the security model -2. **Configure Keycloak**: Follow [KEYCLOAK_SETUP.md](./KEYCLOAK_SETUP.md) to set up authentication -3. **Configure RBAC**: Follow [BACKSTAGE_RBAC_SETUP.md](./BACKSTAGE_RBAC_SETUP.md) for access control -4. **Configure MCP**: Follow [MCP_SERVER_SETUP.md](./MCP_SERVER_SETUP.md) for MCP security -5. **Validate**: Use [VALIDATION.md](./VALIDATION.md) to test your setup - -## Security Feature Summary - -### What Works ✅ - -| Feature | Layer | Description | -| --------------------- | -------------- | ------------------------------- | -| User Authentication | Keycloak | OIDC-based user authentication | -| Group Membership | Keycloak | Groups synced to JWT tokens | -| Plugin Access Control | Backstage RBAC | `augment.access` permission | -| Token Validation | MCP Server | JWT validation against Keycloak | -| Tool Restrictions | MCP Server | Read-only mode, disabled tools | -| Resource Blocking | MCP Server | Block access to K8s resources | - -### What Doesn't Work ❌ - -| Feature | Status | Workaround | -| ------------------ | --------------- | ---------------------------------- | -| Per-User K8s RBAC | Not Implemented | Use MCP tool/resource restrictions | -| Group-Based Tools | Not Implemented | Use global tool restrictions | -| User Impersonation | Not Implemented | All users share ServiceAccount | - -## Architecture Diagram - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Keycloak │────▶│ Backstage │────▶│ Augment│────▶│ MCP Server │ -│ (AuthN) │ │ (RBAC) │ │ (Backend) │ │ (Controls) │ -└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ - │ │ │ │ - │ │ │ │ - ▼ ▼ ▼ ▼ - ┌───────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ - │ JWT │ │Permission│ │ 403 │ │ Tool │ - │ Token │ │ Check │ │ if Deny │ │ Filter │ - │+groups│ │ │ │ │ │+Resource│ - └───────┘ └─────────┘ └─────────┘ │ Block │ - └─────────┘ -``` - -## Configuration Values Reference - -When configuring, you'll need these values from each system: - -### From Keycloak - -``` -KEYCLOAK_URL=https://keycloak.example.com -KEYCLOAK_REALM=demo -KEYCLOAK_BACKSTAGE_CLIENT_ID=backstage -KEYCLOAK_BACKSTAGE_CLIENT_SECRET= -KEYCLOAK_MCP_CLIENT_ID=mcp-server -KEYCLOAK_MCP_CLIENT_SECRET= -``` - -### For Backstage - -```yaml -# app-config.yaml -auth.providers.oidc.development.clientId: backstage -auth.providers.oidc.development.clientSecret: ${KEYCLOAK_BACKSTAGE_CLIENT_SECRET} -permission.enabled: true -``` - -### For MCP Server - -```toml -# config.toml -require_oauth = true -authorization_url = "https://keycloak.example.com/realms/demo" -oauth_audience = "mcp-server" -``` - -## Validation Quick Test - -```bash -# Get a token -TOKEN=$(curl -s -X POST "https://keycloak.example.com/realms/demo/protocol/openid-connect/token" \ - -d "grant_type=password" -d "client_id=backstage" -d "client_secret=SECRET" \ - -d "username=developer" -d "password=developer123" | jq -r '.access_token') - -# Test Backstage permission -curl -X POST "http://localhost:7007/api/permission/authorize" \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"items":[{"permission":{"type":"basic","name":"augment.access","attributes":{"action":"read"}}}]}' - -# Expected: {"items":[{"result":"ALLOW"}]} -``` - -## Support - -For issues with: - -- **Augment Plugin**: File an issue in the rhdh-plugins repository -- **Kubernetes MCP Server**: File an issue at https://github.com/containers/kubernetes-mcp-server -- **Keycloak**: Refer to Keycloak documentation at https://www.keycloak.org/documentation diff --git a/workspaces/augment/plugins/augment-common/docs/SECURITY.md b/workspaces/augment/plugins/augment-common/docs/SECURITY.md deleted file mode 100644 index 16a36b1f27a..00000000000 --- a/workspaces/augment/plugins/augment-common/docs/SECURITY.md +++ /dev/null @@ -1,187 +0,0 @@ -# Augment Security with Keycloak - -This document describes the security architecture and configuration for the Augment plugin when integrated with Keycloak for authentication and authorization. - -## Table of Contents - -1. [Security Modes](#security-modes) -2. [Architecture Overview](#architecture-overview) -3. [Security Layers](#security-layers) -4. [Quick Start](#quick-start) -5. [Detailed Setup Guides](#detailed-setup-guides) - -## Security Modes - -Augment supports **3 security modes** to accommodate different deployment scenarios: - -| Mode | Description | Use Case | -| ------------- | -------------------------------- | ------------------------------ | -| `none` | No access control | Development, demos | -| `plugin-only` | Keycloak group → Backstage RBAC | **Recommended for production** | -| `full` | Full authentication chain to MCP | When MCP requires OAuth | - -**See [SECURITY_MODES.md](./SECURITY_MODES.md) for complete configuration details.** - -```yaml -augment: - security: - mode: 'plugin-only' # Options: 'none' | 'plugin-only' | 'full' -``` - -## Architecture Overview - -Augment implements a multi-layer security model that integrates Keycloak with Backstage RBAC and MCP server security controls. - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ End User │ -│ (Authenticated via Keycloak) │ -│ │ -│ Identity: user:default/developer │ -│ Groups: [group:default/augment-users, group:default/rhdh-team] │ -└──────────────────────────────────┬──────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ LAYER 1: KEYCLOAK AUTHENTICATION │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ • User authenticates via OIDC │ │ -│ │ • Keycloak issues JWT with claims (groups, email, preferred_name) │ │ -│ │ • Token contains group memberships for RBAC decisions │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────┬──────────────────────────────────────────┘ - │ JWT Token - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ LAYER 2: BACKSTAGE RBAC │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ Permission: augment.access │ │ -│ │ │ │ -│ │ Policy Check: │ │ -│ │ IF user IN group:default/augment-users │ │ -│ │ THEN role:default/augment-user │ │ -│ │ AND role HAS permission augment.access → ALLOW │ │ -│ │ │ │ -│ │ Enforcement Points: │ │ -│ │ • Frontend: RequirePermission component │ │ -│ │ • Backend: requirePluginAccess middleware │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────┬──────────────────────────────────────────┘ - │ Backstage Service Token + User JWT - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ LAYER 3: MCP SERVER SECURITY │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ A. Token Validation │ │ -│ │ • Validates JWT signature against Keycloak JWKS │ │ -│ │ • Checks audience claim matches expected value │ │ -│ │ • Verifies token expiration │ │ -│ │ │ │ -│ │ B. Tool Restrictions (Global - applies to all users) │ │ -│ │ • read_only: Only expose read-only tools │ │ -│ │ • disable_destructive: Hide destructive operations │ │ -│ │ • enabled_tools / disabled_tools: Explicit tool lists │ │ -│ │ │ │ -│ │ C. Resource Blocking │ │ -│ │ • denied_resources: Block access to specific K8s resources │ │ -│ │ • Example: Block Secrets, ClusterRoles │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────┬──────────────────────────────────────────┘ - │ ServiceAccount Token - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ LAYER 4: KUBERNETES RBAC │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ Current State: │ │ -│ │ • All requests use MCP ServiceAccount identity │ │ -│ │ • User identity from Keycloak is NOT propagated │ │ -│ │ • K8s RBAC applies to ServiceAccount, not end user │ │ -│ │ │ │ -│ │ ⚠️ Limitation: Per-user K8s RBAC not currently supported │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -## Security Layers - -### Layer 1: Keycloak Authentication - -| Feature | Status | Description | -| ------------------- | ------------ | ------------------------------------ | -| OIDC Authentication | ✅ Supported | Users authenticate via Keycloak OIDC | -| JWT Token Issuance | ✅ Supported | Keycloak issues JWT with user claims | -| Group Claims | ✅ Supported | Groups synced to Backstage for RBAC | -| Session Management | ✅ Supported | Token refresh handled automatically | - -### Layer 2: Backstage RBAC - -| Feature | Status | Description | -| --------------------- | ------------ | ------------------------------------------- | -| Plugin Access Control | ✅ Supported | `augment.access` permission | -| Group-Based Policies | ✅ Supported | Map Keycloak groups to roles | -| Frontend Enforcement | ✅ Supported | `ConditionalPermissionWrapper` (mode-aware) | -| Backend Enforcement | ✅ Supported | Middleware returns 403 (mode-aware) | -| Security Mode Aware | ✅ Supported | `none` mode bypasses permission checks | - -### Layer 3: MCP Server Security - -| Feature | Status | Description | -| -------------------- | ------------ | ------------------------------- | -| JWT Validation | ✅ Supported | Validates against Keycloak JWKS | -| Audience Validation | ✅ Supported | Checks `aud` claim | -| Read-Only Mode | ✅ Supported | Expose only read tools | -| Tool Filtering | ✅ Supported | Enable/disable specific tools | -| Resource Blocking | ✅ Supported | Deny access to K8s resources | -| Token Exchange (STS) | ✅ Supported | Exchange tokens via Keycloak | - -### Layer 4: Kubernetes RBAC - -| Feature | Status | Description | -| ------------------- | ------------------ | ------------------------------ | -| ServiceAccount Auth | ✅ Works | MCP authenticates as SA | -| User Impersonation | ❌ Not Implemented | User identity not propagated | -| Per-User RBAC | ❌ Not Supported | All users share SA permissions | - -## Quick Start - -For a working setup, you need: - -1. **Keycloak** - [Setup Guide](./KEYCLOAK_SETUP.md) -2. **Backstage RBAC** - [Setup Guide](./BACKSTAGE_RBAC_SETUP.md) -3. **MCP Server** - [Setup Guide](./MCP_SERVER_SETUP.md) - -## Detailed Setup Guides - -- [Keycloak Configuration](./KEYCLOAK_SETUP.md) - Complete Keycloak setup -- [Backstage RBAC Configuration](./BACKSTAGE_RBAC_SETUP.md) - Permission policies -- [MCP Server Configuration](./MCP_SERVER_SETUP.md) - Tool and resource restrictions -- [Validation Guide](./VALIDATION.md) - Testing your setup - -## Security Considerations - -### What This Architecture Provides - -1. **User Authentication** - Verifies user identity via Keycloak -2. **Plugin Authorization** - Controls who can access Augment -3. **Tool Restrictions** - Limits available MCP tools globally -4. **Resource Protection** - Blocks access to sensitive K8s resources - -### What This Architecture Does NOT Provide - -1. **Per-User K8s RBAC** - All users have same K8s permissions -2. **Group-Based Tool Access** - Cannot show different tools to different groups -3. **Audit Trail by User** - K8s audit logs show ServiceAccount, not user - -### Recommendations - -For production deployments: - -1. Use `read_only = true` to minimize blast radius -2. Block sensitive resources with `denied_resources` -3. Use least-privilege ServiceAccount for MCP -4. Enable Keycloak audit logging -5. Monitor for unusual activity patterns diff --git a/workspaces/augment/plugins/augment-common/docs/SECURITY_MODES.md b/workspaces/augment/plugins/augment-common/docs/SECURITY_MODES.md deleted file mode 100644 index 1d4d7a6c5ae..00000000000 --- a/workspaces/augment/plugins/augment-common/docs/SECURITY_MODES.md +++ /dev/null @@ -1,247 +0,0 @@ -# Augment Security Modes - -Augment supports **3 security modes** for flexible authentication and authorization. This allows you to choose the right level of security for your environment. - -## Quick Reference - -| Mode | Plugin Access | MCP Server Auth | Status | Use Case | -| ------------- | ------------- | --------------- | --------------- | ---------------------------------------- | -| `none` | Open | No auth | ✅ Tested | Development, demos, trusted environments | -| `plugin-only` | Keycloak RBAC | No auth | ✅ Tested | **Recommended for most deployments** | -| `full` | Keycloak RBAC | OAuth tokens | 🚧 Experimental | End-to-end authentication chain | - -> **⚠️ Note**: `full` mode is experimental and not fully tested. For production, use `plugin-only` mode. - -## Mode 1: `none` - No Access Control - -**Anyone can access Augment. MCP servers receive no authentication.** - -```yaml -augment: - security: - mode: 'none' -``` - -### When to Use - -- Local development -- Internal demos -- Trusted network environments -- Quick testing - -### What Happens - -- No permission checks on Augment endpoints -- MCP servers are called without Authorization headers -- MCP servers should use their own service account for K8s access - ---- - -## Mode 2: `plugin-only` - Plugin Access Control (Default) - -**Users must be in a Keycloak group to access Augment. MCP servers use their own service accounts.** - -```yaml -augment: - security: - mode: 'plugin-only' - accessDeniedMessage: 'Contact your platform team for Augment access.' -``` - -### When to Use - -- **Most production deployments** -- When you want group-based access control -- When MCP server has its own K8s service account with appropriate permissions - -### What Happens - -1. User authenticates via Keycloak → gets JWT with group claims -2. Backstage RBAC checks `augment.access` permission -3. Users in `augment-users` group → access granted -4. Users NOT in group → 403 Access Denied -5. MCP servers are called without auth (they use their own service accounts) - -### RBAC Configuration Required - -Create `rbac-policy.csv`: - -```csv -g, group:default/augment-users, role:default/augment-user -p, role:default/augment-user, augment.access, read, allow -``` - -Configure in `app-config.yaml`: - -```yaml -permission: - enabled: true - rbac: - policies-csv-file: /path/to/rbac-policy.csv -``` - ---- - -## Mode 3: `full` - Full Authentication Chain 🚧 - -> **⚠️ Experimental**: This mode is not fully tested. The code supports it, but the MCP server OAuth validation flow has not been validated end-to-end. Use `plugin-only` mode for production. - -**Keycloak controls plugin access AND MCP servers receive OAuth tokens.** - -```yaml -augment: - security: - mode: 'full' - accessDeniedMessage: 'Contact your platform team for access.' - mcpOAuth: - tokenUrl: 'https://keycloak.example.com/realms/demo/protocol/openid-connect/token' - clientId: 'augment-backend' - clientSecret: ${MCP_OAUTH_CLIENT_SECRET} - scopes: - - openid -``` - -### When to Use - -- When MCP server requires OAuth authentication -- End-to-end authenticated environments -- Strict security requirements - -### Current Limitation - -The MCP server (kubernetes-mcp-server) validates incoming OAuth tokens but then attempts to use them for Kubernetes API calls, which doesn't work with Keycloak tokens. Until this is resolved, use `plugin-only` mode where the MCP server uses its own Kubernetes service account. - -### What Happens - -1. User authenticates via Keycloak → gets JWT with group claims -2. Backstage RBAC checks `augment.access` permission -3. For MCP calls: Augment backend fetches OAuth token using client credentials -4. MCP server receives `Authorization: Bearer ` header -5. MCP server validates the token against Keycloak - -### Keycloak Configuration Required - -1. Create a **service account client** in Keycloak: - - Client ID: `augment-backend` - - Client authentication: ON - - Service accounts roles: enabled - - Authentication flow: Client credentials - -2. Add an **audience mapper** to include `mcp-server` in token audience: - - Mapper type: Audience - - Included audience: `mcp-server` - -3. Configure MCP server to validate tokens: - ```toml - require_oauth = true - authorization_url = "https://keycloak.example.com/realms/demo" - oauth_audience = "mcp-server" - ``` - ---- - -## Configuration Reference - -### Full Schema - -```yaml -augment: - security: - # Security mode: 'none' | 'plugin-only' | 'full' - mode: 'plugin-only' - - # Custom message when access denied - accessDeniedMessage: 'Contact your platform team for access.' - - # OAuth config for MCP auth (mode 3 only) - mcpOAuth: - tokenUrl: 'https://keycloak.example.com/realms/demo/protocol/openid-connect/token' - clientId: 'augment-backend' - clientSecret: ${MCP_OAUTH_CLIENT_SECRET} - scopes: - - openid -``` - -### Environment Variables - -For mode 3, set the client secret as an environment variable: - -```bash -export MCP_OAUTH_CLIENT_SECRET="your-keycloak-client-secret" -``` - ---- - -## Comparison Matrix - -| Feature | `none` | `plugin-only` | `full` | -| ------------------------------ | ------ | ------------- | ------ | -| Backstage login required | No\* | Yes | Yes | -| Keycloak group check | No | Yes | Yes | -| RBAC policy required | No | Yes | Yes | -| MCP gets OAuth token | No | No | Yes | -| Keycloak service client needed | No | No | Yes | -| MCP server OAuth config | No | No | Yes | - -\*Depends on overall Backstage auth configuration - ---- - -## Troubleshooting - -### Mode 2: User gets 403 but is in the group - -1. Check that the user's Keycloak group matches the RBAC policy: - - ```bash - # Decode JWT to see groups - echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq '.groups' - ``` - -2. Verify RBAC policy is loaded: - - ```bash - # Check Backstage logs for RBAC loading - grep -i rbac /var/log/backstage.log - ``` - -3. Ensure group format matches: - - RBAC: `group:default/augment-users` - - Keycloak group: `augment-users` (mapped to `default` namespace) - -### Mode 3: MCP calls fail with 401 - -1. Verify client credentials are correct -2. Check MCP server OAuth configuration matches Keycloak -3. Ensure audience mapper is configured for `mcp-server` -4. Check Augment backend logs for token fetch errors - -### Fallback Behavior - -- If mode 3 is configured without `mcpOAuth`, it falls back to mode 2 -- Warning is logged: "Security mode 'full' requires mcpOAuth configuration" - ---- - -## Migration Guide - -### From Legacy `auth` to `security.mode` - -**Before (deprecated):** - -```yaml -augment: - auth: - requiredGroup: 'augment-users' -``` - -**After:** - -```yaml -augment: - security: - mode: 'plugin-only' -``` - -The new `security` block uses Backstage RBAC permissions instead of direct group checks. -Configure RBAC policies to map Keycloak groups to the `augment.access` permission. diff --git a/workspaces/augment/plugins/augment-common/docs/TROUBLESHOOTING.md b/workspaces/augment/plugins/augment-common/docs/TROUBLESHOOTING.md deleted file mode 100644 index 84aebf4bc07..00000000000 --- a/workspaces/augment/plugins/augment-common/docs/TROUBLESHOOTING.md +++ /dev/null @@ -1,196 +0,0 @@ -# Troubleshooting - -Common issues and diagnostic steps for the Augment plugin. - -## 400 Error When Updating Agent Config via Admin Console - -### Symptom - -Saving agent configuration through the admin console returns HTTP 400 (Bad Request). - -### Diagnostic Steps - -**Step 1: Check the exact error message** - -The 400 response body contains a specific validation error. Check the browser's Network tab or the backend logs for the full message. Common messages: - -| Error Message | Cause | -| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| `Agent "X" must have a non-empty name` | The `name` field is missing or empty | -| `Agent "X" must have non-empty instructions` | The `instructions` field (the agent's system prompt) is missing or empty | -| `Agent "X" has handoff to "Y" which does not exist` | Agent X references agent Y in its `handoffs` array, but Y is not in the roster being saved | -| `Agent "X" has asTools reference to "Y" which does not exist` | Agent X references agent Y in its `asTools` array, but Y is not in the roster | -| `agents must be a non-null object` | The payload structure is wrong (not a `{ key: agentConfig }` object) | -| `Request body must contain a "value" field` | The PUT request body is missing `{ "value": ... }` | - -**Step 2: Verify the plugin is loaded** - -If the YAML ConfigMap was "cleaned up" and `augment.llamaStack.baseUrl` or `augment.llamaStack.model` were removed, the plugin will not load at all. - -Check: - -- `GET /api/augment/status` — if it returns 404, the plugin never loaded -- Pod logs — look for `Augment configuration validated successfully` (plugin loaded) or `ConfigValidationError` (plugin failed to load) - -**Step 3: Understand the agent save behavior** - -The admin UI sends the **entire agent roster** as a single `PUT /admin/config/agents` request. There is no partial update for individual agents. This means: - -- If any single agent in the payload fails validation, the entire save is rejected -- All `handoffs` and `asTools` references must point to agent keys that exist within the same save payload -- Removing an agent from the roster and saving will permanently remove it (DB agents fully replace YAML agents) - -**Step 4: Check if agents were ever in the DB** - -If agents were previously defined in YAML and the YAML was cleaned up without first saving them through the admin console, the DB has no agent data. The system falls back to YAML, which now has nothing. The admin UI may be trying to save agents for the first time with an incomplete payload. - -### Resolution - -Ensure every agent in the save payload has at minimum: - -```json -{ - "agentKey": { - "name": "Agent Display Name", - "instructions": "You are a helpful agent that..." - } -} -``` - -And that all `handoffs` / `asTools` references point to keys that exist in the same object. - ---- - -## Plugin Not Loading in RHDH - -### Symptom - -The Augment page shows a blank screen, 404 errors, or the plugin is missing from the sidebar. - -### Diagnostic Steps - -1. **Verify OCI image is accessible**: The RHDH pod must be able to pull the OCI image specified in `dynamic-plugins.override.yaml`. - -2. **Check ConfigMap**: The plugin requires at minimum: - - ```yaml - augment: - llamaStack: - baseUrl: 'https://your-llama-stack-server.com' - model: 'your-model-id' - ``` - - If these are missing, the Backstage config schema validation will reject the plugin before it even initializes. - -3. **Check `dynamic-plugins.override.yaml`**: Verify the plugin entries are present with `disabled: false`. - -4. **Review pod logs**: - ```bash - # RHDH pod logs - podman compose logs rhdh - # or in OpenShift - oc logs deployment/rhdh - ``` - Look for: - - `Augment configuration validated successfully` — plugin loaded - - `ConfigValidationError` — missing required config - - `Failed to initialize` — provider or network error - ---- - -## RAG Not Returning Results - -### Symptom - -Questions receive generic answers without referencing the knowledge base. - -### Diagnostic Steps - -1. **Check vector store**: Verify `vectorStoreId` or `vectorStoreIds` in the YAML config points to a valid vector store. If omitted, the plugin auto-creates one named `augment-knowledge-base`. - -2. **Check document ingestion**: Go to `GET /api/augment/documents` to see if documents have been indexed. If the list is empty, check: - - The `documents` section in YAML has configured sources - - The sources are accessible (URLs reachable, GitHub token valid, directories exist) - - Pod logs for ingestion errors - -3. **Check Llama Stack server**: Ensure the Llama Stack server has the Files API, Vector Stores API, and file_search tool support enabled. - -4. **Check score threshold**: If `fileSearchScoreThreshold` is set too high in the vector store config, relevant results may be filtered out. - ---- - -## MCP Tools Not Available - -### Symptom - -MCP tools don't appear in the agent status panel or fail when called. - -### Diagnostic Steps - -1. **Verify MCP server URL**: The URL must be reachable from wherever tool execution happens: - - In `direct` mode: reachable from the Llama Stack server - - In `backend` mode: reachable from the Backstage backend pod - -2. **Check server type**: Must be `streamable-http` or `sse`. - -3. **Check disabled servers**: The admin UI can disable MCP servers via `disabledMcpServerIds`. Check `GET /admin/effective-config` to see the merged MCP server list. - -4. **Check agent scoping**: In multi-agent mode, each agent can only access MCP servers listed in its `mcpServers` array. If the array is empty or missing, the agent has no MCP access. - -5. **Test connection**: Use `POST /admin/mcp/test-connection` with the server URL to verify connectivity. - ---- - -## Conversation History Not Loading - -### Symptom - -Previous conversations are not visible or fail to load. - -### Diagnostic Steps - -1. **Check Llama Stack Conversations API**: The plugin uses `/v1/conversations` for persistence. Verify the Llama Stack server has this endpoint available. - -2. **Check ZDR mode**: If `zdrMode: true` is set in YAML, responses are not stored server-side and conversation history is disabled. - -3. **Check network requests**: In the browser's Network tab, look for errors on `GET /api/augment/conversations`. - ---- - -## Admin Console Shows "Config Not Loading" - -### Symptom - -The admin panel tabs display loading spinners or error messages. - -### Diagnostic Steps - -1. **Check admin access**: In `plugin-only` or `full` security mode, only users listed in `security.adminUsers` (or with RBAC admin policy when `permission.enabled: true`) can access the admin panel. - -2. **Check database**: The admin config is stored in the plugin's database. If the database is unreachable: - - Admin reads will fail - - Chat will still work (falls back to YAML baseline) - - Pod logs will show `Failed to read admin config overrides, using YAML baseline` - -3. **Check the effective config endpoint**: `GET /admin/effective-config` returns the merged config. If this works, the issue is in a specific admin panel component. - ---- - -## Config Changes Not Taking Effect - -### Symptom - -After changing a value in the admin console or YAML, the old value is still used. - -### Diagnostic Steps - -1. **Admin console changes**: These take effect within 5 seconds (the runtime config cache TTL). The cache is also invalidated immediately after a save. If changes still don't apply, check: - - The save returned a success response - - `GET /admin/effective-config` shows the new value - - The browser is not caching the old page - -2. **YAML changes**: YAML changes require a full backend restart. There is no hot-reload. - -3. **"Invisible YAML" problem**: If a DB override exists for a key, YAML changes to that key are silently ignored. Check `GET /admin/config/:key` — if `source` is `'database'`, the DB value is active. Reset to defaults (`DELETE /admin/config/:key`) to revert to YAML. - -4. **YAML-only keys**: Some keys cannot be overridden by the admin UI (see [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md)). Changes to these always require a restart. diff --git a/workspaces/augment/plugins/augment-common/docs/VALIDATION.md b/workspaces/augment/plugins/augment-common/docs/VALIDATION.md deleted file mode 100644 index 4b32abd74d4..00000000000 --- a/workspaces/augment/plugins/augment-common/docs/VALIDATION.md +++ /dev/null @@ -1,602 +0,0 @@ -# Validation Guide - -This guide provides step-by-step instructions to validate that your Keycloak + Backstage RBAC + MCP Server security configuration is working correctly. - -## Security Mode - -This guide assumes you're using **`plugin-only`** security mode (the default and recommended mode). See [SECURITY_MODES.md](./SECURITY_MODES.md) for other modes. - -| Mode | This Validation Applies? | -| ------------- | ------------------------------ | -| `none` | ❌ No auth, all access allowed | -| `plugin-only` | ✅ Yes - this guide | -| `full` | ✅ Yes, plus MCP OAuth testing | - -## Prerequisites - -Before running these tests, ensure you have completed: - -- [Keycloak Setup](./KEYCLOAK_SETUP.md) -- [Backstage RBAC Setup](./BACKSTAGE_RBAC_SETUP.md) -- [MCP Server Setup](./MCP_SERVER_SETUP.md) (optional for `plugin-only` mode) - -## Test Users - -For these tests, you should have: - -| User | Password | Groups | Expected Access | -| --------- | ------------- | ------------- | --------------- | -| developer | developer123 | augment-users | ✅ Full Access | -| user1 | user1password | (none) | ❌ Denied | - -## Test 1: Keycloak Token Generation - -### 1.1 Get Token for Authorized User - -```bash -# Set your Keycloak URL -KEYCLOAK_URL="https://YOUR_KEYCLOAK_URL" -REALM="demo" -CLIENT_ID="backstage" -CLIENT_SECRET="YOUR_CLIENT_SECRET" - -# Get token for developer (authorized user) -DEVELOPER_TOKEN=$(curl -s -X POST \ - "${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=password" \ - -d "client_id=${CLIENT_ID}" \ - -d "client_secret=${CLIENT_SECRET}" \ - -d "username=developer" \ - -d "password=developer123" \ - -d "scope=openid profile email" | jq -r '.access_token') - -echo "Developer token obtained: ${DEVELOPER_TOKEN:0:50}..." -``` - -**Expected**: Token is returned (not null or error) - -### 1.2 Verify Token Claims - -```bash -# Decode token (without verification) to check claims -echo $DEVELOPER_TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq . -``` - -**Expected Output**: - -```json -{ - "exp": 1702800000, - "iat": 1702796400, - "aud": ["mcp-server", "backstage"], - "sub": "user-uuid", - "preferred_username": "developer", - "email": "developer@example.com", - "groups": ["augment-users"] -} -``` - -**Verify**: - -- [ ] `groups` contains `augment-users` -- [ ] `aud` contains `mcp-server` -- [ ] `preferred_username` is `developer` - -### 1.3 Get Token for Unauthorized User - -```bash -# Get token for user1 (unauthorized user) -USER1_TOKEN=$(curl -s -X POST \ - "${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=password" \ - -d "client_id=${CLIENT_ID}" \ - -d "client_secret=${CLIENT_SECRET}" \ - -d "username=user1" \ - -d "password=user1password" \ - -d "scope=openid profile email" | jq -r '.access_token') - -# Decode and check groups -echo $USER1_TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq '.groups' -``` - -**Expected**: `null` or `[]` (no groups) - -## Test 2: Backstage RBAC Permission Evaluation - -### 2.1 Start Backstage - -Ensure Backstage is running with RBAC enabled: - -```bash -cd /path/to/backstage -yarn dev -``` - -### 2.2 Test Permission API for Authorized User - -```bash -BACKSTAGE_URL="http://localhost:7007" - -# Test permission for developer -curl -s -X POST "${BACKSTAGE_URL}/api/permission/authorize" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${DEVELOPER_TOKEN}" \ - -d '{ - "items": [ - { - "permission": { - "type": "basic", - "name": "augment.access", - "attributes": { "action": "read" } - } - } - ] - }' | jq . -``` - -**Expected**: - -```json -{ - "items": [ - { - "result": "ALLOW" - } - ] -} -``` - -### 2.3 Test Permission API for Unauthorized User - -```bash -# Test permission for user1 -curl -s -X POST "${BACKSTAGE_URL}/api/permission/authorize" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${USER1_TOKEN}" \ - -d '{ - "items": [ - { - "permission": { - "type": "basic", - "name": "augment.access", - "attributes": { "action": "read" } - } - } - ] - }' | jq . -``` - -**Expected**: - -```json -{ - "items": [ - { - "result": "DENY" - } - ] -} -``` - -### 2.4 Check Backstage Logs - -Look for permission evaluation in logs: - -```bash -# Grep for permission logs -grep "permission.permission-evaluation" /path/to/backstage.log | tail -10 -``` - -**Expected Log Entries**: - -``` -permission info permission.permission-evaluation - meta={"userEntityRef":"user:default/developer","permissionName":"augment.access","result":"ALLOW"} - -permission info permission.permission-evaluation - meta={"userEntityRef":"user:default/user1","permissionName":"augment.access","result":"DENY"} -``` - -## Test 3: Augment Backend API - -### 3.1 Test Health Endpoint (No Auth Required) - -```bash -curl -s "${BACKSTAGE_URL}/api/augment/health" | jq . -``` - -**Expected**: - -```json -{ - "status": "ok" -} -``` - -### 3.2 Test Protected Endpoint with Authorized User - -```bash -# Test workflows endpoint (requires auth) -curl -s "${BACKSTAGE_URL}/api/augment/workflows" \ - -H "Authorization: Bearer ${DEVELOPER_TOKEN}" | jq . -``` - -**Expected**: Valid response (workflows data or empty array) - -### 3.3 Test Protected Endpoint with Unauthorized User - -```bash -# Test workflows endpoint with unauthorized user -curl -s -w "\nHTTP Status: %{http_code}\n" \ - "${BACKSTAGE_URL}/api/augment/workflows" \ - -H "Authorization: Bearer ${USER1_TOKEN}" | jq . -``` - -**Expected**: - -```json -{ - "error": { - "name": "NotAllowedError", - "message": "You don't have permission to access Augment" - } -} -HTTP Status: 403 -``` - -### 3.4 Test All Protected Endpoints - -```bash -# Test all protected endpoints for unauthorized user -for endpoint in workflows prompt-groups quick-actions status documents; do - echo "Testing /api/augment/${endpoint}:" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ - "${BACKSTAGE_URL}/api/augment/${endpoint}" \ - -H "Authorization: Bearer ${USER1_TOKEN}") - echo " Status: ${HTTP_CODE}" -done -``` - -**Expected**: All endpoints return 403 - -## Test 4: MCP Server Token Validation - -### 4.1 Test MCP Health (If Available) - -```bash -MCP_URL="http://localhost:8080" - -curl -s "${MCP_URL}/health" 2>/dev/null || echo "Health endpoint not available" -``` - -### 4.2 Test MCP Without Token - -```bash -curl -s -w "\nHTTP Status: %{http_code}\n" "${MCP_URL}/sse" -``` - -**Expected**: `401 Unauthorized` - -### 4.3 Test MCP With Valid Token - -```bash -curl -s -H "Authorization: Bearer ${DEVELOPER_TOKEN}" "${MCP_URL}/sse" & -MCP_PID=$! -sleep 2 -kill $MCP_PID 2>/dev/null - -echo "Connection established successfully if no errors above" -``` - -**Expected**: Connection establishes (no 401 error) - -### 4.4 Test MCP With Invalid Token - -```bash -curl -s -w "\nHTTP Status: %{http_code}\n" \ - -H "Authorization: Bearer invalid-token" \ - "${MCP_URL}/sse" -``` - -**Expected**: `401 Unauthorized` - -## Test 5: End-to-End UI Testing - -### 5.1 Login as Authorized User (developer) - -1. Open Backstage: http://localhost:7007 -2. Click "Sign In" -3. Select Keycloak/OIDC provider -4. Login with: - - Username: `developer` - - Password: `developer123` -5. Navigate to Augment - -**Expected**: - -- [ ] Login succeeds -- [ ] Augment page loads -- [ ] Workflows display (or empty state) -- [ ] Chat interface is available -- [ ] No "Access Denied" errors - -### 5.2 Login as Unauthorized User (user1) - -1. Log out from Backstage -2. Click "Sign In" -3. Login with: - - Username: `user1` - - Password: `user1password` -4. Navigate to Augment - -**Expected**: - -- [ ] Login succeeds -- [ ] Augment shows "Access Denied" page -- [ ] Message: "You do not have the necessary permissions to access Augment" -- [ ] No workflows or chat visible - -### 5.3 Check Browser Network Tab - -For unauthorized user: - -1. Open Developer Tools (F12) -2. Go to Network tab -3. Navigate to Augment -4. Check API calls - -**Expected**: - -- `/api/augment/workflows` returns 403 -- `/api/augment/prompt-groups` returns 403 -- All other endpoints return 403 - -## Test 6: MCP Tool Restrictions - -### 6.1 Check Available Tools (Read-Only Mode) - -If MCP is configured with `read_only = true`: - -1. Login as authorized user -2. Open Augment Chat -3. Ask: "What tools are available?" - -**Expected Tools** (read-only): - -- list_resources -- get_resource -- get_pod_logs -- get_events -- list_api_resources - -**Should NOT see**: - -- apply_resource -- delete_resource -- patch_resource - -### 6.2 Test Resource Blocking - -If `denied_resources` includes Secrets: - -1. Login as authorized user -2. Open Augment Chat -3. Ask: "List all secrets in default namespace" - -**Expected**: Error or empty response indicating secrets are blocked - -## Validation Checklist - -### Keycloak - -- [ ] Token generation works for all test users -- [ ] Groups claim present in developer token -- [ ] Audience claim includes mcp-server -- [ ] user1 token has no groups - -### Backstage RBAC - -- [ ] Permission API returns ALLOW for developer -- [ ] Permission API returns DENY for user1 -- [ ] Permission logs show correct evaluations - -### Augment Backend - -- [ ] Health endpoint accessible without auth -- [ ] Protected endpoints return 200 for developer -- [ ] Protected endpoints return 403 for user1 - -### MCP Server - -- [ ] Rejects requests without token (401) -- [ ] Accepts requests with valid token -- [ ] Rejects requests with invalid token (401) - -### End-to-End - -- [ ] developer can access Augment UI -- [ ] developer can use chat interface -- [ ] user1 sees Access Denied page -- [ ] user1 cannot access any API endpoints - -## Troubleshooting Failed Tests - -### Token Generation Fails - -```bash -# Check Keycloak is reachable -curl -s "${KEYCLOAK_URL}/realms/${REALM}/.well-known/openid-configuration" | jq .issuer -``` - -### Permission Always Denied - -1. Check user entity exists in catalog: - - ```bash - curl -s "${BACKSTAGE_URL}/api/catalog/entities?filter=kind=user,metadata.name=developer" | jq . - ``` - -2. Check group membership: - - ```bash - curl -s "${BACKSTAGE_URL}/api/catalog/entities?filter=kind=group,metadata.name=augment-users" | jq '.items[0].spec.members' - ``` - -3. Verify RBAC policy syntax - -### MCP Connection Fails - -1. Check MCP server is running: - - ```bash - curl -s "${MCP_URL}/health" || echo "MCP not reachable" - ``` - -2. Check Keycloak is reachable from MCP: - ```bash - # From MCP server container/host - curl -s "${KEYCLOAK_URL}/realms/${REALM}/.well-known/openid-configuration" - ``` - -## Automated Test Script - -Save this as `validate-security.sh`: - -```bash -#!/bin/bash -set -e - -# Configuration -KEYCLOAK_URL="${KEYCLOAK_URL:-https://keycloak.example.com}" -REALM="${REALM:-demo}" -CLIENT_ID="${CLIENT_ID:-backstage}" -CLIENT_SECRET="${CLIENT_SECRET:-your-secret}" -BACKSTAGE_URL="${BACKSTAGE_URL:-http://localhost:7007}" -MCP_URL="${MCP_URL:-http://localhost:8080}" - -echo "=== Augment Security Validation ===" -echo "" - -# Test 1: Get tokens -echo "1. Getting tokens..." -DEVELOPER_TOKEN=$(curl -s -X POST \ - "${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=password" \ - -d "client_id=${CLIENT_ID}" \ - -d "client_secret=${CLIENT_SECRET}" \ - -d "username=developer" \ - -d "password=developer123" | jq -r '.access_token') - -if [ "$DEVELOPER_TOKEN" == "null" ] || [ -z "$DEVELOPER_TOKEN" ]; then - echo " ❌ Failed to get developer token" - exit 1 -fi -echo " ✅ Developer token obtained" - -USER1_TOKEN=$(curl -s -X POST \ - "${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=password" \ - -d "client_id=${CLIENT_ID}" \ - -d "client_secret=${CLIENT_SECRET}" \ - -d "username=user1" \ - -d "password=user1password" | jq -r '.access_token') - -if [ "$USER1_TOKEN" == "null" ] || [ -z "$USER1_TOKEN" ]; then - echo " ❌ Failed to get user1 token" - exit 1 -fi -echo " ✅ User1 token obtained" - -# Test 2: Permission API -echo "" -echo "2. Testing Backstage Permission API..." - -DEV_PERM=$(curl -s -X POST "${BACKSTAGE_URL}/api/permission/authorize" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${DEVELOPER_TOKEN}" \ - -d '{"items":[{"permission":{"type":"basic","name":"augment.access","attributes":{"action":"read"}}}]}' \ - | jq -r '.items[0].result') - -if [ "$DEV_PERM" == "ALLOW" ]; then - echo " ✅ Developer permission: ALLOW" -else - echo " ❌ Developer permission: $DEV_PERM (expected ALLOW)" -fi - -USER1_PERM=$(curl -s -X POST "${BACKSTAGE_URL}/api/permission/authorize" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${USER1_TOKEN}" \ - -d '{"items":[{"permission":{"type":"basic","name":"augment.access","attributes":{"action":"read"}}}]}' \ - | jq -r '.items[0].result') - -if [ "$USER1_PERM" == "DENY" ]; then - echo " ✅ User1 permission: DENY" -else - echo " ❌ User1 permission: $USER1_PERM (expected DENY)" -fi - -# Test 3: Augment API -echo "" -echo "3. Testing Augment API..." - -DEV_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - "${BACKSTAGE_URL}/api/augment/workflows" \ - -H "Authorization: Bearer ${DEVELOPER_TOKEN}") - -if [ "$DEV_STATUS" == "200" ]; then - echo " ✅ Developer API access: 200" -else - echo " ❌ Developer API access: $DEV_STATUS (expected 200)" -fi - -USER1_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - "${BACKSTAGE_URL}/api/augment/workflows" \ - -H "Authorization: Bearer ${USER1_TOKEN}") - -if [ "$USER1_STATUS" == "403" ]; then - echo " ✅ User1 API blocked: 403" -else - echo " ❌ User1 API access: $USER1_STATUS (expected 403)" -fi - -# Test 4: MCP Server -echo "" -echo "4. Testing MCP Server..." - -MCP_NOAUTH=$(curl -s -o /dev/null -w "%{http_code}" "${MCP_URL}/sse" 2>/dev/null || echo "000") - -if [ "$MCP_NOAUTH" == "401" ]; then - echo " ✅ MCP rejects unauthenticated: 401" -elif [ "$MCP_NOAUTH" == "000" ]; then - echo " ⚠️ MCP server not reachable" -else - echo " ❌ MCP without auth: $MCP_NOAUTH (expected 401)" -fi - -echo "" -echo "=== Validation Complete ===" -``` - -Make it executable and run: - -```bash -chmod +x validate-security.sh -./validate-security.sh -``` - -## Next Steps - -If all tests pass: - -- Your security configuration is working correctly -- Deploy to production with confidence - -If tests fail: - -- Review the troubleshooting section -- Check the relevant setup guide -- Verify network connectivity between services diff --git a/workspaces/augment/plugins/augment-common/knip-report.md b/workspaces/augment/plugins/augment-common/knip-report.md deleted file mode 100644 index 2661c353270..00000000000 --- a/workspaces/augment/plugins/augment-common/knip-report.md +++ /dev/null @@ -1,2 +0,0 @@ -# Knip report - diff --git a/workspaces/augment/plugins/augment-common/package.json b/workspaces/augment/plugins/augment-common/package.json deleted file mode 100644 index 3832f230c34..00000000000 --- a/workspaces/augment/plugins/augment-common/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@red-hat-developer-hub/backstage-plugin-augment-common", - "version": "0.3.0", - "main": "src/index.ts", - "types": "src/index.ts", - "license": "Apache-2.0", - "description": "Common types and permissions for Augment plugin", - "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "module": "dist/index.esm.js", - "types": "dist/index.d.ts" - }, - "backstage": { - "role": "common-library", - "pluginId": "augment", - "pluginPackage": "@red-hat-developer-hub/backstage-plugin-augment-common", - "pluginPackages": [ - "@red-hat-developer-hub/backstage-plugin-augment", - "@red-hat-developer-hub/backstage-plugin-augment-backend", - "@red-hat-developer-hub/backstage-plugin-augment-common" - ] - }, - "repository": { - "type": "git", - "url": "https://github.com/redhat-developer/rhdh-plugins", - "directory": "workspaces/augment/plugins/augment-common" - }, - "keywords": [ - "backstage", - "plugin", - "agentic", - "ai", - "chat" - ], - "sideEffects": false, - "scripts": { - "build": "backstage-cli package build", - "clean": "backstage-cli package clean", - "lint": "backstage-cli package lint", - "postpack": "backstage-cli package postpack", - "prepack": "backstage-cli package prepack", - "test": "backstage-cli package test --passWithNoTests --coverage", - "tsc": "tsc" - }, - "files": [ - "dist" - ], - "dependencies": { - "@backstage/plugin-permission-common": "^0.9.2" - }, - "devDependencies": { - "@backstage/cli": "^0.34.5" - } -} diff --git a/workspaces/augment/plugins/augment-common/report.api.md b/workspaces/augment/plugins/augment-common/report.api.md deleted file mode 100644 index 165999d9c38..00000000000 --- a/workspaces/augment/plugins/augment-common/report.api.md +++ /dev/null @@ -1,2564 +0,0 @@ -## API Report File for "@red-hat-developer-hub/backstage-plugin-augment-common" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts -import { BasicPermission } from '@backstage/plugin-permission-common'; - -// @public -export interface AdminConfigEntry { - // (undocumented) - configKey: AdminConfigKey; - // (undocumented) - configValue: unknown; - // (undocumented) - updatedAt: string; - // (undocumented) - updatedBy: string; -} - -// @public -export type AdminConfigKey = - | 'activeProvider' - | 'promptGroups' - | 'systemPrompt' - | 'branding' - | 'safetyPatterns' - | 'vectorStoreConfig' - | 'activeVectorStoreIds' - | 'model' - | 'baseUrl' - | 'kagentiBaseUrl' - | 'toolChoice' - | 'enableWebSearch' - | 'enableCodeInterpreter' - | 'mcpServers' - | 'disabledMcpServerIds' - | 'safetyEnabled' - | 'inputShields' - | 'outputShields' - | 'safetyOnError' - | 'evaluationEnabled' - | 'scoringFunctions' - | 'minScoreThreshold' - | 'evaluationOnError' - | 'agents' - | 'defaultAgent' - | 'maxAgentTurns' - | 'maxToolCalls' - | 'maxOutputTokens' - | 'temperature' - | 'safetyIdentifier' - | 'chatAgents' - | 'chatTools' - | 'devSpacesApiUrl' - | 'devSpacesToken' - | 'devSpacesNamespace' - | 'workflows' - | 'workflowVersions' - | 'workflowTestSuites' - | 'workflowEvaluations'; - -// @public -export interface AgentFunctionDef { - // (undocumented) - description: string; - // (undocumented) - name: string; - // (undocumented) - parameters: Record; -} - -// @public -export type AgentLifecycleAction = - | 'submit' - | 'approve' - | 'reject' - | 'publish' - | 'unpublish' - | 'archive' - | 'reactivate' - | 'withdraw' - | 'request-unpublish' - | 'approve-unpublish' - | 'reject-unpublish'; - -// @public -export type AgentLifecycleStage = - | 'draft' - | 'pending' - | 'published' - | 'archived'; - -// @public -export interface AgentLifecycleTransition { - // (undocumented) - action: AgentLifecycleAction; - // (undocumented) - from: AgentLifecycleStage; - // (undocumented) - label: string; - // (undocumented) - to: AgentLifecycleStage; -} - -// @public -export interface AgentNodeData { - // (undocumented) - agentKey: string; - // (undocumented) - enableCodeInterpreter?: boolean; - // (undocumented) - enabled?: boolean; - // (undocumented) - enableRAG?: boolean; - // (undocumented) - enableWebSearch?: boolean; - // (undocumented) - functions?: AgentFunctionDef[]; - // (undocumented) - guardrails?: string[]; - // (undocumented) - handoffDescription?: string; - // (undocumented) - handoffInputFilter?: string; - // (undocumented) - handoffInputSchema?: Record; - // (undocumented) - instructions: string; - // (undocumented) - maxOutputTokens?: number; - // (undocumented) - maxToolCalls?: number; - // (undocumented) - mcpServers?: string[]; - // (undocumented) - model?: string; - // (undocumented) - name: string; - // (undocumented) - nestHandoffHistory?: boolean; - // (undocumented) - outputSchema?: Record; - // (undocumented) - promptRef?: { - id: string; - version?: number; - variables?: Record; - }; - // (undocumented) - reasoning?: { - effort?: string; - }; - // (undocumented) - resetToolChoice?: boolean; - // (undocumented) - temperature?: number; - // (undocumented) - toolChoice?: string; - // (undocumented) - toolUseBehavior?: string; - // (undocumented) - truncation?: string; - // (undocumented) - vectorStoreIds?: string[]; -} - -// @public -export type AgentRole = 'router' | 'specialist' | 'standalone'; - -// @public -export interface AgentTopologyNode { - // (undocumented) - asTools?: string[]; - // (undocumented) - handoffs?: string[]; -} - -// @public -export const augmentAccessPermission: BasicPermission; - -// @public -export const augmentAdminPermission: BasicPermission; - -// @public -export const augmentPermissions: BasicPermission[]; - -// @public -export interface AugmentStatus { - agentGraphError?: string; - agents?: Array<{ - key: string; - name: string; - isDefault: boolean; - }>; - capabilities?: { - chat: boolean; - rag: { - available: boolean; - reason?: string; - }; - mcpTools: { - available: boolean; - reason?: string; - }; - agentCatalog?: boolean; - agentSelection?: boolean; - agentCards?: boolean; - }; - configurationErrors: string[]; - isAdmin?: boolean; - // (undocumented) - mcpServers: MCPServerStatus[]; - // (undocumented) - provider: ProviderStatus; - providerId: string; - ready: boolean; - securityMode: SecurityMode; - // (undocumented) - timestamp: string; - toolExecutionMode?: 'direct' | 'backend'; - // (undocumented) - vectorStore: VectorStoreStatus; -} - -// @public -export interface BrandingConfig { - // (undocumented) - appName: string; - // (undocumented) - botAvatarUrl?: string; - // (undocumented) - errorColor: string; - // (undocumented) - faviconUrl?: string; - // (undocumented) - infoColor: string; - // (undocumented) - inputPlaceholder: string; - // (undocumented) - logoUrl?: string; - // (undocumented) - primaryColor: string; - // (undocumented) - secondaryColor: string; - // (undocumented) - successColor: string; - // (undocumented) - tagline: string; - // (undocumented) - themePreset?: string; - // (undocumented) - warningColor: string; -} - -// @public -export type BuiltInProviderType = 'llamastack' | 'googleadk' | 'kagenti'; - -// @public -export interface ChatAgent { - agentRole?: AgentRole; - avatarUrl?: string; - chatEndpoint?: string; - createdAt?: string; - createdBy?: string; - description?: string; - framework?: string; - governanceRegistered?: boolean; - id: string; - isDefault?: boolean; - lifecycleStage?: AgentLifecycleStage; - name: string; - namespace?: string; - pendingAction?: 'publish' | 'unpublish'; - promotedAt?: string; - promotedBy?: string; - protocols?: string[]; - providerType: string; - published?: boolean; - rejectedAt?: string; - rejectedBy?: string; - rejectionReason?: string; - source?: string; - starters?: string[]; - status: string; - version?: number; -} - -// @public -export interface ChatAgentConfig { - accentColor?: string; - agentId: string; - approvalWorkflowInstanceId?: string; - avatarUrl?: string; - chatEndpoint?: string; - conversationStarters?: string[]; - createdAt?: string; - createdBy?: string; - description?: string; - displayName?: string; - featured: boolean; - greeting?: string; - lifecycleStage?: AgentLifecycleStage; - namespace?: string; - order?: number; - pendingAction?: 'publish' | 'unpublish'; - promotedAt?: string; - promotedBy?: string; - published: boolean; - rejectedAt?: string; - rejectedBy?: string; - rejectionReason?: string; - version?: number; - visible: boolean; -} - -// @public -export interface ChatMessage { - // (undocumented) - content: string; - // (undocumented) - role: 'system' | 'user' | 'assistant'; -} - -// @public -export interface ChatResponse { - agentName?: string; - // (undocumented) - content: string; - // @deprecated (undocumented) - filesSearched?: string[]; - handoffPath?: string[]; - outputValidationError?: string; - pendingApproval?: PendingApprovalInfo; - pendingApprovals?: PendingApprovalInfo[]; - // (undocumented) - ragContext?: string[]; - // (undocumented) - ragSources?: RAGSource[]; - reasoning?: ReasoningSummary[]; - // (undocumented) - responseId?: string; - // (undocumented) - role: 'assistant'; - // (undocumented) - toolCalls?: ToolCallInfo[]; - // (undocumented) - usage?: ResponseUsage; -} - -// @public -export interface ChatToolConfig { - createdAt?: string; - createdBy?: string; - lifecycleStage?: AgentLifecycleStage; - promotedAt?: string; - promotedBy?: string; - published: boolean; - toolId: string; - version?: number; - visible: boolean; -} - -// @public -export interface ClassifyNodeData { - // (undocumented) - classifications: Array<{ - label: string; - description: string; - }>; - // (undocumented) - instructions?: string; - // (undocumented) - model?: string; -} - -// @public -export interface ConversationSummary { - conversationId?: string; - createdAt: Date; - model: string; - preview: string; - previousResponseId?: string; - responseId: string; - status: 'completed' | 'failed' | 'in_progress'; -} - -// Warning: (ae-missing-release-tag) "createDefaultWorkflow" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export function createDefaultWorkflow( - id: string, - name: string, - createdBy?: string, -): WorkflowDefinition; - -// @public -export const DEFAULT_BRANDING: BrandingConfig; - -// Warning: (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag -// Warning: (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag -// Warning: (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag -// -// @public -export function deriveRoleFromTopology( - agentKey: string, - allAgents: Record, -): AgentRole; - -// @public -export interface DevSpacesCreateWorkspaceRequest { - // (undocumented) - cpu_limit?: string; - // (undocumented) - git_repo: string; - // (undocumented) - memory_limit?: string; - // (undocumented) - namespace: string; -} - -// @public -export interface DevSpacesCreateWorkspaceResponse { - // (undocumented) - created_at?: string; - // (undocumented) - message?: string; - // (undocumented) - name: string; - // (undocumented) - namespace: string; - // (undocumented) - phase: string; - // (undocumented) - url?: string; -} - -// @public -export interface DevSpacesDeleteWorkspaceRequest { - // (undocumented) - name: string; - // (undocumented) - namespace: string; -} - -// @public -export interface DevSpacesHealthResponse { - // (undocumented) - apiUrl?: string; - // (undocumented) - configured: boolean; - // (undocumented) - message: string; - // (undocumented) - ok: boolean; - // (undocumented) - responseTimeMs?: number; -} - -// @public -export interface DevSpacesListWorkspacesResponse { - // (undocumented) - namespace: string; - // (undocumented) - workspaces: DevSpacesWorkspace[]; -} - -// @public -export interface DevSpacesStopWorkspaceRequest { - // (undocumented) - name: string; - // (undocumented) - namespace: string; -} - -// @public -export interface DevSpacesWorkspace { - // (undocumented) - cpu_limit?: string; - // (undocumented) - created_at?: string; - // (undocumented) - git_repo?: string; - // (undocumented) - memory_limit?: string; - // (undocumented) - name: string; - // (undocumented) - namespace: string; - // (undocumented) - phase: string; - // (undocumented) - started_at?: string; - // (undocumented) - stopped_at?: string; - // (undocumented) - url?: string; -} - -// @public -export interface DocumentInfo { - // (undocumented) - fileName: string; - // (undocumented) - fileSize: number; - // (undocumented) - format: FileFormat; - // (undocumented) - id: string; - // (undocumented) - status: 'completed' | 'in_progress' | 'failed' | 'cancelled'; - // (undocumented) - uploadedAt: string; -} - -// @public -export interface EndNodeData { - // (undocumented) - outputExpression?: string; -} - -// @public -export interface EvaluationCriterion { - // (undocumented) - graderPrompt?: string; - // (undocumented) - id: string; - // (undocumented) - name: string; - // (undocumented) - pattern?: string; - // (undocumented) - scoringFunction?: string; - // (undocumented) - threshold?: number; - // (undocumented) - type: 'exact_match' | 'contains' | 'regex' | 'llm_graded' | 'custom'; - // (undocumented) - weight: number; -} - -// @public -export interface EvaluationResult { - error?: string; - evaluatedAt: string; - overallScore: number; - passedThreshold: boolean; - qualityLevel: 'excellent' | 'good' | 'fair' | 'poor'; - scores: Record; - skipped?: boolean; -} - -// @public -export enum FileFormat { - // (undocumented) - JSON = 'json', - // (undocumented) - MARKDOWN = 'markdown', - // (undocumented) - PDF = 'pdf', - // (undocumented) - TEXT = 'text', - // (undocumented) - YAML = 'yaml', -} - -// @public -export interface FileSearchNodeData { - // (undocumented) - maxResults?: number; - // (undocumented) - scoreThreshold?: number; - // (undocumented) - vectorStoreIds?: string[]; -} - -// @public -export function getAvailableTransitions( - stage: AgentLifecycleStage, -): AgentLifecycleTransition[]; - -// @public -export const GLOBAL_CONFIG_KEYS: readonly [ - 'systemPrompt', - 'branding', - 'promptGroups', - 'safetyPatterns', - 'mcpServers', - 'disabledMcpServerIds', - 'agents', - 'defaultAgent', - 'maxAgentTurns', - 'devSpacesApiUrl', -]; - -// @public -export type GlobalConfigKey = (typeof GLOBAL_CONFIG_KEYS)[number]; - -// @public -export interface GuardrailNodeData { - // (undocumented) - customRules?: GuardrailRule[]; - // (undocumented) - fallbackMessage?: string; - // (undocumented) - guardType: 'input' | 'output'; - // (undocumented) - onFailure: 'block' | 'warn' | 'fallback'; - // (undocumented) - shieldIds?: string[]; -} - -// @public -export interface GuardrailRule { - // (undocumented) - graderPrompt?: string; - // (undocumented) - id: string; - // (undocumented) - keywords?: string[]; - // (undocumented) - name: string; - // (undocumented) - pattern?: string; - // (undocumented) - type: 'regex' | 'keyword_block' | 'llm_grader'; -} - -// @public -export interface InputTokensDetails { - cached_tokens?: number; -} - -// Warning: (ae-missing-release-tag) "isAgentNodeData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export function isAgentNodeData(data: WorkflowNodeData): data is AgentNodeData; - -// @public -export function isGlobalConfigKey(key: string): key is GlobalConfigKey; - -// Warning: (ae-missing-release-tag) "isGuardrailNodeData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export function isGuardrailNodeData( - data: WorkflowNodeData, -): data is GuardrailNodeData; - -// Warning: (ae-missing-release-tag) "isLogicNodeData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export function isLogicNodeData(data: WorkflowNodeData): data is LogicNodeData; - -// @public -export function isProviderScopedKey(key: string): key is ProviderScopedKey; - -// Warning: (ae-missing-release-tag) "isToolNodeData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export function isToolNodeData(data: WorkflowNodeData): data is ToolNodeData; - -// Warning: (ae-missing-release-tag) "isUserInteractionNodeData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export function isUserInteractionNodeData( - data: WorkflowNodeData, -): data is UserInteractionNodeData; - -// @public -export function isValidTransition( - from: AgentLifecycleStage, - to: AgentLifecycleStage, -): boolean; - -// @public (undocumented) -export interface KagentiAgentCard { - // (undocumented) - defaultInputModes?: string[]; - // (undocumented) - description?: string; - // (undocumented) - name: string; - // (undocumented) - skills: KagentiAgentCardSkill[]; - // (undocumented) - streaming: boolean; - // (undocumented) - url: string; - // (undocumented) - version: string; -} - -// @public (undocumented) -export interface KagentiAgentCardSkill { - // (undocumented) - description?: string; - // (undocumented) - examples?: string[]; - // (undocumented) - id?: string; - // (undocumented) - name?: string; -} - -// @public (undocumented) -export interface KagentiAgentDetail { - // (undocumented) - metadata: Record; - // (undocumented) - readyStatus?: string; - // (undocumented) - service?: Record; - // (undocumented) - spec: Record; - // (undocumented) - status: Record; - // (undocumented) - workloadType?: string; -} - -// @public (undocumented) -export interface KagentiAgentSummary { - // (undocumented) - createdAt?: string; - // (undocumented) - description: string; - // (undocumented) - labels: KagentiResourceLabels; - // (undocumented) - name: string; - // (undocumented) - namespace: string; - // (undocumented) - status: string; - // (undocumented) - workloadType?: string; -} - -// @public -export interface KagentiAuthConfig { - // (undocumented) - client_id?: string; - // (undocumented) - enabled: boolean; - // (undocumented) - keycloak_url?: string; - // (undocumented) - realm?: string; - // (undocumented) - redirect_uri?: string; -} - -// @public (undocumented) -export interface KagentiBuildInfo { - // (undocumented) - agentConfig?: KagentiResourceConfigFromBuild; - // (undocumented) - buildMessage?: string; - // (undocumented) - buildReason?: string; - // (undocumented) - buildRegistered: boolean; - // (undocumented) - buildRunCompletionTime?: string; - // (undocumented) - buildRunFailureMessage?: string; - // (undocumented) - buildRunName?: string; - // (undocumented) - buildRunOutputDigest?: string; - // (undocumented) - buildRunOutputImage?: string; - // (undocumented) - buildRunPhase?: string; - // (undocumented) - buildRunStartTime?: string; - // (undocumented) - contextDir: string; - // (undocumented) - gitRevision: string; - // (undocumented) - gitUrl: string; - // (undocumented) - hasBuildRun: boolean; - // (undocumented) - name: string; - // (undocumented) - namespace: string; - // (undocumented) - outputImage: string; - // (undocumented) - strategy: string; - // (undocumented) - toolConfig?: KagentiResourceConfigFromBuild; -} - -// @public (undocumented) -export interface KagentiBuildListItem { - // (undocumented) - contextDir?: string; - // (undocumented) - creationTimestamp?: string; - // (undocumented) - gitRevision?: string; - // (undocumented) - gitUrl?: string; - // (undocumented) - name: string; - // (undocumented) - namespace: string; - // (undocumented) - outputImage?: string; - // (undocumented) - registered: boolean; - // (undocumented) - resourceType?: string; - // (undocumented) - strategy?: string; -} - -// @public (undocumented) -export interface KagentiBuildStrategy { - // (undocumented) - description?: string; - // (undocumented) - name: string; -} - -// @public (undocumented) -export interface KagentiConfigMapKeyRef { - // (undocumented) - key: string; - // (undocumented) - name: string; -} - -// @public (undocumented) -export interface KagentiCreateAgentRequest { - // (undocumented) - authBridgeEnabled?: boolean; - // (undocumented) - containerImage?: string; - // (undocumented) - createHttpRoute?: boolean; - // (undocumented) - deploymentMethod?: 'source' | 'image'; - // (undocumented) - envVars?: KagentiEnvVar[]; - // (undocumented) - framework?: string; - // (undocumented) - gitBranch?: string; - // (undocumented) - gitPath?: string; - // (undocumented) - gitUrl?: string; - // (undocumented) - imagePullSecret?: string; - // (undocumented) - imageTag?: string; - // (undocumented) - name: string; - // (undocumented) - namespace: string; - // (undocumented) - protocol?: string; - // (undocumented) - registrySecret?: string; - // (undocumented) - registryUrl?: string; - // (undocumented) - servicePorts?: KagentiServicePort[]; - // (undocumented) - shipwrightConfig?: KagentiShipwrightConfig; - // (undocumented) - spireEnabled?: boolean; - // (undocumented) - startCommand?: string; - // (undocumented) - workloadType?: 'deployment' | 'statefulset' | 'job'; -} - -// @public (undocumented) -export interface KagentiCreateAgentResponse { - // (undocumented) - message: string; - // (undocumented) - name: string; - // (undocumented) - namespace: string; - // (undocumented) - success: boolean; -} - -// @public (undocumented) -export interface KagentiCreateIntegrationRequest { - // (undocumented) - [key: string]: unknown; - // (undocumented) - name: string; - // (undocumented) - namespace: string; -} - -// @public (undocumented) -export interface KagentiCreateKeyRequest { - // (undocumented) - [key: string]: unknown; - // (undocumented) - agentName: string; - // (undocumented) - namespace: string; -} - -// @public (undocumented) -export interface KagentiCreateTeamRequest { - // (undocumented) - [key: string]: unknown; - // (undocumented) - namespace: string; -} - -// @public (undocumented) -export interface KagentiCreateToolRequest { - // (undocumented) - authBridgeEnabled?: boolean; - // (undocumented) - containerImage?: string; - // (undocumented) - contextDir?: string; - // (undocumented) - createHttpRoute?: boolean; - // (undocumented) - deploymentMethod?: 'image' | 'source'; - // (undocumented) - description?: string; - // (undocumented) - envVars?: KagentiEnvVar[]; - // (undocumented) - framework?: string; - // (undocumented) - gitRevision?: string; - // (undocumented) - gitUrl?: string; - // (undocumented) - imagePullSecret?: string; - // (undocumented) - imageTag?: string; - // (undocumented) - name: string; - // (undocumented) - namespace: string; - // (undocumented) - persistentStorage?: KagentiPersistentStorageConfig; - // (undocumented) - protocol?: string; - // (undocumented) - registrySecret?: string; - // (undocumented) - registryUrl?: string; - // (undocumented) - servicePorts?: KagentiServicePort[]; - // (undocumented) - shipwrightConfig?: KagentiShipwrightConfig; - // (undocumented) - spireEnabled?: boolean; - // (undocumented) - workloadType?: 'deployment' | 'statefulset'; -} - -// @public (undocumented) -export interface KagentiCreateToolResponse { - // (undocumented) - message: string; - // (undocumented) - name: string; - // (undocumented) - namespace: string; - // (undocumented) - success: boolean; -} - -// @public (undocumented) -export interface KagentiDashboardConfig { - // (undocumented) - domainName?: string; - // (undocumented) - keycloakConsole?: string; - // (undocumented) - mcpInspector?: string; - // (undocumented) - mcpProxy?: string; - // (undocumented) - network?: string; - // (undocumented) - traces?: string; -} - -// @public (undocumented) -export interface KagentiEnvVar { - // (undocumented) - name: string; - // (undocumented) - value?: string; - // (undocumented) - valueFrom?: KagentiEnvVarSource; -} - -// @public (undocumented) -export interface KagentiEnvVarSource { - // (undocumented) - configMapKeyRef?: KagentiConfigMapKeyRef; - // (undocumented) - secretKeyRef?: KagentiSecretKeyRef; -} - -// @public (undocumented) -export interface KagentiFeatureFlags { - // (undocumented) - integrations: boolean; - // (undocumented) - sandbox: boolean; - // (undocumented) - triggers: boolean; -} - -// @public (undocumented) -export interface KagentiFinalizeAgentBuildRequest { - // (undocumented) - authBridgeEnabled?: boolean; - // (undocumented) - createHttpRoute?: boolean; - // (undocumented) - envVars?: KagentiEnvVar[]; - // (undocumented) - framework?: string; - // (undocumented) - imagePullSecret?: string; - // (undocumented) - protocol?: string; - // (undocumented) - servicePorts?: KagentiServicePort[]; -} - -// @public (undocumented) -export interface KagentiFinalizeToolBuildRequest { - // (undocumented) - authBridgeEnabled?: boolean; - // (undocumented) - createHttpRoute?: boolean; - // (undocumented) - envVars?: KagentiEnvVar[]; - // (undocumented) - framework?: string; - // (undocumented) - imagePullSecret?: string; - // (undocumented) - persistentStorage?: KagentiPersistentStorageConfig; - // (undocumented) - protocol?: string; - // (undocumented) - servicePorts?: KagentiServicePort[]; - // (undocumented) - spireEnabled?: boolean; - // (undocumented) - workloadType?: string; -} - -// @public (undocumented) -export interface KagentiIntegration { - // (undocumented) - [key: string]: unknown; - // (undocumented) - conditions?: Array<{ - type: string; - status: string; - message?: string; - }>; - // (undocumented) - name: string; - // (undocumented) - namespace: string; -} - -// @public (undocumented) -export interface KagentiLlmKey { - // (undocumented) - [key: string]: unknown; - // (undocumented) - agent?: string; - // (undocumented) - alias?: string; - // (undocumented) - namespace?: string; -} - -// @public (undocumented) -export interface KagentiLlmTeam { - // (undocumented) - [key: string]: unknown; - // (undocumented) - namespace: string; - // (undocumented) - teamId: string; -} - -// @public (undocumented) -export interface KagentiMcpInvokeResponse { - // (undocumented) - result: unknown; -} - -// @public (undocumented) -export interface KagentiMcpToolSchema { - // (undocumented) - description?: string; - // (undocumented) - input_schema?: Record; - // (undocumented) - name: string; -} - -// @public (undocumented) -export interface KagentiMigratableAgent { - // (undocumented) - description?: string; - // (undocumented) - has_deployment: boolean; - // (undocumented) - labels: KagentiResourceLabels; - // (undocumented) - name: string; - // (undocumented) - namespace: string; - // (undocumented) - status: string; -} - -// @public (undocumented) -export interface KagentiMigrateAgentResponse { - // (undocumented) - agent_crd_deleted: boolean; - // (undocumented) - deployment_created: boolean; - // (undocumented) - message: string; - // (undocumented) - migrated: boolean; - // (undocumented) - name: string; - // (undocumented) - namespace: string; - // (undocumented) - service_created: boolean; - // (undocumented) - success: boolean; -} - -// @public (undocumented) -export interface KagentiMigrateAllResponse { - // (undocumented) - delete_old: boolean; - // (undocumented) - dry_run: boolean; - // (undocumented) - failed: unknown[]; - // (undocumented) - migrated: unknown[]; - // (undocumented) - namespace: string; - // (undocumented) - skipped: unknown[]; - // (undocumented) - total: number; -} - -// @public (undocumented) -export interface KagentiPersistentStorageConfig { - // (undocumented) - enabled: boolean; - // (undocumented) - size: string; -} - -// @public (undocumented) -export interface KagentiResourceConfigFromBuild { - // (undocumented) - createHttpRoute?: boolean; - // (undocumented) - envVars?: KagentiEnvVar[]; - // (undocumented) - framework?: string; - // (undocumented) - protocol?: string; - // (undocumented) - registrySecret?: string; - // (undocumented) - servicePorts?: KagentiServicePort[]; -} - -// @public (undocumented) -export interface KagentiResourceLabels { - // (undocumented) - framework?: string; - // (undocumented) - protocol?: string | string[]; - // (undocumented) - type?: string; -} - -// @public (undocumented) -export interface KagentiRouteStatus { - [key: string]: unknown; - // (undocumented) - hasRoute: boolean; - url?: string; -} - -// @public (undocumented) -export interface KagentiSandboxAgentInfo { - // (undocumented) - [key: string]: unknown; - // (undocumented) - name: string; - // (undocumented) - namespace: string; - // (undocumented) - sessionCount?: number; -} - -// @public (undocumented) -export interface KagentiSandboxCreateRequest { - // (undocumented) - [key: string]: unknown; -} - -// @public (undocumented) -export interface KagentiSandboxSession { - // (undocumented) - agentName?: string; - // (undocumented) - contextId: string; - // (undocumented) - createdAt?: string; - // (undocumented) - status: string; - // (undocumented) - title?: string; - // (undocumented) - updatedAt?: string; - // (undocumented) - visibility?: 'private' | 'namespace'; -} - -// @public (undocumented) -export interface KagentiSecretKeyRef { - // (undocumented) - key: string; - // (undocumented) - name: string; -} - -// @public (undocumented) -export interface KagentiServicePort { - // (undocumented) - name?: string; - // (undocumented) - port: number; - // (undocumented) - protocol?: string; - // (undocumented) - targetPort?: number; -} - -// @public (undocumented) -export interface KagentiSessionTokenUsage { - // (undocumented) - contextId: string; - // (undocumented) - models?: Array<{ - model: string; - tokens: number; - cost?: number; - }>; - // (undocumented) - totalCost?: number; - // (undocumented) - totalTokens?: number; -} - -// @public (undocumented) -export interface KagentiShipwrightConfig { - // (undocumented) - buildArgs?: string[]; - // (undocumented) - buildStrategy?: string; - // (undocumented) - buildTimeout?: string; - // (undocumented) - dockerfile?: string; -} - -// @public (undocumented) -export interface KagentiSidecar { - // (undocumented) - config?: Record; - // (undocumented) - enabled: boolean; - // (undocumented) - sidecarType: string; -} - -// @public (undocumented) -export interface KagentiToolDetail { - // (undocumented) - metadata: Record; - // (undocumented) - service?: Record; - // (undocumented) - spec: Record; - // (undocumented) - status: Record; - // (undocumented) - workloadType?: string; -} - -// @public (undocumented) -export interface KagentiToolSummary { - // (undocumented) - createdAt?: string; - // (undocumented) - description: string; - // (undocumented) - labels: KagentiResourceLabels; - // (undocumented) - name: string; - // (undocumented) - namespace: string; - // (undocumented) - status: string; - // (undocumented) - workloadType?: string; -} - -// @public (undocumented) -export interface KagentiTriggerBuildRunResponse { - // (undocumented) - buildName: string; - // (undocumented) - buildRunName: string; - // (undocumented) - message?: string; - // (undocumented) - namespace: string; - // (undocumented) - success: boolean; -} - -// @public (undocumented) -export interface KagentiTriggerRequest { - // (undocumented) - [key: string]: unknown; - // (undocumented) - namespace: string; - // (undocumented) - schedule?: string; - // (undocumented) - skill?: string; - // (undocumented) - ttl_hours?: number; - // (undocumented) - type: 'cron' | 'webhook' | 'alert'; -} - -// @public -export const LEGACY_STAGE_MAP: Record; - -// @public -export const LIFECYCLE_STAGE_ORDER: readonly AgentLifecycleStage[]; - -// @public -export const LIFECYCLE_TRANSITIONS: readonly AgentLifecycleTransition[]; - -// @public -export interface LogicNodeData { - // (undocumented) - cases?: Array<{ - label: string; - condition: string; - }>; - // (undocumented) - condition: string; - // (undocumented) - kind: LogicNodeKind; - // (undocumented) - maxIterations?: number; -} - -// @public -export type LogicNodeKind = 'if_else' | 'while_loop' | 'switch'; - -// @public -export interface McpNodeData { - // (undocumented) - allowedTools?: string[]; - // (undocumented) - headers?: Record; - // (undocumented) - requireApproval?: 'always' | 'never' | 'auto'; - // (undocumented) - serverLabel: string; - // (undocumented) - serverUrl: string; -} - -// @public (undocumented) -export interface MCPServerStatus { - // (undocumented) - connected: boolean; - // (undocumented) - error?: string; - // (undocumented) - id: string; - // (undocumented) - name: string; - source?: 'yaml' | 'admin'; - toolCount?: number; - tools?: MCPToolInfo[]; - // (undocumented) - url: string; -} - -// @public -export interface MCPToolInfo { - // (undocumented) - description?: string; - // (undocumented) - name: string; -} - -// @public -export interface NodeExecutionRecord { - // (undocumented) - completedAt?: string; - // (undocumented) - durationMs?: number; - // (undocumented) - error?: string; - // (undocumented) - input?: unknown; - // (undocumented) - nodeId: string; - // (undocumented) - nodeName: string; - // (undocumented) - nodeType: WorkflowNodeType; - // (undocumented) - output?: unknown; - // (undocumented) - startedAt: string; - // (undocumented) - status: 'running' | 'completed' | 'failed' | 'skipped'; - // (undocumented) - tokenUsage?: { - inputTokens: number; - outputTokens: number; - }; -} - -// @public -export interface NodePosition { - // (undocumented) - x: number; - // (undocumented) - y: number; -} - -// @public -export type NormalizedStreamEvent = - | StreamStartedEvent - | StreamTextDeltaEvent - | StreamTextDoneEvent - | StreamReasoningDeltaEvent - | StreamReasoningDoneEvent - | StreamToolDiscoveryEvent - | StreamToolStartedEvent - | StreamToolDeltaEvent - | StreamToolCompletedEvent - | StreamToolFailedEvent - | StreamToolApprovalEvent - | StreamBackendToolExecutingEvent - | StreamRagResultsEvent - | StreamAgentHandoffEvent - | StreamFormRequestEvent - | StreamAuthRequiredEvent - | StreamArtifactEvent - | StreamCitationEvent - | StreamCompletedEvent - | StreamErrorEvent; - -// @public -export function normalizeLifecycleStage( - stage: string | undefined, -): AgentLifecycleStage; - -// @public -export interface NoteNodeData { - // (undocumented) - text: string; -} - -// @public -export interface OutputTokensDetails { - reasoning_tokens?: number; -} - -// @public -export interface PendingApprovalInfo { - // (undocumented) - approvalRequestId: string; - // (undocumented) - arguments?: string; - // (undocumented) - serverLabel?: string; - // (undocumented) - toolName: string; -} - -// @public -export interface PromptCapabilities { - // (undocumented) - enableCodeInterpreter?: boolean; - // (undocumented) - enableWebSearch?: boolean; - // (undocumented) - ragEnabled?: boolean; - // (undocumented) - safetyEnabled?: boolean; - // (undocumented) - safetyShields?: string[]; - // (undocumented) - tools?: ToolCapabilityInfo[]; - // (undocumented) - vectorStoreNames?: string[]; -} - -// @public -export interface PromptCard { - agentId?: string; - comingSoon?: boolean; - comingSoonLabel?: string; - description?: string; - icon?: string; - prompt: string; - title: string; -} - -// @public -export interface PromptGroup { - cards: PromptCard[]; - color?: string; - description?: string; - icon?: string; - id: string; - order?: number; - title: string; -} - -// @public -export const PROVIDER_SCOPED_KEYS: readonly [ - 'model', - 'baseUrl', - 'kagentiBaseUrl', - 'toolChoice', - 'enableWebSearch', - 'enableCodeInterpreter', - 'safetyEnabled', - 'inputShields', - 'outputShields', - 'safetyOnError', - 'evaluationEnabled', - 'scoringFunctions', - 'minScoreThreshold', - 'evaluationOnError', - 'vectorStoreConfig', - 'activeVectorStoreIds', - 'maxToolCalls', - 'maxOutputTokens', - 'temperature', - 'safetyIdentifier', -]; - -// @public -export interface ProviderCapabilities { - readonly agentLifecycle: boolean; - // (undocumented) - readonly chat: boolean; - readonly contextHydration: boolean; - // (undocumented) - readonly conversations: boolean; - readonly devSpaces: boolean; - // (undocumented) - readonly evaluation: boolean; - // (undocumented) - readonly mcpTools: boolean; - readonly providerRoutes: boolean; - // (undocumented) - readonly rag: boolean; - // (undocumented) - readonly safety: boolean; - readonly toolLifecycle: boolean; - // (undocumented) - readonly tools: boolean; -} - -// @public -export interface ProviderConfigField { - readonly description?: string; - readonly key: string; - readonly label: string; - readonly options?: readonly string[]; - readonly placeholder?: string; - readonly required: boolean; - readonly sensitive?: boolean; - readonly type: 'string' | 'boolean' | 'number' | 'select'; -} - -// @public -export interface ProviderDescriptor { - readonly capabilities: ProviderCapabilities; - readonly configFields: readonly ProviderConfigField[]; - readonly description: string; - readonly displayName: string; - readonly id: ProviderType; - readonly implemented: boolean; -} - -// @public -export type ProviderScopedKey = (typeof PROVIDER_SCOPED_KEYS)[number]; - -// @public -export interface ProviderStatus { - // (undocumented) - baseUrl: string; - // (undocumented) - connected: boolean; - // (undocumented) - error?: string; - // (undocumented) - id: string; - // (undocumented) - model: string; -} - -// @public -export type ProviderType = BuiltInProviderType | (string & {}); - -// @public -export interface QuickAction { - category?: string; - comingSoon?: boolean; - comingSoonLabel?: string; - description?: string; - icon?: string; - prompt: string; - title: string; -} - -// @public -export interface QuickPrompt { - // (undocumented) - category: string; - // (undocumented) - description: string; - // (undocumented) - prompt: string; - // (undocumented) - title: string; -} - -// @public -export interface RagGenerateResult { - // (undocumented) - answer: string; - // (undocumented) - chunks: RagTestChunk[]; - // (undocumented) - model: string; - // (undocumented) - query: string; - // (undocumented) - totalResults: number; -} - -// @public -export interface RAGSource { - attributes?: Record; - contentType?: string; - fileId?: string; - filename: string; - score?: number; - sourceUrl?: string; - text?: string; - title?: string; -} - -// @public -export interface RagTestChunk { - // (undocumented) - fileId?: string; - // (undocumented) - fileName?: string; - // (undocumented) - score?: number; - // (undocumented) - text: string; - // (undocumented) - vectorStoreId?: string; -} - -// @public -export interface RagTestResult { - // (undocumented) - chunks: RagTestChunk[]; - // (undocumented) - query: string; - // (undocumented) - totalResults: number; - // (undocumented) - vectorStoreId: string; -} - -// @public -export interface ReasoningSummary { - // (undocumented) - id: string; - // (undocumented) - text: string; -} - -// @public -export interface ResponseUsage { - input_tokens: number; - input_tokens_details?: InputTokensDetails; - output_tokens: number; - output_tokens_details?: OutputTokensDetails | null; - total_tokens: number; -} - -// @public -export function scopedConfigKey(provider: ProviderType, key: string): string; - -// @public -export type SecurityMode = 'none' | 'plugin-only' | 'full'; - -// @public -export interface SetStateNodeData { - // (undocumented) - assignments: Record; -} - -// @public -export interface StartNodeData { - // (undocumented) - inputDescription?: string; - // (undocumented) - inputSchema?: Record; -} - -// @public -export interface StreamAgentHandoffEvent { - // (undocumented) - fromAgent?: string; - reason?: string; - // (undocumented) - toAgent: string; - // (undocumented) - type: 'stream.agent.handoff'; -} - -// @public -export interface StreamArtifactEvent { - // (undocumented) - append?: boolean; - // (undocumented) - artifactId: string; - // (undocumented) - content: string; - // (undocumented) - description?: string; - // (undocumented) - lastChunk?: boolean; - // (undocumented) - name?: string; - // (undocumented) - type: 'stream.artifact'; -} - -// @public -export interface StreamAuthRequiredEvent { - // (undocumented) - authType: 'oauth' | 'secret'; - // (undocumented) - demands?: { - secrets?: StreamSecretDemand[]; - [key: string]: unknown; - }; - // (undocumented) - taskId?: string; - // (undocumented) - type: 'stream.auth.required'; - // (undocumented) - url?: string; -} - -// @public -export interface StreamBackendToolExecutingEvent { - // (undocumented) - toolCount: number; - // (undocumented) - tools: string[]; - // (undocumented) - type: 'stream.backend_tool.executing'; -} - -// @public -export interface StreamCitationEvent { - // (undocumented) - citations: StreamCitationReference[]; - // (undocumented) - type: 'stream.citation'; -} - -// @public -export interface StreamCitationReference { - // (undocumented) - [key: string]: unknown; - // (undocumented) - snippet?: string; - // (undocumented) - title?: string; - // (undocumented) - url?: string; -} - -// @public -export interface StreamCompletedEvent { - agentName?: string; - outputValidationError?: string; - // (undocumented) - responseId?: string; - // (undocumented) - type: 'stream.completed'; - // (undocumented) - usage?: ResponseUsage; -} - -// @public -export interface StreamErrorEvent { - // (undocumented) - code?: string; - // (undocumented) - context?: Record; - // (undocumented) - error: string; - // (undocumented) - title?: string; - // (undocumented) - type: 'stream.error'; -} - -// @public -export interface StreamFormDescriptor { - // (undocumented) - [key: string]: unknown; - // (undocumented) - description?: string; - // (undocumented) - fields?: StreamFormField[]; - // (undocumented) - title?: string; -} - -// @public -export interface StreamFormField { - // (undocumented) - [key: string]: unknown; - // (undocumented) - defaultValue?: unknown; - // (undocumented) - description?: string; - // (undocumented) - label?: string; - // (undocumented) - name: string; - // (undocumented) - options?: Array<{ - label: string; - value: string; - }>; - // (undocumented) - required?: boolean; - // (undocumented) - type?: string; -} - -// @public -export interface StreamFormRequestEvent { - // (undocumented) - contextId?: string; - // (undocumented) - form: StreamFormDescriptor; - // (undocumented) - taskId?: string; - // (undocumented) - type: 'stream.form.request'; -} - -// @public -export interface StreamRagResultsEvent { - // (undocumented) - filesSearched?: string[]; - // (undocumented) - sources: Array<{ - filename: string; - fileId?: string; - text?: string; - score?: number; - title?: string; - sourceUrl?: string; - contentType?: string; - attributes?: Record; - }>; - // (undocumented) - type: 'stream.rag.results'; -} - -// @public -export interface StreamReasoningDeltaEvent { - // (undocumented) - delta: string; - // (undocumented) - type: 'stream.reasoning.delta'; -} - -// @public -export interface StreamReasoningDoneEvent { - // (undocumented) - text: string; - // (undocumented) - type: 'stream.reasoning.done'; -} - -// @public -export interface StreamSecretDemand { - // (undocumented) - [key: string]: unknown; - // (undocumented) - description?: string; - // (undocumented) - name: string; -} - -// @public -export interface StreamStartedEvent { - createdAt?: number; - // (undocumented) - model?: string; - // (undocumented) - responseId: string; - // (undocumented) - type: 'stream.started'; -} - -// @public -export interface StreamTextDeltaEvent { - // (undocumented) - delta: string; - // (undocumented) - type: 'stream.text.delta'; -} - -// @public -export interface StreamTextDoneEvent { - // (undocumented) - text: string; - // (undocumented) - type: 'stream.text.done'; -} - -// @public -export interface StreamToolApprovalEvent { - // (undocumented) - arguments?: string; - // (undocumented) - callId: string; - // (undocumented) - name: string; - responseId?: string; - // (undocumented) - serverLabel?: string; - // (undocumented) - type: 'stream.tool.approval'; -} - -// @public -export interface StreamToolCompletedEvent { - // (undocumented) - callId: string; - // (undocumented) - error?: string; - // (undocumented) - name: string; - // (undocumented) - output?: string; - // (undocumented) - serverLabel?: string; - // (undocumented) - type: 'stream.tool.completed'; -} - -// @public -export interface StreamToolDeltaEvent { - // (undocumented) - callId: string; - // (undocumented) - delta: string; - // (undocumented) - type: 'stream.tool.delta'; -} - -// @public -export interface StreamToolDiscoveryEvent { - // (undocumented) - serverLabel?: string; - // (undocumented) - status: 'in_progress' | 'completed'; - // (undocumented) - toolCount?: number; - // (undocumented) - type: 'stream.tool.discovery'; -} - -// @public -export interface StreamToolFailedEvent { - // (undocumented) - callId: string; - // (undocumented) - error: string; - // (undocumented) - name: string; - // (undocumented) - serverLabel?: string; - // (undocumented) - type: 'stream.tool.failed'; -} - -// @public -export interface StreamToolStartedEvent { - // (undocumented) - callId: string; - // (undocumented) - name: string; - // (undocumented) - serverLabel?: string; - // (undocumented) - type: 'stream.tool.started'; -} - -// @public -export interface SyncResult { - // (undocumented) - added: number; - // (undocumented) - errors: string[]; - // (undocumented) - failed: number; - // (undocumented) - removed: number; - // (undocumented) - unchanged: number; - // (undocumented) - updated: number; -} - -// @public -export interface TestCaseResult { - // (undocumented) - actualAgent?: string; - // (undocumented) - actualOutput: string; - // (undocumented) - criterionResults: Array<{ - criterionId: string; - passed: boolean; - score: number; - details?: string; - }>; - // (undocumented) - durationMs: number; - // (undocumented) - passed: boolean; - // (undocumented) - score: number; - // (undocumented) - testCaseId: string; - // (undocumented) - trace: WorkflowRunTrace; -} - -// @public -export interface ToolCallInfo { - // (undocumented) - arguments: string; - // (undocumented) - error?: string; - // (undocumented) - id: string; - // (undocumented) - name: string; - // (undocumented) - output?: string; - // (undocumented) - serverLabel: string; -} - -// @public -export interface ToolCapabilityInfo { - // (undocumented) - description?: string; - // (undocumented) - name: string; - // (undocumented) - serverLabel?: string; -} - -// @public -export interface ToolNodeData { - // (undocumented) - functionDef?: AgentFunctionDef; - // (undocumented) - kind: ToolNodeKind; - // (undocumented) - label: string; - // (undocumented) - mcpServerId?: string; - // (undocumented) - mcpToolFilter?: string[]; - // (undocumented) - requireApproval?: boolean; - // (undocumented) - vectorStoreIds?: string[]; -} - -// @public -export type ToolNodeKind = - | 'mcp_server' - | 'file_search' - | 'web_search' - | 'code_interpreter' - | 'custom_function'; - -// @public -export interface TransformNodeData { - // (undocumented) - expression: string; - // (undocumented) - outputVariable?: string; -} - -// @public -export interface UploadResult { - // (undocumented) - fileId: string; - // (undocumented) - fileName: string; - // (undocumented) - status: string; -} - -// @public -export interface UserInteractionField { - // (undocumented) - defaultValue?: string; - // (undocumented) - id: string; - // (undocumented) - label: string; - // (undocumented) - options?: string[]; - // (undocumented) - required?: boolean; - // (undocumented) - type: 'text' | 'select' | 'boolean' | 'number'; -} - -// @public -export interface UserInteractionNodeData { - // (undocumented) - defaultAction?: 'approve' | 'reject' | 'skip'; - // (undocumented) - formFields?: UserInteractionField[]; - // (undocumented) - interactionType: 'approval_gate' | 'form_input' | 'confirmation'; - // (undocumented) - prompt: string; - // (undocumented) - timeoutSeconds?: number; -} - -// @public -export interface VectorStoreConfig { - // (undocumented) - bm25Weight?: number; - // (undocumented) - chunkingStrategy: 'auto' | 'static'; - // (undocumented) - chunkOverlapTokens: number; - // (undocumented) - embeddingDimension: number; - // (undocumented) - embeddingModel: string; - // (undocumented) - fileSearchMaxResults?: number; - // (undocumented) - fileSearchScoreThreshold?: number; - // (undocumented) - maxChunkSizeTokens: number; - // (undocumented) - searchMode?: 'semantic' | 'keyword' | 'hybrid'; - // (undocumented) - semanticWeight?: number; - // (undocumented) - vectorStoreName: string; -} - -// @public -export interface VectorStoreCreateResult { - // (undocumented) - created: boolean; - // (undocumented) - embeddingDimension?: number; - // (undocumented) - embeddingModel: string; - // (undocumented) - vectorStoreId: string; - // (undocumented) - vectorStoreName: string; -} - -// @public -export interface VectorStoreInfo { - // (undocumented) - createdAt: number; - // (undocumented) - embeddingDimension?: number; - // (undocumented) - embeddingModel?: string; - // (undocumented) - fileCount: number; - // (undocumented) - fileCounts?: { - completed: number; - inProgress: number; - failed: number; - cancelled: number; - total: number; - }; - // (undocumented) - id: string; - // (undocumented) - lastActiveAt?: number; - // (undocumented) - name: string; - // (undocumented) - providerType?: string; - // (undocumented) - status: string; - // (undocumented) - usageBytes?: number; -} - -// @public -export interface VectorStoreStatus { - // (undocumented) - connected: boolean; - // (undocumented) - error?: string; - // (undocumented) - id: string; - // (undocumented) - totalDocuments?: number; -} - -// @public -export interface VectorStoreStatusResult { - // (undocumented) - documentCount?: number; - // (undocumented) - embeddingModel?: string; - // (undocumented) - exists: boolean; - // (undocumented) - ready: boolean; - // (undocumented) - vectorStoreId?: string; - // (undocumented) - vectorStoreName?: string; -} - -// @public -export interface Workflow { - category?: string; - comingSoon?: boolean; - comingSoonLabel?: string; - description: string; - icon?: string; - id: string; - name: string; - steps: WorkflowStep[]; -} - -// Warning: (ae-missing-release-tag) "WORKFLOW_TEMPLATES" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const WORKFLOW_TEMPLATES: WorkflowDefinition[]; - -// @public -export interface WorkflowDefinition { - // (undocumented) - createdAt: string; - // (undocumented) - createdBy?: string; - // (undocumented) - description: string; - // (undocumented) - edges: WorkflowEdge[]; - // (undocumented) - id: string; - // (undocumented) - name: string; - // (undocumented) - nodes: WorkflowNode[]; - // (undocumented) - settings: WorkflowSettings; - // (undocumented) - status: WorkflowStatus; - // (undocumented) - tags?: string[]; - // (undocumented) - updatedAt: string; - // (undocumented) - updatedBy?: string; - // (undocumented) - version: number; -} - -// @public -export interface WorkflowEdge { - // (undocumented) - condition?: string; - // (undocumented) - id: string; - // (undocumented) - label?: string; - // (undocumented) - source: string; - // (undocumented) - sourceHandle?: string; - // (undocumented) - target: string; - // (undocumented) - targetHandle?: string; - // (undocumented) - type?: WorkflowEdgeType; -} - -// @public -export type WorkflowEdgeType = - | 'handoff' - | 'tool_binding' - | 'guardrail_binding' - | 'sequence' - | 'conditional'; - -// @public -export interface WorkflowEvaluationResult { - // (undocumented) - evaluationId: string; - // (undocumented) - overallScore: number; - // (undocumented) - passRate: number; - // (undocumented) - ranAt: string; - // (undocumented) - ranBy?: string; - // (undocumented) - testCaseResults: TestCaseResult[]; - // (undocumented) - totalDurationMs: number; - // (undocumented) - workflowId: string; - // (undocumented) - workflowVersion: number; -} - -// @public -export interface WorkflowNode { - // (undocumented) - data: WorkflowNodeData; - // (undocumented) - id: string; - // (undocumented) - label?: string; - // (undocumented) - position: NodePosition; - // (undocumented) - type: WorkflowNodeType; -} - -// @public -export type WorkflowNodeData = - | StartNodeData - | AgentNodeData - | ToolNodeData - | GuardrailNodeData - | LogicNodeData - | UserInteractionNodeData - | ClassifyNodeData - | EndNodeData - | NoteNodeData - | TransformNodeData - | SetStateNodeData - | FileSearchNodeData - | McpNodeData; - -// @public -export type WorkflowNodeType = - | 'start' - | 'agent' - | 'tool' - | 'guardrail' - | 'logic' - | 'user_interaction' - | 'classify' - | 'end' - | 'note' - | 'transform' - | 'set_state' - | 'file_search' - | 'mcp'; - -// @public -export type WorkflowRunStatus = - | 'pending' - | 'running' - | 'completed' - | 'failed' - | 'interrupted' - | 'cancelled'; - -// @public -export interface WorkflowRunTrace { - // (undocumented) - completedAt?: string; - // (undocumented) - error?: string; - // (undocumented) - nodeExecutions: NodeExecutionRecord[]; - // (undocumented) - runId: string; - // (undocumented) - startedAt: string; - // (undocumented) - status: WorkflowRunStatus; - // (undocumented) - totalDurationMs?: number; - // (undocumented) - totalTokenUsage?: { - inputTokens: number; - outputTokens: number; - }; - // (undocumented) - workflowId: string; - // (undocumented) - workflowVersion: number; -} - -// @public -export interface WorkflowSettings { - // (undocumented) - conversationPersistence?: boolean; - // (undocumented) - defaultModel?: string; - // (undocumented) - globalGuardrails?: string[]; - // (undocumented) - maxTurns?: number; - // (undocumented) - timeoutSeconds?: number; - // (undocumented) - tracingEnabled?: boolean; -} - -// @public -export type WorkflowStatus = 'draft' | 'published' | 'archived'; - -// @public -export interface WorkflowStep { - description?: string; - prompt: string; - title: string; -} - -// @public -export interface WorkflowTestCase { - // (undocumented) - criteria?: EvaluationCriterion[]; - // (undocumented) - expectedAgent?: string; - // (undocumented) - expectedOutput?: string; - // (undocumented) - id: string; - // (undocumented) - input: string; - // (undocumented) - name: string; - // (undocumented) - tags?: string[]; -} - -// @public -export interface WorkflowTestSuite { - // (undocumented) - createdAt: string; - // (undocumented) - id: string; - // (undocumented) - name: string; - // (undocumented) - testCases: WorkflowTestCase[]; - // (undocumented) - updatedAt: string; - // (undocumented) - workflowId: string; -} - -// @public -export interface WorkflowVersion { - // (undocumented) - changelog?: string; - // (undocumented) - definition: WorkflowDefinition; - // (undocumented) - publishedAt: string; - // (undocumented) - publishedBy: string; - // (undocumented) - version: number; -} - -// (No @packageDocumentation comment for this package) -``` diff --git a/workspaces/augment/plugins/augment-common/src/index.ts b/workspaces/augment/plugins/augment-common/src/index.ts deleted file mode 100644 index e8a60e8b078..00000000000 --- a/workspaces/augment/plugins/augment-common/src/index.ts +++ /dev/null @@ -1,215 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './permissions'; -export { - FileFormat, - DEFAULT_BRANDING, - GLOBAL_CONFIG_KEYS, - PROVIDER_SCOPED_KEYS, - scopedConfigKey, - isProviderScopedKey, - isGlobalConfigKey, - deriveRoleFromTopology, - createDefaultWorkflow, - isAgentNodeData, - isToolNodeData, - isGuardrailNodeData, - isLogicNodeData, - isUserInteractionNodeData, - WORKFLOW_TEMPLATES, - LIFECYCLE_STAGE_ORDER, - LIFECYCLE_TRANSITIONS, - LEGACY_STAGE_MAP, - getAvailableTransitions, - isValidTransition, - normalizeLifecycleStage, -} from './types'; -export type { - AgentLifecycleStage, - AgentLifecycleAction, - AgentLifecycleTransition, - BrandingConfig, - ResponseUsage, - InputTokensDetails, - OutputTokensDetails, - ChatMessage, - ToolCallInfo, - RAGSource, - EvaluationResult, - DocumentInfo, - ProviderStatus, - VectorStoreStatus, - MCPToolInfo, - MCPServerStatus, - SecurityMode, - AugmentStatus, - VectorStoreInfo, - ChatResponse, - SyncResult, - QuickPrompt, - WorkflowStep, - Workflow, - QuickAction, - PromptCard, - ChatAgentConfig, - ChatToolConfig, - ChatAgent, - AgentRole, - AgentTopologyNode, - PromptGroup, - ConversationSummary, - // Prompt generation types - ToolCapabilityInfo, - PromptCapabilities, - // Admin panel types - AdminConfigKey, - AdminConfigEntry, - RagTestChunk, - RagTestResult, - RagGenerateResult, - UploadResult, - VectorStoreConfig, - VectorStoreCreateResult, - VectorStoreStatusResult, - // Provider types - BuiltInProviderType, - ProviderType, - ProviderCapabilities, - ProviderConfigField, - ProviderDescriptor, - GlobalConfigKey, - ProviderScopedKey, - // Normalized streaming events - NormalizedStreamEvent, - StreamStartedEvent, - StreamTextDeltaEvent, - StreamTextDoneEvent, - StreamReasoningDeltaEvent, - StreamReasoningDoneEvent, - StreamToolDiscoveryEvent, - StreamToolStartedEvent, - StreamToolDeltaEvent, - StreamToolCompletedEvent, - StreamToolFailedEvent, - StreamToolApprovalEvent, - StreamBackendToolExecutingEvent, - StreamRagResultsEvent, - StreamAgentHandoffEvent, - StreamFormField, - StreamFormDescriptor, - StreamFormRequestEvent, - StreamSecretDemand, - StreamAuthRequiredEvent, - StreamArtifactEvent, - StreamCitationReference, - StreamCitationEvent, - StreamCompletedEvent, - StreamErrorEvent, - ReasoningSummary, - PendingApprovalInfo, - // Kagenti types - KagentiSecretKeyRef, - KagentiConfigMapKeyRef, - KagentiEnvVarSource, - KagentiEnvVar, - KagentiServicePort, - KagentiResourceLabels, - KagentiAgentSummary, - KagentiAgentDetail, - KagentiAgentCardSkill, - KagentiAgentCard, - KagentiShipwrightConfig, - KagentiCreateAgentRequest, - KagentiCreateAgentResponse, - KagentiFinalizeAgentBuildRequest, - KagentiToolSummary, - KagentiToolDetail, - KagentiCreateToolRequest, - KagentiCreateToolResponse, - KagentiPersistentStorageConfig, - KagentiFinalizeToolBuildRequest, - KagentiMcpInvokeResponse, - KagentiMcpToolSchema, - KagentiFeatureFlags, - KagentiDashboardConfig, - KagentiAuthConfig, - KagentiBuildListItem, - KagentiResourceConfigFromBuild, - KagentiBuildInfo, - KagentiBuildStrategy, - KagentiRouteStatus, - KagentiTriggerBuildRunResponse, - KagentiMigratableAgent, - KagentiMigrateAgentResponse, - KagentiMigrateAllResponse, - KagentiSandboxSession, - KagentiSandboxAgentInfo, - KagentiSandboxCreateRequest, - KagentiSidecar, - KagentiSessionTokenUsage, - KagentiIntegration, - KagentiCreateIntegrationRequest, - KagentiLlmTeam, - KagentiLlmKey, - KagentiCreateTeamRequest, - KagentiCreateKeyRequest, - KagentiTriggerRequest, - DevSpacesCreateWorkspaceRequest, - DevSpacesCreateWorkspaceResponse, - DevSpacesHealthResponse, - DevSpacesWorkspace, - DevSpacesListWorkspacesResponse, - DevSpacesStopWorkspaceRequest, - DevSpacesDeleteWorkspaceRequest, - // Workflow Builder types - WorkflowStatus, - WorkflowNodeType, - NodePosition, - StartNodeData, - AgentNodeData, - AgentFunctionDef, - ToolNodeKind, - ToolNodeData, - GuardrailNodeData, - GuardrailRule, - LogicNodeKind, - LogicNodeData, - UserInteractionNodeData, - UserInteractionField, - ClassifyNodeData, - EndNodeData, - NoteNodeData, - TransformNodeData, - SetStateNodeData, - FileSearchNodeData, - McpNodeData, - WorkflowNodeData, - WorkflowNode, - WorkflowEdgeType, - WorkflowEdge, - WorkflowSettings, - WorkflowVersion, - WorkflowDefinition, - WorkflowRunStatus, - NodeExecutionRecord, - WorkflowRunTrace, - WorkflowTestCase, - EvaluationCriterion, - TestCaseResult, - WorkflowEvaluationResult, - WorkflowTestSuite, -} from './types'; diff --git a/workspaces/augment/plugins/augment-common/src/permissions.test.ts b/workspaces/augment/plugins/augment-common/src/permissions.test.ts deleted file mode 100644 index 7fb10f26dc8..00000000000 --- a/workspaces/augment/plugins/augment-common/src/permissions.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - augmentAccessPermission, - augmentAdminPermission, - augmentPermissions, -} from './permissions'; - -describe('permissions', () => { - describe('augmentAccessPermission', () => { - it('has the correct name', () => { - expect(augmentAccessPermission.name).toBe('augment.access'); - }); - - it('has read action attribute', () => { - expect(augmentAccessPermission.attributes).toEqual({ - action: 'read', - }); - }); - }); - - describe('augmentAdminPermission', () => { - it('has the correct name', () => { - expect(augmentAdminPermission.name).toBe('augment.admin'); - }); - - it('has update action attribute', () => { - expect(augmentAdminPermission.attributes).toEqual({ - action: 'update', - }); - }); - }); - - describe('augmentPermissions', () => { - it('exports both permissions in the array', () => { - expect(augmentPermissions).toHaveLength(2); - expect(augmentPermissions).toContain(augmentAccessPermission); - expect(augmentPermissions).toContain(augmentAdminPermission); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-common/src/permissions.ts b/workspaces/augment/plugins/augment-common/src/permissions.ts deleted file mode 100644 index c56a8f7837f..00000000000 --- a/workspaces/augment/plugins/augment-common/src/permissions.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { createPermission } from '@backstage/plugin-permission-common'; - -/** - * Permission to access the Augment plugin. - * - * Controls access to ALL Augment features. If a user has this - * permission, they get full access. If not, they are blocked entirely. - * - * @public - */ -export const augmentAccessPermission = createPermission({ - name: 'augment.access', - attributes: { - action: 'read', - }, -}); - -/** - * Permission to access admin features of the Augment plugin. - * - * Controls access to admin-only features such as document management, - * swim lane/prompt editing, system prompt configuration, and branding. - * Users with this permission see additional admin tabs in the UI. - * - * @public - */ -export const augmentAdminPermission = createPermission({ - name: 'augment.admin', - attributes: { - action: 'update', - }, -}); - -/** - * List of all Augment permissions. - * - * To restrict access to a Keycloak group, configure RBAC policies in app-config.yaml: - * - * @example - * ```yaml - * permission: - * enabled: true - * rbac: - * policies: - * - g, group:default/augment-users, role:default/augment-user - * - p, role:default/augment-user, augment.access, read, allow - * - g, group:default/augment-admins, role:default/augment-admin - * - p, role:default/augment-admin, augment.access, read, allow - * - p, role:default/augment-admin, augment.admin, update, allow - * ``` - * - * @public - */ -export const augmentPermissions = [ - augmentAccessPermission, - augmentAdminPermission, -]; diff --git a/workspaces/augment/plugins/augment-common/src/templates/workflowTemplates.ts b/workspaces/augment/plugins/augment-common/src/templates/workflowTemplates.ts deleted file mode 100644 index 25579aeb9ea..00000000000 --- a/workspaces/augment/plugins/augment-common/src/templates/workflowTemplates.ts +++ /dev/null @@ -1,380 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - WorkflowDefinition, - AgentNodeData, - StartNodeData, - ClassifyNodeData, - LogicNodeData, - McpNodeData, - TransformNodeData, - SetStateNodeData, - EndNodeData, -} from '../types/workflowBuilder'; - -function template( - id: string, - name: string, - description: string, - nodes: WorkflowDefinition['nodes'], - edges: WorkflowDefinition['edges'], -): WorkflowDefinition { - const now = new Date().toISOString(); - return { - id, - name, - description, - version: 0, - createdAt: now, - updatedAt: now, - status: 'draft', - nodes, - edges, - settings: { - maxTurns: 10, - conversationPersistence: true, - tracingEnabled: true, - }, - }; -} - -export const WORKFLOW_TEMPLATES: WorkflowDefinition[] = [ - // 1. Simple Q&A Agent — the simplest working workflow - template( - 'tpl-simple-qa', - 'Simple Q&A Agent', - 'A basic single-agent workflow that answers questions. The simplest starting point for any new agent.', - [ - { - id: 'start_1', - type: 'start', - position: { x: 50, y: 200 }, - data: { inputDescription: 'User question' } as StartNodeData, - label: 'Start', - }, - { - id: 'agent_1', - type: 'agent', - position: { x: 300, y: 200 }, - data: { - name: 'Assistant', - instructions: - "You are a helpful, knowledgeable assistant. Answer the user's question clearly and concisely. Use markdown formatting when it helps readability (bullet points, bold, code blocks).", - } as AgentNodeData, - label: 'Assistant', - }, - { - id: 'end_1', - type: 'end', - position: { x: 560, y: 200 }, - data: {} as EndNodeData, - label: 'End', - }, - ], - [ - { id: 'e1', source: 'start_1', target: 'agent_1', type: 'sequence' }, - { id: 'e2', source: 'agent_1', target: 'end_1', type: 'sequence' }, - ], - ), - - // 2. Smart Router — classification + if/else branching - template( - 'tpl-smart-router', - 'Smart Router', - 'Classifies user input as technical or general, then routes to the appropriate specialist agent using if/else logic.', - [ - { - id: 'start_1', - type: 'start', - position: { x: 50, y: 250 }, - data: { inputDescription: 'User message' } as StartNodeData, - label: 'Start', - }, - { - id: 'classify_1', - type: 'classify', - position: { x: 270, y: 250 }, - data: { - classifications: [ - { - label: 'technical', - description: - 'Programming, code, APIs, servers, debugging, or technology questions', - }, - { - label: 'general', - description: - 'General conversation, greetings, or non-technical questions', - }, - ], - instructions: - 'Classify the user input. Choose "technical" if it asks about programming, code, APIs, servers, debugging, or technology. Choose "general" for everything else.', - } as ClassifyNodeData, - label: 'Topic Classifier', - }, - { - id: 'logic_1', - type: 'logic', - position: { x: 520, y: 250 }, - data: { - kind: 'if_else', - condition: - "classify_1_output && classify_1_output.classification === 'technical'", - } as LogicNodeData, - label: 'Is Technical?', - }, - { - id: 'agent_tech', - type: 'agent', - position: { x: 780, y: 130 }, - data: { - name: 'Tech Expert', - instructions: - 'You are a senior software engineer. Give concise, technically accurate answers about programming, APIs, DevOps, and technology. Use code examples when helpful. Keep responses brief but precise.', - } as AgentNodeData, - label: 'Tech Expert', - }, - { - id: 'agent_general', - type: 'agent', - position: { x: 780, y: 370 }, - data: { - name: 'Friendly Assistant', - instructions: - 'You are a warm, friendly assistant. Answer general questions in a conversational, approachable tone. Be helpful and brief.', - } as AgentNodeData, - label: 'Friendly Assistant', - }, - { - id: 'end_1', - type: 'end', - position: { x: 1040, y: 250 }, - data: {} as EndNodeData, - label: 'End', - }, - ], - [ - { id: 'e1', source: 'start_1', target: 'classify_1', type: 'sequence' }, - { id: 'e2', source: 'classify_1', target: 'logic_1', type: 'sequence' }, - { - id: 'e3', - source: 'logic_1', - target: 'agent_tech', - type: 'sequence', - label: 'true', - }, - { - id: 'e4', - source: 'logic_1', - target: 'agent_general', - type: 'sequence', - label: 'false', - }, - { id: 'e5', source: 'agent_tech', target: 'end_1', type: 'sequence' }, - { id: 'e6', source: 'agent_general', target: 'end_1', type: 'sequence' }, - ], - ), - - // 3. Research & Summarize — multi-agent chaining - template( - 'tpl-research-summarize', - 'Research & Summarize', - 'A two-agent pipeline: the first agent researches a topic in depth, then a second agent distills the findings into a concise summary.', - [ - { - id: 'start_1', - type: 'start', - position: { x: 50, y: 200 }, - data: { - inputDescription: 'Research topic or question', - } as StartNodeData, - label: 'Start', - }, - { - id: 'agent_research', - type: 'agent', - position: { x: 300, y: 200 }, - data: { - name: 'Researcher', - instructions: - 'You are a thorough research analyst. Given a topic, provide a comprehensive analysis covering key facts, different perspectives, recent developments, and supporting details. Be detailed and well-organized. Use headers and bullet points.', - } as AgentNodeData, - label: 'Researcher', - }, - { - id: 'agent_summarize', - type: 'agent', - position: { x: 580, y: 200 }, - data: { - name: 'Summarizer', - instructions: - 'You are an expert editor and summarizer. Take the detailed research provided and distill it into a clear, concise executive summary (3-5 key bullet points). Highlight only the most important takeaways. End with a one-sentence conclusion.', - } as AgentNodeData, - label: 'Summarizer', - }, - { - id: 'end_1', - type: 'end', - position: { x: 840, y: 200 }, - data: {} as EndNodeData, - label: 'End', - }, - ], - [ - { - id: 'e1', - source: 'start_1', - target: 'agent_research', - type: 'sequence', - }, - { - id: 'e2', - source: 'agent_research', - target: 'agent_summarize', - type: 'sequence', - }, - { - id: 'e3', - source: 'agent_summarize', - target: 'end_1', - type: 'sequence', - }, - ], - ), - - // 4. Kubernetes Assistant — MCP tool integration - template( - 'tpl-k8s-assistant', - 'Kubernetes Assistant', - 'Connects to a Kubernetes MCP server for live cluster operations — list pods, namespaces, deployments, and more.', - [ - { - id: 'start_1', - type: 'start', - position: { x: 50, y: 200 }, - data: { - inputDescription: 'Kubernetes question or command', - } as StartNodeData, - label: 'Start', - }, - { - id: 'mcp_1', - type: 'mcp', - position: { x: 300, y: 200 }, - data: { - serverUrl: - 'https://kubernetes-mcp-server-ocp-mcp-server.apps.ocp.v7hjl.sandbox2288.opentlc.com', - serverLabel: 'k8s', - requireApproval: 'never', - } as McpNodeData, - label: 'K8s MCP Server', - }, - { - id: 'agent_1', - type: 'agent', - position: { x: 580, y: 200 }, - data: { - name: 'K8s Assistant', - instructions: - "You are a Kubernetes expert assistant with access to a live cluster. Use the available MCP tools to query the cluster and answer the user's question. Format output clearly using markdown tables or lists. Always show the namespace and resource name.", - } as AgentNodeData, - label: 'K8s Assistant', - }, - { - id: 'end_1', - type: 'end', - position: { x: 840, y: 200 }, - data: {} as EndNodeData, - label: 'End', - }, - ], - [ - { id: 'e1', source: 'start_1', target: 'mcp_1', type: 'sequence' }, - { id: 'e2', source: 'mcp_1', target: 'agent_1', type: 'sequence' }, - { id: 'e3', source: 'agent_1', target: 'end_1', type: 'sequence' }, - ], - ), - - // 5. Content Moderator — set_state + transform + agent pipeline - template( - 'tpl-content-moderator', - 'Content Moderator', - 'Demonstrates data flow: sets metadata via state, transforms it into context, then an agent uses that context to moderate content.', - [ - { - id: 'start_1', - type: 'start', - position: { x: 50, y: 200 }, - data: { inputDescription: 'Content to moderate' } as StartNodeData, - label: 'Start', - }, - { - id: 'set_state_1', - type: 'set_state', - position: { x: 280, y: 200 }, - data: { - assignments: { - content_type: "'user_generated'", - platform: "'public_forum'", - policy_version: "'v2.1'", - }, - } as SetStateNodeData, - label: 'Set Metadata', - }, - { - id: 'transform_1', - type: 'transform', - position: { x: 520, y: 200 }, - data: { - expression: - '`Content type: ${content_type}, Platform: ${platform}, Policy: ${policy_version}`', - outputVariable: 'moderation_context', - } as TransformNodeData, - label: 'Build Context', - }, - { - id: 'agent_1', - type: 'agent', - position: { x: 780, y: 200 }, - data: { - name: 'Moderator', - instructions: - 'You are a content moderation specialist. Review the user-submitted content for policy violations. Consider the moderation context (content type, platform, policy version). Respond with:\n1. **Verdict**: APPROVE or FLAG\n2. **Reason**: Brief explanation\n3. **Category**: If flagged, specify (spam, hate speech, harassment, misinformation, other)\n4. **Confidence**: High / Medium / Low', - } as AgentNodeData, - label: 'Moderator', - }, - { - id: 'end_1', - type: 'end', - position: { x: 1040, y: 200 }, - data: {} as EndNodeData, - label: 'End', - }, - ], - [ - { id: 'e1', source: 'start_1', target: 'set_state_1', type: 'sequence' }, - { - id: 'e2', - source: 'set_state_1', - target: 'transform_1', - type: 'sequence', - }, - { id: 'e3', source: 'transform_1', target: 'agent_1', type: 'sequence' }, - { id: 'e4', source: 'agent_1', target: 'end_1', type: 'sequence' }, - ], - ), -]; diff --git a/workspaces/augment/plugins/augment-common/src/types/admin.ts b/workspaces/augment/plugins/augment-common/src/types/admin.ts deleted file mode 100644 index 6305c5880db..00000000000 --- a/workspaces/augment/plugins/augment-common/src/types/admin.ts +++ /dev/null @@ -1,170 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ============================================================================= -// Admin Panel Types -// ============================================================================= - -/** - * Allowed keys for admin-managed configuration stored in the database. - * - * Keys fall into three categories: - * - `activeProvider`: which provider is currently active - * - Global keys: shared across all providers (branding, promptGroups, etc.) - * - Provider-scoped keys: namespaced per provider in storage (model, baseUrl, etc.) - * - * @public - */ -export type AdminConfigKey = - | 'activeProvider' - | 'promptGroups' - | 'systemPrompt' - | 'branding' - | 'safetyPatterns' - | 'vectorStoreConfig' - | 'activeVectorStoreIds' - | 'model' - | 'baseUrl' - | 'kagentiBaseUrl' - | 'toolChoice' - | 'enableWebSearch' - | 'enableCodeInterpreter' - | 'mcpServers' - | 'disabledMcpServerIds' - | 'safetyEnabled' - | 'inputShields' - | 'outputShields' - | 'safetyOnError' - | 'evaluationEnabled' - | 'scoringFunctions' - | 'minScoreThreshold' - | 'evaluationOnError' - | 'agents' - | 'defaultAgent' - | 'maxAgentTurns' - | 'maxToolCalls' - | 'maxOutputTokens' - | 'temperature' - | 'safetyIdentifier' - | 'chatAgents' - | 'chatTools' - | 'devSpacesApiUrl' - | 'devSpacesToken' - | 'devSpacesNamespace' - | 'workflows' - | 'workflowVersions' - | 'workflowTestSuites' - | 'workflowEvaluations'; - -/** - * A single admin config entry stored in the database. - * @public - */ -export interface AdminConfigEntry { - configKey: AdminConfigKey; - configValue: unknown; - updatedAt: string; - updatedBy: string; -} - -/** - * A single chunk returned from a RAG test query. - * @public - */ -export interface RagTestChunk { - text: string; - score?: number; - fileId?: string; - fileName?: string; - vectorStoreId?: string; -} - -/** - * Result of a RAG test query against the vector store. - * @public - */ -export interface RagTestResult { - query: string; - chunks: RagTestChunk[]; - vectorStoreId: string; - totalResults: number; -} - -/** - * Result of an end-to-end RAG generation test: retrieval + LLM answer. - * @public - */ -export interface RagGenerateResult { - query: string; - answer: string; - chunks: RagTestChunk[]; - model: string; - totalResults: number; -} - -/** - * Result of uploading a document to the vector store. - * @public - */ -export interface UploadResult { - fileId: string; - fileName: string; - status: string; -} - -/** - * Vector store configuration editable from the admin panel. - * Fields mirror the vector store section of the active provider's app-config. - * @public - */ -export interface VectorStoreConfig { - vectorStoreName: string; - embeddingModel: string; - embeddingDimension: number; - searchMode?: 'semantic' | 'keyword' | 'hybrid'; - bm25Weight?: number; - semanticWeight?: number; - chunkingStrategy: 'auto' | 'static'; - maxChunkSizeTokens: number; - chunkOverlapTokens: number; - fileSearchMaxResults?: number; - fileSearchScoreThreshold?: number; -} - -/** - * Result of creating a vector store. - * @public - */ -export interface VectorStoreCreateResult { - vectorStoreId: string; - vectorStoreName: string; - created: boolean; - embeddingModel: string; - embeddingDimension?: number; -} - -/** - * Current status of the vector store. - * @public - */ -export interface VectorStoreStatusResult { - exists: boolean; - vectorStoreId?: string; - vectorStoreName?: string; - documentCount?: number; - embeddingModel?: string; - ready: boolean; -} diff --git a/workspaces/augment/plugins/augment-common/src/types/branding.test.ts b/workspaces/augment/plugins/augment-common/src/types/branding.test.ts deleted file mode 100644 index b5f5b171a70..00000000000 --- a/workspaces/augment/plugins/augment-common/src/types/branding.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { DEFAULT_BRANDING } from './branding'; -import type { BrandingConfig } from './branding'; - -describe('DEFAULT_BRANDING', () => { - it('has all required BrandingConfig fields', () => { - const required: Array = [ - 'appName', - 'tagline', - 'inputPlaceholder', - 'primaryColor', - 'secondaryColor', - 'successColor', - 'warningColor', - 'errorColor', - 'infoColor', - ]; - for (const field of required) { - expect(DEFAULT_BRANDING[field]).toBeDefined(); - } - }); - - it('has valid hex color values', () => { - const hexPattern = /^#[0-9a-fA-F]{6}$/; - const colorFields: Array = [ - 'primaryColor', - 'secondaryColor', - 'successColor', - 'warningColor', - 'errorColor', - 'infoColor', - ]; - for (const field of colorFields) { - expect(DEFAULT_BRANDING[field]).toMatch(hexPattern); - } - }); - - it('has non-empty string fields', () => { - expect(DEFAULT_BRANDING.appName.length).toBeGreaterThan(0); - expect(DEFAULT_BRANDING.tagline.length).toBeGreaterThan(0); - expect(DEFAULT_BRANDING.inputPlaceholder.length).toBeGreaterThan(0); - }); - - it('uses default theme preset', () => { - expect(DEFAULT_BRANDING.themePreset).toBe('default'); - }); - - it('does not include optional fields that should be undefined', () => { - expect(DEFAULT_BRANDING.logoUrl).toBeUndefined(); - expect(DEFAULT_BRANDING.faviconUrl).toBeUndefined(); - }); -}); diff --git a/workspaces/augment/plugins/augment-common/src/types/branding.ts b/workspaces/augment/plugins/augment-common/src/types/branding.ts deleted file mode 100644 index ab5415a4b77..00000000000 --- a/workspaces/augment/plugins/augment-common/src/types/branding.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Branding configuration loaded from app-config.yaml. - * Shared across frontend and backend. - * @public - */ -export interface BrandingConfig { - appName: string; - tagline: string; - inputPlaceholder: string; - primaryColor: string; - secondaryColor: string; - successColor: string; - warningColor: string; - errorColor: string; - infoColor: string; - logoUrl?: string; - faviconUrl?: string; - botAvatarUrl?: string; - themePreset?: string; -} - -/** - * Single source of truth for branding defaults. - * Used by both frontend hooks and backend API routes. - * @public - */ -export const DEFAULT_BRANDING: BrandingConfig = { - appName: 'Augment', - tagline: 'Your AI-powered platform assistant', - inputPlaceholder: 'Ask me anything...', - primaryColor: '#1e40af', - secondaryColor: '#475569', - successColor: '#10b981', - warningColor: '#f59e0b', - errorColor: '#ef4444', - infoColor: '#0ea5e9', - themePreset: 'default', -}; diff --git a/workspaces/augment/plugins/augment-common/src/types/index.ts b/workspaces/augment/plugins/augment-common/src/types/index.ts deleted file mode 100644 index cfa24b1733c..00000000000 --- a/workspaces/augment/plugins/augment-common/src/types/index.ts +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { - FileFormat, - type ResponseUsage, - type InputTokensDetails, - type OutputTokensDetails, - type ChatMessage, - type ToolCallInfo, - type RAGSource, - type EvaluationResult, - type DocumentInfo, - type ProviderStatus, - type VectorStoreStatus, - type MCPToolInfo, - type MCPServerStatus, - type SecurityMode, - type AugmentStatus, - type VectorStoreInfo, - type QuickPrompt, - type WorkflowStep, - type Workflow, - type QuickAction, - type PromptCard, - type ChatAgentConfig, - type ChatToolConfig, - type PromptGroup, - type ConversationSummary, - type ChatResponse, - type PendingApprovalInfo, - type ReasoningSummary, - type SyncResult, - type ToolCapabilityInfo, - type PromptCapabilities, - type ChatAgent, - type AgentLifecycleStage, - type AgentLifecycleAction, - type AgentLifecycleTransition, - type AgentRole, - type AgentTopologyNode, - deriveRoleFromTopology, - LIFECYCLE_STAGE_ORDER, - LIFECYCLE_TRANSITIONS, - LEGACY_STAGE_MAP, - getAvailableTransitions, - isValidTransition, - normalizeLifecycleStage, -} from './shared'; - -export type { - NormalizedStreamEvent, - StreamStartedEvent, - StreamTextDeltaEvent, - StreamTextDoneEvent, - StreamReasoningDeltaEvent, - StreamReasoningDoneEvent, - StreamToolDiscoveryEvent, - StreamToolStartedEvent, - StreamToolDeltaEvent, - StreamToolCompletedEvent, - StreamToolFailedEvent, - StreamToolApprovalEvent, - StreamBackendToolExecutingEvent, - StreamRagResultsEvent, - StreamAgentHandoffEvent, - StreamFormField, - StreamFormDescriptor, - StreamFormRequestEvent, - StreamSecretDemand, - StreamAuthRequiredEvent, - StreamArtifactEvent, - StreamCitationReference, - StreamCitationEvent, - StreamCompletedEvent, - StreamErrorEvent, -} from './streaming'; - -export { DEFAULT_BRANDING, type BrandingConfig } from './branding'; - -export type { - AdminConfigKey, - AdminConfigEntry, - RagTestChunk, - RagTestResult, - RagGenerateResult, - UploadResult, - VectorStoreConfig, - VectorStoreCreateResult, - VectorStoreStatusResult, -} from './admin'; - -export type { - KagentiSecretKeyRef, - KagentiConfigMapKeyRef, - KagentiEnvVarSource, - KagentiEnvVar, - KagentiServicePort, - KagentiResourceLabels, - KagentiAgentSummary, - KagentiAgentDetail, - KagentiAgentCardSkill, - KagentiAgentCard, - KagentiShipwrightConfig, - KagentiCreateAgentRequest, - KagentiCreateAgentResponse, - KagentiToolSummary, - KagentiToolDetail, - KagentiCreateToolRequest, - KagentiCreateToolResponse, - KagentiPersistentStorageConfig, - KagentiFinalizeAgentBuildRequest, - KagentiFinalizeToolBuildRequest, - KagentiMcpInvokeResponse, - KagentiMcpToolSchema, - KagentiFeatureFlags, - KagentiDashboardConfig, - KagentiAuthConfig, - KagentiBuildListItem, - KagentiResourceConfigFromBuild, - KagentiBuildInfo, - KagentiBuildStrategy, - KagentiRouteStatus, - KagentiTriggerBuildRunResponse, - KagentiMigratableAgent, - KagentiMigrateAgentResponse, - KagentiMigrateAllResponse, - KagentiSandboxSession, - KagentiSandboxAgentInfo, - KagentiSandboxCreateRequest, - KagentiSidecar, - KagentiSessionTokenUsage, - KagentiIntegration, - KagentiCreateIntegrationRequest, - KagentiLlmTeam, - KagentiLlmKey, - KagentiCreateTeamRequest, - KagentiCreateKeyRequest, - KagentiTriggerRequest, - DevSpacesCreateWorkspaceRequest, - DevSpacesCreateWorkspaceResponse, - DevSpacesHealthResponse, - DevSpacesWorkspace, - DevSpacesListWorkspacesResponse, - DevSpacesStopWorkspaceRequest, - DevSpacesDeleteWorkspaceRequest, -} from './kagenti'; - -export { - createDefaultWorkflow, - isAgentNodeData, - isToolNodeData, - isGuardrailNodeData, - isLogicNodeData, - isUserInteractionNodeData, - type WorkflowStatus, - type WorkflowNodeType, - type NodePosition, - type StartNodeData, - type AgentNodeData, - type AgentFunctionDef, - type ToolNodeKind, - type ToolNodeData, - type GuardrailNodeData, - type GuardrailRule, - type LogicNodeKind, - type LogicNodeData, - type UserInteractionNodeData, - type UserInteractionField, - type ClassifyNodeData, - type EndNodeData, - type NoteNodeData, - type TransformNodeData, - type SetStateNodeData, - type FileSearchNodeData, - type McpNodeData, - type WorkflowNodeData, - type WorkflowNode, - type WorkflowEdgeType, - type WorkflowEdge, - type WorkflowSettings, - type WorkflowVersion, - type WorkflowDefinition, - type WorkflowRunStatus, - type NodeExecutionRecord, - type WorkflowRunTrace, - type WorkflowTestCase, - type EvaluationCriterion, - type TestCaseResult, - type WorkflowEvaluationResult, - type WorkflowTestSuite, -} from './workflowBuilder'; - -export { WORKFLOW_TEMPLATES } from '../templates/workflowTemplates'; - -export { - GLOBAL_CONFIG_KEYS, - PROVIDER_SCOPED_KEYS, - scopedConfigKey, - isProviderScopedKey, - isGlobalConfigKey, - type BuiltInProviderType, - type ProviderType, - type ProviderCapabilities, - type ProviderConfigField, - type ProviderDescriptor, - type GlobalConfigKey, - type ProviderScopedKey, -} from './provider'; diff --git a/workspaces/augment/plugins/augment-common/src/types/kagenti.ts b/workspaces/augment/plugins/augment-common/src/types/kagenti.ts deleted file mode 100644 index 0d38b891d31..00000000000 --- a/workspaces/augment/plugins/augment-common/src/types/kagenti.ts +++ /dev/null @@ -1,559 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ============================================================================= -// Kagenti Shared Types -// ============================================================================= -// -// Types shared between augment-backend and augment (frontend) for the -// Kagenti provider. These represent the shapes exposed through the -// /kagenti/* routes and used in UI rendering. - -/** @public */ -export interface KagentiSecretKeyRef { - name: string; - key: string; -} - -/** @public */ -export interface KagentiConfigMapKeyRef { - name: string; - key: string; -} - -/** @public */ -export interface KagentiEnvVarSource { - secretKeyRef?: KagentiSecretKeyRef; - configMapKeyRef?: KagentiConfigMapKeyRef; -} - -/** @public */ -export interface KagentiEnvVar { - name: string; - value?: string; - valueFrom?: KagentiEnvVarSource; -} - -/** @public */ -export interface KagentiServicePort { - name?: string; - port: number; - targetPort?: number; - protocol?: string; -} - -// -- Resource Labels ---------------------------------------------------------- - -/** @public */ -export interface KagentiResourceLabels { - protocol?: string | string[]; - framework?: string; - type?: string; -} - -// -- Agents ------------------------------------------------------------------- - -/** @public */ -export interface KagentiAgentSummary { - name: string; - namespace: string; - description: string; - status: string; - labels: KagentiResourceLabels; - workloadType?: string; - createdAt?: string; -} - -/** @public */ -export interface KagentiAgentDetail { - metadata: Record; - spec: Record; - status: Record; - workloadType?: string; - readyStatus?: string; - service?: Record; -} - -/** @public */ -export interface KagentiAgentCardSkill { - id?: string; - name?: string; - description?: string; - examples?: string[]; -} - -/** @public */ -export interface KagentiAgentCard { - name: string; - description?: string; - version: string; - url: string; - streaming: boolean; - defaultInputModes?: string[]; - skills: KagentiAgentCardSkill[]; -} - -/** @public */ -export interface KagentiShipwrightConfig { - buildStrategy?: string; - dockerfile?: string; - buildArgs?: string[]; - buildTimeout?: string; -} - -/** @public */ -export interface KagentiCreateAgentRequest { - name: string; - namespace: string; - protocol?: string; - framework?: string; - envVars?: KagentiEnvVar[]; - workloadType?: 'deployment' | 'statefulset' | 'job'; - deploymentMethod?: 'source' | 'image'; - gitUrl?: string; - gitPath?: string; - gitBranch?: string; - imageTag?: string; - registryUrl?: string; - registrySecret?: string; - startCommand?: string; - containerImage?: string; - imagePullSecret?: string; - servicePorts?: KagentiServicePort[]; - createHttpRoute?: boolean; - authBridgeEnabled?: boolean; - spireEnabled?: boolean; - shipwrightConfig?: KagentiShipwrightConfig; -} - -/** @public */ -export interface KagentiCreateAgentResponse { - success: boolean; - name: string; - namespace: string; - message: string; -} - -/** @public */ -export interface KagentiFinalizeAgentBuildRequest { - protocol?: string; - framework?: string; - envVars?: KagentiEnvVar[]; - servicePorts?: KagentiServicePort[]; - createHttpRoute?: boolean; - authBridgeEnabled?: boolean; - imagePullSecret?: string; -} - -// -- Tools -------------------------------------------------------------------- - -/** @public */ -export interface KagentiToolSummary { - name: string; - namespace: string; - description: string; - status: string; - labels: KagentiResourceLabels; - createdAt?: string; - workloadType?: string; -} - -/** @public */ -export interface KagentiToolDetail { - metadata: Record; - spec: Record; - status: Record; - workloadType?: string; - service?: Record; -} - -/** @public */ -export interface KagentiCreateToolRequest { - name: string; - namespace: string; - protocol?: string; - framework?: string; - description?: string; - envVars?: KagentiEnvVar[]; - servicePorts?: KagentiServicePort[]; - workloadType?: 'deployment' | 'statefulset'; - persistentStorage?: KagentiPersistentStorageConfig; - deploymentMethod?: 'image' | 'source'; - containerImage?: string; - imagePullSecret?: string; - gitUrl?: string; - gitRevision?: string; - contextDir?: string; - registryUrl?: string; - registrySecret?: string; - imageTag?: string; - shipwrightConfig?: KagentiShipwrightConfig; - createHttpRoute?: boolean; - authBridgeEnabled?: boolean; - spireEnabled?: boolean; -} - -/** @public */ -export interface KagentiCreateToolResponse { - success: boolean; - name: string; - namespace: string; - message: string; -} - -/** @public */ -export interface KagentiPersistentStorageConfig { - enabled: boolean; - size: string; -} - -/** @public */ -export interface KagentiFinalizeToolBuildRequest { - protocol?: string; - framework?: string; - workloadType?: string; - persistentStorage?: KagentiPersistentStorageConfig; - envVars?: KagentiEnvVar[]; - servicePorts?: KagentiServicePort[]; - createHttpRoute?: boolean; - authBridgeEnabled?: boolean; - spireEnabled?: boolean; - imagePullSecret?: string; -} - -/** @public */ -export interface KagentiMcpInvokeResponse { - result: unknown; -} - -/** @public */ -export interface KagentiMcpToolSchema { - name: string; - description?: string; - input_schema?: Record; -} - -// -- Config / Platform -------------------------------------------------------- - -/** @public */ -export interface KagentiFeatureFlags { - sandbox: boolean; - integrations: boolean; - triggers: boolean; -} - -/** @public */ -export interface KagentiDashboardConfig { - traces?: string; - network?: string; - mcpInspector?: string; - mcpProxy?: string; - keycloakConsole?: string; - domainName?: string; -} - -/** - * Kagenti server's user-facing SSO / Keycloak configuration. - * - * **This is NOT the Backstage service credentials** configured in - * `augment.kagenti.auth` (which uses OAuth2 client-credentials grant - * for backend-to-Kagenti API authentication). This type represents - * the Kagenti server's own authentication settings for end users, - * as returned by the `GET /api/v1/auth/config` endpoint. - * - * @public - */ -export interface KagentiAuthConfig { - enabled: boolean; - keycloak_url?: string; - realm?: string; - client_id?: string; - redirect_uri?: string; -} - -// -- Shipwright / Builds ------------------------------------------------------ - -/** @public */ -export interface KagentiBuildListItem { - name: string; - namespace: string; - resourceType?: string; - registered: boolean; - strategy?: string; - gitUrl?: string; - gitRevision?: string; - contextDir?: string; - outputImage?: string; - creationTimestamp?: string; -} - -/** @public */ -export interface KagentiResourceConfigFromBuild { - protocol?: string; - framework?: string; - createHttpRoute?: boolean; - registrySecret?: string; - envVars?: KagentiEnvVar[]; - servicePorts?: KagentiServicePort[]; -} - -/** @public */ -export interface KagentiBuildInfo { - name: string; - namespace: string; - buildRegistered: boolean; - buildReason?: string; - buildMessage?: string; - outputImage: string; - strategy: string; - gitUrl: string; - gitRevision: string; - contextDir: string; - hasBuildRun: boolean; - buildRunName?: string; - buildRunPhase?: string; - buildRunStartTime?: string; - buildRunCompletionTime?: string; - buildRunOutputImage?: string; - buildRunOutputDigest?: string; - buildRunFailureMessage?: string; - agentConfig?: KagentiResourceConfigFromBuild; - toolConfig?: KagentiResourceConfigFromBuild; -} - -/** @public */ -export interface KagentiBuildStrategy { - name: string; - description?: string; -} - -/** @public */ -export interface KagentiRouteStatus { - hasRoute: boolean; - /** Present when the API exposes a public URL for the route. */ - url?: string; - /** Additional fields returned by the Kagenti API (e.g. ready, status). */ - [key: string]: unknown; -} - -/** @public */ -export interface KagentiTriggerBuildRunResponse { - success: boolean; - buildRunName: string; - namespace: string; - buildName: string; - message?: string; -} - -// -- Migration ---------------------------------------------------------------- - -/** @public */ -export interface KagentiMigratableAgent { - name: string; - namespace: string; - status: string; - has_deployment: boolean; - labels: KagentiResourceLabels; - description?: string; -} - -/** @public */ -export interface KagentiMigrateAgentResponse { - success: boolean; - migrated: boolean; - name: string; - namespace: string; - message: string; - deployment_created: boolean; - service_created: boolean; - agent_crd_deleted: boolean; -} - -/** @public */ -export interface KagentiMigrateAllResponse { - namespace: string; - dry_run: boolean; - delete_old: boolean; - total: number; - migrated: unknown[]; - skipped: unknown[]; - failed: unknown[]; -} - -// -- Sandbox ------------------------------------------------------------------ - -/** @public */ -export interface KagentiSandboxSession { - contextId: string; - agentName?: string; - status: string; - title?: string; - visibility?: 'private' | 'namespace'; - createdAt?: string; - updatedAt?: string; -} - -/** @public */ -export interface KagentiSandboxAgentInfo { - name: string; - namespace: string; - sessionCount?: number; - [key: string]: unknown; -} - -/** @public */ -export interface KagentiSandboxCreateRequest { - [key: string]: unknown; -} - -// -- Sidecars ----------------------------------------------------------------- - -/** @public */ -export interface KagentiSidecar { - sidecarType: string; - enabled: boolean; - config?: Record; -} - -// -- Token Usage -------------------------------------------------------------- - -/** @public */ -export interface KagentiSessionTokenUsage { - contextId: string; - totalTokens?: number; - totalCost?: number; - models?: Array<{ model: string; tokens: number; cost?: number }>; -} - -// -- Integrations ------------------------------------------------------------- - -/** @public */ -export interface KagentiIntegration { - name: string; - namespace: string; - conditions?: Array<{ type: string; status: string; message?: string }>; - [key: string]: unknown; -} - -/** @public */ -export interface KagentiCreateIntegrationRequest { - name: string; - namespace: string; - [key: string]: unknown; -} - -// -- LLM Keys / Teams --------------------------------------------------------- - -/** @public */ -export interface KagentiLlmTeam { - teamId: string; - namespace: string; - [key: string]: unknown; -} - -/** @public */ -export interface KagentiLlmKey { - alias?: string; - agent?: string; - namespace?: string; - [key: string]: unknown; -} - -/** @public */ -export interface KagentiCreateTeamRequest { - namespace: string; - [key: string]: unknown; -} - -/** @public */ -export interface KagentiCreateKeyRequest { - namespace: string; - agentName: string; - [key: string]: unknown; -} - -// -- Triggers ----------------------------------------------------------------- - -/** @public */ -export interface KagentiTriggerRequest { - type: 'cron' | 'webhook' | 'alert'; - namespace: string; - skill?: string; - schedule?: string; - ttl_hours?: number; - [key: string]: unknown; -} - -// -- Dev Spaces --------------------------------------------------------------- - -/** Request body for creating a Dev Spaces workspace. @public */ -export interface DevSpacesCreateWorkspaceRequest { - namespace: string; - git_repo: string; - memory_limit?: string; - cpu_limit?: string; -} - -/** Response from the Dev Spaces workspace creation API. @public */ -export interface DevSpacesCreateWorkspaceResponse { - name: string; - namespace: string; - phase: string; - message?: string; - url?: string; - created_at?: string; -} - -/** Health check response for the Dev Spaces API. @public */ -export interface DevSpacesHealthResponse { - ok: boolean; - configured: boolean; - message: string; - apiUrl?: string; - responseTimeMs?: number; -} - -/** A single workspace entry returned by list/get operations. @public */ -export interface DevSpacesWorkspace { - name: string; - namespace: string; - phase: string; - url?: string; - created_at?: string; - started_at?: string; - stopped_at?: string; - git_repo?: string; - memory_limit?: string; - cpu_limit?: string; -} - -/** Response from the list-workspaces endpoint. @public */ -export interface DevSpacesListWorkspacesResponse { - workspaces: DevSpacesWorkspace[]; - namespace: string; -} - -/** Request to stop a workspace. @public */ -export interface DevSpacesStopWorkspaceRequest { - namespace: string; - name: string; -} - -/** Request to delete a workspace. @public */ -export interface DevSpacesDeleteWorkspaceRequest { - namespace: string; - name: string; -} diff --git a/workspaces/augment/plugins/augment-common/src/types/provider.test.ts b/workspaces/augment/plugins/augment-common/src/types/provider.test.ts deleted file mode 100644 index bd33548eea4..00000000000 --- a/workspaces/augment/plugins/augment-common/src/types/provider.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - GLOBAL_CONFIG_KEYS, - PROVIDER_SCOPED_KEYS, - scopedConfigKey, - isProviderScopedKey, - isGlobalConfigKey, -} from './provider'; - -describe('provider config key utilities', () => { - describe('GLOBAL_CONFIG_KEYS', () => { - it('contains expected global keys', () => { - expect(GLOBAL_CONFIG_KEYS).toContain('systemPrompt'); - expect(GLOBAL_CONFIG_KEYS).toContain('branding'); - expect(GLOBAL_CONFIG_KEYS).toContain('promptGroups'); - expect(GLOBAL_CONFIG_KEYS).toContain('safetyPatterns'); - expect(GLOBAL_CONFIG_KEYS).toContain('mcpServers'); - expect(GLOBAL_CONFIG_KEYS).toContain('disabledMcpServerIds'); - }); - - it('does not contain provider-scoped keys', () => { - for (const key of PROVIDER_SCOPED_KEYS) { - expect(GLOBAL_CONFIG_KEYS).not.toContain(key); - } - }); - - it('does not overlap with PROVIDER_SCOPED_KEYS', () => { - const globalSet = new Set(GLOBAL_CONFIG_KEYS); - const scopedSet = new Set(PROVIDER_SCOPED_KEYS); - const intersection = [...globalSet].filter(k => scopedSet.has(k)); - expect(intersection).toEqual([]); - }); - }); - - describe('PROVIDER_SCOPED_KEYS', () => { - it('contains expected provider-scoped keys', () => { - expect(PROVIDER_SCOPED_KEYS).toContain('model'); - expect(PROVIDER_SCOPED_KEYS).toContain('baseUrl'); - expect(PROVIDER_SCOPED_KEYS).toContain('toolChoice'); - expect(PROVIDER_SCOPED_KEYS).toContain('vectorStoreConfig'); - expect(PROVIDER_SCOPED_KEYS).toContain('activeVectorStoreIds'); - expect(PROVIDER_SCOPED_KEYS).toContain('safetyEnabled'); - expect(PROVIDER_SCOPED_KEYS).toContain('evaluationEnabled'); - }); - }); - - describe('scopedConfigKey', () => { - it('creates a scoped key with provider prefix', () => { - expect(scopedConfigKey('llamastack', 'model')).toBe('llamastack::model'); - }); - - it('works with googleadk provider', () => { - expect(scopedConfigKey('googleadk', 'baseUrl')).toBe( - 'googleadk::baseUrl', - ); - }); - - it('handles arbitrary key strings', () => { - expect(scopedConfigKey('llamastack', 'customKey')).toBe( - 'llamastack::customKey', - ); - }); - }); - - describe('isProviderScopedKey', () => { - it('returns true for provider-scoped keys', () => { - expect(isProviderScopedKey('model')).toBe(true); - expect(isProviderScopedKey('baseUrl')).toBe(true); - expect(isProviderScopedKey('toolChoice')).toBe(true); - expect(isProviderScopedKey('vectorStoreConfig')).toBe(true); - expect(isProviderScopedKey('safetyEnabled')).toBe(true); - expect(isProviderScopedKey('evaluationEnabled')).toBe(true); - }); - - it('returns false for global keys', () => { - expect(isProviderScopedKey('systemPrompt')).toBe(false); - expect(isProviderScopedKey('branding')).toBe(false); - expect(isProviderScopedKey('promptGroups')).toBe(false); - expect(isProviderScopedKey('mcpServers')).toBe(false); - }); - - it('returns false for unknown keys', () => { - expect(isProviderScopedKey('unknownKey')).toBe(false); - expect(isProviderScopedKey('')).toBe(false); - expect(isProviderScopedKey('activeProvider')).toBe(false); - }); - }); - - describe('isGlobalConfigKey', () => { - it('returns true for global keys', () => { - expect(isGlobalConfigKey('systemPrompt')).toBe(true); - expect(isGlobalConfigKey('branding')).toBe(true); - expect(isGlobalConfigKey('promptGroups')).toBe(true); - expect(isGlobalConfigKey('safetyPatterns')).toBe(true); - expect(isGlobalConfigKey('mcpServers')).toBe(true); - expect(isGlobalConfigKey('disabledMcpServerIds')).toBe(true); - }); - - it('returns false for provider-scoped keys', () => { - expect(isGlobalConfigKey('model')).toBe(false); - expect(isGlobalConfigKey('baseUrl')).toBe(false); - expect(isGlobalConfigKey('vectorStoreConfig')).toBe(false); - }); - - it('returns false for unknown keys', () => { - expect(isGlobalConfigKey('unknownKey')).toBe(false); - expect(isGlobalConfigKey('')).toBe(false); - expect(isGlobalConfigKey('activeProvider')).toBe(false); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment-common/src/types/provider.ts b/workspaces/augment/plugins/augment-common/src/types/provider.ts deleted file mode 100644 index b7989f8f589..00000000000 --- a/workspaces/augment/plugins/augment-common/src/types/provider.ts +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ============================================================================= -// Provider Types -// ============================================================================= - -/** - * Built-in AI provider identifiers shipped with the plugin. - * @public - */ -export type BuiltInProviderType = 'llamastack' | 'googleadk' | 'kagenti'; - -/** - * All accepted provider identifiers. - * - * Built-in types (`llamastack`, `googleadk`) are autocompleted, - * but arbitrary string IDs from extension-registered providers - * are also accepted via the `(string & {})` widening pattern. - * - * @public - */ -export type ProviderType = BuiltInProviderType | (string & {}); - -/** - * Capability categories that a provider may support. - * - * Used by the admin panel to conditionally render config tabs - * and by the backend to gate API endpoints. - * - * @public - */ -export interface ProviderCapabilities { - readonly chat: boolean; - readonly rag: boolean; - readonly safety: boolean; - readonly evaluation: boolean; - readonly conversations: boolean; - readonly mcpTools: boolean; - readonly tools: boolean; - /** Provider manages tool lifecycle (build, deploy, route) */ - readonly toolLifecycle: boolean; - /** Provider manages agent lifecycle (build, deploy, migrate) */ - readonly agentLifecycle: boolean; - /** Provider supports DevSpaces integration */ - readonly devSpaces: boolean; - /** Provider needs context hydrated from DB on non-streaming paths */ - readonly contextHydration: boolean; - /** Provider registers its own sub-routes (e.g. kagenti-specific endpoints) */ - readonly providerRoutes: boolean; -} - -/** - * Definition of a single provider-specific config field. - * - * The admin panel uses these to render dynamic forms based - * on which provider is active. - * - * @public - */ -export interface ProviderConfigField { - /** Config key (matches AdminConfigKey) */ - readonly key: string; - /** Human-readable label for the form field */ - readonly label: string; - /** Field type determines the form control rendered */ - readonly type: 'string' | 'boolean' | 'number' | 'select'; - /** Whether the field is required for the provider to function */ - readonly required: boolean; - /** Help text shown below the field */ - readonly description?: string; - /** Options for 'select' type fields */ - readonly options?: readonly string[]; - /** Placeholder text for text inputs */ - readonly placeholder?: string; - /** Whether the field value is sensitive (passwords, secrets) and should be masked in UI */ - readonly sensitive?: boolean; -} - -/** - * Describes an AI provider's identity, capabilities, and config shape. - * - * The provider registry holds one descriptor per known provider, - * including placeholders for providers that are not yet implemented. - * - * @public - */ -export interface ProviderDescriptor { - /** Unique provider identifier */ - readonly id: ProviderType; - /** Human-readable display name (e.g., "Llama Stack") */ - readonly displayName: string; - /** Short description of the provider */ - readonly description: string; - /** Whether this provider has a working implementation */ - readonly implemented: boolean; - /** Which capability categories this provider supports */ - readonly capabilities: ProviderCapabilities; - /** Provider-specific config field definitions for the admin panel */ - readonly configFields: readonly ProviderConfigField[]; -} - -// ============================================================================= -// Config Key Scoping -// ============================================================================= - -/** - * Config keys that are shared across all providers. - * - * These are stored without a provider prefix and apply - * regardless of which provider is active. - * - * @public - */ -export const GLOBAL_CONFIG_KEYS = [ - 'systemPrompt', - 'branding', - 'promptGroups', - 'safetyPatterns', - 'mcpServers', - 'disabledMcpServerIds', - 'agents', - 'defaultAgent', - 'maxAgentTurns', - 'devSpacesApiUrl', -] as const; - -/** - * Config keys that are scoped per provider. - * - * When stored in the database, these are prefixed with the - * provider ID (e.g., `llamastack::model`). This allows each - * provider to retain its own settings when switching. - * - * @public - */ -export const PROVIDER_SCOPED_KEYS = [ - 'model', - 'baseUrl', - 'kagentiBaseUrl', - 'toolChoice', - 'enableWebSearch', - 'enableCodeInterpreter', - 'safetyEnabled', - 'inputShields', - 'outputShields', - 'safetyOnError', - 'evaluationEnabled', - 'scoringFunctions', - 'minScoreThreshold', - 'evaluationOnError', - 'vectorStoreConfig', - 'activeVectorStoreIds', - 'maxToolCalls', - 'maxOutputTokens', - 'temperature', - 'safetyIdentifier', -] as const; - -/** - * Type-safe element of {@link GLOBAL_CONFIG_KEYS}. - * @public - */ -export type GlobalConfigKey = (typeof GLOBAL_CONFIG_KEYS)[number]; - -/** - * Type-safe element of {@link PROVIDER_SCOPED_KEYS}. - * @public - */ -export type ProviderScopedKey = (typeof PROVIDER_SCOPED_KEYS)[number]; - -/** - * Build a provider-scoped config key for database storage. - * - * @param provider - The provider ID to scope under - * @param key - The base config key - * @returns A scoped key in the format `provider::key` - * @public - */ -export function scopedConfigKey(provider: ProviderType, key: string): string { - return `${provider}::${key}`; -} - -/** - * Check whether a config key is provider-scoped. - * - * @param key - The config key to check - * @returns `true` if the key should be stored per-provider - * @public - */ -export function isProviderScopedKey(key: string): key is ProviderScopedKey { - return (PROVIDER_SCOPED_KEYS as readonly string[]).includes(key); -} - -/** - * Check whether a config key is global (shared across providers). - * - * @param key - The config key to check - * @returns `true` if the key is shared across all providers - * @public - */ -export function isGlobalConfigKey(key: string): key is GlobalConfigKey { - return (GLOBAL_CONFIG_KEYS as readonly string[]).includes(key); -} diff --git a/workspaces/augment/plugins/augment-common/src/types/shared.test.ts b/workspaces/augment/plugins/augment-common/src/types/shared.test.ts deleted file mode 100644 index 5b04ddb11b1..00000000000 --- a/workspaces/augment/plugins/augment-common/src/types/shared.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - normalizeLifecycleStage, - isValidTransition, - getAvailableTransitions, - deriveRoleFromTopology, -} from './shared'; - -describe('normalizeLifecycleStage', () => { - it('returns draft for undefined', () => { - expect(normalizeLifecycleStage(undefined)).toBe('draft'); - }); - - it('returns draft for empty string', () => { - expect(normalizeLifecycleStage('')).toBe('draft'); - expect(normalizeLifecycleStage(' ')).toBe('draft'); - }); - - it('normalizes valid stages to lowercase', () => { - expect(normalizeLifecycleStage('Published')).toBe('published'); - expect(normalizeLifecycleStage('PENDING')).toBe('pending'); - expect(normalizeLifecycleStage('archived')).toBe('archived'); - }); - - it('returns trimmed value for padded input (Finding C fix)', () => { - const result = normalizeLifecycleStage(' Published '); - expect(result).toBe('published'); - expect(result).not.toContain(' '); - }); - - it('maps legacy stage names', () => { - expect(normalizeLifecycleStage('registered')).toBe('pending'); - expect(normalizeLifecycleStage('deployed')).toBe('published'); - expect(normalizeLifecycleStage('Registered')).toBe('pending'); - expect(normalizeLifecycleStage('review')).toBe('pending'); - expect(normalizeLifecycleStage('staging')).toBe('pending'); - expect(normalizeLifecycleStage('production')).toBe('published'); - expect(normalizeLifecycleStage('retired')).toBe('archived'); - }); - - it('returns draft for unknown stage names', () => { - expect(normalizeLifecycleStage('unknown')).toBe('draft'); - expect(normalizeLifecycleStage('active')).toBe('draft'); - }); -}); - -describe('isValidTransition', () => { - it('allows draft -> pending', () => { - expect(isValidTransition('draft', 'pending')).toBe(true); - }); - - it('allows pending -> published', () => { - expect(isValidTransition('pending', 'published')).toBe(true); - }); - - it('allows pending -> draft (reject)', () => { - expect(isValidTransition('pending', 'draft')).toBe(true); - }); - - it('allows published -> pending (unpublish)', () => { - expect(isValidTransition('published', 'pending')).toBe(true); - }); - - it('allows published -> archived', () => { - expect(isValidTransition('published', 'archived')).toBe(true); - }); - - it('allows archived -> draft (reactivate)', () => { - expect(isValidTransition('archived', 'draft')).toBe(true); - }); - - it('rejects invalid transitions', () => { - expect(isValidTransition('draft', 'published')).toBe(false); - expect(isValidTransition('draft', 'archived')).toBe(false); - expect(isValidTransition('archived', 'published')).toBe(false); - expect(isValidTransition('published', 'draft')).toBe(false); - }); -}); - -describe('getAvailableTransitions', () => { - it('returns submit for draft', () => { - const transitions = getAvailableTransitions('draft'); - expect(transitions).toHaveLength(1); - expect(transitions[0].action).toBe('submit'); - }); - - it('returns approve, reject, and withdraw for pending', () => { - const transitions = getAvailableTransitions('pending'); - expect(transitions).toHaveLength(3); - expect(transitions.map(t => t.action)).toEqual( - expect.arrayContaining(['approve', 'reject', 'withdraw']), - ); - }); - - it('returns request-unpublish and archive for published', () => { - const transitions = getAvailableTransitions('published'); - expect(transitions).toHaveLength(2); - expect(transitions.map(t => t.action)).toEqual( - expect.arrayContaining(['request-unpublish', 'archive']), - ); - }); - - it('returns reactivate for archived', () => { - const transitions = getAvailableTransitions('archived'); - expect(transitions).toHaveLength(1); - expect(transitions[0].action).toBe('reactivate'); - }); -}); - -describe('deriveRoleFromTopology', () => { - it('returns standalone for agent with no connections', () => { - expect(deriveRoleFromTopology('agent-a', { 'agent-a': {} })).toBe( - 'standalone', - ); - }); - - it('returns router for agent with outgoing handoffs', () => { - expect( - deriveRoleFromTopology('router', { - router: { handoffs: ['specialist'] }, - specialist: {}, - }), - ).toBe('router'); - }); - - it('returns specialist for agent targeted by another', () => { - expect( - deriveRoleFromTopology('specialist', { - router: { handoffs: ['specialist'] }, - specialist: {}, - }), - ).toBe('specialist'); - }); - - it('returns standalone for null agent entry', () => { - expect(deriveRoleFromTopology('missing', { missing: null })).toBe( - 'standalone', - ); - }); -}); diff --git a/workspaces/augment/plugins/augment-common/src/types/shared.ts b/workspaces/augment/plugins/augment-common/src/types/shared.ts deleted file mode 100644 index 6886784e8d7..00000000000 --- a/workspaces/augment/plugins/augment-common/src/types/shared.ts +++ /dev/null @@ -1,828 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Token usage reported by the inference server for a single response turn. - * - * Mirrors the OpenAI Responses API `ResponseUsage` object. - * Llama Stack implements the same schema, so this type is - * the single source of truth for both frontend and backend. - * - * @see https://platform.openai.com/docs/api-reference/responses/object - * @public - */ -export interface ResponseUsage { - /** Total input tokens (cumulative across all internal agentic calls) */ - input_tokens: number; - /** Total output tokens (cumulative across all internal agentic calls) */ - output_tokens: number; - /** Sum of input_tokens + output_tokens (may include internal overhead) */ - total_tokens: number; - /** - * Breakdown of input tokens. - * Present when the server supports prompt caching. - */ - input_tokens_details?: InputTokensDetails; - /** - * Breakdown of output tokens. - * Llama Stack may return `null` when the model does not emit reasoning tokens. - */ - output_tokens_details?: OutputTokensDetails | null; -} - -/** - * Detailed breakdown of input tokens. - * @public - */ -export interface InputTokensDetails { - /** Tokens retrieved from the KV-cache (no additional compute cost) */ - cached_tokens?: number; -} - -/** - * Detailed breakdown of output tokens. - * @public - */ -export interface OutputTokensDetails { - /** Internal chain-of-thought tokens used by reasoning models (e.g. o3, o4-mini) */ - reasoning_tokens?: number; -} - -// ============================================================================= -// Shared Types (used by both frontend and backend) -// ============================================================================= - -/** - * Chat message in a conversation - * @public - */ -export interface ChatMessage { - role: 'system' | 'user' | 'assistant'; - content: string; -} - -/** - * Tool call information - * @public - */ -export interface ToolCallInfo { - id: string; - name: string; - serverLabel: string; - arguments: string; - output?: string; - error?: string; -} - -/** - * RAG source information from file_search_call.results - * This comes directly from Llama Stack Responses API when include: ['file_search_call.results'] - * @public - */ -export interface RAGSource { - /** Filename or document identifier */ - filename: string; - /** Relevant text snippet from the document */ - text?: string; - /** Relevance score (0-1) if provided by the API */ - score?: number; - /** File ID for reference */ - fileId?: string; - /** Document title from attributes (for display in citations) */ - title?: string; - /** Source URL from attributes (for clickable citations) */ - sourceUrl?: string; - /** Content type from attributes */ - contentType?: string; - /** Raw attributes object from Llama Stack */ - attributes?: Record; -} - -/** - * Aggregated evaluation result for a response - * @public - */ -export interface EvaluationResult { - /** Overall quality score (0-1) */ - overallScore: number; - /** Individual scores by function */ - scores: Record; - /** Whether the response passed quality threshold */ - passedThreshold: boolean; - /** Human-readable quality assessment */ - qualityLevel: 'excellent' | 'good' | 'fair' | 'poor'; - /** Timestamp of evaluation */ - evaluatedAt: string; - /** Error message if evaluation failed */ - error?: string; - /** True when evaluation was skipped (e.g. not available) rather than run */ - skipped?: boolean; -} - -/** - * File format enum - * @public - */ -export enum FileFormat { - YAML = 'yaml', - PDF = 'pdf', - TEXT = 'text', - MARKDOWN = 'markdown', - JSON = 'json', -} - -/** - * Information about an uploaded document - * @public - */ -export interface DocumentInfo { - id: string; - fileName: string; - format: FileFormat; - fileSize: number; - uploadedAt: string; - status: 'completed' | 'in_progress' | 'failed' | 'cancelled'; -} - -/** - * Provider status information - * @public - */ -export interface ProviderStatus { - id: string; - model: string; - baseUrl: string; - connected: boolean; - error?: string; -} - -/** - * Vector store status information - * @public - */ -export interface VectorStoreStatus { - id: string; - connected: boolean; - totalDocuments?: number; - error?: string; -} - -/** - * MCP server status information - * @public - */ -export interface MCPToolInfo { - name: string; - description?: string; -} - -/** - * @public - */ -export interface MCPServerStatus { - id: string; - name: string; - url: string; - connected: boolean; - error?: string; - /** Tools available on this MCP server (populated when connected) */ - tools?: MCPToolInfo[]; - /** Total number of tools (convenience field) */ - toolCount?: number; - /** Whether this server was defined in YAML (true) or added via admin UI (false) */ - source?: 'yaml' | 'admin'; -} - -/** - * Security mode for Augment - * @public - */ -export type SecurityMode = 'none' | 'plugin-only' | 'full'; - -/** - * Combined status for Augment - * @public - */ -export interface AugmentStatus { - /** Active provider identifier (e.g., 'llamastack', 'googleadk') */ - providerId: string; - provider: ProviderStatus; - vectorStore: VectorStoreStatus; - mcpServers: MCPServerStatus[]; - /** Current security mode: 'none' | 'plugin-only' | 'full' */ - securityMode: SecurityMode; - timestamp: string; - /** Whether the plugin is ready to handle requests (AI provider connected) */ - ready: boolean; - /** Blocking configuration errors that prevent the plugin from working */ - configurationErrors: string[]; - /** Whether the current user has admin privileges */ - isAdmin?: boolean; - /** Optional capability status — these are not required for basic chat */ - capabilities?: { - chat: boolean; - rag: { available: boolean; reason?: string }; - mcpTools: { available: boolean; reason?: string }; - /** Whether this provider supports listing agents for the catalog */ - agentCatalog?: boolean; - /** Whether the user must select an agent before chatting */ - agentSelection?: boolean; - /** Whether agents have rich metadata (cards, skills) */ - agentCards?: boolean; - }; - /** Summary of configured agents (multi-agent only) */ - agents?: Array<{ key: string; name: string; isDefault: boolean }>; - /** Tool execution mode: 'direct' or 'backend' */ - toolExecutionMode?: 'direct' | 'backend'; - /** Error from last agent graph resolution attempt, if any */ - agentGraphError?: string; -} - -/** - * Information about an available vector store - * @public - */ -export interface VectorStoreInfo { - id: string; - name: string; - status: string; - fileCount: number; - createdAt: number; - embeddingModel?: string; - embeddingDimension?: number; - providerType?: string; - usageBytes?: number; - lastActiveAt?: number; - fileCounts?: { - completed: number; - inProgress: number; - failed: number; - cancelled: number; - total: number; - }; -} - -/** - * Quick prompt configuration - * @public - */ -export interface QuickPrompt { - title: string; - description: string; - prompt: string; - category: string; -} - -// ============================================================================= -// Workflow Types (Configurable multi-step templates) -// ============================================================================= - -/** - * A single step within a workflow - * @public - */ -export interface WorkflowStep { - /** Step title shown in UI */ - title: string; - /** Prompt to send to chat when this step is executed */ - prompt: string; - /** Optional description for the step */ - description?: string; -} - -/** - * A workflow template - multi-step guided experience - * @public - */ -export interface Workflow { - /** Unique identifier */ - id: string; - /** Display name */ - name: string; - /** Short description */ - description: string; - /** Icon name (material icons) */ - icon?: string; - /** Category for grouping */ - category?: string; - /** Ordered list of steps */ - steps: WorkflowStep[]; - /** Whether this workflow is coming soon (disabled, non-clickable) */ - comingSoon?: boolean; - /** Custom coming soon label (defaults to "Coming Soon") */ - comingSoonLabel?: string; -} - -/** - * Quick action - single click prompt - * @public - */ -export interface QuickAction { - /** Display title */ - title: string; - /** Short description */ - description?: string; - /** Prompt to send to chat */ - prompt: string; - /** Icon name */ - icon?: string; - /** Category for grouping */ - category?: string; - /** Whether this action is coming soon (disabled, non-clickable) */ - comingSoon?: boolean; - /** Custom coming soon label (defaults to "Coming Soon") */ - comingSoonLabel?: string; -} - -/** - * A clickable prompt card within a prompt group on the welcome screen - * @public - */ -export interface PromptCard { - /** Display title */ - title: string; - /** Short description */ - description?: string; - /** Prompt to send to chat */ - prompt: string; - /** Icon name */ - icon?: string; - /** Agent to auto-select when this card is clicked (e.g. "namespace/agentName") */ - agentId?: string; - /** Whether this feature is coming soon (disabled, non-clickable) */ - comingSoon?: boolean; - /** Custom coming soon label (defaults to "Coming Soon") */ - comingSoonLabel?: string; -} - -/** - * Per-agent chat experience configuration set by admins. - * Controls which agents are visible to end users and how they appear. - * @public - */ -export interface ChatAgentConfig { - /** Agent identifier: "namespace/name" */ - agentId: string; - /** Whether this agent is published to the end-user catalog (derived from lifecycleStage === 'published') */ - published: boolean; - /** Whether this agent appears in end-user chat (only applies when published) */ - visible: boolean; - /** Whether this agent is featured prominently on the welcome screen */ - featured: boolean; - /** Lifecycle stage: draft → pending → published → archived */ - lifecycleStage?: AgentLifecycleStage; - /** Pending action when in 'pending' stage (e.g. publish or unpublish) */ - pendingAction?: 'publish' | 'unpublish'; - /** Promotion version — incremented each time the agent is promoted forward */ - version?: number; - /** ISO timestamp of last promotion */ - promotedAt?: string; - /** User ref of who last promoted this agent */ - promotedBy?: string; - /** User ref of who originally created/registered this agent in the lifecycle */ - createdBy?: string; - /** ISO timestamp of when the agent was first registered in the lifecycle */ - createdAt?: string; - /** Reason the agent was rejected (set on review → draft demotion, cleared on re-promote) */ - rejectionReason?: string; - /** User ref of who rejected the agent */ - rejectedBy?: string; - /** ISO timestamp of when the agent was rejected */ - rejectedAt?: string; - /** SonataFlow process instance id while awaiting admin approval */ - approvalWorkflowInstanceId?: string; - /** Display order (lower first) */ - order?: number; - /** Override display name */ - displayName?: string; - /** Override description */ - description?: string; - /** Custom avatar image URL */ - avatarUrl?: string; - /** Per-agent accent color (hex) */ - accentColor?: string; - /** Greeting message shown as first bot message on new conversation */ - greeting?: string; - /** Suggested prompts shown on the agent card and below the input */ - conversationStarters?: string[]; - /** Direct chat endpoint URL for skill agents (e.g. http://agent.ns.svc:8000) */ - chatEndpoint?: string; - /** K8s namespace where the skill agent is deployed */ - namespace?: string; -} - -/** - * Per-tool lifecycle configuration set by admins. - * Controls which tools are published to end users, mirroring the agent lifecycle. - * @public - */ -export interface ChatToolConfig { - /** Tool identifier: "namespace/name" */ - toolId: string; - /** Whether this tool is published to the end-user catalog (derived from lifecycleStage === 'published') */ - published: boolean; - /** Whether this tool appears in end-user listings (only applies when published) */ - visible: boolean; - /** Lifecycle stage: draft → pending → published → archived */ - lifecycleStage?: AgentLifecycleStage; - /** Promotion version — incremented each time the tool is promoted forward */ - version?: number; - /** ISO timestamp of last promotion */ - promotedAt?: string; - /** User ref of who last promoted this tool */ - promotedBy?: string; - /** User ref of who created this tool */ - createdBy?: string; - /** ISO timestamp of creation */ - createdAt?: string; -} - -/** - * A group of related prompts displayed on the welcome screen - * @public - */ -export interface PromptGroup { - /** Unique identifier */ - id: string; - /** Display title */ - title: string; - /** Description of this prompt group */ - description?: string; - /** Icon name */ - icon?: string; - /** Theme color (hex) */ - color?: string; - /** Display order (lower numbers appear first, defaults to array order if not specified) */ - order?: number; - /** Cards within this prompt group */ - cards: PromptCard[]; -} - -// ============================================================================= -// Unified Agent Catalog Types -// ============================================================================= - -/** - * Lifecycle stage of an agent in the promotion pipeline. - * - * draft → review → staging → production → retired - * - * - **draft**: Under development. Visible only to creator/admin. - * - **review**: Submitted for admin review. Appears in the Review Queue. - * - **staging**: Approved, available for internal testing. Not visible to end users. - * - **published**: Live in the end-user marketplace/catalog. - * - **archived**: Removed from production, preserved for audit/history. - * @public - */ -export type AgentLifecycleStage = - | 'draft' - | 'pending' - | 'published' - | 'archived'; - -/** Human-readable action name for a lifecycle transition. @public */ -export type AgentLifecycleAction = - | 'submit' - | 'approve' - | 'reject' - | 'publish' - | 'unpublish' - | 'archive' - | 'reactivate' - | 'withdraw' - | 'request-unpublish' - | 'approve-unpublish' - | 'reject-unpublish'; - -/** A single allowed lifecycle transition. @public */ -export interface AgentLifecycleTransition { - from: AgentLifecycleStage; - to: AgentLifecycleStage; - action: AgentLifecycleAction; - label: string; -} - -/** Ordered list of lifecycle stages for pipeline display. @public */ -export const LIFECYCLE_STAGE_ORDER: readonly AgentLifecycleStage[] = [ - 'draft', - 'pending', - 'published', - 'archived', -] as const; - -/** All valid lifecycle transitions with human-readable labels. @public */ -export const LIFECYCLE_TRANSITIONS: readonly AgentLifecycleTransition[] = [ - { - from: 'draft', - to: 'pending', - action: 'submit', - label: 'Submit for Review', - }, - { - from: 'pending', - to: 'published', - action: 'approve', - label: 'Approve and Publish', - }, - { from: 'pending', to: 'draft', action: 'reject', label: 'Reject' }, - { from: 'pending', to: 'draft', action: 'withdraw', label: 'Withdraw' }, - { - from: 'published', - to: 'pending', - action: 'request-unpublish', - label: 'Request Unpublish', - }, - { from: 'published', to: 'archived', action: 'archive', label: 'Archive' }, - { from: 'archived', to: 'draft', action: 'reactivate', label: 'Reactivate' }, -] as const; - -/** - * Maps old stage names to new ones for backward compatibility. - * @public - */ -export const LEGACY_STAGE_MAP: Record = { - registered: 'pending', - deployed: 'published', - review: 'pending', - staging: 'pending', - production: 'published', - retired: 'archived', -}; - -/** Returns the valid forward transitions from a given stage. @public */ -export function getAvailableTransitions( - stage: AgentLifecycleStage, -): AgentLifecycleTransition[] { - return LIFECYCLE_TRANSITIONS.filter(t => t.from === stage); -} - -/** Checks whether a transition from one stage to another is valid. @public */ -export function isValidTransition( - from: AgentLifecycleStage, - to: AgentLifecycleStage, -): boolean { - return LIFECYCLE_TRANSITIONS.some(t => t.from === from && t.to === to); -} - -/** Normalizes a legacy stage name to the current stage name. @public */ -export function normalizeLifecycleStage( - stage: string | undefined, -): AgentLifecycleStage { - if (!stage) return 'draft'; - const trimmed = stage.trim().toLocaleLowerCase('en-US'); - if (!trimmed) return 'draft'; - if (Object.hasOwn(LEGACY_STAGE_MAP, trimmed)) - return LEGACY_STAGE_MAP[trimmed]; - if ((LIFECYCLE_STAGE_ORDER as readonly string[]).includes(trimmed)) { - return trimmed as AgentLifecycleStage; - } - return 'draft'; -} - -/** - * Provider-agnostic representation of a chat agent. - * Used by the agent catalog/gallery to display agents from any provider - * (Kagenti, Llama Stack config-driven, or custom). - * @public - */ -export interface ChatAgent { - /** Unique agent identifier (e.g. "namespace/name" for Kagenti, agent key for Llama Stack) */ - id: string; - /** Display name */ - name: string; - /** Human-readable description */ - description?: string; - /** Runtime status (e.g. "Ready", "Pending", "config") */ - status: string; - /** Whether this is the default/entry agent */ - isDefault?: boolean; - /** Provider that owns this agent */ - providerType: string; - /** Conversation starters / example prompts */ - starters?: string[]; - /** Avatar image URL */ - avatarUrl?: string; - /** Agent creation timestamp */ - createdAt?: string; - /** Agent framework label (e.g. "a2a", "llamastack") */ - framework?: string; - /** Protocol labels (e.g. "A2A", "MCP") */ - protocols?: string[]; - /** Whether this agent is published to the end-user catalog (derived: lifecycleStage === 'published') */ - published?: boolean; - /** Origin of this agent: 'kagenti' | 'orchestration' | 'external' */ - source?: string; - /** Namespace the agent belongs to (for Kagenti agents) */ - namespace?: string; - /** Current lifecycle stage in the promotion pipeline */ - lifecycleStage?: AgentLifecycleStage; - /** Pending action when in 'pending' stage (e.g. publish or unpublish) */ - pendingAction?: 'publish' | 'unpublish'; - /** Promotion version (increments each time the agent is promoted) */ - version?: number; - /** When this agent was last promoted */ - promotedAt?: string; - /** Who promoted this agent */ - promotedBy?: string; - /** User ref of who originally created/registered this agent in the lifecycle */ - createdBy?: string; - /** Reason the agent was rejected (set on review → draft demotion, cleared on re-promote) */ - rejectionReason?: string; - /** User ref of who rejected the agent */ - rejectedBy?: string; - /** ISO timestamp of when the agent was rejected */ - rejectedAt?: string; - /** Role of this agent in the orchestration topology */ - agentRole?: AgentRole; - /** - * True when a chatAgents lifecycle entry exists for this agent. - * False for runtime-only agents (Kagenti/orchestration) not yet in governance. - */ - governanceRegistered?: boolean; - /** Direct chat endpoint URL for skill agents (e.g. http://agent.ns.svc:8000) */ - chatEndpoint?: string; -} - -/** - * Possible roles an agent can have in the orchestration topology. - * Always auto-derived from connections, never manually set. - * @public - */ -export type AgentRole = 'router' | 'specialist' | 'standalone'; - -/** - * Minimal agent shape required for topology-based role derivation. - * Both frontend form data and backend config objects satisfy this. - * @public - */ -export interface AgentTopologyNode { - handoffs?: string[]; - asTools?: string[]; -} - -/** - * Derives an agent's role from the multi-agent topology. - * This is the single source of truth for role derivation across - * both frontend and backend. - * - * Rules: - * - Has outgoing handoffs or asTools -> 'router' - * - Is a target of another agent's handoffs or asTools -> 'specialist' - * - Neither -> 'standalone' - * - * @public - */ -export function deriveRoleFromTopology( - agentKey: string, - allAgents: Record, -): AgentRole { - const agent = allAgents[agentKey]; - if (!agent) return 'standalone'; - - const hasOutgoing = - (agent.handoffs && agent.handoffs.length > 0) || - (agent.asTools && agent.asTools.length > 0); - - if (hasOutgoing) return 'router'; - - const isTarget = Object.entries(allAgents).some( - ([k, a]) => - k !== agentKey && - a !== null && - a !== undefined && - ((a.handoffs ?? []).includes(agentKey) || - (a.asTools ?? []).includes(agentKey)), - ); - - if (isTarget) return 'specialist'; - return 'standalone'; -} - -// ============================================================================= -// Conversation History Types -// ============================================================================= - -/** - * Conversation summary for UI display. - * Derived from stored responses. - * @public - */ -export interface ConversationSummary { - /** Response ID — use to continue this conversation */ - responseId: string; - /** Preview of the conversation (first user message or assistant response) */ - preview: string; - /** When the conversation was created */ - createdAt: Date; - /** Model used */ - model: string; - /** Status */ - status: 'completed' | 'failed' | 'in_progress'; - /** Llama Stack conversation ID (present for new-style conversations) */ - conversationId?: string; - /** Previous response ID for chain deduplication */ - previousResponseId?: string; -} - -/** - * Base response from the chat endpoint, shared between frontend and backend. - * Frontend extends this with additional fields (filtered, evaluation, etc.). - * @public - */ -export interface ChatResponse { - role: 'assistant'; - content: string; - ragContext?: string[]; - /** @deprecated Use ragSources instead */ - filesSearched?: string[]; - ragSources?: RAGSource[]; - toolCalls?: ToolCallInfo[]; - responseId?: string; - usage?: ResponseUsage; - /** Name of the agent that produced the final response (multi-agent only) */ - agentName?: string; - /** Ordered list of agent keys visited during handoffs, e.g. ["triage", "billing"] */ - handoffPath?: string[]; - /** Present when a backend-executed tool requires HITL approval before execution */ - pendingApproval?: PendingApprovalInfo; - /** All pending approvals when multiple backend tools need HITL approval */ - pendingApprovals?: PendingApprovalInfo[]; - /** Structured output validation error (G5). Present when the agent's outputSchema validation failed. */ - outputValidationError?: string; - /** Reasoning/thinking summaries from the model's chain-of-thought. */ - reasoning?: ReasoningSummary[]; -} - -/** - * Summary of model reasoning from chain-of-thought output. - * @public - */ -export interface ReasoningSummary { - id: string; - text: string; -} - -/** - * Information about a single pending HITL approval. - * @public - */ -export interface PendingApprovalInfo { - approvalRequestId: string; - toolName: string; - serverLabel?: string; - arguments?: string; -} - -/** - * Result of a document sync operation. - * @public - */ -export interface SyncResult { - added: number; - removed: number; - updated: number; - failed: number; - unchanged: number; - errors: string[]; -} - -/** - * Detailed tool information for the capabilities-aware prompt generator. - * Used by the frontend capabilities selector to send tool descriptions - * to the backend so the meta-prompt can reference what each tool does. - * @public - */ -export interface ToolCapabilityInfo { - name: string; - description?: string; - serverLabel?: string; -} - -/** - * User-selected capabilities to emphasize when generating agent instructions. - * Sent to the backend so the meta-prompt includes specific tool descriptions - * and contextual detail about the agent's environment. - * @public - */ -export interface PromptCapabilities { - tools?: ToolCapabilityInfo[]; - enableWebSearch?: boolean; - enableCodeInterpreter?: boolean; - ragEnabled?: boolean; - vectorStoreNames?: string[]; - safetyEnabled?: boolean; - safetyShields?: string[]; -} diff --git a/workspaces/augment/plugins/augment-common/src/types/streaming.ts b/workspaces/augment/plugins/augment-common/src/types/streaming.ts deleted file mode 100644 index eff41beec8a..00000000000 --- a/workspaces/augment/plugins/augment-common/src/types/streaming.ts +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ResponseUsage } from './shared'; - -// ============================================================================= -// Normalized Streaming Events -// ============================================================================= -// -// These events form the contract between the backend provider and the frontend. -// Each provider adapter maps its native streaming format to these events. -// The frontend reducer ONLY processes normalized events — never raw provider events. -// -// Design principles: -// - Discriminated union on `type` for exhaustive switch handling -// - Each event carries only the data needed for that event -// - Event names use dot notation: stream.. -// - Provider-specific data is normalized away before reaching the frontend - -/** Response has been created, streaming is starting. @public */ -export interface StreamStartedEvent { - type: 'stream.started'; - responseId: string; - model?: string; - /** Server-side creation timestamp (Unix epoch seconds) */ - createdAt?: number; -} - -/** A chunk of generated text. @public */ -export interface StreamTextDeltaEvent { - type: 'stream.text.delta'; - delta: string; -} - -/** Text generation is complete for this response. @public */ -export interface StreamTextDoneEvent { - type: 'stream.text.done'; - text: string; -} - -/** A chunk of reasoning/thinking text (models with chain-of-thought). @public */ -export interface StreamReasoningDeltaEvent { - type: 'stream.reasoning.delta'; - delta: string; -} - -/** Reasoning text is complete. @public */ -export interface StreamReasoningDoneEvent { - type: 'stream.reasoning.done'; - text: string; -} - -/** Provider is discovering available tools (e.g., listing MCP server tools). @public */ -export interface StreamToolDiscoveryEvent { - type: 'stream.tool.discovery'; - serverLabel?: string; - status: 'in_progress' | 'completed'; - toolCount?: number; -} - -/** A tool call has started. @public */ -export interface StreamToolStartedEvent { - type: 'stream.tool.started'; - callId: string; - name: string; - serverLabel?: string; -} - -/** Tool call arguments are streaming in. @public */ -export interface StreamToolDeltaEvent { - type: 'stream.tool.delta'; - callId: string; - delta: string; -} - -/** Tool call completed with output. @public */ -export interface StreamToolCompletedEvent { - type: 'stream.tool.completed'; - callId: string; - name: string; - serverLabel?: string; - output?: string; - error?: string; -} - -/** Tool call failed. @public */ -export interface StreamToolFailedEvent { - type: 'stream.tool.failed'; - callId: string; - name: string; - serverLabel?: string; - error: string; -} - -/** Tool call requires human approval (HITL). @public */ -export interface StreamToolApprovalEvent { - type: 'stream.tool.approval'; - callId: string; - name: string; - serverLabel?: string; - arguments?: string; - /** Response ID for this approval request, for reliable approval submission. */ - responseId?: string; -} - -/** Backend is executing tool calls on behalf of the LLM. @public */ -export interface StreamBackendToolExecutingEvent { - type: 'stream.backend_tool.executing'; - toolCount: number; - tools: string[]; -} - -/** RAG search results arrived. @public */ -export interface StreamRagResultsEvent { - type: 'stream.rag.results'; - sources: Array<{ - filename: string; - fileId?: string; - text?: string; - score?: number; - title?: string; - sourceUrl?: string; - contentType?: string; - attributes?: Record; - }>; - filesSearched?: string[]; -} - -/** The response is fully complete. @public */ -export interface StreamCompletedEvent { - type: 'stream.completed'; - responseId?: string; - usage?: ResponseUsage; - /** Display name of the agent that produced the final response (multi-agent only) */ - agentName?: string; - /** Output validation error when structured output schema validation failed */ - outputValidationError?: string; -} - -/** An agent handoff occurred during multi-agent streaming. @public */ -export interface StreamAgentHandoffEvent { - type: 'stream.agent.handoff'; - fromAgent?: string; - toAgent: string; - /** Optional reason provided by the model for the handoff */ - reason?: string; -} - -/** A form field descriptor for input-required forms. @public */ -export interface StreamFormField { - name: string; - type?: string; - label?: string; - description?: string; - required?: boolean; - defaultValue?: unknown; - options?: Array<{ label: string; value: string }>; - [key: string]: unknown; -} - -/** Shape of a form render request from an A2A agent. @public */ -export interface StreamFormDescriptor { - title?: string; - description?: string; - fields?: StreamFormField[]; - [key: string]: unknown; -} - -/** Agent requests structured form input from the user (A2A INPUT_REQUIRED). @public */ -export interface StreamFormRequestEvent { - type: 'stream.form.request'; - taskId?: string; - contextId?: string; - form: StreamFormDescriptor; -} - -/** Shape of a secret demand from an A2A agent. @public */ -export interface StreamSecretDemand { - name: string; - description?: string; - [key: string]: unknown; -} - -/** Agent requires authentication (A2A AUTH_REQUIRED -- OAuth or secrets). @public */ -export interface StreamAuthRequiredEvent { - type: 'stream.auth.required'; - taskId?: string; - authType: 'oauth' | 'secret'; - url?: string; - demands?: { secrets?: StreamSecretDemand[]; [key: string]: unknown }; -} - -/** Agent is streaming an artifact (code, file, document). @public */ -export interface StreamArtifactEvent { - type: 'stream.artifact'; - artifactId: string; - name?: string; - description?: string; - content: string; - append?: boolean; - lastChunk?: boolean; -} - -/** A single citation reference. @public */ -export interface StreamCitationReference { - title?: string; - url?: string; - snippet?: string; - [key: string]: unknown; -} - -/** Agent provides source citations for its response. @public */ -export interface StreamCitationEvent { - type: 'stream.citation'; - citations: StreamCitationReference[]; -} - -/** An error occurred during streaming. @public */ -export interface StreamErrorEvent { - type: 'stream.error'; - error: string; - code?: string; - title?: string; - context?: Record; -} - -/** - * Union of all normalized streaming events. - * - * This is the single source of truth for the streaming contract - * between the backend and frontend. Both the backend's EventNormalizer - * and the frontend's StreamingMessage reducer use this type. - * @public - */ -export type NormalizedStreamEvent = - | StreamStartedEvent - | StreamTextDeltaEvent - | StreamTextDoneEvent - | StreamReasoningDeltaEvent - | StreamReasoningDoneEvent - | StreamToolDiscoveryEvent - | StreamToolStartedEvent - | StreamToolDeltaEvent - | StreamToolCompletedEvent - | StreamToolFailedEvent - | StreamToolApprovalEvent - | StreamBackendToolExecutingEvent - | StreamRagResultsEvent - | StreamAgentHandoffEvent - | StreamFormRequestEvent - | StreamAuthRequiredEvent - | StreamArtifactEvent - | StreamCitationEvent - | StreamCompletedEvent - | StreamErrorEvent; diff --git a/workspaces/augment/plugins/augment-common/src/types/workflowBuilder.ts b/workspaces/augment/plugins/augment-common/src/types/workflowBuilder.ts deleted file mode 100644 index ad27e3b846b..00000000000 --- a/workspaces/augment/plugins/augment-common/src/types/workflowBuilder.ts +++ /dev/null @@ -1,621 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ============================================================================= -// Workflow Builder Types -// -// These types define the serializable workflow definition format used by the -// visual agent builder canvas. A WorkflowDefinition is a directed graph of -// nodes (agents, tools, guardrails, logic, etc.) connected by typed edges. -// At runtime the backend hydrates this definition into live @openai/agents-core -// Agent / Runner instances for execution against LlamaStack APIs. -// ============================================================================= - -/** - * Status of a workflow in its lifecycle. - * @public - */ -export type WorkflowStatus = 'draft' | 'published' | 'archived'; - -/** - * Discriminated node types available in the workflow canvas. - * @public - */ -export type WorkflowNodeType = - | 'start' - | 'agent' - | 'tool' - | 'guardrail' - | 'logic' - | 'user_interaction' - | 'classify' - | 'end' - | 'note' - | 'transform' - | 'set_state' - | 'file_search' - | 'mcp'; - -/** - * Position on the canvas for visual layout. - * @public - */ -export interface NodePosition { - x: number; - y: number; -} - -// --------------------------------------------------------------------------- -// Node Data Payloads -// --------------------------------------------------------------------------- - -/** - * Data for the Start node -- defines the workflow's input contract. - * @public - */ -export interface StartNodeData { - inputDescription?: string; - inputSchema?: Record; -} - -/** - * Data for an Agent node -- carries the full agent configuration. - * @public - */ -export interface AgentNodeData { - agentKey: string; - name: string; - instructions: string; - handoffDescription?: string; - model?: string; - mcpServers?: string[]; - enableRAG?: boolean; - vectorStoreIds?: string[]; - enableWebSearch?: boolean; - enableCodeInterpreter?: boolean; - functions?: AgentFunctionDef[]; - toolChoice?: string; - temperature?: number; - maxOutputTokens?: number; - maxToolCalls?: number; - reasoning?: { effort?: string }; - guardrails?: string[]; - outputSchema?: Record; - toolUseBehavior?: string; - resetToolChoice?: boolean; - handoffInputFilter?: string; - handoffInputSchema?: Record; - nestHandoffHistory?: boolean; - truncation?: string; - promptRef?: { - id: string; - version?: number; - variables?: Record; - }; - enabled?: boolean; -} - -/** - * A custom function definition attached to an agent. - * @public - */ -export interface AgentFunctionDef { - name: string; - description: string; - parameters: Record; -} - -/** - * Tool type discriminator for ToolNodeData. - * @public - */ -export type ToolNodeKind = - | 'mcp_server' - | 'file_search' - | 'web_search' - | 'code_interpreter' - | 'custom_function'; - -/** - * Data for a Tool node -- references an external capability. - * @public - */ -export interface ToolNodeData { - kind: ToolNodeKind; - label: string; - mcpServerId?: string; - mcpToolFilter?: string[]; - vectorStoreIds?: string[]; - functionDef?: AgentFunctionDef; - requireApproval?: boolean; -} - -/** - * Data for a Guardrail node -- safety checks on input/output. - * @public - */ -export interface GuardrailNodeData { - guardType: 'input' | 'output'; - shieldIds?: string[]; - customRules?: GuardrailRule[]; - onFailure: 'block' | 'warn' | 'fallback'; - fallbackMessage?: string; -} - -/** - * A custom guardrail rule (regex, keyword, or LLM-graded). - * @public - */ -export interface GuardrailRule { - id: string; - name: string; - type: 'regex' | 'keyword_block' | 'llm_grader'; - pattern?: string; - keywords?: string[]; - graderPrompt?: string; -} - -/** - * Logic node sub-type. - * @public - */ -export type LogicNodeKind = 'if_else' | 'while_loop' | 'switch'; - -/** - * Data for a Logic node -- conditional routing and loops. - * @public - */ -export interface LogicNodeData { - kind: LogicNodeKind; - condition: string; - cases?: Array<{ label: string; condition: string }>; - maxIterations?: number; -} - -/** - * Data for a User Interaction node -- HITL approval gates and forms. - * @public - */ -export interface UserInteractionNodeData { - interactionType: 'approval_gate' | 'form_input' | 'confirmation'; - prompt: string; - formFields?: UserInteractionField[]; - timeoutSeconds?: number; - defaultAction?: 'approve' | 'reject' | 'skip'; -} - -/** - * A field in a user interaction form. - * @public - */ -export interface UserInteractionField { - id: string; - label: string; - type: 'text' | 'select' | 'boolean' | 'number'; - required?: boolean; - options?: string[]; - defaultValue?: string; -} - -/** - * Data for a Classify node -- routes to different branches based on LLM classification. - * @public - */ -export interface ClassifyNodeData { - classifications: Array<{ label: string; description: string }>; - instructions?: string; - model?: string; -} - -/** - * Data for an End node -- terminates the workflow execution. - * @public - */ -export interface EndNodeData { - outputExpression?: string; -} - -/** - * Data for a Note node -- annotation-only, no execution semantics. - * @public - */ -export interface NoteNodeData { - text: string; -} - -/** - * Data for a Transform node -- transforms data between steps using expressions. - * @public - */ -export interface TransformNodeData { - expression: string; - outputVariable?: string; -} - -/** - * Data for a Set State node -- assigns values to workflow state variables. - * @public - */ -export interface SetStateNodeData { - assignments: Record; -} - -/** - * Data for a File Search node -- configures vector store search. - * @public - */ -export interface FileSearchNodeData { - vectorStoreIds?: string[]; - maxResults?: number; - scoreThreshold?: number; -} - -/** - * Data for an MCP node -- configures a Model Context Protocol server. - * @public - */ -export interface McpNodeData { - serverUrl: string; - serverLabel: string; - requireApproval?: 'always' | 'never' | 'auto'; - allowedTools?: string[]; - headers?: Record; -} - -/** - * Union of all node data payloads, discriminated by the parent node's `type`. - * @public - */ -export type WorkflowNodeData = - | StartNodeData - | AgentNodeData - | ToolNodeData - | GuardrailNodeData - | LogicNodeData - | UserInteractionNodeData - | ClassifyNodeData - | EndNodeData - | NoteNodeData - | TransformNodeData - | SetStateNodeData - | FileSearchNodeData - | McpNodeData; - -// --------------------------------------------------------------------------- -// Workflow Graph Structure -// --------------------------------------------------------------------------- - -/** - * A single node in the workflow graph. - * @public - */ -export interface WorkflowNode { - id: string; - type: WorkflowNodeType; - position: NodePosition; - data: WorkflowNodeData; - label?: string; -} - -/** - * Edge type determines the connection semantics. - * @public - */ -export type WorkflowEdgeType = - | 'handoff' - | 'tool_binding' - | 'guardrail_binding' - | 'sequence' - | 'conditional'; - -/** - * A connection between two nodes in the workflow graph. - * @public - */ -export interface WorkflowEdge { - id: string; - source: string; - target: string; - sourceHandle?: string; - targetHandle?: string; - type?: WorkflowEdgeType; - label?: string; - condition?: string; -} - -// --------------------------------------------------------------------------- -// Workflow Settings and Metadata -// --------------------------------------------------------------------------- - -/** - * Global settings that apply to the entire workflow. - * @public - */ -export interface WorkflowSettings { - maxTurns?: number; - defaultModel?: string; - globalGuardrails?: string[]; - conversationPersistence?: boolean; - tracingEnabled?: boolean; - timeoutSeconds?: number; -} - -/** - * A published, immutable version snapshot of a workflow. - * @public - */ -export interface WorkflowVersion { - version: number; - publishedAt: string; - publishedBy: string; - changelog?: string; - definition: WorkflowDefinition; -} - -/** - * The complete workflow definition -- the top-level serializable artifact. - * - * This is persisted in the admin config store and exchanged between the - * frontend canvas editor and the backend hydration engine. - * @public - */ -export interface WorkflowDefinition { - id: string; - name: string; - description: string; - version: number; - createdAt: string; - updatedAt: string; - createdBy?: string; - updatedBy?: string; - status: WorkflowStatus; - nodes: WorkflowNode[]; - edges: WorkflowEdge[]; - settings: WorkflowSettings; - tags?: string[]; -} - -// --------------------------------------------------------------------------- -// Workflow Execution Types -// --------------------------------------------------------------------------- - -/** - * Status of a workflow execution run. - * @public - */ -export type WorkflowRunStatus = - | 'pending' - | 'running' - | 'completed' - | 'failed' - | 'interrupted' - | 'cancelled'; - -/** - * A record of a single node's execution within a run. - * @public - */ -export interface NodeExecutionRecord { - nodeId: string; - nodeType: WorkflowNodeType; - nodeName: string; - startedAt: string; - completedAt?: string; - status: 'running' | 'completed' | 'failed' | 'skipped'; - input?: unknown; - output?: unknown; - error?: string; - tokenUsage?: { inputTokens: number; outputTokens: number }; - durationMs?: number; -} - -/** - * A complete execution trace for a workflow run. - * @public - */ -export interface WorkflowRunTrace { - runId: string; - workflowId: string; - workflowVersion: number; - status: WorkflowRunStatus; - startedAt: string; - completedAt?: string; - nodeExecutions: NodeExecutionRecord[]; - totalTokenUsage?: { inputTokens: number; outputTokens: number }; - totalDurationMs?: number; - error?: string; -} - -// --------------------------------------------------------------------------- -// Evaluation Types -// --------------------------------------------------------------------------- - -/** - * A single test case for evaluating a workflow. - * @public - */ -export interface WorkflowTestCase { - id: string; - name: string; - input: string; - expectedOutput?: string; - expectedAgent?: string; - criteria?: EvaluationCriterion[]; - tags?: string[]; -} - -/** - * A criterion for grading a workflow run. - * @public - */ -export interface EvaluationCriterion { - id: string; - name: string; - type: 'exact_match' | 'contains' | 'regex' | 'llm_graded' | 'custom'; - weight: number; - pattern?: string; - graderPrompt?: string; - scoringFunction?: string; - threshold?: number; -} - -/** - * Result of evaluating a single test case. - * @public - */ -export interface TestCaseResult { - testCaseId: string; - passed: boolean; - score: number; - actualOutput: string; - actualAgent?: string; - criterionResults: Array<{ - criterionId: string; - passed: boolean; - score: number; - details?: string; - }>; - trace: WorkflowRunTrace; - durationMs: number; -} - -/** - * Result of an evaluation run across multiple test cases. - * @public - */ -export interface WorkflowEvaluationResult { - evaluationId: string; - workflowId: string; - workflowVersion: number; - ranAt: string; - ranBy?: string; - testCaseResults: TestCaseResult[]; - overallScore: number; - passRate: number; - totalDurationMs: number; -} - -/** - * A stored set of test cases for repeatable evaluation. - * @public - */ -export interface WorkflowTestSuite { - id: string; - name: string; - workflowId: string; - testCases: WorkflowTestCase[]; - createdAt: string; - updatedAt: string; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Creates a default empty workflow definition. - */ -export function createDefaultWorkflow( - id: string, - name: string, - createdBy?: string, -): WorkflowDefinition { - const now = new Date().toISOString(); - return { - id, - name, - description: '', - version: 0, - createdAt: now, - updatedAt: now, - createdBy, - updatedBy: createdBy, - status: 'draft', - nodes: [ - { - id: 'start-1', - type: 'start', - position: { x: 50, y: 150 }, - data: { inputDescription: 'User message' } as StartNodeData, - label: 'Start', - }, - { - id: 'agent-1', - type: 'agent', - position: { x: 300, y: 140 }, - data: { - agentKey: 'agent-1', - name: 'Agent', - instructions: '', - handoffDescription: '', - } as AgentNodeData, - label: 'Agent', - }, - ], - edges: [ - { - id: 'edge-start-agent-1', - source: 'start-1', - target: 'agent-1', - type: 'sequence', - }, - ], - settings: { - maxTurns: 10, - conversationPersistence: true, - tracingEnabled: true, - }, - }; -} - -/** - * Type guard for AgentNodeData. - */ -export function isAgentNodeData(data: WorkflowNodeData): data is AgentNodeData { - return 'agentKey' in data && 'instructions' in data; -} - -/** - * Type guard for ToolNodeData. - */ -export function isToolNodeData(data: WorkflowNodeData): data is ToolNodeData { - return 'kind' in data && 'label' in data && !('agentKey' in data); -} - -/** - * Type guard for GuardrailNodeData. - */ -export function isGuardrailNodeData( - data: WorkflowNodeData, -): data is GuardrailNodeData { - return 'guardType' in data && 'onFailure' in data; -} - -/** - * Type guard for LogicNodeData. - */ -export function isLogicNodeData(data: WorkflowNodeData): data is LogicNodeData { - return 'kind' in data && 'condition' in data && !('label' in data); -} - -/** - * Type guard for UserInteractionNodeData. - */ -export function isUserInteractionNodeData( - data: WorkflowNodeData, -): data is UserInteractionNodeData { - return 'interactionType' in data && 'prompt' in data; -} diff --git a/workspaces/augment/plugins/augment/.eslintrc.js b/workspaces/augment/plugins/augment/.eslintrc.js deleted file mode 100644 index 9d25fb8c391..00000000000 --- a/workspaces/augment/plugins/augment/.eslintrc.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - ...require('@backstage/cli/config/eslint-factory')(__dirname), - ignorePatterns: ['dist/**'], -}; - diff --git a/workspaces/augment/plugins/augment/CHANGELOG.md b/workspaces/augment/plugins/augment/CHANGELOG.md deleted file mode 100644 index 9f29dc7fb38..00000000000 --- a/workspaces/augment/plugins/augment/CHANGELOG.md +++ /dev/null @@ -1,40 +0,0 @@ -# @red-hat-developer-hub/backstage-plugin-augment - -## 0.3.0 - -### Minor Changes - -- aa44c81: Bump minor after the release-2.1 cut so main patch versions do not collide with the maintenance line on npm. - -### Patch Changes - -- Updated dependencies [aa44c81] - - @red-hat-developer-hub/backstage-plugin-augment-common@0.3.0 - -## 0.2.1 - -### Patch Changes - -- 44358a3: Updated dependency `@openai/agents-core` to `^0.20.0`. - Updated dependency `katex` to `^0.19.0`. - -## 0.2.0 - -### Minor Changes - -- b418448: Add agent registry governance: unified agent listings with governanceRegistered flag, register-for-governance API, strict lifecycle transitions on publish/unpublish, and Registry UI with unregistered agent warnings and bulk actions. -- afaed59: Initial release of the Augment AI assistant plugin for Backstage. Provides a chat interface backed by Llama Stack with RAG, tool calling, multi-agent orchestration, and configurable safety guardrails. -- 3e18b17: Agent lifecycle hardening: add 5-stage promotion pipeline (draft/review/staging/production/retired), ownership-gated operations, rejection tracking, role-aware UI, admin polling dashboards, cascading delete, and SonataFlow agent-approval workflow. -- 717a651: Add SonataFlow agent approval workflow integration with dual-mode support (immediate or workflow-based transitions), request-unpublish and withdraw endpoints. - -### Patch Changes - -- 7b3f093: Updated dependency `katex` to `^0.18.0`. -- fa3478a: Updated dependency `@openai/agents-core` to `^0.11.0`. - Updated dependency `katex` to `^0.17.0`. -- f5f0d02: Updated translations for RHDH 2.1 (s3297). -- Updated dependencies [b418448] -- Updated dependencies [afaed59] -- Updated dependencies [3e18b17] -- Updated dependencies [717a651] - - @red-hat-developer-hub/backstage-plugin-augment-common@0.2.0 diff --git a/workspaces/augment/plugins/augment/README.md b/workspaces/augment/plugins/augment/README.md deleted file mode 100644 index 257dffbfec4..00000000000 --- a/workspaces/augment/plugins/augment/README.md +++ /dev/null @@ -1,412 +0,0 @@ -# Augment Plugin - -A Backstage frontend plugin for **Augment** — a multi-agent AI assistant built on Llama Stack's OpenAI-compatible Responses API. - -Augment adapts to YOUR documentation and tools. Configure agents, connect MCP servers, ingest knowledge bases, and get AI-powered answers — all from a single chat interface inside Backstage. - -## Features - -- **Multi-Agent Chat**: Conversations can span multiple specialized agents via handoffs, with the active agent displayed in the UI -- **RAG-Powered Search**: Ask questions and get accurate answers grounded in your documentation -- **Config-Driven Ingestion**: Documents are automatically synced from configured sources (directories, URLs, GitHub repos) -- **Agentic AI**: Powered by the Responses API — agents reason about questions, call tools, and synthesize answers -- **Rich Chat Interface**: Streaming responses with markdown, code blocks, and source citations -- **MCP Integration**: Connect to MCP servers for extended tool capabilities (e.g., Kubernetes, OpenShift) -- **Configurable Prompts**: Customize quick prompts for your specific use cases -- **Conversation History**: Persistent chat sessions backed by Llama Stack's Conversations API -- **3 Security Modes**: Flexible authentication from open access to full Keycloak integration - -## Screenshots - -### Welcome Screen - -The welcome screen displays quick-action workflow cards and common tasks to help users get started quickly. - -![Welcome Screen](./docs/images/welcome-screen.png) - -### Chat Interface - -Ask questions and receive AI-powered responses with markdown formatting, code blocks, and helpful explanations. - -![Chat Interface](./docs/images/chat-response.png) - -### Right Pane with Conversation History - -The collapsible right pane shows conversation history, agent status, knowledge base info, and connected MCP servers. - -![Right Pane](./docs/images/right-pane-expanded.png) - -## Installation - -### Static Plugin Installation (Standard Backstage) - -#### Backend Installation - -1. **Install the backend plugin**: - -```bash -# From your Backstage root directory -yarn --cwd packages/backend add @red-hat-developer-hub/backstage-plugin-augment-backend -``` - -2. **Add to your backend**: - -```ts -// In packages/backend/src/index.ts -const backend = createBackend(); -// ... other plugins -backend.add(import('@red-hat-developer-hub/backstage-plugin-augment-backend')); -``` - -#### Frontend Installation - -1. **Install the frontend plugin**: - -```bash -# From your Backstage root directory -yarn --cwd packages/app add @red-hat-developer-hub/backstage-plugin-augment -``` - -2. **Add to your app**: - -```tsx -// In packages/app/src/App.tsx -import { AugmentPage } from '@red-hat-developer-hub/backstage-plugin-augment'; - -// Add to your routes -} />; -``` - -3. **Add navigation**: - -```tsx -// In packages/app/src/components/Root/Root.tsx -import { AugmentIcon } from '@red-hat-developer-hub/backstage-plugin-augment'; - -// In your sidebar items -; -``` - ---- - -## Dynamic Plugin Deployment (Red Hat Developer Hub) - -This section describes how to build, package, and deploy Augment as a dynamic plugin for Red Hat Developer Hub (RHDH). - -### Prerequisites - -- Node.js 22+ and Yarn installed -- Podman or Docker installed -- Access to a container registry (e.g., Quay.io, Docker Hub) -- `@red-hat-developer-hub/cli` package (installed via npx) - -### Step 1: Install Dependencies - -```bash -cd workspaces/augment -yarn install -``` - -### Step 2: Build the Plugins - -```bash -# Build frontend plugin -cd plugins/augment -yarn build - -# Build backend plugin -cd ../augment-backend -yarn build -``` - -### Step 3: Export as Dynamic Plugins - -```bash -# Export frontend plugin -cd plugins/augment -npx @red-hat-developer-hub/cli@latest plugin export - -# Export backend plugin -cd ../augment-backend -npx @red-hat-developer-hub/cli@latest plugin export -``` - -This creates `dist-dynamic/` directories in each plugin folder containing the dynamic plugin packages. - -### Step 4: Package into OCI Image - -```bash -# Create a temporary directory for packaging -rm -rf /tmp/augment-dynamic-plugins -mkdir -p /tmp/augment-dynamic-plugins - -# Copy exported plugins -cp -r plugins/augment/dist-dynamic /tmp/augment-dynamic-plugins/rhdh-plugin-augment -cp -r plugins/augment-backend/dist-dynamic /tmp/augment-dynamic-plugins/rhdh-plugin-augment-backend - -# Create metadata file -cat > /tmp/augment-dynamic-plugins/index.json << 'EOF' -[ - { - "rhdh-plugin-augment": { - "name": "@red-hat-developer-hub/backstage-plugin-augment-dynamic", - "version": "0.1.0", - "description": "Frontend plugin for Augment", - "backstage": { "role": "frontend-plugin", "pluginId": "augment", "supported-versions": "1.42.0" }, - "license": "Apache-2.0" - } - }, - { - "rhdh-plugin-augment-backend": { - "name": "@red-hat-developer-hub/backstage-plugin-augment-backend-dynamic", - "version": "0.1.0", - "description": "Backend plugin for Augment", - "backstage": { "role": "backend-plugin", "pluginId": "augment", "supported-versions": "1.42.0" }, - "license": "Apache-2.0" - } - } -] -EOF - -# Build OCI image -cd /tmp/augment-dynamic-plugins -echo "FROM scratch -COPY . ." | podman build --platform linux/amd64 -t /: -f - . -``` - -Replace `/:` with your actual registry path, e.g., `quay.io/myorg/augment:v0.1.0`. - -### Step 5: Push to Registry - -```bash -# Login to your registry -podman login -u -p - -# Push the image -podman push /: -``` - -### Step 6: Configure in RHDH - -Add the following to your `dynamic-plugins.override.yaml`: - -```yaml -plugins: - # Frontend plugin - - package: oci:///:!rhdh-plugin-augment - disabled: false - pluginConfig: - dynamicPlugins: - frontend: - rhdh-plugin-augment: - dynamicRoutes: - - path: '/augment - importName: AugmentPage - menuItems: - augment: - title: Augment - icon: chat - - # Backend plugin - - package: oci:///:!rhdh-plugin-augment-backend - disabled: false -``` - -### Step 7: Apply Changes in RHDH - -```bash -# Reinstall plugins -podman compose run install-dynamic-plugins - -# Restart RHDH -podman compose stop rhdh && podman compose start rhdh -``` - ---- - -## Configuration - -Add the following to your `app-config.yaml`: - -```yaml -augment: - # Security mode (see docs/SECURITY_MODES.md for details) - security: - mode: 'plugin-only' # 'none' | 'plugin-only' | 'full' - - llamaStack: - # Base URL for the Llama Stack server - baseUrl: 'https://your-llama-stack-server.com' - # ID of the vector store to use for RAG - vectorStoreId: 'your-vector-store-id' - # Model to use for chat completions - model: 'meta-llama/Llama-3.2-3B-Instruct' - # Optional: Chunking strategy for file uploads - chunkingStrategy: 'static' # or 'auto' - maxChunkSizeTokens: 200 - chunkOverlapTokens: 50 - # Optional: API token for authentication - # token: ${LLAMA_STACK_TOKEN} - - # Document sources for automatic ingestion - documents: - # Sync mode: 'full' (add new + remove deleted) or 'append' (only add new) - syncMode: full - # Optional: How often to sync (e.g., '1h', '30m', '1d') - syncSchedule: '1h' - - sources: - # Local directory - - type: directory - path: ./docs/knowledge-base - patterns: - - '**/*.md' - - '**/*.yaml' - - # URLs - - type: url - urls: - - https://raw.githubusercontent.com/org/repo/main/docs/README.md - - # GitHub repository - - type: github - repo: 'your-org/documentation' - branch: main - path: docs/ - patterns: - - '*.md' - # token: ${GITHUB_TOKEN} # For private repos - - # Optional: MCP servers for extended capabilities - mcpServers: - - id: kubernetes - name: 'Kubernetes Tools' - type: streamable-http - url: 'http://localhost:8080/mcp' - - # Optional: Custom system prompt - systemPrompt: 'You are a helpful documentation assistant...' - - # Optional: Quick prompts for common queries - quickPrompts: - - title: 'Search Documentation' - description: 'Find relevant information' - prompt: 'Search the documentation for...' - category: Search -``` - -## How It Works - -1. **Automatic Ingestion**: On startup, documents are fetched from configured sources and uploaded to Llama Stack's vector store via the Files API -2. **Chunking**: Documents are automatically chunked and indexed for semantic search -3. **Periodic Sync**: If `syncSchedule` is configured, documents are re-synced at the specified interval -4. **Multi-Agent Routing**: The backend's `ResponsesApiCoordinator` assembles each agent's `POST /v1/responses` call from YAML config — each agent gets its own `instructions`, `tools`, `model`, and `temperature` -5. **Handoffs**: When a router agent calls a `transfer_to_{specialist}` tool, the coordinator switches the active agent and continues the conversation loop with the specialist's config -6. **RAG Search**: Agents with `enableRAG: true` include the `file_search` tool, which queries the vector store for relevant document chunks -7. **Tool Execution**: MCP tools, custom functions, and built-in tools (web search, code interpreter) are executed by the coordinator between LLM turns -8. **Conversation Persistence**: Chat sessions are persisted via Llama Stack's Conversations API (`/v1/conversations`) and can be resumed later - -## UI Components - -| Component | Description | -| ------------------------ | ------------------------------------------------------------------------- | -| **Chat Interface** | Streaming AI responses with markdown, code blocks, and source citations | -| **Welcome Screen** | Quick action cards and workflow suggestions | -| **Conversation History** | Browse and resume previous chat sessions (backed by Conversations API) | -| **Knowledge Base Panel** | Read-only view of indexed documents with sync status | -| **Agent Status Panel** | Shows active agent, available handoff targets, and MCP server connections | -| **Right Pane** | Collapsible sidebar with conversation history, agent info, and settings | - -## Prerequisites - -- Backstage v1.35+ (new backend system) -- Llama Stack server with: - - Responses API enabled (OpenAI-compatible `POST /v1/responses`) - - Conversations API enabled (`POST /v1/conversations`) - - Files API enabled for document upload - - Vector store created for RAG - - Safety API enabled (optional, for guardrails) -- For RHDH: Red Hat Developer Hub 1.3+ with dynamic plugin support - -## API Endpoints - -| Endpoint | Method | Description | -| -------------------------------- | ------ | ------------------------------------------------------------------------------------ | -| `/api/augment/chat` | POST | Send chat messages — the backend routes through agents and streams responses via SSE | -| `/api/augment/documents` | GET | List indexed documents (read-only) | -| `/api/augment/conversations` | GET | List conversation history (proxied from Llama Stack Conversations API) | -| `/api/augment/conversations/:id` | GET | Get a specific conversation with full message history | -| `/api/augment/conversations/:id` | DELETE | Delete a conversation | -| `/api/augment/sync` | POST | Trigger manual document sync from configured sources | -| `/api/augment/status` | GET | Get service status (provider, vector store, MCP server connections) | - -## Exports - -The frontend plugin exports the following: - -```ts -// Main exports -export { augmentPlugin } from './plugin'; // The plugin instance -export { AugmentPage } from './plugin'; // Main page component -export { AugmentIcon } from './plugin'; // Icon component for navigation -export { augmentApiRef } from './api'; // API reference for dependency injection -export * from './types'; // TypeScript types -``` - -## Development - -```bash -# Navigate to the workspace -cd workspaces/augment - -# Install dependencies -yarn install - -# Start the development server -yarn start - -# Run tests -yarn test - -# Run linting -yarn lint - -# Build all plugins -yarn build:all -``` - -## Troubleshooting - -### Common Issues - -**Plugin not loading in RHDH:** - -- Verify the OCI image is accessible from RHDH -- Check `dynamic-plugins.override.yaml` syntax -- Review RHDH logs: `podman compose logs rhdh` - -**RAG not returning results:** - -- Verify vector store ID in configuration -- Check that documents have been ingested -- Confirm Llama Stack server is accessible - -**MCP tools not available:** - -- Verify MCP server URL is correct -- Check MCP server is running and accessible -- Review backend logs for connection errors - -**Conversation history not loading:** - -- Check Llama Stack Responses API is accessible -- Verify no corrupted data in Llama Stack storage -- Review network requests for error responses - -For detailed troubleshooting steps, see [TROUBLESHOOTING.md](../augment-common/docs/TROUBLESHOOTING.md). - -For a full configuration reference (YAML requirements, admin UI scope, defaults, precedence rules), see [CONFIG_REFERENCE.md](../augment-common/docs/CONFIG_REFERENCE.md). - -## License - -Apache-2.0 diff --git a/workspaces/augment/plugins/augment/config.d.ts b/workspaces/augment/plugins/augment/config.d.ts deleted file mode 100644 index 012afdd0d76..00000000000 --- a/workspaces/augment/plugins/augment/config.d.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export interface Config { - /** - * Configuration for Augment plugin - * - * Note: Branding configuration (colors, logos, theme presets) is - * defined in the backend configuration and fetched via the /branding API. - * See @red-hat-developer-hub/backstage-plugin-augment-backend/config.d.ts - * for branding options including: - * - themePreset: 'default' | 'enterprise' | custom - * - primaryColor, secondaryColor, etc. - */ - augment?: { - /** - * Enable debug logging in the browser console - * Can also be toggled via localStorage or browser console - * @visibility frontend - */ - debug?: boolean; - - /** - * Inference server configuration (frontend-visible parts) - */ - llamaStack?: { - /** - * Base URL for the inference server - * @visibility frontend - */ - baseUrl?: string; - - /** - * Model to use for chat completions - * @visibility frontend - */ - model?: string; - }; - - /** - * Quick prompts for common queries - */ - quickPrompts?: Array<{ - /** - * Title of the quick prompt - * @visibility frontend - */ - title: string; - - /** - * Description of what this prompt does - * @visibility frontend - */ - description: string; - - /** - * The actual prompt text - * @visibility frontend - */ - prompt: string; - - /** - * Category for grouping prompts - * @visibility frontend - */ - category: string; - }>; - }; -} diff --git a/workspaces/augment/plugins/augment/dev/index.tsx b/workspaces/augment/plugins/augment/dev/index.tsx deleted file mode 100644 index 0a8dfbf470f..00000000000 --- a/workspaces/augment/plugins/augment/dev/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { createDevApp } from '@backstage/dev-utils'; -import { OAuth2 } from '@backstage/core-app-api'; -import { - createApiFactory, - createApiRef, - discoveryApiRef, - oauthRequestApiRef, -} from '@backstage/core-plugin-api'; -import type { - OAuthApi, - OpenIdConnectApi, - ProfileInfoApi, - BackstageIdentityApi, - SessionApi, -} from '@backstage/core-plugin-api'; -import { augmentPlugin, AugmentPage } from '../src/plugin'; - -const oidcAuthApiRef = createApiRef< - OAuthApi & - OpenIdConnectApi & - ProfileInfoApi & - BackstageIdentityApi & - SessionApi ->({ - id: 'internal.auth.oidc', -}); - -createDevApp() - .registerApi( - createApiFactory({ - api: oidcAuthApiRef, - deps: { - discoveryApi: discoveryApiRef, - oauthRequestApi: oauthRequestApiRef, - }, - factory: ({ discoveryApi, oauthRequestApi }) => - OAuth2.create({ - discoveryApi, - oauthRequestApi, - provider: { id: 'oidc', title: 'Keycloak', icon: () => null }, - }), - }), - ) - .addSignInProvider({ - id: 'oidc', - title: 'Keycloak', - message: 'Sign in with Keycloak SSO', - apiRef: oidcAuthApiRef, - }) - .registerPlugin(augmentPlugin) - .addPage({ - element: , - title: 'Augment', - path: '/augment', - }) - .render(); diff --git a/workspaces/augment/plugins/augment/docs/images/chat-response.png b/workspaces/augment/plugins/augment/docs/images/chat-response.png deleted file mode 100644 index adae3ca9de2e5de079fdb2d4a5e3011a8a3e420e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 154610 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�`(M762OLFtPUxstEuqh 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%0AAnrYdc-LajZklCa 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 F{{wWjG+_V$ diff --git a/workspaces/augment/plugins/augment/docs/images/right-pane-expanded.png b/workspaces/augment/plugins/augment/docs/images/right-pane-expanded.png deleted file mode 100644 index d0320be61519cb9a100247e9625139b05a91d6bd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 202054 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 z zBa*9)U}ZIhyZ`NiiI5n3!lHge;m8+&JB1?ZqXT80@$Es*x-gtbswykdwzXF#H6$p@ z)$^ltI_MBp5TNLvK7E?JAa{dkiTxmAH^c>ayxm-( zcPecH-GqJRfaZoH)zFX9gm2bcrAgrIdhay5%%ImDPuF#?#_5hHfre#bPK)-u7u9+O z1~HQ#HDVA^urK-Av7*XgGI9T9l4uG+Bn#Rvus`=}My)eZHTNN+jWTDGV+&g9^5QTI zt>w8Ner}3}A^Yl4BSrzs>|=C28IN}x&R>Bf3i9a{xqPi(+o72;(JT=+ z*y^#Q+7GfxY1JK#6_9Y7#byu@JmaP~U?q zgQt+q67uI^6-VMXG-cX(C{Y}2BgUeF3s9Cp{2}Y(nPi6Tf?hkSPjkGoLl5(#A>~v$ zg@EEuH;kYQ-Rz%MjMf55Tw|z14BNW7gXY`Kr-m6X!7k%O2f>ffene#MgQAdZW}re| zLeH)42C^$QD8BB0wIkReyfz=zPuj<|5SF9A`LXg=}WA$|M|{#>D7B{WOhi6dB~le*_#+pY+2OQm!P}z&U4A^+wtg9g%n1y z60jGoBR4kC+|HD|!y*6u=5&>sDqxS6U)`#CgNj<9oaq=Qxz-L%a;r$gl)-y&(xS3j zZ4Yi09mh2M&|e`Rf~i30v%ZZwhvOnj`Iz`kCzrUwtvVjW-n_ahAObhv4O0gnU@FabCfNc;voJvNmhi3c(UiQ8P! z6NDr}sDJMYCYJ(et+=7Nl&FVbDj;Q}HXJ1fmsj9$v5GculhqH3$kUwL+;+j)g@Y62 z0GSg^x4^~4bl0v|z2zrIGFN-0Dbc{5yU#lSu-B_V=$m-{LK6-PaxEk^0|}sbAavmN z)%VzVIrI?F3@Il2%H6D08*F$zAZ{Y~{NC2}!ztKe-0y$E#1oJs2=iN7DR#Z;3DF?2 zh~`zb;X?koI(uw8dbFno5l)s@=){`Sn+7%;ZUD#3v?+jXkA^my;fzL?@_upkUW zxe?L|!J?ICnY^XAPzW?tAlHB-ngQ}rF~urTKy&wRT9Vw{m?VeW30f|7GRe6ye~2DI z>KY2Te2r%8r9EDlTs#vnB}FGlgE*ylkm2Ds@p*9*_N)*2GV8)5(IK_&pf&$SoKg7E zvcJ}hIFd`A*T~2Eg4$!&cBan2EmT!eghanB^Y^AdiE|?a#=iwQ&-R4ctRMhB2kw9Y zHY;c5#dkrS9+gVsQuN`IBZXKLL@pKqyxpK`_!Rn;BbdOB%1y~VAir2Kd@>@GSs9cl zwe{84PFVr+OT^w$_K|%dWZ7pnDkW%( zetq=M<7h@S_kPA+w=QR3G2Oa>!k`LhwivkI7;j5o3W)*rZhi*z zn)(39DXOJ#)==HJevBNEgG0O@5m2mV?M~KV4w@YsD_UJO?D+JQ^Y>ft*TF|0Dtg-Pb*{}XljODe&}{acdPBE3RE5Cpj4ph1P5$rw~Z)f zEHF-#?F?roH&OvEOdtLzX0(+!bBZWe4}Rhn@XBc}J$DQ4TGQBVffu6~E)qCAhV(k- zAutzfYmF=>*tVFxf zV*p>X0wAN)t%z^xHRT)vTPJ=**r4qpG#Y0W<=fr7KIf&9%8XYr| z%e;1fRiYqAv)R-Ay$C39)#w@YBQNMWX`4BKSc?REsr?^WQMx`IaD)T5alI^+4cUWq zfv}f+mVDy=UqAg8Bw66VOJ*ZN5ou~o(Ss4&r!%8~U9tik$Cb4C-++MTI8Z207_>RG z@rMx-1B<|i>8YP#Mpu`q4E++UfhajPf z`f(l;dj7?zCWl`bSCU34Sf}hNFSi*=@V2@jPV^lV7JqQUU1@SFM0Xu|@h0i-ntzS7 z(NTN`zk>Z3QrZa3Hi6XR9;`)z7hH;^^GRxP@z9RGyGxs=T66IY>Wi5t!@|N62xsuT zqbTUZ#5fMb#Ke2ePaVLi-}2 z==R`KZ544ed5!0T7}zygvYj?;b`l7>XX0~jodTgaQ}b8wD74JQLf5ftmkZ9d`zwMT zWjw2O&}(3ocHYv5@phR94KN^W5hSY2?;vkcGR}Ms0xs64aupUlRy9XjBZK^~0GxXM za(a|?&^uS`cM#NFi_ztyjEdske=Jay$DCVKpcE$13JVI(s7tWc85@Y)$0DW9G!e z2O({NHwm*+pY{HZ)7vf7PC>94EsE8Y{a180uQe>3-k31*y7<$VS5$K}3B0&&F9xreX~96`W}ibKF#$kHs=E-Pqio=rfQSRJe}KZ>+@k`t^=x|{ex?vzi%^CkLAfH7PDpRlo^o1 z!b(~Pq%w=uuC@MgZY5a}2H1{<{T^>UvP_mR*a(X^ntc)VJ%UnJ66oaMim^sx94E`8 zGMF@Cut5vxcBSofPs$JzASp=SNgj$rEndx+awNm2Pfv7b)X?p+1I6#R>z;zn=Ly$J zUG>2>ojQ=EMLYeuNT3kn3CW@O3wPXyQ()j!vnFO{h;F2*ubbA64 z!GQ(ql8pQ#BMe0bR`3iCYm!?aXkp!-U}!`NdP_IPt1CCVQ-RJ5vD}ulBx>cIzYTQk?ksJBBbZCtt@dpJZKvAg&R3vLoA95R z^OxXi+Of$0Ukf0dVb}%Lf@@pr#CCJ7eq;9z80T0xc_yJ(fd6VVe{BgwqKez8un&B( z-zDN?0&!UqS_F5^HV8*9hv8URpGdA?*MZob5WQx0Wl-c4;McbOWb+7K%~w_Qe+1LP zty`*Y8|5Xw-n6r`3)k@S^4bF-&Jcv&%!0*VoBI~i(TEmvDRX{kVo2Z!5lxbEXAHDV z;veg)su~)bnBd(hleBS7*!4p?CkmI|rm>Cpct`vKdno!hx9fuLc2P~u2_zT^VwKz( zNw-EZg%e^+OyR+8z8=m`>N5}$MldfYk4W^?1MbX8fe+^j6^%Nk>z@9e!GHw5rpX^v z7EKh6>^ONne*wQ8l!x?_J=n}Vkv`P~X-hDkyG$ChEz8bI*(kg@?&dPOAo*l$nRB>g zJ4iId1e!e*y%^LZm39`R%1!0^^H^r{t1yC*ERB_Lp|&|Ig)WtcChep8uwk-L1>dD_ zN{RymkcimK^1wZ(DcJiZWfTb?Xg zI`_(XS3sG+GgIXuob(hSv)}jPXnu#>jdVOB{LaRJ?Bk~Xv&8Rd;kYuhBje|%i(UG3 z^(a2>mE67=qmrq0QCymw0g2Rni#*YvH&ITU{(I_$0pV|1Vb($x6Ik^Hd4Qx zf$zyzmbgGW80o=#83V8P!*~8SSpE?d;k3AtVtC_H!mq9yHR&?^b!0i?O($XoUXC}F zEwiHdct`(2HnN+1{;3`BDR-~j|LHEJGZmTr@C`W#6YYPzR#j$%5+S+|dYtb#;^~LC z%42Ds4;eNoN<>+nai;Zl@~Rk%`vfrO>XR;v&c>)4&@f%5oexolgJ{D_7&e=oevD{} zHyI~Mh~>Md9_bCX1>uy4X4f@Xe`Bw$tU)~{CYM%_Ng%I3e+s;~7Diw})g39MV%-@s z_bSur$*5FFl8)+<8t_n+%+@oAx*ldIF8wB4<`=S$`a!{R4!QbrUJS+r{~Iy5)BMs{@T2t};WNV*17KDOm?*udY?#>% ztAhqkR)Ry+Np|uW!>}cK7nrJFTj*6hNjmq5N6RdJM`7%wXb+RP zvG0a?$GP0ufy!h@&_*SFZh=Y`n~jHc#X%N>Xug_jv1(+~kUt-W^@Ml2xbKRqLJkU*58n4zGd1^hQp9r& z31P(zwVHiDRrh|$dB$wV7ZRLwx%Gt>?jlLHU0hxxi`bb>eHuq$(ym#bvdA6+x!FTO>rjmdaQ$1-D@|yi% z0g2BAf-29&?zqy4??AXFnJ63Nwlt~9d8FAJut&eHgVZb!IX{Pnl-a~;O-(R6I+A5aUoFFwhQ4Ab&*mUyvt;p_@;%Yj<0TIWcB_m@%fakJ1#kg!Dtr4SQm!7+@~WM zJyws&$ecBhP@ho6ld;O4iz|lSmq>%i6z|;BGtJ7a&qUpV`)k?B+4_USkV^svCAl)5;{F%#pQTrY^f zCLQAxQWTX-%P~H*JyIZe{`dxD4z~W4r%Ai*{h}#4`_pV@U=^`KgH~R|TH&|89d6ej zF57J8aK#yMT}G3;ztGAy)K*w;(!Q>7ZB;mW*~fZRo1Dw3WUND~a)Ui}JH9DrzlN6A zrf$!<6BZQxl6t~DPapuY*jWg%7XQYvws;We!0Q4jaD=3Rcz8zDgQyNVyXrVER{{;1 z1MaQo9?tkSjA$z5_GK!JgBJy~kR9s&c75#4u1~AMrUZl6++Kr_x}BNNbdvg&qOouMkLG7(F17=%$`=Cuyq8{v zdaaLTa4D_S)Y%@tqeo*-1;D zX9ydPoj`PjI#PEc*`OJ@`|A23RIRy;A9CHsG@U9ZY7>0*d9myA*@hS`vK!{kM+ z0}Tsw=hus@(bc@$G>IB#lTpF_QT?2ehao^j^8rPpvcAgr8e$au@QvFioB88Jq&m-3 zAvi?im5`TFlFj?mb|YceRr=AWn&f0|xBc|G^FEICEH??{?GH%vFBJ}QwHe<_mWmFA zyn$`+G%27|p3#Pvm`1HG-1#GL{BfdR_6rtmyFSxF9*-6DLac5t`lj>*Er@s6Etr25 zsY0UMLr!_t-{D(joyppLWmn5H;&`N2!S8*m>vtX`baAlmzP#v$gA#HrT_NEoteTZ$ zP~HA?uPIg-Cjs5UTM@cC|DhRpSv@sIJ_CUU@VwK&s{M1Ov|i$yJNKUQ+me?vc>fO- zRvYVX50tP}?71he1DAk#RTMoC8^KTUGE~ztk~ zmGui_3a8gAbOqQO&uIIY6LrW}19S}QLhN3NhO$f*2vHAJvFgDcri(p47&XV9pXyOS%#TaVAmjqN_xWKX*~pB9$h$<*yn&klU;<+VJ2OLb-~JI@CvgRi3^gRP2%UGFrdU2qoD~N5-yTj-rw{z6$pJzmXlAuV2h+}EdO)8A`N?k9 zO;cDE%Z-JjuUR9e+JdF&X#()i0D0i6h;FWbkE?TP_;Cb2C09DcEIqy0h9;JyKl>L} zQK+WFvI7e@le=9@Wf;;wIZV0}R&PCoE{3G^E;*@0D+@rB8+4(MIp2HvoCD_051XPc z0&NA(J3T%-eA_A&&qlB!FoF1}-S+-@P;U;pZ5$!Kj(4BMef_Y!imCuGddk|BZh9Pr z0_75oo8sU2vlypmRJPsWd}|u}>ldXus$!s6p;onQi$G6;#76K^ei910pyj0GWwo7@ z$6UEB6fb$-R^~+M2XsfZ_4^ok<$xLTf~;!6npsglH!9ul#`fKh_6>Sd_3(D4*~c5f z&T7OdC=jNQtDn>{lU$UVRNa%_vkt4oMAc?h1q2XKy*@-zu>B61r_llzhYk9AU~0uX z_yXpD4TK`q-Pxj&%<<@&iMmTexW9L?u9x!FQfkXgA+JYWY|Q&>Zk4=FbFS)PjCVZ@gX~k;8BK0sKwpyAKu+zs0}VP;1#RG2)&0 z20@2yI%s(->x0OP=&X$>J!AdyK&d*nk!_iejfV^+BBs1tPnMjRBdRbwvi5si%Y`y-u*tnb!s51dFXU0fpL2p~Y z=7!Dnj4v!&s?77*dIPJpGsUleeM%Q|)MQ;p5sD4Ie#bdewp|pPabMJx;r)&|9UNee z7X>O{1=*0QU>bP}1&^C<(?En%ByIv;;iXLcbo*{5`h``O<(O>{W;GQVf+Y2Sc;%r% zoEyIW_&V&fG2LRkO?+MQj`JbHd@051la5%)LhaXRWT+&uh|;-cWYVDIk`hvy2Wz>8 zPU&nfA}@T!%5hmSRNA#!f0hRM;n|QB33T*C9FG%S!`1)6b>o?c*<_z`1K!gfY9a}c zd`=?a9sM`1o;r$JH;XZ}1esRRQ|gIuGXJR!O?H|(6lNnH=Hba;rZULltBSF+l%^@g z6bLV5sjWF7lp~CoXE&nKrR(Do(4I-kkz;WM%{~UFh~g8unpO^zD(%A3){O87C87g!E^xaWz45`-H7+uP0{_b7q7vH7yTe_ z`|K?{TKZ_>vkULh&qPKCrSe?sRqFo2J?#o1EGlnzD|T5{2>*?fb;kbUwZ)tT8|aOu z2`0uTHeBBOG<4{Ghk$pC-?E*9hOZ2*M`lpR&Glt$WQ6nSEPNWh_iSk)a-@){=##M(_oqg{P^|~bt?#s_RFN%x#UNTvhy%L^1qO)zdHvB z3j(vx^o2uA+2Tx;%tqG=4SCY}dp;Qm-prCmFnB*S1n<{?{UG^CD~o3iadE;^9p@l# z*W`vk?L)`FB~75+zOHQvJgRjM^g);D!QM>VSN{eg@-RfJB+5_?vCiua_aB8g zo{?Vb^d34LQ@-pHhAIkOkDS!qCoOv9Ys=xv1ZIF0DV}o8Iuzk9^fVEWYKZAgv&hqVvHSHEy>>T2@b}esi{D~wBtrQq?(T+LC79hfU9*~S zHtJV6Rt!->?xnR=rFMQ;QsFRkvFU6bM?pN!wk~Rihna0d+0ZDh*)1Ba${@vc40(Z1 z74BJQS?+{x`fa{tv=q04U)HlzmOu90+dhN70ZwYb=?5|>#fkvACJKl&#VCW$TNsNd zaIb;n+QGqrbS~=eedK=s7b zgIuch#ds8*3y4`^l40w`wd0zaC|GIFE#py?wtD^b=u1x#hLF<;jTkb2lew;+pL9uW z(Kf~%%qI;bZU%nyx9-XYen)j@-<-#{AATPqVmHHQ_P$hpErC`l6yK~(SM@Q49$0qV z`uGjbkX&~iyQ>#wc;HLo+C8m1<>)JsdA@R8g5hqBN9CJ$Ms@a;*mrnF)+6RTBIkGX zcBUd=WrAVGZA@^5%B5-4OsAOl=S zFBu!s26)d;*YLSmKK``$V%j#&Q*-LSmpk@+k=Jt1x+LXEYPiSdDg9{IZ?E2BaM&fX z_6$kwOikBn)q;6ixB>@9VmMJ!okK4F95HZI>?RVXpzbsfFY)2$&!6kP#1@!;pwxdr zWMo_6)a9TA-i_@u88_tmk>%@`|JrHK;=)jc!~o8o^~q&wD>F`Ql>L0fx(^Sx&$q8} z=ZtRXha70TcJire)1V&k1@X|TxBnsWjmZI-4-Wgq?`@yUT8T^VS6N_Dn%i0LdV!;? zG9idlL7RiqcGiT;jCk;h3%_XntiFlPm-eW-Pi;Dr4Uwr$3>!5ayFVOsV<(#qKfFoC zWZg%oP0hAT%0>D<YJ@-6*^H3tdShz$tQHieXK-~X` zFQUG(Z2cYkCx3I{is}ZsMzkE$@7i1iSZRd$Wg)maZVwvFJr&M`%OasDm<@JLDr=R^3J>}))VZ|=Z$aU~o zP}IMXVv2Bx9Gu*z(sl-2&b-R#!j&5FQk)Fp0Io_tELFevF9Jk{-@xXv@U>fWw@j)7S0-<9yQD?u$7QgJm$tz;q6#(N16P}=8QVR0H}FQS~I10k&mcS_7uM4^l$XcU|ad1M-mCO!1y~ z3{rOV{+~P>R2{o5Mf+@NA8mNmV%*KYLpY{w`8cM2cS%C}3q;vcE>z}EZ%0@r`EG|H3JTkZ7Ywqt=p-JG4? zuqde0&B^jQfW(N{QDYf`b*jO#0O;`Wpo&(x@dKAvXaK#TwuTYv6zk7f*_p|0Zse!E zsa?yFPpR{)+IwHi`lGA>?{Ky#_CN{`yT70Z9Rhi=w^K_d{m>qRKmJCit`^-NSo-lU z0*(R*<0%|})fE3Oaw*`gKp~5vx1N!fHzbvKVrVL&9_Qx{7xQ6jwn`J*#_oEQnkAno z=UY1xN|tWCRYwDRV;+RgxJtI^XU{KSOf#U2D$itXK_C+ldfaei|26S24zr5^LjVUiwT*h=m)SH!u-)V*X-B=> z05+N;LTSlVwo2S^H3(Jtz;9evVsg5a!c=n&`>8qFLt2O%?>#vGnw zFLE(6j!{V#qj0B+7>+pzyY&VehsMlj*$WwWi==PQ&x?|TuR6)9JH7*_$uRtIFC{ts z1W}L_C`)6b}}8Jd2T-DvKnk6Thxcar_OGPemptC)WXGG<9CP>Sp@j zy|Lmtw^eceWFGMA*8TE2fCJe{Wg2*Mf(3L1%r*{Zb6^}dpw;%48KRMsSt?g?Illu$ynK+XTVdIAT`a#Z|uR0TyzZhdFkRS(|ngoZk5FGb7Wg1 zbwh57Oi41F`(K}|A$U~iJZ|@0_jHi2E%G#On1(Rf@9w9Y)!II`L^*)kHDQouGeS1> z&c#u&f5ix(75*V@f86GZ0rEkP{(q61h9v`SFhG^lQ2?N7JFVjk#*slsf-rvxz?V-vgDxAC6y3CK9H2;)?}t_4`@cXcsQ zurNKdBvRz-xxn)6#v?-d;j_6qO0McRsP4F*@NNIpyxP2e^XjYM1aPk#sHiAbif%92 zD4wq@sc!8zY)IoqB}Sx1e86eddPUp#GP$Erg9HKJzH>t@BJ|Hq=&D;GTlQHnfC8QQ zxx}NK#Ax~0br>({4-mQhnuG2KM$8h_wLz}ak)(BJ*0wdFv5aE6l5m+0Pj%yoS8DQCvY%Ih3%HF3h8{-stn4 zZT^iuf8>9UET~+kEkCBT6Cv5^L4$tl*O$)E0;B*_hMN|(C=8SrK}YdVhZRxyn{686 zNY95N0tHuKjJq^@7ZK-Wm#=fW!g?Co(vUE%zS#eq2;#FJL7g@8GrL_wk@yQTt+G0R znN8Hc&tj%=eE?07GZ^6tHwJ~ojV%bMxBFXc775fzej=X9<#U*Ndo`t_QOPtbcC*Bn zz5_P?=Q{sm>0&rLJ+~EdHEpzj_#AUC92o;x?FLYa~Oni_E^5+Sb}C610X$t?9;e zh(%=xAoRWs)26_(eQniqABVC*=K@mMm<`Wy5sCX_r~(vkS~gn0ebblN5(8>-Zx0MQ z$PASgMY1Ux5}}K$zrO|8tW)-xp#79_89??&_}>Pmnm$zrkw~6VK%Z%ivKRTVr&mT! zF3Q~cMMER>0qQmJLO|faQxelgpJ>yTdH>h3{(xl|xvdF-^*!P|OyXP>Nx+fk`k zE;IfH^6!=7|4WG^@a&?&OazP+Mo885$@2TlcVw7eH(_L$lpU~clK(t$K;1meM`U)I zUgquS%0Z~R|Nj^WfBo(M^V$ZG5rhRT?MlbZqk)03MG2p>gXl~Uz8Byva34T|_Jo|k zzDAN7p}|h_QIFGc{<*8I1&EiQsN>A*@BK!nNG3)g+og}MQiPa@mi;rjovEnJ@Xm^{ zqT%$KL8yDf7(L_-vf@x&gXbAgNBRuEZCL_Mq|P3ukp9uXwu@qnF^c>0ZKRQ&z2;pA2y4mD(z$6Ga{C$V`7)G6!iQv(w0cD??JNk*H$PR12J=O zU~F&Oo%@bX(5{}%uX52Hw9Hkc57|o?)qE7Zy!4GZIoZoN!77bP&Tm=;KA|sJQpvH> zUDKag54+2oP|)IvD^!-^`4-ZtEX7pTHAnxG$_*QEmv0kMxx)?2%|UA=+QOq0bjWXt z?+0JnD#{u{%9Y%4MS(ubeTqy{!@u2z$(?U;-4SQru3d~I1APRYsy0!OyDyMx)9WJq z+L*{)z1o9hG*Gp|J>Db(eRwrjyl#Mr+rS;cpd`Gh#(tKJ5^x*D&l*&snrdoly5Rav z_JJALOoq$TG1mgI%6cSz68KBBfr&)!@{USCL~m zK4HOF&d>k@JN|3z5$o_o$ddq1D&`Kgg8|3AGXDH3VW4Sq(n#o_JBk>U#AqtC&f7R zKwNcW2&5+tX!#L03kpdS?+xC-JnR#m0o>>1EE@`_VPr#73O!LUaS^R4Sl^WNfJ4@p zM@TFPGBtnUzRs`GZBSpE@RqRM(&pe{6>Y5m6EbG8~UG&k||i6<9F_ z8ElJ(`!#TIsJKL0pg)BOe*WL`7r(+I9roh&tph7O@AGEpw3Wp($hjhBEt0P+w#gR# zQR#+JxD#exNxUkZ#j$T=jConG#5hA(6_XYzszm%hcw80%cY;v}1NR$IUzZ3z+UoD( zdjh6}odQ?9PZm!dJTwZg6wNgi6Y0~``O%%&_Sgt2G@hR+R};SK80#s5)tJaeHl2Wc z|10x!sMa@F3A1{~h9xsD=1=8hrIdlZ%>T{rDT5B^R!~b1%yt@Q=b8&}Bl3DA;OB9=oQ zHhq`uITn-464>bNs@CT`zxIqkNoMOLHDIbyDoMK>5FN>H(r!kRK1s`ZnlVMG4r)zU zFp$NNmc3ju$P0h$RaGd6&!b=M2y*^o3z?P>2_KUmB6DnupC(Wd%LICUpiK}HHs7N2 z;Mx0UMTzOy*x;|hS0L;W33Y-TZH5Q#3hC!24k;fgQdXBid}jEcD)Ep2W=ht4+?1qFSuPhN3Qx6n8tM}?O-{n zujfVj!>6qJB)-MKiLGL7ZgW4U+5lB}6vB}0i4U^^c{;$EpGHnG!dULYTofVs;VyVy zjkN}9lHt+$biY6U6_!;|U1tESXLiP;*K*;ZkCCV4&CNbL!FuwDvF?M_j>)R(oD_35 zZk&>XSwdc)0b)GeO`Eullan3|z5gA>p|@56B?=Svpu{(0JGq!E6^2)Wm@q+nq1mLx zsahA@ZxnALf!ls+!eoSI9Zr zz(r+Cx`!XFI;p)LT$jJX0>YWDQ_pg`ichQHQ$JuU?bdCqg)+S)of z=>{=PUQCJBzuS}S2PP?i^*xYw7siwcrr-C-c365&SU=kwS`8sF?)SO1km_&~Lb8xT zKz^rWyYhnUW0xqG{5J4Zw?N;3-t;IX3;VXAR7zNv* z)5Ek=2tZ8y;zoaQ2w*|0158=i0Lr9p_AcY zCqtYJ3YRGZL>a$?XbANk;E(mF@Pk<;|0LurY$4@ofzHYVQ=kTm&64G(rX}}{bDk{q z1i;gZwA?Ko;{sP64P?Q$&uMuiSBLY3p={We3>Z;k`{lnmT6RA;VFH5ar_#4ms<*3B zb5BC;=R)Zh-(L6!;ld(MV5N+DozU)7IF53&2{!OZ{+gU zktiCpalP5wd&&CnvIcqbel9#$we2^N?dS&e4j2`!SNb=boPlKJ#;xQ5$JYUz3*hx= zg;*R`;Ezw8I|8?!@+No!xAWrON2DY*CwYVguMl<2kkv> zTwEDC8f`G>#v(&%UGc6h?AQ*!X!-Xt$+HY9)Goc$bI7BV{?2{ixAiUy z1Goj^sE9qd-%M2m5CKk+#7jV}9G0F=PDL{fTaB@RVP__irme4sox87(3~qzBGrU4U zfj8ZQc6p4>hSin|H_v#^QNX^zRHcfZ%dkQU$LN{ULL>JxJE4~51dAejIMWxkr}1V} z4f7BE^n3F(7pR*EMFB|j&HeGXFQ6Nh5y#Pu-$}e`Q#7Xc@7?4mzi1A$eA|v`-+GqL z6&z~maPVqmI5)M@93#{#B0H?ovm1x6=)2HYEJVc3Oy485*sm}c?2EehMn66g_6J?S zV9|TULJ|jQ1t>WaX24pfRd)-Km3#KzlP-b4U-o<8ii56E_)*IunJkT~ettkxu2eMw-~Yxuz!Ewb)8*Jz05Epwa^nU5Er#J#plI8Sld)(ax}9hevurIZA#D) z0SE4JWiau5jRGIxCH#I~q+gW-x_~wPLy7#t-WX%|D(wKB@ZS`bG?q6)p6R8^YqI9j z4SrE%u+wMyXgyYpn%eh(`;xh)-zquug9GzoPH#rj;S?%*$h7A#(wHh z103abW6ud(NFlmM-8Z3LYn^+y&#{n~ez!v=)1IPt2Q{nD;j=gJnRaRp!^UjKuVO3Y zgWuoTe5^JCrN}O*1DIxb8oNau&w>hv|k%BU_m3=Y0qk z$tVA5k{HwAZ#aQLVP?sLts8a6v>Ha#VgmRp0$s?g)o=3|P&-3*0vb$-?y!44L_~f4 zaq(IJ+8+>t-QRD7ZKpPi{JAiCr3uCrj7T2K1bd&U0}{Q-j@bv++HPuoRx)vn*+Fj~ z>lwzG)SK%b){*y1Yj&jj!QEk?u)E`Sll_hK7N@|w`6L@b0s@y4eZPrVUSJ@_fA-Rr zwR$=?R`ge<0pFr~0(FyI#%uddffWh8t00RT%QcRa`%Z!V){uR98r()Ni(EJBN;Qg(!_=@uLJrwNgWTC>~8z@CrdM z&I1V|c7FV(AY11x&~GY?AVC^_MywuzF@jnMbqm8uhdBn_kWDJ0w64^E005Xh-}`=j z9Rc>k<1kLYRjg)BkWB2IVQ%(MsOxBHK^=h^UOw*6ey2sz&nlhj4LHzOe-jZabFk+XZoPT7ge${033boxP>ZN+Mlzj4i66piKZ7p zz_okOjX8+3?Iwo2;hmHTb)P=$jXErW{E+F7#5_C1)_wXgj<=zHpkdvdl-gp z65y!oJX~~~9u}%4abqQGJXedy0XpX{G$E;sA5GBe^S?!xO=JEan@Apck6vi( zMKyH75Bmlp#ihCNL9f(yazizP7}KOx@NCib`vMY+Q{+;9w}SCnhZ{R zG)*MosI|8BPMf&uohP++sNMh_NqpoUbK7`@w7xDy1CKjHSx@4IM?3jRHGN?WBuyuy zQ2q?K=*O%h3arlr9L{ttdM#&AI-QUY<(1(oseI?kZoO_jo`S~(#ko8Kp(h>4pdv3~ zLxjK%lYWu@H_Da%KmmXQEUKq+kAM96W&I8Y4WDrWZ&Lz`7u=qkO2LSZFs_h$&(k%T z45(#`#&+PtOdtzWjWU^vN|bjkvIU919}qu@R=Wsa`A?tVheE$weR8)d0^E*+5YaEl zvf#^#=vL@9X9|XwjQFd+o`%g|dC}QsV;{Bb)>nRhfQ_>Z;_kAk#tc{uyuEzoZKV(< zOuvxLwRZZ2HuxLFwYQooN*@hG{rBxBAT^|pItU{OlP)|>QFK`P5$1FsiYblmOqz>_ zqSl7zqdNHPPjCzHaEL*;FNh$MfDb`eY~a0x(N@?t(luT`Gnx!2cN~cH^=Bu@$3lD= zg#99RRy|m7P(m%=A3x@FZXzqUXO$>OEoM{vfDxiT#Pd)v_MD-sRdm<>bSJh*Q2K^$ zXF?VpGl4~n{$qPI!)=p83ugt{_P0q|RI_0j+4&EJc>*l(1Pjhj-)8@`GwUrq78arGBp%3fa0h}ab z-o&!)Ll{)YnY(#q!YHOIOe#?A&K|d%9t(`S=}6;NI`~>vaUSNdRXWgWk;l*pKN9NC z4aI{+0i)7-JE1f@QxmbZ)7N*;Ay}B4e&9YTuc9@DtP{ap6_vqE6Vfr}*t6pELa~Qp zpTZ@-^AR(khdUq$=iwN6;U5~dD%JSotexpzp_Ps9>orS4E8^@np4A2PVjuLl0@4(C z5f}w`rdWuSpb_nZKs*6XY7Jd_%RZGkGUEhFzS#^ zBLL{u6~maZKa!4~Lgln`jRDRltp(CRx(v4JZSc!B1mjBaICg)h=43lZ&v$gj{UJvd z<<%H>)fK(b3&p8u1+YUOP-gxiX@3~2eed1&vnhlsDbOP+;c8=Y%4guhM4=C2)%yGn z5p#7Ag-u8u1M{ahKOPN+e zSx_C)EN{vpDYIK)pcx|U6SYMf5>x%qIkGT?l= z(ri81d9ut2bJAuw@x4D~-tDc___m0eo~9yUR!Do48;pP&iTM+0`6|^-nA;81MeYmM ziH|9^xpR;hS2Gvv!=KvM+yNLTFRX;m`Wb$)eIuzrN)$ZnL;iuXoky{pS9d|FCY**K z9^b<(W3b8>b?z(+B#F3E4AM9TOUXydvECL#?!girfy}>{6Kn1HgX*gR&yC72Vrrt? z6Wv)TC&81@^+!+-xX)GcCf&2M)wEM2SxW?HDG$y}kFC~rPbw3mpr)4R#4v@9Bj76H z;k%AUrPWAUY1Llm7Z9v#P1WwM;_xlKtpD6D0UJzro~Lrcb=w}Hp+?(%)a&kBgi**+ zwNS=0fS2OkxXf4S1{%YdP9*ta=tQWMqKL3tqwKKP!FCpuUHz6p@4+T?%cc&J9Oi5q zyoSkkhVMMN4Qo`GUD0QflX*MeMR^Es?V`c#j2{t}|MBxY_WaH`e@5m%l}bK#l|-D$ zD+yfSAd9SLV#vU;ue-9?;zJ`FW8<-mt*Qw!|5u@eKVw(tzL&-VND<=df$vy81oK8G z{LY!)5u3uZkThr_g?#p3&v-j8A{VWvrae2O5F8;BnH+o;p=g9!BTtw)Seyj6rkO}B z457-Mm2FD!G%5YrPD>~Nq)y`LeG9`2wdsC0CY&o7ne=4^A|8qm%0$!!DnDcPLl<1| zRpS+v`65*$Q7QRvm68!Ns6SD5Q2M5GTv}ilGbgdyAZbY<@3+Xcwps z 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 diff --git a/workspaces/augment/plugins/augment/knip-report.md b/workspaces/augment/plugins/augment/knip-report.md deleted file mode 100644 index f41cae39687..00000000000 --- a/workspaces/augment/plugins/augment/knip-report.md +++ /dev/null @@ -1,16 +0,0 @@ -# Knip report - -## Unused dependencies (2) - -| Name | Location | Severity | -| :-------- | :---------------- | :------- | -| react-use | package.json:72:6 | error | -| motion | package.json:69:6 | error | - -## Unlisted dependencies (2) - -| Name | Location | Severity | -| :----------------------- | :---------------------------------------------------------------------- | :------- | -| @backstage/catalog-model | src/components/AdminPanels/KagentiPanels/AgentTemplateBrowser.tsx:37:30 | error | -| @backstage/catalog-model | src/components/AdminPanels/KagentiPanels/useAgentTemplates.ts:20:30 | error | - diff --git a/workspaces/augment/plugins/augment/package.json b/workspaces/augment/plugins/augment/package.json deleted file mode 100644 index 24e9b3cb95b..00000000000 --- a/workspaces/augment/plugins/augment/package.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "name": "@red-hat-developer-hub/backstage-plugin-augment", - "version": "0.3.0", - "main": "src/index.ts", - "types": "src/index.ts", - "license": "Apache-2.0", - "description": "Frontend plugin for Augment - AI assistant with RAG, tool calling, and safety guardrails", - "publishConfig": { - "access": "public", - "main": "dist/index.esm.js", - "types": "dist/index.d.ts" - }, - "backstage": { - "role": "frontend-plugin", - "pluginId": "augment", - "pluginPackage": "@red-hat-developer-hub/backstage-plugin-augment", - "pluginPackages": [ - "@red-hat-developer-hub/backstage-plugin-augment", - "@red-hat-developer-hub/backstage-plugin-augment-backend", - "@red-hat-developer-hub/backstage-plugin-augment-common" - ], - "supported-versions": "1.45.0" - }, - "repository": { - "type": "git", - "url": "https://github.com/redhat-developer/rhdh-plugins", - "directory": "workspaces/augment/plugins/augment" - }, - "keywords": [ - "backstage", - "backstage-plugin", - "rag", - "agentic", - "ai", - "chat", - "mcp", - "responses-api" - ], - "sideEffects": [ - "**/*.css", - "./src/components/AdminPanels/shared/TourProvider.tsx" - ], - "scripts": { - "build": "backstage-cli package build", - "clean": "backstage-cli package clean", - "lint": "backstage-cli package lint", - "prepack": "backstage-cli package prepack", - "postpack": "backstage-cli package postpack", - "start": "backstage-cli package start", - "test": "backstage-cli package test" - }, - "dependencies": { - "@backstage/core-components": "^0.17.3", - "@backstage/core-plugin-api": "^1.10.8", - "@backstage/errors": "^1.2.7", - "@backstage/plugin-catalog-react": "^1.20.1", - "@backstage/plugin-permission-react": "^0.4.30", - "@backstage/theme": "^0.6.6", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/modifiers": "^9.0.0", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@mui/icons-material": "^5.17.1", - "@mui/material": "^5.17.1", - "@red-hat-developer-hub/backstage-plugin-augment-common": "workspace:^", - "@xyflow/react": "^12.10.2", - "driver.js": "^1.4.0", - "katex": "^0.19.0", - "motion": "^12.38.0", - "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^16.1.0", - "react-use": "^17.2.4", - "rehype-katex": "^7.0.1", - "remark-gfm": "^4.0.0", - "remark-math": "^6.0.0" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0", - "react-router": "^6.3.0", - "react-router-dom": "^6.3.0" - }, - "devDependencies": { - "@backstage/cli": "^0.34.5", - "@backstage/core-app-api": "^1.17.1", - "@backstage/dev-utils": "^1.1.11", - "@backstage/test-utils": "^1.7.8", - "@testing-library/jest-dom": "^6.0.0", - "@testing-library/react": "^14.0.0", - "@testing-library/user-event": "^14.6.1", - "@types/react": "^17.0.0 || ^18.0.0", - "@types/react-syntax-highlighter": "^15.5.13", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0", - "react-router-dom": "^6.3.0" - }, - "files": [ - "dist", - "config.d.ts" - ], - "configSchema": "config.d.ts" -} diff --git a/workspaces/augment/plugins/augment/report.api.md b/workspaces/augment/plugins/augment/report.api.md deleted file mode 100644 index b93cdbcab49..00000000000 --- a/workspaces/augment/plugins/augment/report.api.md +++ /dev/null @@ -1,1137 +0,0 @@ -## API Report File for "@red-hat-developer-hub/backstage-plugin-augment" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts -import { AdminConfigEntry } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { AdminConfigKey } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { AgentLifecycleStage } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { ApiRef } from '@backstage/frontend-plugin-api'; -import { augmentAccessPermission } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { AugmentStatus } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { BackstagePlugin } from '@backstage/core-plugin-api'; -import { BrandingConfig } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ChatAgent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { ChatAgentConfig } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { ChatMessage } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ChatResponse as ChatResponse_2 } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { ConversationSummary } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { DEFAULT_BRANDING } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { DevSpacesCreateWorkspaceRequest } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { DevSpacesCreateWorkspaceResponse } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { DevSpacesHealthResponse } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { DevSpacesListWorkspacesResponse } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { DevSpacesWorkspace } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { DocumentInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { EvaluationResult } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { FileFormat } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { IconComponent } from '@backstage/core-plugin-api'; -import { InputTokensDetails } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { JSX as JSX_2 } from 'react/jsx-runtime'; -import { KagentiAgentCard } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiAgentDetail } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiAgentSummary } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiBuildInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiBuildListItem } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiBuildStrategy } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiCreateAgentRequest } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiCreateAgentResponse } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiCreateToolRequest } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiCreateToolResponse } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiDashboardConfig } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiFeatureFlags } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiFinalizeAgentBuildRequest } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiFinalizeToolBuildRequest } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiMcpInvokeResponse } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiMcpToolSchema } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiMigratableAgent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiMigrateAgentResponse } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiMigrateAllResponse } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiRouteStatus } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiToolDetail } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiToolSummary } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { KagentiTriggerBuildRunResponse } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { MCPServerStatus } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { NormalizedStreamEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { OutputTokensDetails } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { PromptCapabilities } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { PromptCard } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { PromptGroup } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { ProviderDescriptor } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { ProviderStatus } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { QuickAction } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { RagGenerateResult } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { RAGSource } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { RagTestChunk } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { RagTestResult } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { ResponseUsage } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { RouteRef } from '@backstage/core-plugin-api'; -import { SecurityMode } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamAgentHandoffEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamArtifactEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamAuthRequiredEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamBackendToolExecutingEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamCitationEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamCompletedEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamErrorEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamFormRequestEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamRagResultsEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamReasoningDeltaEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamReasoningDoneEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamStartedEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamTextDeltaEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamTextDoneEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamToolApprovalEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamToolCompletedEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamToolDeltaEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamToolDiscoveryEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamToolFailedEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { StreamToolStartedEvent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { SyncResult } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { ToolCallInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { ToolCapabilityInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { TranslationRef } from '@backstage/frontend-plugin-api'; -import { TranslationResource } from '@backstage/frontend-plugin-api'; -import { UploadResult } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { VectorStoreConfig } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { VectorStoreCreateResult } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { VectorStoreInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { VectorStoreStatus } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { VectorStoreStatusResult } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { Workflow } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { WorkflowStep } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -export { AdminConfigEntry }; - -export { AdminConfigKey }; - -export { augmentAccessPermission }; - -// @public -export interface AugmentApi { - bulkPublishAgents(agentIds: string[], published: boolean): Promise; - chatStream( - messages: ChatMessage[], - onEvent: StreamingEventCallback, - enableRAG?: boolean, - signal?: AbortSignal, - previousResponseId?: string, - conversationId?: string, - model?: string, - ): Promise; - chatStreamWithSession( - messages: ChatMessage[], - onEvent: StreamingEventCallback, - sessionId: string, - enableRAG?: boolean, - signal?: AbortSignal, - model?: string, - ): Promise; - // (undocumented) - checkDevSpacesHealth(): Promise; - // (undocumented) - connectKagentiTool( - namespace: string, - name: string, - ): Promise<{ - tools: KagentiMcpToolSchema[]; - }>; - connectVectorStore(vectorStoreId: string): Promise<{ - activeVectorStoreIds: string[]; - }>; - createConversation(): Promise<{ - conversationId: string; - }>; - // (undocumented) - createDevSpacesWorkspace( - request: DevSpacesCreateWorkspaceRequest, - ): Promise; - // (undocumented) - createKagentiAgent( - body: KagentiCreateAgentRequest, - ): Promise; - // (undocumented) - createKagentiTool( - body: KagentiCreateToolRequest, - ): Promise; - createSession( - title?: string, - model?: string, - providerId?: string, - ): Promise; - createVectorStore( - config?: Record, - ): Promise; - deleteAdminConfig(key: AdminConfigKey): Promise<{ - deleted: boolean; - }>; - deleteAgentConfig(agentId: string): Promise; - // (undocumented) - deleteDevSpacesWorkspace(namespace: string, name: string): Promise; - deleteDocument( - fileId: string, - vectorStoreId?: string, - ): Promise<{ - success: boolean; - }>; - // (undocumented) - deleteKagentiAgent(namespace: string, name: string): Promise; - // (undocumented) - deleteKagentiTool(namespace: string, name: string): Promise; - deleteSession(sessionId: string): Promise; - demoteAgent( - agentId: string, - targetStage?: AgentLifecycleStage, - reason?: string, - ): Promise<{ - lifecycleStage: string; - }>; - demoteToolLifecycle( - toolId: string, - targetStage?: AgentLifecycleStage, - ): Promise<{ - lifecycleStage: string; - }>; - // (undocumented) - finalizeKagentiAgentBuild( - namespace: string, - name: string, - body?: KagentiFinalizeAgentBuildRequest, - ): Promise; - // (undocumented) - finalizeToolBuild( - namespace: string, - name: string, - body?: KagentiFinalizeToolBuildRequest, - ): Promise; - generateRagAnswer( - query: string, - maxResults?: number, - vectorStoreId?: string, - vectorStoreIds?: string[], - ): Promise; - generateSystemPrompt( - description: string, - model?: string, - capabilities?: PromptCapabilities, - ): Promise; - getActiveProvider(): Promise<{ - providerId: string; - }>; - getAdminConfig(key: AdminConfigKey): Promise<{ - entry: AdminConfigEntry | null; - source: 'database' | 'default'; - }>; - getAdminSessionMessages(sessionId: string): Promise; - getBranding(): Promise; - // (undocumented) - getDevSpacesWorkspace( - namespace: string, - name: string, - ): Promise; - getEffectiveConfig(): Promise>; - getEvaluationStatus(): Promise; - // (undocumented) - getKagentiAgent( - namespace: string, - name: string, - ): Promise< - KagentiAgentDetail & { - agentCard?: KagentiAgentCard; - } - >; - // (undocumented) - getKagentiAgentRouteStatus( - namespace: string, - name: string, - ): Promise; - // (undocumented) - getKagentiBuildInfo( - namespace: string, - name: string, - ): Promise; - // (undocumented) - getKagentiDashboards(): Promise; - // (undocumented) - getKagentiFeatureFlags(): Promise; - // (undocumented) - getKagentiTool(namespace: string, name: string): Promise; - getPromptGroups(): Promise; - getQuickActions(): Promise; - getSafetyStatus(): Promise; - getSessionMessages(sessionId: string): Promise; - getSessionState(sessionId: string): Promise>; - getStatus(): Promise; - // (undocumented) - getToolBuildInfo(namespace: string, name: string): Promise; - // (undocumented) - getToolRouteStatus( - namespace: string, - name: string, - ): Promise; - // Warning: (ae-forgotten-export) The symbol "TourDefinition" needs to be exported by the entry point index.d.ts - getTours(): Promise; - getVectorStoreConfig(): Promise<{ - config: VectorStoreConfig; - source: 'yaml' | 'database' | 'merged'; - }>; - getVectorStoreStatus(): Promise; - getWorkflows(): Promise; - // (undocumented) - invokeKagentiTool( - namespace: string, - name: string, - toolName: string, - args?: Record, - ): Promise; - listActiveVectorStores(): Promise<{ - stores: Array< - VectorStoreInfo & { - active: boolean; - } - >; - unconnected?: Array< - VectorStoreInfo & { - active: false; - } - >; - }>; - listAdminConfig(): Promise< - Array<{ - configKey: AdminConfigKey; - updatedAt: string; - updatedBy: string; - }> - >; - // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters - listAgents(options?: { published?: boolean }): Promise; - listAllSessions( - limit?: number, - offset?: number, - ): Promise; - // (undocumented) - listDevSpacesWorkspaces( - namespace: string, - ): Promise; - listDocumentsForStore(vectorStoreId: string): Promise; - // (undocumented) - listKagentiAgents( - namespace?: string, - options?: { - includeCards?: boolean; - }, - ): Promise<{ - agents: (KagentiAgentSummary & { - agentCard?: KagentiAgentCard; - })[]; - }>; - // (undocumented) - listKagentiBuildStrategies(): Promise<{ - strategies: KagentiBuildStrategy[]; - }>; - // (undocumented) - listKagentiMigratableAgents(): Promise<{ - agents: KagentiMigratableAgent[]; - }>; - // (undocumented) - listKagentiNamespaces(enabledOnly?: boolean): Promise<{ - namespaces: string[]; - defaultNamespace?: string; - }>; - // (undocumented) - listKagentiShipwrightBuilds(options?: { - namespace?: string; - allNamespaces?: boolean; - }): Promise<{ - builds: KagentiBuildListItem[]; - }>; - // (undocumented) - listKagentiTools(namespace?: string): Promise<{ - tools: KagentiToolSummary[]; - }>; - listModels(): Promise< - Array<{ - id: string; - owned_by?: string; - model_type?: string; - }> - >; - listProviders(): Promise<{ - providers: ProviderDescriptor[]; - activeProviderId: string; - }>; - listSessions( - limit?: number, - offset?: number, - providerId?: string, - ): Promise; - listToolsWithLifecycle(options?: { published?: boolean }): Promise< - (KagentiToolSummary & { - published?: boolean; - lifecycleStage?: AgentLifecycleStage; - version?: number; - })[] - >; - // (undocumented) - migrateAllKagentiAgents(options?: { - namespace?: string; - dryRun?: boolean; - deleteOld?: boolean; - }): Promise; - // (undocumented) - migrateKagentiAgent( - namespace: string, - name: string, - deleteOld?: boolean, - ): Promise; - promoteAgent( - agentId: string, - targetStage?: AgentLifecycleStage, - ): Promise<{ - lifecycleStage: string; - version?: number; - workflowDecisionSent?: boolean; - }>; - promoteToolLifecycle( - toolId: string, - targetStage?: AgentLifecycleStage, - ): Promise<{ - lifecycleStage: string; - version: number; - }>; - publishAgent(agentId: string): Promise; - registerAgentForGovernance(agentId: string): Promise<{ - agentId: string; - lifecycleStage: string; - }>; - removeVectorStore( - vectorStoreId: string, - permanent?: boolean, - ): Promise<{ - removed: string; - permanent: boolean; - filesDeleted: number; - activeVectorStoreIds: string[]; - }>; - requestUnpublish(agentId: string): Promise; - resetVectorStoreConfig(): Promise<{ - deleted: boolean; - }>; - saveVectorStoreConfig(overrides: Partial): Promise; - setActiveProvider(providerId: string): Promise<{ - success: boolean; - providerId: string; - error?: string; - }>; - setAdminConfig( - key: AdminConfigKey, - value: unknown, - ): Promise<{ - warnings?: string[]; - }>; - // (undocumented) - stopDevSpacesWorkspace(namespace: string, name: string): Promise; - submitMessageFeedback(payload: { - messageId: string; - sessionId?: string; - direction: 'positive' | 'negative'; - reasons?: string[]; - comment?: string; - }): Promise; - submitToolApproval( - responseId: string, - callId: string, - approved: boolean, - toolName?: string, - toolArguments?: string, - signal?: AbortSignal, - reason?: string, - ): Promise<{ - success: boolean; - content?: string; - responseId?: string; - rejected?: boolean; - toolOutput?: string; - toolExecuted?: boolean; - outputTruncated?: boolean; - pendingApproval?: { - approvalRequestId: string; - toolName: string; - serverLabel?: string; - arguments?: string; - }; - handoff?: { - fromAgent: string; - toAgent: string; - }; - }>; - syncDocuments(): Promise<{ - added: number; - updated: number; - removed: number; - failed: number; - unchanged: number; - errors: string[]; - }>; - testMcpConnection( - url: string, - type?: string, - headers?: Record, - ): Promise; - testModelConnection( - model?: string, - baseUrl?: string, - ): Promise<{ - connected: boolean; - modelFound: boolean; - canGenerate: boolean; - error?: string; - }>; - testRagQuery( - query: string, - maxResults?: number, - vectorStoreId?: string, - vectorStoreIds?: string[], - ): Promise; - // (undocumented) - triggerKagentiBuild( - namespace: string, - name: string, - ): Promise; - // (undocumented) - triggerToolBuild( - namespace: string, - name: string, - ): Promise; - unpublishAgent(agentId: string): Promise; - updateAgentConfig( - agentId: string, - config: Partial, - ): Promise; - uploadDocument( - file: File, - vectorStoreId?: string, - replace?: boolean, - ): Promise; - withdrawAgent(agentId: string): Promise; -} - -// @public -export const augmentApiRef: ApiRef; - -// @public -export const AugmentIcon: IconComponent; - -// @public -export const AugmentPage: () => JSX_2.Element; - -// @public -export const augmentPlugin: BackstagePlugin< - { - root: RouteRef; - }, - {}, - {} ->; - -export { AugmentStatus }; - -// @public -export const augmentTranslationRef: TranslationRef< - 'plugin.augment', - { - readonly 'chat.disclaimer': string; - readonly 'chat.emptySessionTitle': string; - readonly 'chat.emptySessionHint': string; - readonly 'chat.messagesUnavailableTitle': string; - readonly 'chat.messagesUnavailableHint': string; - readonly 'chat.you': string; - readonly 'chat.editMessage': string; - readonly 'chat.cancelEdit': string; - readonly 'chat.submitEdit': string; - readonly 'chat.copiedToClipboard': string; - readonly 'chat.copyResponse': string; - readonly 'chat.regenerateResponse': string; - readonly 'securityGate.configurationRequired': string; - readonly 'securityGate.configurationErrors': string; - readonly 'securityGate.configurationErrorLabel': string; - readonly 'securityGate.configurationHint': string; - readonly 'securityGate.accessDenied': string; - readonly 'securityGate.accessDeniedMessage': string; - readonly 'adminOnboarding.welcomeAdmin': string; - readonly 'adminOnboarding.adminAccessMessage': string; - readonly 'adminOnboarding.continueToChat': string; - readonly 'adminOnboarding.continueToChatDescription': string; - readonly 'adminOnboarding.openCommandCenter': string; - readonly 'adminOnboarding.openCommandCenterDescription': string; - readonly 'adminOnboarding.switchHint': string; - readonly 'commandCenter.title': string; - readonly 'commandCenter.sandbox': string; - readonly 'commandCenter.home': string; - readonly 'commandCenter.agents': string; - readonly 'commandCenter.platform': string; - readonly 'commandCenter.branding': string; - readonly 'commandCenter.backToChat': string; - readonly 'commandCenter.agentCatalog': string; - readonly 'commandCenter.toolCatalog': string; - readonly 'commandCenter.buildPipelines': string; - readonly 'commandCenter.orchestration': string; - readonly 'commandCenter.platformConfig': string; - readonly 'commandCenter.observability': string; - readonly 'commandCenter.administration': string; - readonly 'providerOffline.title': string; - readonly 'providerOffline.backendUnreachable': string; - readonly 'providerOffline.modelUnreachable': string; - readonly 'switchDialog.title': string; - readonly 'switchDialog.message': string; - readonly 'switchDialog.stay': string; - readonly 'switchDialog.switchAnyway': string; - readonly 'chatInput.newConversation': string; - readonly 'chatInput.newConversationShortcut': string; - readonly 'chatInput.startNewConversation': string; - readonly 'chatInput.attachFile': string; - readonly 'chatInput.chatMessageInput': string; - readonly 'chatInput.stopGeneration': string; - readonly 'chatInput.stopMessageGeneration': string; - readonly 'chatInput.sendMessage': string; - readonly 'chatInput.selectAgentPrompt': string; - readonly 'welcomeScreen.logoAlt': string; - readonly 'welcomeScreen.emptyPromptHint': string; - readonly 'welcomeScreen.logoError': string; - readonly 'conversationHistory.title': string; - readonly 'conversationHistory.delete': string; - readonly 'conversationHistory.cancel': string; - readonly 'conversationHistory.refresh': string; - readonly 'conversationHistory.refreshAriaLabel': string; - readonly 'conversationHistory.noConversationsYet': string; - readonly 'conversationHistory.startChatting': string; - readonly 'conversationHistory.noMatchingConversations': string; - readonly 'conversationHistory.deleteConversation': string; - readonly 'conversationHistory.renameConversation': string; - readonly 'conversationHistory.searchPlaceholder': string; - readonly 'conversationHistory.clearSearch': string; - readonly 'conversationHistory.mine': string; - readonly 'conversationHistory.allUsers': string; - readonly 'conversationHistory.today': string; - readonly 'conversationHistory.yesterday': string; - readonly 'conversationHistory.thisWeek': string; - readonly 'conversationHistory.older': string; - readonly 'rightPane.collapse': string; - readonly 'rightPane.admin': string; - readonly 'rightPane.expand': string; - readonly 'rightPane.openCommandCenter': string; - readonly 'rightPane.commandCenter': string; - readonly 'rightPane.expandSidebar': string; - readonly 'rightPane.collapseSidebar': string; - readonly 'rightPane.scrollToBottom': string; - readonly 'agentInfo.title': string; - readonly 'agentInfo.mcpServers': string; - readonly 'agentInfo.defaultAgent': string; - readonly 'agentInfo.offline': string; - readonly 'agentInfo.connecting': string; - readonly 'agentInfo.ready': string; - readonly 'agentInfo.selectAgentHint': string; - readonly 'agentInfo.team': string; - readonly 'agentInfo.vectorRag': string; - readonly 'agentInfo.vectorStoreUnavailable': string; - readonly 'agentInfo.vectorStoreId': string; - readonly 'agentInfo.mcpConnected': string; - readonly 'agentInfo.mcpDisconnected': string; - readonly 'toolApproval.running': string; - readonly 'toolApproval.approve': string; - readonly 'toolApproval.reject': string; - readonly 'toolApproval.destructiveOperation': string; - readonly 'toolApproval.requiresApproval': string; - readonly 'toolApproval.toolExecution': string; - readonly 'toolApproval.approveHint': string; - readonly 'toolApproval.rejectHint': string; - readonly 'toolApproval.enterKey': string; - readonly 'toolApproval.escapeKey': string; - readonly 'toolApproval.invalidJson': string; - readonly 'toolApproval.editJson': string; - readonly 'toolApproval.hideEditor': string; - readonly 'toolApproval.editArguments': string; - readonly 'streaming.done': string; - readonly 'streaming.thinking': string; - readonly 'streaming.working': string; - readonly 'streaming.searching': string; - readonly 'streaming.executingTools': string; - readonly 'streaming.needsApproval': string; - readonly 'streaming.responding': string; - readonly 'streaming.processing': string; - readonly 'streaming.thoughtFor': string; - readonly 'streaming.thinkingEllipsis': string; - readonly 'streaming.connectingWith': string; - readonly 'errors.error': string; - readonly 'errors.copied': string; - readonly 'errors.contentFiltered': string; - readonly 'errors.connectionError': string; - readonly 'errors.tryAgain': string; - readonly 'errors.safetyHint': string; - readonly 'errors.networkHint': string; - readonly 'errors.copyErrorDetails': string; - readonly 'toolCalls.output': string; - readonly 'toolCalls.copy': string; - readonly 'toolCalls.copiedToClipboard': string; - readonly 'toolCalls.usedTools': string; - readonly 'toolCalls.collapseToolCalls': string; - readonly 'toolCalls.expandToolCalls': string; - readonly 'toolCalls.arguments': string; - readonly 'toolCalls.collapseOutput': string; - readonly 'toolCalls.expandOutput': string; - readonly 'toolCalls.copyOutput': string; - readonly 'ragSources.sourcesFromVectorRag': string; - readonly 'ragSources.unknownSource': string; - readonly 'ragSources.collapseKnowledgeSources': string; - readonly 'ragSources.expandKnowledgeSources': string; - readonly 'tokenUsage.reasoning': string; - readonly 'tokenUsage.inputTokens': string; - readonly 'tokenUsage.outputTokens': string; - readonly 'tokenUsage.totalTokens': string; - readonly 'tokenUsage.cached': string; - readonly 'tokenUsage.reportedBy': string; - readonly 'agentsPanel.reset': string; - readonly 'agentsPanel.mcpServers': string; - readonly 'agentsPanel.instructions': string; - readonly 'agentsPanel.transfers': string; - readonly 'agentsPanel.delegates': string; - readonly 'agentsPanel.startingAgent': string; - readonly 'agentsPanel.maxTurns': string; - readonly 'agentsPanel.basePrompt': string; - readonly 'agentsPanel.newAgent': string; - readonly 'agentsPanel.save': string; - readonly 'agentsPanel.saved': string; - readonly 'agentsPanel.saveSuccess': string; - readonly 'agentsPanel.noAgentsTitle': string; - readonly 'agentsPanel.noAgentsSubtitle': string; - readonly 'agentsPanel.createFirstAgent': string; - readonly 'agentsPanel.selectAgent': string; - readonly 'agentsPanel.topology': string; - readonly 'agentsPanel.outConnections': string; - readonly 'agentsPanel.inConnections': string; - readonly 'agentsPanel.inheritBasePrompt': string; - readonly 'agentsPanel.agentInstructions': string; - readonly 'agentsPanel.applyTemplate': string; - readonly 'agentsPanel.capabilities': string; - readonly 'agentsPanel.modelOverride': string; - readonly 'agentsPanel.modelOverrideHint': string; - readonly 'agentsPanel.rag': string; - readonly 'agentsPanel.webSearch': string; - readonly 'agentsPanel.codeInterpreter': string; - readonly 'agentsPanel.connections': string; - readonly 'agentsPanel.canTransferTo': string; - readonly 'agentsPanel.canDelegateTo': string; - readonly 'agentsPanel.advanced': string; - readonly 'agentsPanel.adminModified': string; - readonly 'agentsPanel.deleteAgent': string; - readonly 'agentsPanel.confirmRemoveTitle': string; - readonly 'agentsPanel.confirmRemoveMessage': string; - readonly 'agentsPanel.confirmRemoveButton': string; - readonly 'agentsPanel.confirmResetTitle': string; - readonly 'agentsPanel.confirmResetMessage': string; - readonly 'agentsPanel.confirmResetButton': string; - readonly 'agentsPanel.createModal.title': string; - readonly 'agentsPanel.createModal.displayName': string; - readonly 'agentsPanel.createModal.cancel': string; - readonly 'agentsPanel.createModal.subtitle': string; - readonly 'agentsPanel.createModal.displayNamePlaceholder': string; - readonly 'agentsPanel.createModal.agentId': string; - readonly 'agentsPanel.createModal.agentIdHint': string; - readonly 'agentsPanel.createModal.agentIdExists': string; - readonly 'agentsPanel.createModal.templateTitle': string; - readonly 'agentsPanel.createModal.createButton': string; - readonly 'keyboardShortcuts.title': string; - readonly 'keyboardShortcuts.close': string; - readonly 'keyboardShortcuts.newConversation': string; - readonly 'keyboardShortcuts.chatSection': string; - readonly 'keyboardShortcuts.approvalSection': string; - readonly 'keyboardShortcuts.focusChatInput': string; - readonly 'keyboardShortcuts.cancelStreaming': string; - readonly 'keyboardShortcuts.showHelp': string; - readonly 'keyboardShortcuts.approveTool': string; - readonly 'keyboardShortcuts.rejectTool': string; - readonly 'keyboardShortcuts.navigateMessages': string; - readonly 'confirmDialog.cancel': string; - readonly 'confirmDialog.confirm': string; - readonly 'onboardingBanner.title': string; - readonly 'onboardingBanner.subtitle': string; - readonly 'onboardingBanner.dismissAriaLabel': string; - readonly 'onboardingBanner.step1Title': string; - readonly 'onboardingBanner.step1Description': string; - readonly 'onboardingBanner.step2Title': string; - readonly 'onboardingBanner.step2Description': string; - readonly 'onboardingBanner.step3Title': string; - readonly 'onboardingBanner.step3Description': string; - readonly 'agentGallery.heading': string; - readonly 'agentGallery.searchPlaceholder': string; - readonly 'agentGallery.noAgentsTitle': string; - readonly 'agentGallery.searchAriaLabel': string; - readonly 'agentGallery.tabAll': string; - readonly 'agentGallery.tabRecent': string; - readonly 'agentGallery.tabPinned': string; - readonly 'agentGallery.listAriaLabel': string; - readonly 'agentGallery.noMatchSearch': string; - readonly 'agentGallery.noAgentsInCategory': string; - readonly 'agentGallery.retry': string; - readonly 'agentGallery.noAgentsHint': string; - readonly 'agentDetail.details': string; - readonly 'agentDetail.about': string; - readonly 'agentDetail.streaming': string; - readonly 'agentDetail.capabilities': string; - readonly 'agentDetail.closeAriaLabel': string; - readonly 'agentDetail.skillsWithCount': string; - readonly 'agentDetail.skillFallback': string; - readonly 'agentDetail.nonStreaming': string; - readonly 'agentDetail.a2aProtocol': string; - readonly 'agentDetail.fieldNamespace': string; - readonly 'agentDetail.fieldName': string; - readonly 'agentDetail.fieldVersion': string; - readonly 'agentDetail.fieldEndpoint': string; - readonly 'agentDetail.startConversation': string; - readonly 'formRequestCard.submit': string; - readonly 'formRequestCard.cancel': string; - readonly 'formRequestCard.inputRequired': string; - readonly 'authRequiredCard.oauthTitle': string; - readonly 'authRequiredCard.oauthSubtitle': string; - readonly 'authRequiredCard.signIn': string; - readonly 'authRequiredCard.afterSignInHint': string; - readonly 'authRequiredCard.signedIn': string; - readonly 'authRequiredCard.noUrlHint': string; - readonly 'authRequiredCard.credentialsTitle': string; - readonly 'authRequiredCard.credentialsSubtitle': string; - readonly 'authRequiredCard.submitCredentials': string; - readonly 'artifact.download': string; - readonly 'artifact.collapse': string; - readonly 'artifact.copied': string; - readonly 'artifact.expand': string; - readonly 'artifact.streaming': string; - readonly 'artifact.defaultName': string; - readonly 'artifact.headerAriaLabel': string; - readonly 'artifact.copyContent': string; - readonly 'artifact.downloadAriaLabel': string; - readonly 'citation.sourcesWithCount': string; - readonly 'citation.unnamedSource': string; - readonly 'chatHeader.changeAgent': string; - } ->; - -// @public -export const augmentTranslationsResource: TranslationResource<'plugin.augment'>; - -export { BrandingConfig }; - -export { ChatAgentConfig }; - -export { ChatMessage }; - -// @public -export interface ChatResponse extends ChatResponse_2 { - evaluation?: EvaluationResult; - filtered?: boolean; - filterReason?: string; - pendingAction?: string; - requiresConfirmation?: boolean; -} - -// @public -export interface ChatSessionSummary { - // (undocumented) - conversationId: string | null; - // (undocumented) - createdAt: string; - // (undocumented) - id: string; - model?: string; - providerId?: string; - // (undocumented) - title: string; - // (undocumented) - updatedAt: string; - // (undocumented) - userRef?: string; -} - -// @public -export interface ConversationDetails { - // (undocumented) - createdAt: Date; - // (undocumented) - id: string; - input?: unknown; - // (undocumented) - model: string; - // (undocumented) - output: Array<{ - type: string; - id?: string; - role?: string; - content?: Array<{ - type: string; - text: string; - }>; - }>; - previousResponseId?: string; - // (undocumented) - status: string; -} - -// @public -export interface ConversationInputItem { - arguments?: string; - // (undocumented) - call_id?: string; - // (undocumented) - content?: - | string - | Array<{ - type: string; - text?: string; - }>; - error?: string; - // (undocumented) - id?: string; - name?: string; - // (undocumented) - output?: string; - queries?: string[]; - results?: Array<{ - filename?: string; - file_id?: string; - text?: string; - score?: number; - attributes?: Record; - }>; - // (undocumented) - role?: 'user' | 'assistant' | 'system'; - server_label?: string; - // (undocumented) - status?: string; - // (undocumented) - type: string; -} - -export { ConversationSummary }; - -export { DEFAULT_BRANDING }; - -export { DocumentInfo }; - -export { EvaluationResult }; - -// @public -export interface EvaluationStatusResponse { - // (undocumented) - enabled: boolean; - // (undocumented) - scoringFunctions: string[]; - // (undocumented) - timestamp: string; -} - -export { FileFormat }; - -export { InputTokensDetails }; - -export { MCPServerStatus }; - -// @public -export interface McpTestConnectionResult { - // (undocumented) - error?: string; - // (undocumented) - serverType?: string; - // (undocumented) - success: boolean; - // (undocumented) - toolCount?: number; - // (undocumented) - tools?: Array<{ - name: string; - description?: string; - }>; - // (undocumented) - warning?: string; -} - -// @public -export interface Message { - agentName?: string; - artifacts?: Array<{ - artifactId: string; - name?: string; - description?: string; - content: string; - }>; - citations?: Array<{ - title?: string; - url?: string; - snippet?: string; - }>; - conversationId?: string; - errorCode?: string; - // @deprecated (undocumented) - filesSearched?: string[]; - handoffPath?: string[]; - id: string; - isUser: boolean; - outputValidationError?: string; - ragSources?: RAGSource[]; - reasoning?: string; - reasoningDuration?: number; - reasoningSummaries?: Array<{ - id: string; - text: string; - }>; - responseId?: string; - text: string; - timestamp: Date; - toolCalls?: ToolCallInfo[]; - usage?: TokenUsage; -} - -export { NormalizedStreamEvent }; - -export { OutputTokensDetails }; - -// @public -export interface ProcessedMessage { - agentName?: string; - createdAt?: string; - // (undocumented) - ragSources?: Array<{ - filename: string; - text?: string; - score?: number; - fileId?: string; - attributes?: Record; - }>; - reasoning?: string; - // (undocumented) - role: 'user' | 'assistant'; - // (undocumented) - text: string; - // (undocumented) - toolCalls?: Array<{ - id: string; - name: string; - serverLabel: string; - arguments?: string; - output?: string; - error?: string; - status: string; - }>; - usage?: ResponseUsage; -} - -export { PromptCapabilities }; - -export { PromptCard }; - -export { PromptGroup }; - -export { ProviderStatus }; - -export { QuickAction }; - -export { RagGenerateResult }; - -export { RAGSource }; - -export { RagTestChunk }; - -export { RagTestResult }; - -export { ResponseUsage }; - -// @public -export interface SafetyStatusResponse { - // (undocumented) - enabled: boolean; - // (undocumented) - shields: string[]; - // (undocumented) - timestamp: string; -} - -export { SecurityMode }; - -// @public -export interface SessionMessagesResponse { - hasConversationId?: boolean; - // (undocumented) - messages: ProcessedMessage[]; - // (undocumented) - sessionCreatedAt?: string; -} - -export { StreamAgentHandoffEvent }; - -export { StreamArtifactEvent }; - -export { StreamAuthRequiredEvent }; - -export { StreamBackendToolExecutingEvent }; - -export { StreamCitationEvent }; - -export { StreamCompletedEvent }; - -export { StreamErrorEvent }; - -export { StreamFormRequestEvent }; - -// @public -export type StreamingEvent = NormalizedStreamEvent; - -// @public -export type StreamingEventCallback = (event: StreamingEvent) => void; - -export { StreamRagResultsEvent }; - -export { StreamReasoningDeltaEvent }; - -export { StreamReasoningDoneEvent }; - -export { StreamStartedEvent }; - -export { StreamTextDeltaEvent }; - -export { StreamTextDoneEvent }; - -export { StreamToolApprovalEvent }; - -export { StreamToolCompletedEvent }; - -export { StreamToolDeltaEvent }; - -export { StreamToolDiscoveryEvent }; - -export { StreamToolFailedEvent }; - -export { StreamToolStartedEvent }; - -export { SyncResult }; - -// @public -export type TokenUsage = ResponseUsage; - -export { ToolCallInfo }; - -export { ToolCapabilityInfo }; - -export { UploadResult }; - -export { VectorStoreConfig }; - -export { VectorStoreCreateResult }; - -export { VectorStoreInfo }; - -export { VectorStoreStatus }; - -export { VectorStoreStatusResult }; - -export { Workflow }; - -export { WorkflowStep }; -``` diff --git a/workspaces/augment/plugins/augment/src/api/AugmentApi.test.ts b/workspaces/augment/plugins/augment/src/api/AugmentApi.test.ts deleted file mode 100644 index 821ef55207b..00000000000 --- a/workspaces/augment/plugins/augment/src/api/AugmentApi.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { AugmentApiClient, augmentApiRef } from './AugmentApi'; -import { - DiscoveryApi, - FetchApi, - ConfigApi, - IdentityApi, -} from '@backstage/core-plugin-api'; -import { createMockResponse } from '../test-utils/factories'; - -describe('AugmentApi', () => { - let api: AugmentApiClient; - let mockDiscoveryApi: jest.Mocked; - let mockFetchApi: jest.Mocked; - let mockConfigApi: jest.Mocked; - let mockIdentityApi: jest.Mocked; - - beforeEach(() => { - mockDiscoveryApi = { - getBaseUrl: jest - .fn() - .mockResolvedValue('http://localhost:7007/api/augment'), - }; - - mockIdentityApi = { - getBackstageIdentity: jest.fn().mockResolvedValue({ - type: 'user', - userEntityRef: 'user:default/guest', - ownershipEntityRefs: [], - }), - getCredentials: jest.fn().mockResolvedValue({ token: 'test-token' }), - getProfileInfo: jest.fn().mockResolvedValue({ displayName: 'Guest' }), - signOut: jest.fn(), - }; - - mockFetchApi = { - fetch: jest.fn(), - }; - - mockConfigApi = { - getString: jest.fn(), - getOptionalString: jest.fn(), - getOptional: jest.fn(), - getOptionalStringArray: jest.fn(), - getConfig: jest.fn().mockReturnValue({ - getOptionalConfigArray: jest.fn().mockReturnValue([]), - }), - getOptionalConfig: jest.fn().mockReturnValue(undefined), - getOptionalConfigArray: jest.fn().mockReturnValue([]), - has: jest.fn(), - keys: jest.fn().mockReturnValue([]), - get: jest.fn(), - getNumber: jest.fn(), - getOptionalNumber: jest.fn(), - getBoolean: jest.fn(), - getOptionalBoolean: jest.fn(), - getStringArray: jest.fn(), - getConfigArray: jest.fn(), - }; - - api = new AugmentApiClient({ - discoveryApi: mockDiscoveryApi, - fetchApi: mockFetchApi, - configApi: mockConfigApi, - identityApi: mockIdentityApi, - }); - }); - - describe('augmentApiRef', () => { - it('should have correct id', () => { - expect(augmentApiRef.id).toBe('plugin.augment.api'); - }); - }); - - describe('getStatus', () => { - it('should fetch status from backend', async () => { - const mockStatus = { - success: true, - status: { - vectorStoreIds: ['vs_test'], - model: 'test-model', - documentsCount: 5, - }, - }; - - mockFetchApi.fetch.mockResolvedValue( - createMockResponse({ - ok: true, - json: jest.fn().mockResolvedValue(mockStatus), - }), - ); - - const result = await api.getStatus(); - - expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('augment'); - expect(mockFetchApi.fetch).toHaveBeenCalledWith( - 'http://localhost:7007/api/augment/status', - expect.objectContaining({ - headers: expect.any(Headers), - }), - ); - expect(result).toEqual(mockStatus); - }); - }); - - describe('getBranding', () => { - it('should fetch branding configuration', async () => { - const mockBrandingData = { - appName: 'Augment', - tagline: 'Your AI assistant', - primaryColor: '#9333ea', - }; - - mockFetchApi.fetch.mockResolvedValue( - createMockResponse({ - ok: true, - json: jest.fn().mockResolvedValue({ branding: mockBrandingData }), - }), - ); - - const result = await api.getBranding(); - - expect(mockFetchApi.fetch).toHaveBeenCalledWith( - 'http://localhost:7007/api/augment/branding', - expect.objectContaining({ - headers: expect.any(Headers), - }), - ); - expect(result).toEqual(mockBrandingData); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/api/AugmentApi.ts b/workspaces/augment/plugins/augment/src/api/AugmentApi.ts deleted file mode 100644 index b1006edf609..00000000000 --- a/workspaces/augment/plugins/augment/src/api/AugmentApi.ts +++ /dev/null @@ -1,1513 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - ConfigApi, - createApiRef, - DiscoveryApi, - FetchApi, - IdentityApi, -} from '@backstage/core-plugin-api'; -import { ResponseError } from '@backstage/errors'; -import { initializeDebug } from '../utils'; -import { - ChatMessage, - DocumentInfo, - AugmentStatus, - Workflow, - QuickAction, - PromptGroup, - StreamingEventCallback, - SessionMessagesResponse, - ChatSessionSummary, - BrandingConfig, - AdminConfigKey, - AdminConfigEntry, - RagTestResult, - RagGenerateResult, - UploadResult, - VectorStoreConfig, - VectorStoreCreateResult, - VectorStoreStatusResult, - VectorStoreInfo, - McpTestConnectionResult, - SafetyStatusResponse, - EvaluationStatusResponse, -} from '../types'; -import type { - ProviderDescriptor, - ChatAgent, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import * as chatEndpoints from './chatEndpoints'; -import * as conversationEndpoints from './conversationEndpoints'; -import * as adminEndpoints from './adminEndpoints'; -import * as documentEndpoints from './documentEndpoints'; -import * as sessionEndpoints from './sessionEndpoints'; -import * as kagentiEndpoints from './kagentiEndpoints'; - -/** - * API interface for Augment - * @public - */ -export interface AugmentApi { - /** - * Get the status of the Augment service - */ - getStatus(): Promise; - - /** - * List agents available for chat in a provider-agnostic format. - * @param options.published - When true, only return published agents - */ - listAgents(options?: { published?: boolean }): Promise; - - /** - * Register a runtime agent in the governance overlay (draft stage). - */ - registerAgentForGovernance(agentId: string): Promise<{ - agentId: string; - lifecycleStage: string; - }>; - - /** - * Publish an agent to the end-user catalog (staging → production only). - */ - publishAgent(agentId: string): Promise; - - /** - * Unpublish an agent from the end-user catalog (production → staging only). - */ - unpublishAgent(agentId: string): Promise; - - /** - * Request that a published agent be unpublished (non-admin flow). - */ - requestUnpublish(agentId: string): Promise; - - /** - * Withdraw a pending-review agent back to draft. - */ - withdrawAgent(agentId: string): Promise; - - /** - * Promote an agent to the next lifecycle stage (draft → registered → deployed). - */ - promoteAgent( - agentId: string, - targetStage?: import('@red-hat-developer-hub/backstage-plugin-augment-common').AgentLifecycleStage, - ): Promise<{ - lifecycleStage: string; - version?: number; - workflowDecisionSent?: boolean; - }>; - - /** - * Demote an agent to a previous lifecycle stage (deployed → registered → draft). - * @param reason - Optional rejection reason (used for review → draft transitions) - */ - demoteAgent( - agentId: string, - targetStage?: import('@red-hat-developer-hub/backstage-plugin-augment-common').AgentLifecycleStage, - reason?: string, - ): Promise<{ lifecycleStage: string }>; - - /** - * Delete an agent's lifecycle config entry. - * Non-admins can only delete their own draft agents. - */ - deleteAgentConfig(agentId: string): Promise; - - /** - * Bulk publish or unpublish agents. - */ - bulkPublishAgents(agentIds: string[], published: boolean): Promise; - - /** - * Update agent display configuration (name, description, starters, etc.) - */ - updateAgentConfig( - agentId: string, - config: Partial< - import('@red-hat-developer-hub/backstage-plugin-augment-common').ChatAgentConfig - >, - ): Promise; - - /** - * List tools with lifecycle overlay in a provider-agnostic format. - */ - listToolsWithLifecycle(options?: { published?: boolean }): Promise< - (import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiToolSummary & { - published?: boolean; - lifecycleStage?: import('@red-hat-developer-hub/backstage-plugin-augment-common').AgentLifecycleStage; - version?: number; - })[] - >; - - /** - * Promote a tool to the next lifecycle stage (draft → registered → deployed). - */ - promoteToolLifecycle( - toolId: string, - targetStage?: import('@red-hat-developer-hub/backstage-plugin-augment-common').AgentLifecycleStage, - ): Promise<{ lifecycleStage: string; version: number }>; - - /** - * Demote a tool to a previous lifecycle stage (deployed → registered → draft). - */ - demoteToolLifecycle( - toolId: string, - targetStage?: import('@red-hat-developer-hub/backstage-plugin-augment-common').AgentLifecycleStage, - ): Promise<{ lifecycleStage: string }>; - - /** - * Get branding configuration for enterprise customization - */ - getBranding(): Promise; - - /** - * Get configured workflows (multi-step templates) - */ - getWorkflows(): Promise; - - /** - * Get configured quick actions (single-click prompts) - */ - getQuickActions(): Promise; - - /** - * Get configured guided experience tours (from YAML or defaults) - */ - getTours(): Promise< - import('../components/AdminPanels/shared/defaultTours').TourDefinition[] - >; - - /** - * Get configured prompt groups (grouped prompt cards for the welcome screen) - */ - getPromptGroups(): Promise; - - /** - * Stream a chat message with real-time events from Responses API - * Events are forwarded directly from the API - no hardcoding - * @param messages - Chat messages - * @param onEvent - Callback for each streaming event (raw from API) - * @param enableRAG - Whether to enable RAG - * @param signal - AbortSignal for cancellation - * @param previousResponseId - Previous response ID for conversation branching - * @param conversationId - Conversation ID to link to - */ - chatStream( - messages: ChatMessage[], - onEvent: StreamingEventCallback, - enableRAG?: boolean, - signal?: AbortSignal, - previousResponseId?: string, - conversationId?: string, - model?: string, - ): Promise; - - /** - * Create a new conversation container - */ - createConversation(): Promise<{ conversationId: string }>; - - // =========================================================================== - // Human-in-the-Loop (HITL) Tool Approval - // =========================================================================== - - /** - * Submit approval or rejection for a pending tool call - * Used when require_approval is configured for an MCP server - * - * LLAMA STACK WORKAROUND (tracked for removal): - * toolName and toolArguments are required for approvals because Llama Stack's - * mcp_approval_response does NOT auto-execute pending tools. The backend uses - * these to send an explicit tool execution request instead. - * TODO: Remove when Llama Stack natively supports mcp_approval_response auto-execution. - * Track: https://github.com/meta-llama/llama-stack/issues — search "mcp_approval_response" - * - * @param responseId - The response ID containing the pending approval - * @param callId - The approval_request_id from mcp_approval_request event - * @param approved - Whether to approve (true) or reject (false) - * @param toolName - Name of the tool to execute (REQUIRED if approved) - * @param toolArguments - JSON string of tool arguments (REQUIRED if approved) - */ - submitToolApproval( - responseId: string, - callId: string, - approved: boolean, - toolName?: string, - toolArguments?: string, - signal?: AbortSignal, - reason?: string, - ): Promise<{ - success: boolean; - content?: string; - responseId?: string; - rejected?: boolean; - toolOutput?: string; - toolExecuted?: boolean; - outputTruncated?: boolean; - pendingApproval?: { - approvalRequestId: string; - toolName: string; - serverLabel?: string; - arguments?: string; - }; - handoff?: { - fromAgent: string; - toAgent: string; - }; - }>; - - // =========================================================================== - // Chat Sessions (local DB — mirrors ai-virtual-agent pattern) - // =========================================================================== - - /** List chat sessions from local DB, optionally paginated and filtered by provider */ - listSessions( - limit?: number, - offset?: number, - providerId?: string, - ): Promise; - - /** Create a new chat session, tagged with the active provider */ - createSession( - title?: string, - model?: string, - providerId?: string, - ): Promise; - - /** Delete a chat session */ - deleteSession(sessionId: string): Promise; - - /** Fetch session state for debug inspector */ - getSessionState(sessionId: string): Promise>; - - /** Submit per-message feedback (thumbs up/down with optional reasons) */ - submitMessageFeedback(payload: { - messageId: string; - sessionId?: string; - direction: 'positive' | 'negative'; - reasons?: string[]; - comment?: string; - }): Promise; - - /** Get processed messages for a session (server-side grouping) */ - getSessionMessages(sessionId: string): Promise; - - /** List all sessions across all users (admin only) */ - listAllSessions( - limit?: number, - offset?: number, - ): Promise; - - /** Get messages for any session without ownership check (admin only) */ - getAdminSessionMessages(sessionId: string): Promise; - - /** - * Stream a chat message, identified by session ID. - * Same as chatStream but uses sessionId for backend conversation management. - */ - chatStreamWithSession( - messages: ChatMessage[], - onEvent: StreamingEventCallback, - sessionId: string, - enableRAG?: boolean, - signal?: AbortSignal, - model?: string, - ): Promise; - - // =========================================================================== - // Admin API - // =========================================================================== - - /** List available models from the inference server */ - listModels(): Promise< - Array<{ id: string; owned_by?: string; model_type?: string }> - >; - - /** Generate a system prompt from a natural language description using the configured LLM */ - generateSystemPrompt( - description: string, - model?: string, - capabilities?: import('../types').PromptCapabilities, - ): Promise; - - /** Get the full effective config (YAML + DB merged) for admin panel pre-population */ - getEffectiveConfig(): Promise>; - - /** Get an admin config entry by key */ - getAdminConfig(key: AdminConfigKey): Promise<{ - entry: AdminConfigEntry | null; - source: 'database' | 'default'; - }>; - - /** Set / update an admin config entry. Returns warnings (if any) from soft validation. */ - setAdminConfig( - key: AdminConfigKey, - value: unknown, - ): Promise<{ warnings?: string[] }>; - - /** Delete an admin config entry (revert to YAML defaults) */ - deleteAdminConfig(key: AdminConfigKey): Promise<{ deleted: boolean }>; - - /** List all admin config entries */ - listAdminConfig(): Promise< - Array<{ configKey: AdminConfigKey; updatedAt: string; updatedBy: string }> - >; - - /** List all known providers (implemented + placeholders) */ - listProviders(): Promise<{ - providers: ProviderDescriptor[]; - activeProviderId: string; - }>; - - /** Get the currently active provider ID */ - getActiveProvider(): Promise<{ providerId: string }>; - - /** Set the active provider (triggers hot-swap on backend) */ - setActiveProvider( - providerId: string, - ): Promise<{ success: boolean; providerId: string; error?: string }>; - - /** Trigger a document sync */ - syncDocuments(): Promise<{ - added: number; - updated: number; - removed: number; - failed: number; - unchanged: number; - errors: string[]; - }>; - - /** Upload a document to the knowledge base */ - uploadDocument( - file: File, - vectorStoreId?: string, - replace?: boolean, - ): Promise; - - /** Delete a document from the knowledge base */ - deleteDocument( - fileId: string, - vectorStoreId?: string, - ): Promise<{ success: boolean }>; - - /** List documents for a specific vector store */ - listDocumentsForStore(vectorStoreId: string): Promise; - - /** Test a RAG query against one or more vector stores */ - testRagQuery( - query: string, - maxResults?: number, - vectorStoreId?: string, - vectorStoreIds?: string[], - ): Promise; - - /** End-to-end RAG test: retrieve chunks then generate an LLM answer */ - generateRagAnswer( - query: string, - maxResults?: number, - vectorStoreId?: string, - vectorStoreIds?: string[], - ): Promise; - - /** Get safety status including available shields from the provider */ - getSafetyStatus(): Promise; - - /** Get evaluation status including available scoring functions from the provider */ - getEvaluationStatus(): Promise; - - /** Test model connectivity, availability, and generation capability */ - testModelConnection( - model?: string, - baseUrl?: string, - ): Promise<{ - connected: boolean; - modelFound: boolean; - canGenerate: boolean; - error?: string; - }>; - - /** Test MCP server connection and discover tools */ - testMcpConnection( - url: string, - type?: string, - headers?: Record, - ): Promise; - - /** Get effective vector store config (YAML defaults merged with DB overrides) */ - getVectorStoreConfig(): Promise<{ - config: VectorStoreConfig; - source: 'yaml' | 'database' | 'merged'; - }>; - - /** Save vector store config overrides to DB */ - saveVectorStoreConfig(overrides: Partial): Promise; - - /** Reset vector store config to YAML defaults */ - resetVectorStoreConfig(): Promise<{ deleted: boolean }>; - - /** Create a vector store using the effective config */ - createVectorStore( - config?: Record, - ): Promise; - - /** Get current vector store status */ - getVectorStoreStatus(): Promise; - - // =========================================================================== - // Multi-Vector-Store Management - // =========================================================================== - - /** List all active vector stores with their status, plus unconnected server stores */ - listActiveVectorStores(): Promise<{ - stores: Array; - unconnected?: Array; - }>; - - /** Connect an existing vector store by ID */ - connectVectorStore(vectorStoreId: string): Promise<{ - activeVectorStoreIds: string[]; - }>; - - /** Remove a vector store. If permanent=true, deletes it and all files from the server. */ - removeVectorStore( - vectorStoreId: string, - permanent?: boolean, - ): Promise<{ - removed: string; - permanent: boolean; - filesDeleted: number; - activeVectorStoreIds: string[]; - }>; - - // =========================================================================== - // Kagenti API - // =========================================================================== - - listKagentiAgents( - namespace?: string, - options?: { includeCards?: boolean }, - ): Promise<{ - agents: (import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiAgentSummary & { - agentCard?: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiAgentCard; - })[]; - }>; - getKagentiAgent( - namespace: string, - name: string, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiAgentDetail & { - agentCard?: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiAgentCard; - } - >; - getKagentiAgentRouteStatus( - namespace: string, - name: string, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiRouteStatus - >; - createKagentiAgent( - body: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiCreateAgentRequest, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiCreateAgentResponse - >; - deleteKagentiAgent(namespace: string, name: string): Promise; - listKagentiMigratableAgents(): Promise<{ - agents: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiMigratableAgent[]; - }>; - migrateKagentiAgent( - namespace: string, - name: string, - deleteOld?: boolean, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiMigrateAgentResponse - >; - migrateAllKagentiAgents(options?: { - namespace?: string; - dryRun?: boolean; - deleteOld?: boolean; - }): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiMigrateAllResponse - >; - listKagentiTools(namespace?: string): Promise<{ - tools: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiToolSummary[]; - }>; - getKagentiTool( - namespace: string, - name: string, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiToolDetail - >; - createKagentiTool( - body: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiCreateToolRequest, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiCreateToolResponse - >; - deleteKagentiTool(namespace: string, name: string): Promise; - getKagentiFeatureFlags(): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiFeatureFlags - >; - getKagentiDashboards(): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiDashboardConfig - >; - listKagentiNamespaces( - enabledOnly?: boolean, - ): Promise<{ namespaces: string[]; defaultNamespace?: string }>; - triggerKagentiBuild( - namespace: string, - name: string, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiTriggerBuildRunResponse - >; - getKagentiBuildInfo( - namespace: string, - name: string, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiBuildInfo - >; - finalizeKagentiAgentBuild( - namespace: string, - name: string, - body?: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiFinalizeAgentBuildRequest, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiCreateAgentResponse - >; - listKagentiBuildStrategies(): Promise<{ - strategies: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiBuildStrategy[]; - }>; - listKagentiShipwrightBuilds(options?: { - namespace?: string; - allNamespaces?: boolean; - }): Promise<{ - builds: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiBuildListItem[]; - }>; - connectKagentiTool( - namespace: string, - name: string, - ): Promise<{ - tools: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiMcpToolSchema[]; - }>; - invokeKagentiTool( - namespace: string, - name: string, - toolName: string, - args?: Record, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiMcpInvokeResponse - >; - getToolRouteStatus( - namespace: string, - name: string, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiRouteStatus - >; - getToolBuildInfo( - namespace: string, - name: string, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiBuildInfo - >; - triggerToolBuild( - namespace: string, - name: string, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiTriggerBuildRunResponse - >; - finalizeToolBuild( - namespace: string, - name: string, - body?: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiFinalizeToolBuildRequest, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiCreateToolResponse - >; - createDevSpacesWorkspace( - request: import('@red-hat-developer-hub/backstage-plugin-augment-common').DevSpacesCreateWorkspaceRequest, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').DevSpacesCreateWorkspaceResponse - >; - checkDevSpacesHealth(): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').DevSpacesHealthResponse - >; - listDevSpacesWorkspaces( - namespace: string, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').DevSpacesListWorkspacesResponse - >; - getDevSpacesWorkspace( - namespace: string, - name: string, - ): Promise< - import('@red-hat-developer-hub/backstage-plugin-augment-common').DevSpacesWorkspace - >; - stopDevSpacesWorkspace(namespace: string, name: string): Promise; - deleteDevSpacesWorkspace(namespace: string, name: string): Promise; -} - -/** - * API reference for Augment - * @public - */ -export const augmentApiRef = createApiRef({ - id: 'plugin.augment.api', -}); - -/** - * Implementation of the Augment API client - * @public - */ -export class AugmentApiClient implements AugmentApi { - private readonly discoveryApi: DiscoveryApi; - private readonly fetchApi: FetchApi; - constructor(options: { - discoveryApi: DiscoveryApi; - fetchApi: FetchApi; - configApi: ConfigApi; - /** @deprecated Auth is now handled by fetchApi; retained for API compat */ - identityApi: IdentityApi; - }) { - this.discoveryApi = options.discoveryApi; - this.fetchApi = options.fetchApi; - - const debugEnabled = options.configApi.getOptionalBoolean('augment.debug'); - initializeDebug(debugEnabled); - } - - // --------------------------------------------------------------------------- - // Internal fetch helpers — eliminate per-method boilerplate - // --------------------------------------------------------------------------- - - private addCsrfHeader(init?: RequestInit): RequestInit { - const headers = new Headers(init?.headers); - headers.set('X-Backstage-Request', 'augment'); - return { ...init, headers }; - } - - /** Fetch JSON from a path relative to the plugin base URL; throws on non-2xx. */ - private async fetchJson(path: string, init?: RequestInit): Promise { - const baseUrl = await this.discoveryApi.getBaseUrl('augment'); - const url = `${baseUrl}${path}`; - const opts = this.addCsrfHeader(init); - const response = await this.fetchApi.fetch(url, opts); - if (!response.ok) throw await ResponseError.fromResponse(response); - try { - return await response.json(); - } catch { - throw new Error( - `Invalid JSON response from ${path} (status ${response.status})`, - ); - } - } - - /** Like fetchJson but returns `fallback` instead of throwing on non-2xx. */ - private async fetchJsonSafe( - path: string, - fallback: T, - init?: RequestInit, - ): Promise { - const baseUrl = await this.discoveryApi.getBaseUrl('augment'); - const url = `${baseUrl}${path}`; - const opts = this.addCsrfHeader(init); - const response = await this.fetchApi.fetch(url, opts); - if (!response.ok) return fallback; - return response.json(); - } - - // --------------------------------------------------------------------------- - // Public API — Chat - // --------------------------------------------------------------------------- - - private get chatDeps(): chatEndpoints.ChatApiDeps { - return { - fetchJson: this.fetchJson.bind(this), - discoveryApi: this.discoveryApi, - fetchApi: this.fetchApi, - }; - } - - private get conversationDeps(): conversationEndpoints.ConversationApiDeps { - return { - fetchJson: this.fetchJson.bind(this), - fetchJsonSafe: this.fetchJsonSafe.bind(this), - discoveryApi: this.discoveryApi, - fetchApi: this.fetchApi, - }; - } - - private get adminDeps(): adminEndpoints.AdminApiDeps { - return { - fetchJson: this.fetchJson.bind(this), - discoveryApi: this.discoveryApi, - fetchApi: this.fetchApi, - }; - } - - private get documentDeps(): documentEndpoints.DocumentApiDeps { - return { - fetchJson: this.fetchJson.bind(this), - discoveryApi: this.discoveryApi, - fetchApi: this.fetchApi, - }; - } - - private get sessionDeps(): sessionEndpoints.SessionApiDeps { - return { - fetchJson: this.fetchJson.bind(this), - fetchJsonSafe: this.fetchJsonSafe.bind(this), - discoveryApi: this.discoveryApi, - fetchApi: this.fetchApi, - }; - } - - async getStatus(): Promise { - return this.fetchJson('/status'); - } - - async listAgents(options?: { published?: boolean }): Promise { - const qs = options?.published ? '?published=true' : ''; - const data = await this.fetchJson<{ agents: ChatAgent[] }>(`/agents${qs}`); - return data.agents ?? []; - } - - async deleteAgentConfig(agentId: string): Promise { - await this.fetchJson(`/agents/${encodeURIComponent(agentId)}`, { - method: 'DELETE', - }); - } - - async registerAgentForGovernance(agentId: string): Promise<{ - agentId: string; - lifecycleStage: string; - }> { - return this.fetchJson(`/agents/${encodeURIComponent(agentId)}/register`, { - method: 'PUT', - }); - } - - async publishAgent(agentId: string): Promise { - await this.fetchJson(`/agents/${encodeURIComponent(agentId)}/publish`, { - method: 'PUT', - }); - } - - async unpublishAgent(agentId: string): Promise { - await this.fetchJson(`/agents/${encodeURIComponent(agentId)}/unpublish`, { - method: 'PUT', - }); - } - - async requestUnpublish(agentId: string): Promise { - await this.fetchJson( - `/agents/${encodeURIComponent(agentId)}/request-unpublish`, - { method: 'PUT' }, - ); - } - - async withdrawAgent(agentId: string): Promise { - await this.fetchJson(`/agents/${encodeURIComponent(agentId)}/withdraw`, { - method: 'PUT', - }); - } - - async promoteAgent( - agentId: string, - targetStage?: import('@red-hat-developer-hub/backstage-plugin-augment-common').AgentLifecycleStage, - ): Promise<{ - lifecycleStage: string; - version?: number; - workflowDecisionSent?: boolean; - }> { - return this.fetchJson(`/agents/${encodeURIComponent(agentId)}/promote`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ targetStage }), - }); - } - - async demoteAgent( - agentId: string, - targetStage?: import('@red-hat-developer-hub/backstage-plugin-augment-common').AgentLifecycleStage, - reason?: string, - ): Promise<{ lifecycleStage: string }> { - return this.fetchJson(`/agents/${encodeURIComponent(agentId)}/demote`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ targetStage, reason }), - }); - } - - async bulkPublishAgents( - agentIds: string[], - published: boolean, - ): Promise { - await this.fetchJson('/agents/bulk-publish', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ agentIds, published }), - }); - } - - async updateAgentConfig( - agentId: string, - config: Partial< - import('@red-hat-developer-hub/backstage-plugin-augment-common').ChatAgentConfig - >, - ): Promise { - await this.fetchJson(`/agents/${encodeURIComponent(agentId)}/config`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config), - }); - } - - async listToolsWithLifecycle(options?: { published?: boolean }): Promise< - (import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiToolSummary & { - published?: boolean; - lifecycleStage?: import('@red-hat-developer-hub/backstage-plugin-augment-common').AgentLifecycleStage; - version?: number; - })[] - > { - const qs = options?.published ? '?published=true' : ''; - const data = await this.fetchJson<{ - tools: (import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiToolSummary & { - published?: boolean; - lifecycleStage?: import('@red-hat-developer-hub/backstage-plugin-augment-common').AgentLifecycleStage; - version?: number; - })[]; - }>(`/tools${qs}`); - return data.tools ?? []; - } - - async promoteToolLifecycle( - toolId: string, - targetStage?: import('@red-hat-developer-hub/backstage-plugin-augment-common').AgentLifecycleStage, - ): Promise<{ lifecycleStage: string; version: number }> { - return this.fetchJson(`/tools/${encodeURIComponent(toolId)}/promote`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ targetStage }), - }); - } - - async demoteToolLifecycle( - toolId: string, - targetStage?: import('@red-hat-developer-hub/backstage-plugin-augment-common').AgentLifecycleStage, - ): Promise<{ lifecycleStage: string }> { - return this.fetchJson(`/tools/${encodeURIComponent(toolId)}/demote`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ targetStage }), - }); - } - - async getBranding(): Promise { - const data = await this.fetchJson<{ branding: BrandingConfig }>( - '/branding', - ); - return data.branding; - } - - async getWorkflows(): Promise { - return adminEndpoints.getWorkflows(this.adminDeps); - } - - async getQuickActions(): Promise { - return adminEndpoints.getQuickActions(this.adminDeps); - } - - async getTours(): Promise< - import('../components/AdminPanels/shared/defaultTours').TourDefinition[] - > { - const data = await this.fetchJson<{ - tours: import('../components/AdminPanels/shared/defaultTours').TourDefinition[]; - }>('/tours'); - return data.tours ?? []; - } - - async getPromptGroups(): Promise { - return adminEndpoints.getPromptGroups(this.adminDeps); - } - - async chatStream( - messages: ChatMessage[], - onEvent: StreamingEventCallback, - enableRAG = true, - signal?: AbortSignal, - previousResponseId?: string, - conversationId?: string, - model?: string, - ): Promise { - return chatEndpoints.chatStream( - this.chatDeps, - messages, - onEvent, - enableRAG, - signal, - previousResponseId, - conversationId, - model, - ); - } - - async createConversation(): Promise<{ conversationId: string }> { - return conversationEndpoints.createConversation(this.conversationDeps); - } - - async submitToolApproval( - responseId: string, - callId: string, - approved: boolean, - toolName?: string, - toolArguments?: string, - signal?: AbortSignal, - reason?: string, - ): Promise<{ - success: boolean; - content?: string; - responseId?: string; - rejected?: boolean; - toolOutput?: string; - toolExecuted?: boolean; - outputTruncated?: boolean; - pendingApproval?: { - approvalRequestId: string; - toolName: string; - serverLabel?: string; - arguments?: string; - }; - handoff?: { - fromAgent: string; - toAgent: string; - }; - }> { - return chatEndpoints.submitToolApproval( - this.chatDeps, - responseId, - callId, - approved, - toolName, - toolArguments, - signal, - reason, - ); - } - - // --------------------------------------------------------------------------- - // Chat Sessions (local DB) - // --------------------------------------------------------------------------- - - async listSessions( - limit?: number, - offset?: number, - providerId?: string, - ): Promise { - return sessionEndpoints.listSessions( - this.sessionDeps, - limit, - offset, - providerId, - ); - } - - async createSession( - title?: string, - model?: string, - providerId?: string, - ): Promise { - return sessionEndpoints.createSession( - this.sessionDeps, - title, - model, - providerId, - ); - } - - async deleteSession(sessionId: string): Promise { - return sessionEndpoints.deleteSession(this.sessionDeps, sessionId); - } - - async getSessionState(sessionId: string): Promise> { - return sessionEndpoints.getSessionState(this.sessionDeps, sessionId); - } - - async submitMessageFeedback(payload: { - messageId: string; - sessionId?: string; - direction: 'positive' | 'negative'; - reasons?: string[]; - comment?: string; - }): Promise { - return sessionEndpoints.submitMessageFeedback(this.sessionDeps, payload); - } - - async getSessionMessages( - sessionId: string, - ): Promise { - return sessionEndpoints.getSessionMessages(this.sessionDeps, sessionId); - } - - async listAllSessions( - limit?: number, - offset?: number, - ): Promise { - return sessionEndpoints.listAllSessions(this.sessionDeps, limit, offset); - } - - async getAdminSessionMessages( - sessionId: string, - ): Promise { - return sessionEndpoints.getAdminSessionMessages( - this.sessionDeps, - sessionId, - ); - } - - async chatStreamWithSession( - messages: ChatMessage[], - onEvent: StreamingEventCallback, - sessionId: string, - enableRAG = true, - signal?: AbortSignal, - model?: string, - ): Promise { - return chatEndpoints.chatStreamWithSession( - this.chatDeps, - messages, - onEvent, - sessionId, - enableRAG, - signal, - model, - ); - } - - async getEffectiveConfig(): Promise> { - return adminEndpoints.getEffectiveConfig(this.adminDeps); - } - - async listModels(): Promise< - Array<{ id: string; owned_by?: string; model_type?: string }> - > { - return adminEndpoints.listModels(this.adminDeps); - } - - async generateSystemPrompt( - description: string, - model?: string, - capabilities?: import('../types').PromptCapabilities, - ): Promise { - return adminEndpoints.generateSystemPrompt( - this.adminDeps, - description, - model, - capabilities, - ); - } - - async getAdminConfig(key: AdminConfigKey): Promise<{ - entry: AdminConfigEntry | null; - source: 'database' | 'default'; - }> { - return adminEndpoints.getAdminConfig(this.adminDeps, key); - } - - async setAdminConfig( - key: AdminConfigKey, - value: unknown, - ): Promise<{ warnings?: string[] }> { - return adminEndpoints.setAdminConfig(this.adminDeps, key, value); - } - - async deleteAdminConfig(key: AdminConfigKey): Promise<{ deleted: boolean }> { - return adminEndpoints.deleteAdminConfig(this.adminDeps, key); - } - - async listAdminConfig(): Promise< - Array<{ configKey: AdminConfigKey; updatedAt: string; updatedBy: string }> - > { - return adminEndpoints.listAdminConfig(this.adminDeps); - } - - async listProviders(): Promise<{ - providers: ProviderDescriptor[]; - activeProviderId: string; - }> { - return adminEndpoints.listProviders(this.adminDeps); - } - - async getActiveProvider(): Promise<{ providerId: string }> { - return adminEndpoints.getActiveProvider(this.adminDeps); - } - - async setActiveProvider( - providerId: string, - ): Promise<{ success: boolean; providerId: string; error?: string }> { - return adminEndpoints.setActiveProvider(this.adminDeps, providerId); - } - - async syncDocuments(): Promise<{ - added: number; - updated: number; - removed: number; - failed: number; - unchanged: number; - errors: string[]; - }> { - return documentEndpoints.syncDocuments(this.documentDeps); - } - - async uploadDocument( - file: File, - vectorStoreId?: string, - replace?: boolean, - ): Promise { - return documentEndpoints.uploadDocument( - this.documentDeps, - file, - vectorStoreId, - replace, - ); - } - - async deleteDocument( - fileId: string, - vectorStoreId?: string, - ): Promise<{ success: boolean }> { - return documentEndpoints.deleteDocument( - this.documentDeps, - fileId, - vectorStoreId, - ); - } - - async listDocumentsForStore(vectorStoreId: string): Promise { - return documentEndpoints.listDocumentsForStore( - this.documentDeps, - vectorStoreId, - ); - } - - async testRagQuery( - query: string, - maxResults?: number, - vectorStoreId?: string, - vectorStoreIds?: string[], - ): Promise { - return documentEndpoints.testRagQuery( - this.documentDeps, - query, - maxResults, - vectorStoreId, - vectorStoreIds, - ); - } - - async generateRagAnswer( - query: string, - maxResults?: number, - vectorStoreId?: string, - vectorStoreIds?: string[], - ): Promise { - return documentEndpoints.generateRagAnswer( - this.documentDeps, - query, - maxResults, - vectorStoreId, - vectorStoreIds, - ); - } - - async getSafetyStatus(): Promise { - return adminEndpoints.getSafetyStatus(this.adminDeps); - } - - async getEvaluationStatus(): Promise { - return adminEndpoints.getEvaluationStatus(this.adminDeps); - } - - async testModelConnection( - model?: string, - baseUrl?: string, - ): Promise<{ - connected: boolean; - modelFound: boolean; - canGenerate: boolean; - error?: string; - }> { - return adminEndpoints.testModelConnection(this.adminDeps, model, baseUrl); - } - - async testMcpConnection( - url: string, - type?: string, - headers?: Record, - ): Promise { - return adminEndpoints.testMcpConnection(this.adminDeps, url, type, headers); - } - - async getVectorStoreConfig(): Promise<{ - config: VectorStoreConfig; - source: 'yaml' | 'database' | 'merged'; - }> { - return documentEndpoints.getVectorStoreConfig(this.documentDeps); - } - - async saveVectorStoreConfig( - overrides: Partial, - ): Promise { - return documentEndpoints.saveVectorStoreConfig( - this.documentDeps, - overrides, - ); - } - - async resetVectorStoreConfig(): Promise<{ deleted: boolean }> { - return documentEndpoints.resetVectorStoreConfig(this.documentDeps); - } - - async createVectorStore( - config?: Record, - ): Promise { - return documentEndpoints.createVectorStore(this.documentDeps, config); - } - - async getVectorStoreStatus(): Promise { - return documentEndpoints.getVectorStoreStatus(this.documentDeps); - } - - async listActiveVectorStores(): Promise<{ - stores: Array; - unconnected?: Array; - }> { - return documentEndpoints.listActiveVectorStores(this.documentDeps); - } - - async connectVectorStore(vectorStoreId: string): Promise<{ - activeVectorStoreIds: string[]; - }> { - return documentEndpoints.connectVectorStore( - this.documentDeps, - vectorStoreId, - ); - } - - async removeVectorStore( - vectorStoreId: string, - permanent?: boolean, - ): Promise<{ - removed: string; - permanent: boolean; - filesDeleted: number; - activeVectorStoreIds: string[]; - }> { - return documentEndpoints.removeVectorStore( - this.documentDeps, - vectorStoreId, - permanent, - ); - } - - // --------------------------------------------------------------------------- - // Kagenti API - // --------------------------------------------------------------------------- - - private get kagentiDeps(): kagentiEndpoints.KagentiApiDeps { - return { fetchJson: this.fetchJson.bind(this) }; - } - - async listKagentiAgents( - namespace?: string, - options?: { includeCards?: boolean }, - ) { - return kagentiEndpoints.listAgents(this.kagentiDeps, namespace, options); - } - async getKagentiAgent(namespace: string, name: string) { - return kagentiEndpoints.getAgent(this.kagentiDeps, namespace, name); - } - async getKagentiAgentRouteStatus(namespace: string, name: string) { - return kagentiEndpoints.getAgentRouteStatus( - this.kagentiDeps, - namespace, - name, - ); - } - async createKagentiAgent( - body: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiCreateAgentRequest, - ) { - return kagentiEndpoints.createAgent(this.kagentiDeps, body); - } - async deleteKagentiAgent(namespace: string, name: string) { - return kagentiEndpoints.deleteAgent(this.kagentiDeps, namespace, name); - } - async listKagentiMigratableAgents() { - return kagentiEndpoints.listMigratableAgents(this.kagentiDeps); - } - async migrateKagentiAgent( - namespace: string, - name: string, - deleteOld?: boolean, - ) { - return kagentiEndpoints.migrateAgent( - this.kagentiDeps, - namespace, - name, - deleteOld, - ); - } - async migrateAllKagentiAgents(options?: { - namespace?: string; - dryRun?: boolean; - deleteOld?: boolean; - }) { - return kagentiEndpoints.migrateAllAgents(this.kagentiDeps, options); - } - async listKagentiTools(namespace?: string) { - return kagentiEndpoints.listTools(this.kagentiDeps, namespace); - } - async getKagentiTool(namespace: string, name: string) { - return kagentiEndpoints.getTool(this.kagentiDeps, namespace, name); - } - async createKagentiTool( - body: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiCreateToolRequest, - ) { - return kagentiEndpoints.createTool(this.kagentiDeps, body); - } - async deleteKagentiTool(namespace: string, name: string) { - return kagentiEndpoints.deleteTool(this.kagentiDeps, namespace, name); - } - async getKagentiFeatureFlags() { - return kagentiEndpoints.getFeatureFlags(this.kagentiDeps); - } - async getKagentiDashboards() { - return kagentiEndpoints.getDashboards(this.kagentiDeps); - } - async listKagentiNamespaces(enabledOnly?: boolean) { - return kagentiEndpoints.listNamespaces(this.kagentiDeps, enabledOnly); - } - async triggerKagentiBuild(namespace: string, name: string) { - return kagentiEndpoints.triggerAgentBuild( - this.kagentiDeps, - namespace, - name, - ); - } - async getKagentiBuildInfo(namespace: string, name: string) { - return kagentiEndpoints.getAgentBuildInfo( - this.kagentiDeps, - namespace, - name, - ); - } - async finalizeKagentiAgentBuild( - namespace: string, - name: string, - body?: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiFinalizeAgentBuildRequest, - ) { - return kagentiEndpoints.finalizeAgentBuild( - this.kagentiDeps, - namespace, - name, - body, - ); - } - async listKagentiBuildStrategies() { - return kagentiEndpoints.listBuildStrategies(this.kagentiDeps); - } - async listKagentiShipwrightBuilds(options?: { - namespace?: string; - allNamespaces?: boolean; - }) { - return kagentiEndpoints.listShipwrightBuilds(this.kagentiDeps, options); - } - async connectKagentiTool(namespace: string, name: string) { - return kagentiEndpoints.connectTool(this.kagentiDeps, namespace, name); - } - async invokeKagentiTool( - namespace: string, - name: string, - toolName: string, - args?: Record, - ) { - return kagentiEndpoints.invokeTool( - this.kagentiDeps, - namespace, - name, - toolName, - args, - ); - } - async getToolRouteStatus(namespace: string, name: string) { - return kagentiEndpoints.getToolRouteStatus( - this.kagentiDeps, - namespace, - name, - ); - } - async getToolBuildInfo(namespace: string, name: string) { - return kagentiEndpoints.getToolBuildInfo(this.kagentiDeps, namespace, name); - } - async triggerToolBuild(namespace: string, name: string) { - return kagentiEndpoints.triggerToolBuild(this.kagentiDeps, namespace, name); - } - async finalizeToolBuild( - namespace: string, - name: string, - body?: import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiFinalizeToolBuildRequest, - ) { - return kagentiEndpoints.finalizeToolBuild( - this.kagentiDeps, - namespace, - name, - body, - ); - } - async createDevSpacesWorkspace( - request: import('@red-hat-developer-hub/backstage-plugin-augment-common').DevSpacesCreateWorkspaceRequest, - ) { - return kagentiEndpoints.createDevSpacesWorkspace(this.kagentiDeps, request); - } - async checkDevSpacesHealth() { - return kagentiEndpoints.checkDevSpacesHealth(this.kagentiDeps); - } - async listDevSpacesWorkspaces(namespace: string) { - return kagentiEndpoints.listDevSpacesWorkspaces( - this.kagentiDeps, - namespace, - ); - } - async getDevSpacesWorkspace(namespace: string, name: string) { - return kagentiEndpoints.getDevSpacesWorkspace( - this.kagentiDeps, - namespace, - name, - ); - } - async stopDevSpacesWorkspace(namespace: string, name: string) { - return kagentiEndpoints.stopDevSpacesWorkspace( - this.kagentiDeps, - namespace, - name, - ); - } - async deleteDevSpacesWorkspace(namespace: string, name: string) { - return kagentiEndpoints.deleteDevSpacesWorkspace( - this.kagentiDeps, - namespace, - name, - ); - } -} diff --git a/workspaces/augment/plugins/augment/src/api/adminEndpoints.test.ts b/workspaces/augment/plugins/augment/src/api/adminEndpoints.test.ts deleted file mode 100644 index 517ff47d9dc..00000000000 --- a/workspaces/augment/plugins/augment/src/api/adminEndpoints.test.ts +++ /dev/null @@ -1,428 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { AdminApiDeps } from './adminEndpoints'; -import { - getEffectiveConfig, - getAdminConfig, - setAdminConfig, - deleteAdminConfig, - listAdminConfig, - listModels, - generateSystemPrompt, - getSafetyStatus, - getEvaluationStatus, - testModelConnection, - testMcpConnection, - getWorkflows, - getQuickActions, - getPromptGroups, -} from './adminEndpoints'; -import { createMockResponse } from '../test-utils/factories'; - -describe('adminEndpoints', () => { - const baseUrl = 'http://localhost:7007/api/augment'; - - function createDeps(overrides: Partial = {}): AdminApiDeps { - return { - fetchJson: jest.fn(), - discoveryApi: { - getBaseUrl: jest.fn().mockResolvedValue(baseUrl), - } as unknown as AdminApiDeps['discoveryApi'], - fetchApi: { - fetch: jest.fn(), - } as unknown as AdminApiDeps['fetchApi'], - ...overrides, - }; - } - - describe('Admin Config', () => { - describe('getEffectiveConfig', () => { - it('should fetch effective config', async () => { - const deps = createDeps(); - const mockConfig = { model: 'llama', baseUrl: 'https://test' }; - (deps.fetchJson as jest.Mock).mockResolvedValue({ config: mockConfig }); - - const result = await getEffectiveConfig(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith('/admin/effective-config'); - expect(result).toEqual(mockConfig); - }); - - it('should return empty object when config is missing', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({}); - - const result = await getEffectiveConfig(deps); - - expect(result).toEqual({}); - }); - - it('should propagate fetch errors', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockRejectedValue( - new Error('Config failed'), - ); - - await expect(getEffectiveConfig(deps)).rejects.toThrow('Config failed'); - }); - }); - - describe('getAdminConfig', () => { - it('should fetch config entry by key', async () => { - const deps = createDeps(); - const mockEntry = { - configKey: 'model', - configValue: 'llama-3', - updatedAt: '2025-01-15T10:00:00Z', - updatedBy: 'user', - }; - (deps.fetchJson as jest.Mock).mockResolvedValue({ - entry: mockEntry, - source: 'database', - }); - - const result = await getAdminConfig(deps, 'model'); - - expect(deps.fetchJson).toHaveBeenCalledWith('/admin/config/model'); - expect(result.entry).toEqual(mockEntry); - expect(result.source).toBe('database'); - }); - - it('should return null entry when not found', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({ - entry: null, - source: 'default', - }); - - const result = await getAdminConfig(deps, 'model'); - - expect(result.entry).toBeNull(); - expect(result.source).toBe('default'); - }); - }); - - describe('setAdminConfig', () => { - it('should PUT config value', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({ warnings: [] }); - - const result = await setAdminConfig(deps, 'model', 'llama-3'); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/config/model', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ value: 'llama-3' }), - }), - ); - expect(result.warnings).toEqual([]); - }); - - it('should return warnings when present', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({ - warnings: ['Deprecated key'], - }); - - const result = await setAdminConfig(deps, 'model', 'value'); - - expect(result.warnings).toEqual(['Deprecated key']); - }); - }); - - describe('deleteAdminConfig', () => { - it('should DELETE config and return deleted status', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({ deleted: true }); - - const result = await deleteAdminConfig(deps, 'model'); - - expect(deps.fetchJson).toHaveBeenCalledWith('/admin/config/model', { - method: 'DELETE', - }); - expect(result).toEqual({ deleted: true }); - }); - }); - - describe('listAdminConfig', () => { - it('should list all config entries', async () => { - const deps = createDeps(); - const mockEntries = [ - { - configKey: 'model' as const, - updatedAt: '2025-01-15', - updatedBy: 'admin', - }, - ]; - (deps.fetchJson as jest.Mock).mockResolvedValue({ - entries: mockEntries, - }); - - const result = await listAdminConfig(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith('/admin/config'); - expect(result).toEqual(mockEntries); - }); - - it('should return empty array when no entries', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({}); - - const result = await listAdminConfig(deps); - - expect(result).toEqual([]); - }); - }); - }); - - describe('Models', () => { - describe('listModels', () => { - it('should fetch models from backend', async () => { - const deps = createDeps(); - const mockModels = [ - { id: 'llama-3', owned_by: 'meta', model_type: 'chat' }, - ]; - (deps.fetchApi.fetch as jest.Mock).mockResolvedValue( - createMockResponse({ - ok: true, - json: jest.fn().mockResolvedValue({ models: mockModels }), - }), - ); - - const result = await listModels(deps); - - expect(deps.discoveryApi.getBaseUrl).toHaveBeenCalledWith('augment'); - expect(deps.fetchApi.fetch).toHaveBeenCalledWith( - `${baseUrl}/admin/models`, - ); - expect(result).toEqual(mockModels); - }); - - it('should return empty array on 501', async () => { - const deps = createDeps(); - (deps.fetchApi.fetch as jest.Mock).mockResolvedValue( - createMockResponse({ ok: false, status: 501 }), - ); - - const result = await listModels(deps); - - expect(result).toEqual([]); - }); - - it('should throw on other errors', async () => { - const deps = createDeps(); - (deps.fetchApi.fetch as jest.Mock).mockResolvedValue( - createMockResponse({ ok: false, status: 500 }), - ); - - await expect(listModels(deps)).rejects.toBeDefined(); - }); - }); - - describe('generateSystemPrompt', () => { - it('should generate prompt from description', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({ - prompt: 'You are a helpful assistant.', - }); - - const result = await generateSystemPrompt(deps, 'Helpful assistant'); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/generate-system-prompt', - expect.objectContaining({ - body: JSON.stringify({ description: 'Helpful assistant' }), - }), - ); - expect(result).toBe('You are a helpful assistant.'); - }); - - it('should pass optional model and capabilities', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({ - prompt: 'Generated', - }); - - await generateSystemPrompt(deps, 'Desc', 'llama-3', { - tools: [{ name: 'search', description: 'Search' }], - }); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/generate-system-prompt', - expect.objectContaining({ - body: expect.stringContaining('"model":"llama-3"'), - }), - ); - }); - }); - }); - - describe('Safety & Evaluation', () => { - describe('getSafetyStatus', () => { - it('should fetch safety status', async () => { - const deps = createDeps(); - const mockStatus = { - enabled: true, - shields: ['pii'], - timestamp: '2025-01-15', - }; - (deps.fetchJson as jest.Mock).mockResolvedValue(mockStatus); - - const result = await getSafetyStatus(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith('/safety/status'); - expect(result).toEqual(mockStatus); - }); - }); - - describe('getEvaluationStatus', () => { - it('should fetch evaluation status', async () => { - const deps = createDeps(); - const mockStatus = { - enabled: false, - scoringFunctions: [], - timestamp: '2025-01-15', - }; - (deps.fetchJson as jest.Mock).mockResolvedValue(mockStatus); - - const result = await getEvaluationStatus(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith('/evaluation/status'); - expect(result).toEqual(mockStatus); - }); - }); - }); - - describe('Model & MCP Testing', () => { - describe('testModelConnection', () => { - it('should test model connection', async () => { - const deps = createDeps(); - const mockResult = { - connected: true, - modelFound: true, - canGenerate: true, - }; - (deps.fetchJson as jest.Mock).mockResolvedValue(mockResult); - - const result = await testModelConnection(deps, 'llama-3'); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/test-model', - expect.objectContaining({ - body: JSON.stringify({ model: 'llama-3' }), - }), - ); - expect(result).toEqual(mockResult); - }); - }); - - describe('testMcpConnection', () => { - it('should test MCP connection with url and type', async () => { - const deps = createDeps(); - const mockResult = { success: true, tools: [], toolCount: 0 }; - (deps.fetchJson as jest.Mock).mockResolvedValue(mockResult); - - const result = await testMcpConnection( - deps, - 'https://mcp:8080', - 'stdio', - ); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/mcp/test-connection', - expect.objectContaining({ - body: JSON.stringify({ url: 'https://mcp:8080', type: 'stdio' }), - }), - ); - expect(result).toEqual(mockResult); - }); - - it('should include headers when provided', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({}); - - await testMcpConnection(deps, 'https://mcp', 'stdio', { - Authorization: 'Bearer token', - }); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/mcp/test-connection', - expect.objectContaining({ - body: expect.stringContaining('"headers"'), - }), - ); - }); - }); - }); - - describe('Workflows', () => { - describe('getWorkflows', () => { - it('should fetch workflows', async () => { - const deps = createDeps(); - const mockWorkflows = [{ id: 'w1', name: 'Workflow 1' }]; - (deps.fetchJson as jest.Mock).mockResolvedValue({ - workflows: mockWorkflows, - }); - - const result = await getWorkflows(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith('/guided-workflows'); - expect(result).toEqual(mockWorkflows); - }); - - it('should return empty array when no workflows', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({}); - - const result = await getWorkflows(deps); - - expect(result).toEqual([]); - }); - }); - - describe('getQuickActions', () => { - it('should fetch quick actions', async () => { - const deps = createDeps(); - const mockActions = [{ id: 'a1', label: 'Summarize' }]; - (deps.fetchJson as jest.Mock).mockResolvedValue({ - quickActions: mockActions, - }); - - const result = await getQuickActions(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith('/quick-actions'); - expect(result).toEqual(mockActions); - }); - }); - - describe('getPromptGroups', () => { - it('should fetch prompt groups', async () => { - const deps = createDeps(); - const mockGroups = [{ id: 'g1', title: 'Group 1', cards: [] }]; - (deps.fetchJson as jest.Mock).mockResolvedValue({ - promptGroups: mockGroups, - }); - - const result = await getPromptGroups(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith('/prompt-groups'); - expect(result).toEqual(mockGroups); - }); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/api/adminEndpoints.ts b/workspaces/augment/plugins/augment/src/api/adminEndpoints.ts deleted file mode 100644 index 600e2c6d7f0..00000000000 --- a/workspaces/augment/plugins/augment/src/api/adminEndpoints.ts +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; -import { ResponseError } from '@backstage/errors'; -import type { ProviderDescriptor } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { - Workflow, - QuickAction, - PromptGroup, - AdminConfigKey, - AdminConfigEntry, - McpTestConnectionResult, - SafetyStatusResponse, - EvaluationStatusResponse, -} from '../types'; -import type { PromptCapabilities } from '../types'; -import { jsonBody } from './fetchHelpers'; - -export interface AdminApiDeps { - fetchJson: (path: string, init?: RequestInit) => Promise; - discoveryApi: DiscoveryApi; - fetchApi: FetchApi; -} - -// --------------------------------------------------------------------------- -// Providers -// --------------------------------------------------------------------------- - -export async function listProviders( - deps: AdminApiDeps, -): Promise<{ providers: ProviderDescriptor[]; activeProviderId: string }> { - return deps.fetchJson('/admin/providers'); -} - -export async function getActiveProvider( - deps: AdminApiDeps, -): Promise<{ providerId: string }> { - return deps.fetchJson('/admin/active-provider'); -} - -export async function setActiveProvider( - deps: AdminApiDeps, - providerId: string, -): Promise<{ success: boolean; providerId: string; error?: string }> { - return deps.fetchJson( - '/admin/active-provider', - jsonBody({ providerId }, 'PUT'), - ); -} - -// --------------------------------------------------------------------------- -// Admin Config -// --------------------------------------------------------------------------- - -export async function getEffectiveConfig( - deps: AdminApiDeps, -): Promise> { - const data = await deps.fetchJson<{ config?: Record }>( - '/admin/effective-config', - ); - return data.config ?? {}; -} - -export async function getAdminConfig( - deps: AdminApiDeps, - key: AdminConfigKey, -): Promise<{ - entry: AdminConfigEntry | null; - source: 'database' | 'default'; -}> { - const data = await deps.fetchJson<{ - entry?: AdminConfigEntry | null; - source: 'database' | 'default'; - }>(`/admin/config/${key}`); - return { entry: data.entry ?? null, source: data.source }; -} - -export async function setAdminConfig( - deps: AdminApiDeps, - key: AdminConfigKey, - value: unknown, -): Promise<{ warnings?: string[] }> { - const data = await deps.fetchJson<{ warnings?: string[] }>( - `/admin/config/${key}`, - jsonBody({ value }, 'PUT'), - ); - return { warnings: data.warnings }; -} - -export async function deleteAdminConfig( - deps: AdminApiDeps, - key: AdminConfigKey, -): Promise<{ deleted: boolean }> { - return deps.fetchJson(`/admin/config/${key}`, { method: 'DELETE' }); -} - -export async function listAdminConfig( - deps: AdminApiDeps, -): Promise< - Array<{ configKey: AdminConfigKey; updatedAt: string; updatedBy: string }> -> { - const data = await deps.fetchJson<{ - entries?: Array<{ - configKey: AdminConfigKey; - updatedAt: string; - updatedBy: string; - }>; - }>('/admin/config'); - return data.entries ?? []; -} - -// --------------------------------------------------------------------------- -// Models -// --------------------------------------------------------------------------- - -export async function listModels( - deps: AdminApiDeps, -): Promise> { - const baseUrl = await deps.discoveryApi.getBaseUrl('augment'); - const response = await deps.fetchApi.fetch(`${baseUrl}/admin/models`); - if (!response.ok) { - if (response.status === 501) return []; - throw await ResponseError.fromResponse(response); - } - const data = await response.json(); - return data.models ?? []; -} - -export async function generateSystemPrompt( - deps: AdminApiDeps, - description: string, - model?: string, - capabilities?: PromptCapabilities, -): Promise { - const body: Record = { description }; - if (model) body.model = model; - if (capabilities) body.capabilities = capabilities; - const data = await deps.fetchJson<{ prompt: string }>( - '/admin/generate-system-prompt', - jsonBody(body), - ); - return data.prompt; -} - -// --------------------------------------------------------------------------- -// Safety & Evaluation -// --------------------------------------------------------------------------- - -export async function getSafetyStatus( - deps: AdminApiDeps, -): Promise { - return deps.fetchJson('/safety/status'); -} - -export async function getEvaluationStatus( - deps: AdminApiDeps, -): Promise { - return deps.fetchJson('/evaluation/status'); -} - -// --------------------------------------------------------------------------- -// Model & MCP Testing -// --------------------------------------------------------------------------- - -export async function testModelConnection( - deps: AdminApiDeps, - model?: string, - baseUrl?: string, -): Promise<{ - connected: boolean; - modelFound: boolean; - canGenerate: boolean; - error?: string; -}> { - return deps.fetchJson('/admin/test-model', jsonBody({ model, baseUrl })); -} - -export async function testMcpConnection( - deps: AdminApiDeps, - url: string, - type?: string, - headers?: Record, -): Promise { - const body: Record = { url, type }; - if (headers && Object.keys(headers).length > 0) { - body.headers = headers; - } - return deps.fetchJson('/admin/mcp/test-connection', jsonBody(body)); -} - -// --------------------------------------------------------------------------- -// Workflows -// --------------------------------------------------------------------------- - -export async function getWorkflows(deps: AdminApiDeps): Promise { - const data = await deps.fetchJson<{ workflows?: Workflow[] }>( - '/guided-workflows', - ); - return data.workflows || []; -} - -export async function getQuickActions( - deps: AdminApiDeps, -): Promise { - const data = await deps.fetchJson<{ quickActions?: QuickAction[] }>( - '/quick-actions', - ); - return data.quickActions || []; -} - -export async function getPromptGroups( - deps: AdminApiDeps, -): Promise { - const data = await deps.fetchJson<{ promptGroups?: PromptGroup[] }>( - '/prompt-groups', - ); - return data.promptGroups || []; -} diff --git a/workspaces/augment/plugins/augment/src/api/chatEndpoints.test.ts b/workspaces/augment/plugins/augment/src/api/chatEndpoints.test.ts deleted file mode 100644 index 890006620d3..00000000000 --- a/workspaces/augment/plugins/augment/src/api/chatEndpoints.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -jest.mock('./sseStreaming', () => ({ - parseSSEStream: jest.fn().mockResolvedValue(undefined), -})); - -import type { ChatApiDeps } from './chatEndpoints'; -import { - chatStream, - chatStreamWithSession, - submitToolApproval, -} from './chatEndpoints'; -import { createMockResponse } from '../test-utils/factories'; - -describe('chatEndpoints', () => { - const baseUrl = 'http://localhost:7007/api/augment'; - - function createDeps(overrides: Partial = {}): ChatApiDeps { - return { - fetchJson: jest.fn(), - discoveryApi: { - getBaseUrl: jest.fn().mockResolvedValue(baseUrl), - } as unknown as ChatApiDeps['discoveryApi'], - fetchApi: { - fetch: jest.fn(), - } as unknown as ChatApiDeps['fetchApi'], - ...overrides, - }; - } - - describe('chatStream', () => { - it('should call fetchApi.fetch with stream URL and invoke parseSSEStream', async () => { - const deps = createDeps(); - const onEvent = jest.fn(); - const messages = [{ role: 'user' as const, content: 'Stream me' }]; - - const mockReader = { - read: jest.fn(), - releaseLock: jest.fn(), - } as unknown as ReadableStreamDefaultReader; - (deps.fetchApi.fetch as jest.Mock).mockResolvedValue( - createMockResponse({ - ok: true, - body: { - getReader: () => mockReader, - } as unknown as ReadableStream, - }), - ); - - await chatStream(deps, messages, onEvent, true); - - expect(deps.discoveryApi.getBaseUrl).toHaveBeenCalledWith('augment'); - expect(deps.fetchApi.fetch).toHaveBeenCalledWith( - `${baseUrl}/chat/stream`, - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Backstage-Request': 'augment', - }, - body: JSON.stringify({ - messages, - enableRAG: true, - previousResponseId: undefined, - conversationId: undefined, - }), - }), - ); - const { parseSSEStream } = require('./sseStreaming'); - expect(parseSSEStream).toHaveBeenCalledWith( - mockReader, - expect.any(Function), - undefined, - ); - }); - - it('should pass optional previousResponseId and conversationId', async () => { - const deps = createDeps(); - const onEvent = jest.fn(); - const messages = [{ role: 'user' as const, content: 'Hi' }]; - - (deps.fetchApi.fetch as jest.Mock).mockResolvedValue( - createMockResponse({ - ok: true, - body: { - getReader: () => - ({ - read: jest - .fn() - .mockResolvedValue({ done: true, value: undefined }), - releaseLock: jest.fn(), - }) as unknown as ReadableStreamDefaultReader, - } as unknown as ReadableStream, - }), - ); - - await chatStream( - deps, - messages, - onEvent, - false, - undefined, - 'prev-1', - 'conv-1', - ); - - expect(deps.fetchApi.fetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: JSON.stringify({ - messages, - enableRAG: false, - previousResponseId: 'prev-1', - conversationId: 'conv-1', - }), - }), - ); - }); - - it('should throw when response is not ok', async () => { - const deps = createDeps(); - const onEvent = jest.fn(); - - (deps.fetchApi.fetch as jest.Mock).mockResolvedValue( - createMockResponse({ - ok: false, - status: 400, - text: jest - .fn() - .mockResolvedValue('{"error":{"message":"Bad request"}}'), - }), - ); - - await expect(chatStream(deps, [], onEvent, true)).rejects.toThrow( - /Stream request failed: 400/, - ); - }); - - it('should throw when response has no body', async () => { - const deps = createDeps(); - const onEvent = jest.fn(); - - (deps.fetchApi.fetch as jest.Mock).mockResolvedValue( - createMockResponse({ ok: true, body: null }), - ); - - await expect(chatStream(deps, [], onEvent, true)).rejects.toThrow( - 'No response body for streaming', - ); - }); - }); - - describe('chatStreamWithSession', () => { - it('should call stream with sessionId in body', async () => { - const deps = createDeps(); - const onEvent = jest.fn(); - const messages = [{ role: 'user' as const, content: 'Session chat' }]; - const sessionId = 'session-xyz'; - - (deps.fetchApi.fetch as jest.Mock).mockResolvedValue( - createMockResponse({ - ok: true, - body: { - getReader: () => - ({ - read: jest - .fn() - .mockResolvedValue({ done: true, value: undefined }), - releaseLock: jest.fn(), - }) as unknown as ReadableStreamDefaultReader, - } as unknown as ReadableStream, - }), - ); - - await chatStreamWithSession(deps, messages, onEvent, sessionId, true); - - expect(deps.fetchApi.fetch).toHaveBeenCalledWith( - `${baseUrl}/chat/stream`, - expect.objectContaining({ - body: JSON.stringify({ - messages, - enableRAG: true, - sessionId, - }), - }), - ); - }); - }); - - describe('submitToolApproval', () => { - it('should call fetchJson with approval payload', async () => { - const deps = createDeps(); - const mockResult = { - success: true, - content: 'Done', - responseId: 'resp-2', - }; - - (deps.fetchJson as jest.Mock).mockResolvedValue(mockResult); - - const result = await submitToolApproval( - deps, - 'resp-1', - 'call-1', - true, - 'tool_name', - '{"arg":"value"}', - ); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/chat/approve', - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Backstage-Request': 'augment', - }, - body: JSON.stringify({ - responseId: 'resp-1', - callId: 'call-1', - approved: true, - toolName: 'tool_name', - toolArguments: '{"arg":"value"}', - }), - }), - ); - expect(result).toEqual(mockResult); - }); - - it('should work without toolName and toolArguments', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({ - success: false, - rejected: true, - }); - - const result = await submitToolApproval(deps, 'resp-1', 'call-1', false); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/chat/approve', - expect.objectContaining({ - body: JSON.stringify({ - responseId: 'resp-1', - callId: 'call-1', - approved: false, - toolName: undefined, - toolArguments: undefined, - }), - }), - ); - expect(result.rejected).toBe(true); - }); - - it('should pass AbortSignal when provided', async () => { - const deps = createDeps(); - const signal = new AbortController().signal; - - (deps.fetchJson as jest.Mock).mockResolvedValue({ success: true }); - - await submitToolApproval( - deps, - 'r', - 'c', - true, - undefined, - undefined, - signal, - ); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/chat/approve', - expect.objectContaining({ signal }), - ); - }); - - it('should propagate fetch errors', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockRejectedValue( - new Error('Approval failed'), - ); - - await expect(submitToolApproval(deps, 'r', 'c', true)).rejects.toThrow( - 'Approval failed', - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/api/chatEndpoints.ts b/workspaces/augment/plugins/augment/src/api/chatEndpoints.ts deleted file mode 100644 index 06a34356e43..00000000000 --- a/workspaces/augment/plugins/augment/src/api/chatEndpoints.ts +++ /dev/null @@ -1,282 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; -import type { - ChatMessage, - StreamingEvent, - StreamingEventCallback, -} from '../types'; -import { parseSSEStream } from './sseStreaming'; -import { jsonBody } from './fetchHelpers'; -import { isAbortError } from '../utils'; - -export interface ChatApiDeps { - fetchJson: (path: string, init?: RequestInit) => Promise; - discoveryApi: DiscoveryApi; - fetchApi: FetchApi; -} - -/** - * Internal SSE streaming helper shared by chatStream and chatStreamWithSession. - * Handles timeout, fetch, SSE line parsing, event forwarding, and retry with backoff. - */ -async function streamSSE( - deps: ChatApiDeps, - body: Record, - onEvent: StreamingEventCallback, - signal?: AbortSignal, -): Promise { - const MAX_RETRIES = 3; - const BASE_DELAY_MS = 1000; - const baseUrl = await deps.discoveryApi.getBaseUrl('augment'); - - let lastError: Error | undefined; - let receivedContent = false; - - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - if (signal?.aborted) return; - - if (attempt > 0) { - if (receivedContent) { - throw lastError || new Error('Stream failed after partial content'); - } - onEvent({ - type: 'stream.error', - error: `Reconnecting... (attempt ${attempt}/${MAX_RETRIES})`, - code: 'reconnecting', - } as StreamingEvent); - const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1); - await new Promise((resolve, reject) => { - const timer = setTimeout(resolve, delay); - if (signal) { - const onAbort = () => { - clearTimeout(timer); - reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); - }; - if (signal.aborted) { - clearTimeout(timer); - reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); - return; - } - signal.addEventListener('abort', onAbort, { once: true }); - } - }); - if (signal?.aborted) return; - } - - try { - // eslint-disable-next-line no-loop-func - const wrappedOnEvent: StreamingEventCallback = evt => { - const t = (evt as { type?: string }).type; - if ( - t === 'stream.text.delta' || - t === 'stream.completed' || - t === 'stream.tool.completed' - ) { - receivedContent = true; - } - onEvent(evt); - }; - await streamSSEAttempt(deps, baseUrl, body, wrappedOnEvent, signal); - return; - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - - if (signal?.aborted) return; - if (isAbortError(err)) throw err; - - const isRetryable = - err instanceof TypeError || - (lastError.message.includes('timeout') && - !lastError.message.includes('5 minutes')) || - lastError.message.includes('Stream request failed: 5'); - - if (!isRetryable || attempt === MAX_RETRIES) { - throw lastError; - } - } - } - - throw lastError || new Error('Stream failed after retries'); -} - -/** - * Single SSE connection attempt — extracted from streamSSE for retry logic. - */ -async function streamSSEAttempt( - deps: ChatApiDeps, - baseUrl: string, - body: Record, - onEvent: StreamingEventCallback, - signal?: AbortSignal, -): Promise { - const STREAM_CONNECT_TIMEOUT_MS = 300_000; - const timeoutController = new AbortController(); - const timeoutId = setTimeout(() => { - timeoutController.abort(); - }, STREAM_CONNECT_TIMEOUT_MS); - - const onAbort = () => { - clearTimeout(timeoutId); - timeoutController.abort(); - }; - - if (signal) { - signal.addEventListener('abort', onAbort); - } - - let response: Response; - try { - response = await deps.fetchApi.fetch(`${baseUrl}/chat/stream`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Backstage-Request': 'augment', - }, - body: JSON.stringify(body), - signal: timeoutController.signal, - }); - clearTimeout(timeoutId); - } catch (err) { - clearTimeout(timeoutId); - signal?.removeEventListener('abort', onAbort); - if (timeoutController.signal.aborted && !signal?.aborted) { - throw new Error( - 'Connection timeout: Server did not respond within 5 minutes. The request may have been too complex.', - ); - } - throw err; - } - - if (!response.ok) { - signal?.removeEventListener('abort', onAbort); - let detail = ''; - try { - const rawBody = await response.text(); - try { - const parsed = JSON.parse(rawBody); - detail = - parsed.error?.message || parsed.message || parsed.error || rawBody; - } catch { - if (rawBody.length > 0) { - detail = rawBody.slice(0, 200); - } - } - } catch { - // Couldn't read body at all - } - throw new Error( - detail - ? `Stream request failed: ${response.status} — ${detail}` - : `Stream request failed: ${response.status}`, - ); - } - - if (!response.body) { - signal?.removeEventListener('abort', onAbort); - throw new Error('No response body for streaming'); - } - - const reader = response.body.getReader(); - try { - await parseSSEStream(reader, onEvent, signal); - } finally { - signal?.removeEventListener('abort', onAbort); - } -} - -/** - * Streaming chat request. - */ -export async function chatStream( - deps: ChatApiDeps, - messages: ChatMessage[], - onEvent: StreamingEventCallback, - enableRAG: boolean, - signal?: AbortSignal, - previousResponseId?: string, - conversationId?: string, - model?: string, -): Promise { - return streamSSE( - deps, - { messages, enableRAG, previousResponseId, conversationId, model }, - onEvent, - signal, - ); -} - -/** - * Streaming chat with session ID. - */ -export async function chatStreamWithSession( - deps: ChatApiDeps, - messages: ChatMessage[], - onEvent: StreamingEventCallback, - sessionId: string, - enableRAG: boolean, - signal?: AbortSignal, - model?: string, -): Promise { - return streamSSE( - deps, - { messages, enableRAG, sessionId, model }, - onEvent, - signal, - ); -} - -/** - * Submit tool approval (continue after approval). - */ -export async function submitToolApproval( - deps: ChatApiDeps, - responseId: string, - callId: string, - approved: boolean, - toolName?: string, - toolArguments?: string, - signal?: AbortSignal, - reason?: string, -): Promise<{ - success: boolean; - content?: string; - responseId?: string; - rejected?: boolean; - toolOutput?: string; - toolExecuted?: boolean; - outputTruncated?: boolean; - pendingApproval?: { - approvalRequestId: string; - toolName: string; - serverLabel?: string; - arguments?: string; - }; - handoff?: { - fromAgent: string; - toAgent: string; - }; -}> { - return deps.fetchJson( - '/chat/approve', - jsonBody( - { responseId, callId, approved, toolName, toolArguments, reason }, - 'POST', - { signal }, - ), - ); -} diff --git a/workspaces/augment/plugins/augment/src/api/conversationEndpoints.test.ts b/workspaces/augment/plugins/augment/src/api/conversationEndpoints.test.ts deleted file mode 100644 index 854691a7457..00000000000 --- a/workspaces/augment/plugins/augment/src/api/conversationEndpoints.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ConversationApiDeps } from './conversationEndpoints'; -import { createConversation } from './conversationEndpoints'; - -describe('conversationEndpoints', () => { - const baseUrl = 'http://localhost:7007/api/augment'; - - function createDeps( - overrides: Partial = {}, - ): ConversationApiDeps { - return { - fetchJson: jest.fn(), - fetchJsonSafe: jest.fn(), - discoveryApi: { - getBaseUrl: jest.fn().mockResolvedValue(baseUrl), - } as unknown as ConversationApiDeps['discoveryApi'], - fetchApi: { - fetch: jest.fn(), - } as unknown as ConversationApiDeps['fetchApi'], - ...overrides, - }; - } - - describe('createConversation', () => { - it('should create and return conversationId', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({ - conversationId: 'conv-new-123', - }); - - const result = await createConversation(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/conversations/create', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({}), - }), - ); - expect(result).toEqual({ conversationId: 'conv-new-123' }); - }); - - it('should propagate fetch errors', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockRejectedValue( - new Error('Create failed'), - ); - - await expect(createConversation(deps)).rejects.toThrow('Create failed'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/api/conversationEndpoints.ts b/workspaces/augment/plugins/augment/src/api/conversationEndpoints.ts deleted file mode 100644 index 68d6f362085..00000000000 --- a/workspaces/augment/plugins/augment/src/api/conversationEndpoints.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; -import { jsonBody } from './fetchHelpers'; - -export interface ConversationApiDeps { - fetchJson: (path: string, init?: RequestInit) => Promise; - fetchJsonSafe: ( - path: string, - fallback: T, - init?: RequestInit, - ) => Promise; - discoveryApi: DiscoveryApi; - fetchApi: FetchApi; -} - -/** - * Create a new conversation container. - */ -export async function createConversation( - deps: ConversationApiDeps, -): Promise<{ conversationId: string }> { - const data = await deps.fetchJson<{ conversationId: string }>( - '/conversations/create', - jsonBody({}), - ); - return { conversationId: data.conversationId }; -} diff --git a/workspaces/augment/plugins/augment/src/api/documentEndpoints.test.ts b/workspaces/augment/plugins/augment/src/api/documentEndpoints.test.ts deleted file mode 100644 index 9c601bdb025..00000000000 --- a/workspaces/augment/plugins/augment/src/api/documentEndpoints.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { DocumentApiDeps } from './documentEndpoints'; -import { - syncDocuments, - uploadDocument, - deleteDocument, - listDocumentsForStore, - testRagQuery, - getVectorStoreConfig, - saveVectorStoreConfig, - resetVectorStoreConfig, - createVectorStore, - getVectorStoreStatus, - listActiveVectorStores, - connectVectorStore, - removeVectorStore, -} from './documentEndpoints'; - -describe('documentEndpoints', () => { - const baseUrl = 'http://localhost:7007/api/augment'; - - function createDeps( - overrides: Partial = {}, - ): DocumentApiDeps { - return { - fetchJson: jest.fn(), - discoveryApi: { - getBaseUrl: jest.fn().mockResolvedValue(baseUrl), - } as unknown as DocumentApiDeps['discoveryApi'], - fetchApi: { - fetch: jest.fn(), - } as unknown as DocumentApiDeps['fetchApi'], - ...overrides, - }; - } - - describe('Documents', () => { - describe('syncDocuments', () => { - it('should POST to sync and return result', async () => { - const deps = createDeps(); - const mockResult = { - added: 2, - updated: 1, - removed: 0, - failed: 0, - unchanged: 5, - errors: [], - }; - (deps.fetchJson as jest.Mock).mockResolvedValue(mockResult); - - const result = await syncDocuments(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith('/sync', { - method: 'POST', - }); - expect(result).toEqual(mockResult); - }); - }); - - describe('uploadDocument', () => { - it('should upload file via FormData', async () => { - const deps = createDeps(); - const file = new File(['content'], 'doc.txt', { type: 'text/plain' }); - const mockResult = { fileId: 'file-123', status: 'completed' }; - (deps.fetchJson as jest.Mock).mockResolvedValue(mockResult); - - const result = await uploadDocument(deps, file); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/documents', - expect.objectContaining({ - method: 'POST', - body: expect.any(FormData), - }), - ); - expect(result).toEqual(mockResult); - }); - - it('should include vectorStoreId and replace in query', async () => { - const deps = createDeps(); - const file = new File(['x'], 'x.txt'); - (deps.fetchJson as jest.Mock).mockResolvedValue({}); - - await uploadDocument(deps, file, 'vs-1', true); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/documents?vectorStoreId=vs-1&replace=true', - expect.any(Object), - ); - }); - }); - - describe('deleteDocument', () => { - it('should DELETE document by fileId', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({ success: true }); - - const result = await deleteDocument(deps, 'file-123'); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/documents/file-123', - { method: 'DELETE' }, - ); - expect(result).toEqual({ success: true }); - }); - - it('should include vectorStoreId in query when provided', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({ success: true }); - - await deleteDocument(deps, 'file-123', 'vs-1'); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/documents/file-123?vectorStoreId=vs-1', - { method: 'DELETE' }, - ); - }); - }); - - describe('listDocumentsForStore', () => { - it('should fetch documents for vector store', async () => { - const deps = createDeps(); - const mockDocs = [{ id: 'f1', fileName: 'a.md' }]; - (deps.fetchJson as jest.Mock).mockResolvedValue({ - documents: mockDocs, - }); - - const result = await listDocumentsForStore(deps, 'vs-1'); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/documents?vectorStoreId=vs-1', - ); - expect(result).toEqual(mockDocs); - }); - }); - }); - - describe('RAG', () => { - describe('testRagQuery', () => { - it('should POST query and return results', async () => { - const deps = createDeps(); - const mockResult = { - chunks: [{ text: 'match', score: 0.9 }], - query: 'test', - }; - (deps.fetchJson as jest.Mock).mockResolvedValue(mockResult); - - const result = await testRagQuery(deps, 'test query', 5); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/rag-test', - expect.objectContaining({ - body: JSON.stringify({ - query: 'test query', - maxResults: 5, - vectorStoreId: undefined, - vectorStoreIds: undefined, - }), - }), - ); - expect(result).toEqual(mockResult); - }); - - it('should pass vectorStoreId and vectorStoreIds', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({}); - - await testRagQuery(deps, 'q', 3, 'vs-1', ['vs-1', 'vs-2']); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/rag-test', - expect.objectContaining({ - body: JSON.stringify({ - query: 'q', - maxResults: 3, - vectorStoreId: 'vs-1', - vectorStoreIds: ['vs-1', 'vs-2'], - }), - }), - ); - }); - }); - }); - - describe('Vector Store Config', () => { - describe('getVectorStoreConfig', () => { - it('should fetch config', async () => { - const deps = createDeps(); - const mockConfig = { config: {}, source: 'yaml' as const }; - (deps.fetchJson as jest.Mock).mockResolvedValue(mockConfig); - - const result = await getVectorStoreConfig(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/vector-store-config', - ); - expect(result).toEqual(mockConfig); - }); - }); - - describe('saveVectorStoreConfig', () => { - it('should PUT overrides', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue(undefined); - - await saveVectorStoreConfig(deps, { vectorStoreName: 'test-store' }); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/config/vectorStoreConfig', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ value: { vectorStoreName: 'test-store' } }), - }), - ); - }); - }); - - describe('resetVectorStoreConfig', () => { - it('should DELETE config', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({ deleted: true }); - - const result = await resetVectorStoreConfig(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/config/vectorStoreConfig', - { method: 'DELETE' }, - ); - expect(result).toEqual({ deleted: true }); - }); - }); - - describe('createVectorStore', () => { - it('should create vector store', async () => { - const deps = createDeps(); - const mockResult = { vectorStoreId: 'vs-new' }; - (deps.fetchJson as jest.Mock).mockResolvedValue(mockResult); - - const result = await createVectorStore(deps, { - vectorStoreName: 'test-store', - embeddingModel: 'test-model', - }); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/vector-store/create', - expect.objectContaining({ - body: JSON.stringify({ - vectorStoreName: 'test-store', - embeddingModel: 'test-model', - }), - }), - ); - expect(result).toEqual(mockResult); - }); - }); - - describe('getVectorStoreStatus', () => { - it('should fetch status', async () => { - const deps = createDeps(); - const mockStatus = { connected: true }; - (deps.fetchJson as jest.Mock).mockResolvedValue(mockStatus); - - const result = await getVectorStoreStatus(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/vector-store/status', - ); - expect(result).toEqual(mockStatus); - }); - }); - }); - - describe('Multi-Vector-Store Management', () => { - describe('listActiveVectorStores', () => { - it('should list stores', async () => { - const deps = createDeps(); - const mockStores = [{ id: 'vs-1', active: true }]; - (deps.fetchJson as jest.Mock).mockResolvedValue({ stores: mockStores }); - - const result = await listActiveVectorStores(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith('/admin/vector-stores'); - expect(result.stores).toEqual(mockStores); - }); - - it('should include unconnected stores when returned', async () => { - const deps = createDeps(); - const mockStores = [{ id: 'vs-1', active: true }]; - const mockUnconnected = [{ id: 'vs-2', name: 'other', active: false }]; - (deps.fetchJson as jest.Mock).mockResolvedValue({ - stores: mockStores, - unconnected: mockUnconnected, - }); - - const result = await listActiveVectorStores(deps); - - expect(result.stores).toEqual(mockStores); - expect(result.unconnected).toEqual(mockUnconnected); - }); - }); - - describe('connectVectorStore', () => { - it('should connect store', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({ - activeVectorStoreIds: ['vs-1'], - }); - - const result = await connectVectorStore(deps, 'vs-1'); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/vector-stores/connect', - expect.objectContaining({ - body: JSON.stringify({ vectorStoreId: 'vs-1' }), - }), - ); - expect(result.activeVectorStoreIds).toEqual(['vs-1']); - }); - }); - - describe('removeVectorStore', () => { - it('should remove store', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({ - removed: 'vs-1', - permanent: false, - filesDeleted: 0, - activeVectorStoreIds: [], - }); - - const result = await removeVectorStore(deps, 'vs-1'); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/vector-stores/vs-1', - { method: 'DELETE' }, - ); - expect(result.removed).toBe('vs-1'); - }); - - it('should pass permanent=true in query', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({}); - - await removeVectorStore(deps, 'vs-1', true); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/admin/vector-stores/vs-1?permanent=true', - { method: 'DELETE' }, - ); - }); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/api/documentEndpoints.ts b/workspaces/augment/plugins/augment/src/api/documentEndpoints.ts deleted file mode 100644 index 6cad92ce566..00000000000 --- a/workspaces/augment/plugins/augment/src/api/documentEndpoints.ts +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; -import type { - DocumentInfo, - RagTestResult, - RagGenerateResult, - UploadResult, - VectorStoreConfig, - VectorStoreCreateResult, - VectorStoreStatusResult, - VectorStoreInfo, -} from '../types'; -import { jsonBody } from './fetchHelpers'; - -export interface DocumentApiDeps { - fetchJson: (path: string, init?: RequestInit) => Promise; - discoveryApi: DiscoveryApi; - fetchApi: FetchApi; -} - -// --------------------------------------------------------------------------- -// Documents -// --------------------------------------------------------------------------- - -export async function syncDocuments(deps: DocumentApiDeps): Promise<{ - added: number; - updated: number; - removed: number; - failed: number; - unchanged: number; - errors: string[]; -}> { - return deps.fetchJson('/sync', { method: 'POST' }); -} - -export async function uploadDocument( - deps: DocumentApiDeps, - file: File, - vectorStoreId?: string, - replace?: boolean, -): Promise { - const formData = new FormData(); - formData.append('file', file); - const params = new URLSearchParams(); - if (vectorStoreId) params.set('vectorStoreId', vectorStoreId); - if (replace) params.set('replace', 'true'); - const qs = params.toString() ? `?${params.toString()}` : ''; - return deps.fetchJson(`/admin/documents${qs}`, { - method: 'POST', - body: formData, - }); -} - -export async function deleteDocument( - deps: DocumentApiDeps, - fileId: string, - vectorStoreId?: string, -): Promise<{ success: boolean }> { - const qs = vectorStoreId - ? `?vectorStoreId=${encodeURIComponent(vectorStoreId)}` - : ''; - return deps.fetchJson(`/admin/documents/${encodeURIComponent(fileId)}${qs}`, { - method: 'DELETE', - }); -} - -export async function listDocumentsForStore( - deps: DocumentApiDeps, - vectorStoreId: string, -): Promise { - const data = await deps.fetchJson<{ documents: DocumentInfo[] }>( - `/documents?vectorStoreId=${encodeURIComponent(vectorStoreId)}`, - ); - return data.documents; -} - -// --------------------------------------------------------------------------- -// RAG -// --------------------------------------------------------------------------- - -export async function testRagQuery( - deps: DocumentApiDeps, - query: string, - maxResults?: number, - vectorStoreId?: string, - vectorStoreIds?: string[], -): Promise { - return deps.fetchJson( - '/admin/rag-test', - jsonBody({ query, maxResults, vectorStoreId, vectorStoreIds }), - ); -} - -export async function generateRagAnswer( - deps: DocumentApiDeps, - query: string, - maxResults?: number, - vectorStoreId?: string, - vectorStoreIds?: string[], -): Promise { - return deps.fetchJson( - '/admin/rag-generate', - jsonBody({ query, maxResults, vectorStoreId, vectorStoreIds }), - ); -} - -// --------------------------------------------------------------------------- -// Vector Store Config -// --------------------------------------------------------------------------- - -export async function getVectorStoreConfig(deps: DocumentApiDeps): Promise<{ - config: VectorStoreConfig; - source: 'yaml' | 'database' | 'merged'; -}> { - return deps.fetchJson('/admin/vector-store-config'); -} - -export async function saveVectorStoreConfig( - deps: DocumentApiDeps, - overrides: Partial, -): Promise { - await deps.fetchJson( - '/admin/config/vectorStoreConfig', - jsonBody({ value: overrides }, 'PUT'), - ); -} - -export async function resetVectorStoreConfig( - deps: DocumentApiDeps, -): Promise<{ deleted: boolean }> { - return deps.fetchJson('/admin/config/vectorStoreConfig', { - method: 'DELETE', - }); -} - -export async function createVectorStore( - deps: DocumentApiDeps, - config?: Record, -): Promise { - return deps.fetchJson('/admin/vector-store/create', jsonBody(config ?? {})); -} - -export async function getVectorStoreStatus( - deps: DocumentApiDeps, -): Promise { - return deps.fetchJson('/admin/vector-store/status'); -} - -// --------------------------------------------------------------------------- -// Multi-Vector-Store Management -// --------------------------------------------------------------------------- - -export async function listActiveVectorStores(deps: DocumentApiDeps): Promise<{ - stores: Array; - unconnected?: Array; -}> { - return deps.fetchJson('/admin/vector-stores'); -} - -export async function connectVectorStore( - deps: DocumentApiDeps, - vectorStoreId: string, -): Promise<{ - activeVectorStoreIds: string[]; -}> { - return deps.fetchJson( - '/admin/vector-stores/connect', - jsonBody({ vectorStoreId }), - ); -} - -export async function removeVectorStore( - deps: DocumentApiDeps, - vectorStoreId: string, - permanent?: boolean, -): Promise<{ - removed: string; - permanent: boolean; - filesDeleted: number; - activeVectorStoreIds: string[]; -}> { - const qs = permanent ? '?permanent=true' : ''; - return deps.fetchJson( - `/admin/vector-stores/${encodeURIComponent(vectorStoreId)}${qs}`, - { method: 'DELETE' }, - ); -} diff --git a/workspaces/augment/plugins/augment/src/api/fetchHelpers.ts b/workspaces/augment/plugins/augment/src/api/fetchHelpers.ts deleted file mode 100644 index 0cb1644b653..00000000000 --- a/workspaces/augment/plugins/augment/src/api/fetchHelpers.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Build a RequestInit for a JSON body. - * Pure helper — no instance dependencies. - */ -export function jsonBody( - body: unknown, - method: string = 'POST', - extra?: RequestInit, -): RequestInit { - return { - method, - headers: { - 'Content-Type': 'application/json', - 'X-Backstage-Request': 'augment', - }, - body: JSON.stringify(body), - ...extra, - }; -} diff --git a/workspaces/augment/plugins/augment/src/api/index.ts b/workspaces/augment/plugins/augment/src/api/index.ts deleted file mode 100644 index 0d6f1c74e41..00000000000 --- a/workspaces/augment/plugins/augment/src/api/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { augmentApiRef, AugmentApiClient, type AugmentApi } from './AugmentApi'; diff --git a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints.test.ts b/workspaces/augment/plugins/augment/src/api/kagentiEndpoints.test.ts deleted file mode 100644 index bfe35549616..00000000000 --- a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { KagentiApiDeps } from './kagentiEndpoints'; -import { - getHealth, - getFeatureFlags, - getDashboards, - listNamespaces, - listAgents, - getAgent, - createAgent, - deleteAgent, - getAgentRouteStatus, - listMigratableAgents, - migrateAgent, - listBuildStrategies, - getAgentBuildInfo, - triggerAgentBuild, - finalizeAgentBuild, - parseEnv, - fetchEnvUrl, - listTools, - getTool, - createTool, - deleteTool, - getToolRouteStatus, - getToolBuildInfo, - triggerToolBuild, - finalizeToolBuild, - connectTool, - invokeTool, - listShipwrightBuilds, -} from './kagentiEndpoints'; - -function createDeps(): KagentiApiDeps { - return { - fetchJson: jest.fn().mockResolvedValue({}), - }; -} - -describe('kagentiEndpoints', () => { - describe('Health & Config', () => { - it('getHealth calls /kagenti/health', async () => { - const deps = createDeps(); - await getHealth(deps); - expect(deps.fetchJson).toHaveBeenCalledWith('/kagenti/health'); - }); - - it('getFeatureFlags calls /kagenti/config/features', async () => { - const deps = createDeps(); - await getFeatureFlags(deps); - expect(deps.fetchJson).toHaveBeenCalledWith('/kagenti/config/features'); - }); - - it('getDashboards calls /kagenti/config/dashboards', async () => { - const deps = createDeps(); - await getDashboards(deps); - expect(deps.fetchJson).toHaveBeenCalledWith('/kagenti/config/dashboards'); - }); - }); - - describe('Namespaces', () => { - it('listNamespaces defaults to enabled_only=true', async () => { - const deps = createDeps(); - await listNamespaces(deps); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/namespaces?enabled_only=true', - ); - }); - - it('listNamespaces with enabledOnly=false omits query param', async () => { - const deps = createDeps(); - await listNamespaces(deps, false); - expect(deps.fetchJson).toHaveBeenCalledWith('/kagenti/namespaces'); - }); - }); - - describe('Agents', () => { - it('listAgents with namespace includes query param', async () => { - const deps = createDeps(); - await listAgents(deps, 'team1'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/agents?namespace=team1', - ); - }); - - it('listAgents without namespace omits query param', async () => { - const deps = createDeps(); - await listAgents(deps); - expect(deps.fetchJson).toHaveBeenCalledWith('/kagenti/agents'); - }); - - it('getAgent encodes namespace and name', async () => { - const deps = createDeps(); - await getAgent(deps, 'my ns', 'my agent'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/agents/my%20ns/my%20agent', - ); - }); - - it('createAgent sends POST with body', async () => { - const deps = createDeps(); - const body = { name: 'bot', namespace: 'ns1', containerImage: 'img:v1' }; - await createAgent(deps, body as any); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/agents', - expect.objectContaining({ method: 'POST' }), - ); - }); - - it('deleteAgent sends DELETE', async () => { - const deps = createDeps(); - await deleteAgent(deps, 'ns1', 'bot'); - expect(deps.fetchJson).toHaveBeenCalledWith('/kagenti/agents/ns1/bot', { - method: 'DELETE', - }); - }); - - it('getAgentRouteStatus calls correct path', async () => { - const deps = createDeps(); - await getAgentRouteStatus(deps, 'ns1', 'bot'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/agents/ns1/bot/route-status', - ); - }); - }); - - describe('Agent Migration', () => { - it('listMigratableAgents calls correct path', async () => { - const deps = createDeps(); - await listMigratableAgents(deps); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/agents/migration/migratable', - ); - }); - - it('migrateAgent sends POST with delete_old', async () => { - const deps = createDeps(); - await migrateAgent(deps, 'ns1', 'bot', true); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/agents/ns1/bot/migrate', - expect.objectContaining({ method: 'POST' }), - ); - }); - }); - - describe('Agent Builds', () => { - it('listBuildStrategies calls correct path', async () => { - const deps = createDeps(); - await listBuildStrategies(deps); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/agents/build-strategies', - ); - }); - - it('getAgentBuildInfo calls correct path', async () => { - const deps = createDeps(); - await getAgentBuildInfo(deps, 'ns1', 'bot'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/agents/ns1/bot/build-info', - ); - }); - - it('triggerAgentBuild sends POST', async () => { - const deps = createDeps(); - await triggerAgentBuild(deps, 'ns1', 'bot'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/agents/ns1/bot/buildrun', - expect.objectContaining({ method: 'POST' }), - ); - }); - - it('finalizeAgentBuild sends POST', async () => { - const deps = createDeps(); - await finalizeAgentBuild(deps, 'ns1', 'bot'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/agents/ns1/bot/finalize-build', - expect.objectContaining({ method: 'POST' }), - ); - }); - }); - - describe('Env Parsing', () => { - it('parseEnv sends POST with content', async () => { - const deps = createDeps(); - await parseEnv(deps, 'FOO=bar'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/agents/parse-env', - expect.objectContaining({ method: 'POST' }), - ); - }); - - it('fetchEnvUrl sends POST with url', async () => { - const deps = createDeps(); - await fetchEnvUrl(deps, 'https://example.com/env'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/agents/fetch-env-url', - expect.objectContaining({ method: 'POST' }), - ); - }); - }); - - describe('Tools', () => { - it('listTools with namespace includes query param', async () => { - const deps = createDeps(); - await listTools(deps, 'ns1'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/tools?namespace=ns1', - ); - }); - - it('getTool calls correct path', async () => { - const deps = createDeps(); - await getTool(deps, 'ns1', 'my-tool'); - expect(deps.fetchJson).toHaveBeenCalledWith('/kagenti/tools/ns1/my-tool'); - }); - - it('createTool sends POST', async () => { - const deps = createDeps(); - await createTool(deps, { name: 't1', namespace: 'ns1' } as any); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/tools', - expect.objectContaining({ method: 'POST' }), - ); - }); - - it('deleteTool sends DELETE', async () => { - const deps = createDeps(); - await deleteTool(deps, 'ns1', 'my-tool'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/tools/ns1/my-tool', - { method: 'DELETE' }, - ); - }); - - it('getToolRouteStatus calls correct path', async () => { - const deps = createDeps(); - await getToolRouteStatus(deps, 'ns1', 'my-tool'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/tools/ns1/my-tool/route-status', - ); - }); - - it('getToolBuildInfo calls correct path', async () => { - const deps = createDeps(); - await getToolBuildInfo(deps, 'ns1', 'my-tool'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/tools/ns1/my-tool/build-info', - ); - }); - - it('triggerToolBuild sends POST', async () => { - const deps = createDeps(); - await triggerToolBuild(deps, 'ns1', 'my-tool'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/tools/ns1/my-tool/buildrun', - expect.objectContaining({ method: 'POST' }), - ); - }); - - it('finalizeToolBuild sends POST', async () => { - const deps = createDeps(); - await finalizeToolBuild(deps, 'ns1', 'my-tool'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/tools/ns1/my-tool/finalize-build', - expect.objectContaining({ method: 'POST' }), - ); - }); - - it('connectTool sends POST', async () => { - const deps = createDeps(); - await connectTool(deps, 'ns1', 'my-tool'); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/tools/ns1/my-tool/connect', - expect.objectContaining({ method: 'POST' }), - ); - }); - - it('invokeTool sends POST with tool_name and arguments', async () => { - const deps = createDeps(); - await invokeTool(deps, 'ns1', 'my-tool', 'search', { query: 'hi' }); - expect(deps.fetchJson).toHaveBeenCalledWith( - '/kagenti/tools/ns1/my-tool/invoke', - expect.objectContaining({ method: 'POST' }), - ); - }); - }); - - describe('Shipwright Builds', () => { - it('listShipwrightBuilds with namespace', async () => { - const deps = createDeps(); - await listShipwrightBuilds(deps, { namespace: 'ns1' }); - expect(deps.fetchJson).toHaveBeenCalledWith( - expect.stringContaining('/kagenti/shipwright/builds'), - ); - }); - - it('listShipwrightBuilds without namespace', async () => { - const deps = createDeps(); - await listShipwrightBuilds(deps); - expect(deps.fetchJson).toHaveBeenCalledWith('/kagenti/shipwright/builds'); - }); - }); - - describe('error handling', () => { - it('propagates fetchJson errors', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockRejectedValue( - new Error('Network error'), - ); - await expect(getHealth(deps)).rejects.toThrow('Network error'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/adminEndpoints.ts b/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/adminEndpoints.ts deleted file mode 100644 index 395f46c6a83..00000000000 --- a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/adminEndpoints.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - KagentiIntegration, - KagentiCreateIntegrationRequest, - KagentiLlmTeam, - KagentiLlmKey, - KagentiCreateTeamRequest, - KagentiCreateKeyRequest, - KagentiTriggerRequest, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { KagentiApiDeps } from './types'; -import { e, jsonBody } from './types'; - -// ============================================================================= -// Admin: LLM Models / Teams / Keys -// ============================================================================= - -export async function listLlmModels( - deps: KagentiApiDeps, -): Promise> { - return deps.fetchJson('/kagenti/models'); -} - -export async function createLlmTeam( - deps: KagentiApiDeps, - body: KagentiCreateTeamRequest, -): Promise { - return deps.fetchJson('/kagenti/llm/teams', jsonBody(body)); -} - -export async function listLlmTeams( - deps: KagentiApiDeps, -): Promise<{ teams: KagentiLlmTeam[] }> { - return deps.fetchJson('/kagenti/llm/teams'); -} - -export async function getLlmTeam( - deps: KagentiApiDeps, - namespace: string, -): Promise { - return deps.fetchJson(`/kagenti/llm/teams/${e(namespace)}`); -} - -export async function createLlmKey( - deps: KagentiApiDeps, - body: KagentiCreateKeyRequest, -): Promise { - return deps.fetchJson('/kagenti/llm/keys', jsonBody(body)); -} - -export async function listLlmKeys( - deps: KagentiApiDeps, -): Promise<{ keys: KagentiLlmKey[] }> { - return deps.fetchJson('/kagenti/llm/keys'); -} - -export async function deleteLlmKey( - deps: KagentiApiDeps, - namespace: string, - agentName: string, -): Promise { - return deps.fetchJson(`/kagenti/llm/keys/${e(namespace)}/${e(agentName)}`, { - method: 'DELETE', - }); -} - -export async function getAgentModels( - deps: KagentiApiDeps, - namespace: string, - agentName: string, -): Promise> { - return deps.fetchJson( - `/kagenti/llm/agent-models/${e(namespace)}/${e(agentName)}`, - ); -} - -// ============================================================================= -// Admin: Integrations -// ============================================================================= - -export async function listIntegrations( - deps: KagentiApiDeps, - namespace?: string, -): Promise<{ integrations: KagentiIntegration[] }> { - const qs = namespace ? `?namespace=${e(namespace)}` : ''; - return deps.fetchJson(`/kagenti/integrations${qs}`); -} - -export async function getIntegration( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - return deps.fetchJson(`/kagenti/integrations/${e(namespace)}/${e(name)}`); -} - -export async function createIntegration( - deps: KagentiApiDeps, - body: KagentiCreateIntegrationRequest, -): Promise { - return deps.fetchJson('/kagenti/integrations', jsonBody(body)); -} - -export async function updateIntegration( - deps: KagentiApiDeps, - namespace: string, - name: string, - body: Record, -): Promise { - return deps.fetchJson( - `/kagenti/integrations/${e(namespace)}/${e(name)}`, - jsonBody(body, 'PUT'), - ); -} - -export async function deleteIntegration( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - return deps.fetchJson(`/kagenti/integrations/${e(namespace)}/${e(name)}`, { - method: 'DELETE', - }); -} - -export async function testIntegration( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise> { - return deps.fetchJson( - `/kagenti/integrations/${e(namespace)}/${e(name)}/test`, - jsonBody({}), - ); -} - -// ============================================================================= -// Admin: Triggers -// ============================================================================= - -export async function createTrigger( - deps: KagentiApiDeps, - body: KagentiTriggerRequest, -): Promise> { - return deps.fetchJson('/kagenti/sandbox/trigger', jsonBody(body)); -} diff --git a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/agentEndpoints.ts b/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/agentEndpoints.ts deleted file mode 100644 index 51efb388d09..00000000000 --- a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/agentEndpoints.ts +++ /dev/null @@ -1,213 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - KagentiAgentSummary, - KagentiAgentDetail, - KagentiAgentCard, - KagentiCreateAgentRequest, - KagentiCreateAgentResponse, - KagentiFinalizeAgentBuildRequest, - KagentiMigratableAgent, - KagentiBuildInfo, - KagentiBuildStrategy, - KagentiRouteStatus, - KagentiTriggerBuildRunResponse, - KagentiMigrateAgentResponse, - KagentiMigrateAllResponse, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { KagentiApiDeps } from './types'; -import { e, jsonBody } from './types'; - -// ============================================================================= -// Agents -// ============================================================================= - -export async function listAgents( - deps: KagentiApiDeps, - namespace?: string, - options?: { includeCards?: boolean }, -): Promise<{ - agents: (KagentiAgentSummary & { agentCard?: KagentiAgentCard })[]; -}> { - const params: string[] = []; - if (namespace) params.push(`namespace=${e(namespace)}`); - if (options?.includeCards) params.push('include=cards'); - const qs = params.length ? `?${params.join('&')}` : ''; - return deps.fetchJson(`/kagenti/agents${qs}`); -} - -export async function getAgent( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - return deps.fetchJson(`/kagenti/agents/${e(namespace)}/${e(name)}`); -} - -export async function createAgent( - deps: KagentiApiDeps, - body: KagentiCreateAgentRequest, -): Promise { - return deps.fetchJson('/kagenti/agents', jsonBody(body)); -} - -export async function deleteAgent( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - return deps.fetchJson(`/kagenti/agents/${e(namespace)}/${e(name)}`, { - method: 'DELETE', - }); -} - -export async function getAgentRouteStatus( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - return deps.fetchJson( - `/kagenti/agents/${e(namespace)}/${e(name)}/route-status`, - ); -} - -// ============================================================================= -// Agent Migration -// ============================================================================= - -export async function listMigratableAgents( - deps: KagentiApiDeps, -): Promise<{ agents: KagentiMigratableAgent[] }> { - return deps.fetchJson('/kagenti/agents/migration/migratable'); -} - -export async function migrateAgent( - deps: KagentiApiDeps, - namespace: string, - name: string, - deleteOld?: boolean, -): Promise { - return deps.fetchJson( - `/kagenti/agents/${e(namespace)}/${e(name)}/migrate`, - jsonBody({ delete_old: deleteOld }), - ); -} - -export async function migrateAllAgents( - deps: KagentiApiDeps, - options?: { namespace?: string; dryRun?: boolean; deleteOld?: boolean }, -): Promise { - const params = new URLSearchParams(); - if (options?.namespace) params.set('namespace', options.namespace); - if (options?.dryRun) params.set('dry_run', 'true'); - if (options?.deleteOld !== undefined) - params.set('delete_old', String(options.deleteOld)); - const qs = params.toString() ? `?${params}` : ''; - return deps.fetchJson( - `/kagenti/agents/migration/migrate-all${qs}`, - jsonBody({}), - ); -} - -// ============================================================================= -// Agent Builds -// ============================================================================= - -export async function getAgentBuildInfo( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - return deps.fetchJson( - `/kagenti/agents/${e(namespace)}/${e(name)}/build-info`, - ); -} - -export async function triggerAgentBuild( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - return deps.fetchJson( - `/kagenti/agents/${e(namespace)}/${e(name)}/buildrun`, - jsonBody({}), - ); -} - -export async function finalizeAgentBuild( - deps: KagentiApiDeps, - namespace: string, - name: string, - body?: KagentiFinalizeAgentBuildRequest, -): Promise { - return deps.fetchJson( - `/kagenti/agents/${e(namespace)}/${e(name)}/finalize-build`, - jsonBody(body || {}), - ); -} - -export async function listBuildStrategies( - deps: KagentiApiDeps, -): Promise<{ strategies: KagentiBuildStrategy[] }> { - return deps.fetchJson('/kagenti/agents/build-strategies'); -} - -export async function listAgentBuilds( - deps: KagentiApiDeps, - namespace?: string, - allNamespaces = false, -): Promise> { - const params = new URLSearchParams(); - if (namespace) params.set('namespace', namespace); - if (allNamespaces) params.set('all_namespaces', 'true'); - const qs = params.toString() ? `?${params}` : ''; - return deps.fetchJson(`/kagenti/agents/shipwright-builds${qs}`); -} - -export async function getAgentBuildStatus( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise> { - return deps.fetchJson( - `/kagenti/agents/${e(namespace)}/${e(name)}/shipwright-build`, - ); -} - -export async function getAgentBuildRunStatus( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise> { - return deps.fetchJson( - `/kagenti/agents/${e(namespace)}/${e(name)}/shipwright-buildrun`, - ); -} - -export async function parseEnv( - deps: KagentiApiDeps, - content: string, -): Promise> { - return deps.fetchJson('/kagenti/agents/parse-env', jsonBody({ content })); -} - -export async function fetchEnvUrl( - deps: KagentiApiDeps, - url: string, -): Promise> { - return deps.fetchJson('/kagenti/agents/fetch-env-url', jsonBody({ url })); -} diff --git a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/configEndpoints.ts b/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/configEndpoints.ts deleted file mode 100644 index cd0fde1c068..00000000000 --- a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/configEndpoints.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - KagentiFeatureFlags, - KagentiDashboardConfig, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { KagentiApiDeps } from './types'; - -// ============================================================================= -// Health & Config -// ============================================================================= - -export async function getHealth( - deps: KagentiApiDeps, -): Promise<{ health: string; ready: boolean }> { - return deps.fetchJson('/kagenti/health'); -} - -export async function getFeatureFlags( - deps: KagentiApiDeps, -): Promise { - return deps.fetchJson('/kagenti/config/features'); -} - -export async function getDashboards( - deps: KagentiApiDeps, -): Promise { - return deps.fetchJson('/kagenti/config/dashboards'); -} - -// ============================================================================= -// Namespaces -// ============================================================================= - -export async function listNamespaces( - deps: KagentiApiDeps, - enabledOnly = true, -): Promise<{ namespaces: string[]; defaultNamespace?: string }> { - const qs = enabledOnly ? '?enabled_only=true' : ''; - return deps.fetchJson(`/kagenti/namespaces${qs}`); -} diff --git a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/devSpacesEndpoints.ts b/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/devSpacesEndpoints.ts deleted file mode 100644 index 8f9d16cc39c..00000000000 --- a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/devSpacesEndpoints.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - DevSpacesCreateWorkspaceRequest, - DevSpacesCreateWorkspaceResponse, - DevSpacesHealthResponse, - DevSpacesListWorkspacesResponse, - DevSpacesWorkspace, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { KagentiApiDeps } from './types'; -import { jsonBody, e } from './types'; - -/** - * Check Dev Spaces API health and configuration status. - */ -export async function checkDevSpacesHealth( - deps: KagentiApiDeps, -): Promise { - return deps.fetchJson('/devspaces/health'); -} - -/** - * Create a Dev Spaces workspace via the backend proxy. - * Authentication is handled server-side using the platform's - * Keycloak credentials — no user-supplied token needed. - */ -export async function createDevSpacesWorkspace( - deps: KagentiApiDeps, - request: DevSpacesCreateWorkspaceRequest, -): Promise { - return deps.fetchJson('/devspaces/workspaces', jsonBody(request)); -} - -/** - * List workspaces in a namespace. - */ -export async function listDevSpacesWorkspaces( - deps: KagentiApiDeps, - namespace: string, -): Promise { - return deps.fetchJson(`/devspaces/workspaces?namespace=${e(namespace)}`); -} - -/** - * Get a single workspace's status. - */ -export async function getDevSpacesWorkspace( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - return deps.fetchJson(`/devspaces/workspaces/${e(namespace)}/${e(name)}`); -} - -/** - * Stop a running workspace. - */ -export async function stopDevSpacesWorkspace( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - await deps.fetchJson( - `/devspaces/workspaces/${e(namespace)}/${e(name)}/stop`, - { method: 'PATCH' }, - ); -} - -/** - * Delete a workspace. - */ -export async function deleteDevSpacesWorkspace( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - await deps.fetchJson(`/devspaces/workspaces/${e(namespace)}/${e(name)}`, { - method: 'DELETE', - }); -} diff --git a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/index.ts b/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/index.ts deleted file mode 100644 index 39dc3f224d6..00000000000 --- a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from './types'; -export * from './configEndpoints'; -export * from './agentEndpoints'; -export * from './toolEndpoints'; -export * from './sandboxEndpoints'; -export * from './adminEndpoints'; -export * from './devSpacesEndpoints'; diff --git a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/sandboxEndpoints.ts b/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/sandboxEndpoints.ts deleted file mode 100644 index 2a908c430a3..00000000000 --- a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/sandboxEndpoints.ts +++ /dev/null @@ -1,485 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - KagentiSandboxSession, - KagentiSandboxAgentInfo, - KagentiSandboxCreateRequest, - KagentiSidecar, - KagentiSessionTokenUsage, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { KagentiApiDeps } from './types'; -import { e, jsonBody } from './types'; - -// ============================================================================= -// Sandbox Sessions -// ============================================================================= - -export async function listSandboxSessions( - deps: KagentiApiDeps, - namespace: string, - options?: { - limit?: number; - offset?: number; - search?: string; - agentName?: string; - }, -): Promise<{ sessions: KagentiSandboxSession[]; total?: number }> { - const params = new URLSearchParams(); - if (options?.limit) params.set('limit', String(options.limit)); - if (options?.offset) params.set('offset', String(options.offset)); - if (options?.search) params.set('search', options.search); - if (options?.agentName) params.set('agent_name', options.agentName); - const qs = params.toString() ? `?${params}` : ''; - return deps.fetchJson(`/kagenti/sandbox/${e(namespace)}/sessions${qs}`); -} - -export async function getSandboxSession( - deps: KagentiApiDeps, - namespace: string, - contextId: string, -): Promise { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}`, - ); -} - -export async function getSandboxSessionChain( - deps: KagentiApiDeps, - namespace: string, - contextId: string, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}/chain`, - ); -} - -export async function getSandboxSessionHistory( - deps: KagentiApiDeps, - namespace: string, - contextId: string, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}/history`, - ); -} - -export async function deleteSandboxSession( - deps: KagentiApiDeps, - namespace: string, - contextId: string, -): Promise { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}`, - { method: 'DELETE' }, - ); -} - -export async function renameSandboxSession( - deps: KagentiApiDeps, - namespace: string, - contextId: string, - title: string, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}/rename`, - jsonBody({ title }, 'PUT'), - ); -} - -export async function killSandboxSession( - deps: KagentiApiDeps, - namespace: string, - contextId: string, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}/kill`, - jsonBody({}), - ); -} - -export async function approveSandboxSession( - deps: KagentiApiDeps, - namespace: string, - contextId: string, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}/approve`, - jsonBody({}), - ); -} - -export async function denySandboxSession( - deps: KagentiApiDeps, - namespace: string, - contextId: string, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}/deny`, - jsonBody({}), - ); -} - -export async function setSandboxSessionVisibility( - deps: KagentiApiDeps, - namespace: string, - contextId: string, - visibility: 'private' | 'namespace', -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}/visibility`, - jsonBody({ visibility }, 'PUT'), - ); -} - -export async function cleanupSandboxSessions( - deps: KagentiApiDeps, - namespace: string, - ttlMinutes?: number, -): Promise> { - const qs = ttlMinutes ? `?ttl_minutes=${ttlMinutes}` : ''; - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/cleanup${qs}`, - jsonBody({}), - ); -} - -// ============================================================================= -// Sandbox Agents / Pods -// ============================================================================= - -export async function listSandboxAgents( - deps: KagentiApiDeps, - namespace: string, -): Promise<{ agents: KagentiSandboxAgentInfo[] }> { - return deps.fetchJson(`/kagenti/sandbox/${e(namespace)}/agents`); -} - -export async function getSandboxAgentCard( - deps: KagentiApiDeps, - namespace: string, - agent: string, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/agent-card/${e(agent)}`, - ); -} - -export async function getSandboxAgentPodStatus( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/agents/${e(name)}/pod-status`, - ); -} - -export async function getSandboxAgentMetrics( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/agents/${e(name)}/metrics`, - ); -} - -export async function getSandboxAgentEvents( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/agents/${e(name)}/events`, - ); -} - -// ============================================================================= -// Sandbox Chat -// ============================================================================= - -export async function sandboxChat( - deps: KagentiApiDeps, - namespace: string, - body: { - message: string; - session_id?: string; - agent_name?: string; - skill?: string; - }, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/chat`, - jsonBody(body), - ); -} - -// ============================================================================= -// Sandbox CRUD -// ============================================================================= - -export async function getSandboxDefaults( - deps: KagentiApiDeps, -): Promise> { - return deps.fetchJson('/kagenti/sandbox/defaults'); -} - -export async function createSandbox( - deps: KagentiApiDeps, - namespace: string, - body: KagentiSandboxCreateRequest, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/create`, - jsonBody(body), - ); -} - -export async function deleteSandbox( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - return deps.fetchJson(`/kagenti/sandbox/${e(namespace)}/${e(name)}`, { - method: 'DELETE', - }); -} - -export async function getSandboxConfig( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise> { - return deps.fetchJson(`/kagenti/sandbox/${e(namespace)}/${e(name)}/config`); -} - -export async function updateSandbox( - deps: KagentiApiDeps, - namespace: string, - name: string, - body: Record, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/${e(name)}`, - jsonBody(body, 'PUT'), - ); -} - -// ============================================================================= -// Sandbox Files -// ============================================================================= - -export async function browseSandboxFiles( - deps: KagentiApiDeps, - namespace: string, - agent: string, - path = '/', -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/files/${e(agent)}?path=${e(path)}`, - ); -} - -export async function listSandboxDirectory( - deps: KagentiApiDeps, - namespace: string, - agent: string, - path = '/', -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/files/${e(agent)}/list?path=${e(path)}`, - ); -} - -export async function getSandboxFileContent( - deps: KagentiApiDeps, - namespace: string, - agent: string, - path: string, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/files/${e(agent)}/content?path=${e(path)}`, - ); -} - -export async function browseSandboxContextFiles( - deps: KagentiApiDeps, - namespace: string, - agent: string, - contextId: string, - path = '/', -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/files/${e(agent)}/${e(contextId)}?path=${e(path)}`, - ); -} - -export async function getSandboxStorageStats( - deps: KagentiApiDeps, - namespace: string, - agent: string, -): Promise> { - return deps.fetchJson(`/kagenti/sandbox/${e(namespace)}/stats/${e(agent)}`); -} - -// ============================================================================= -// Sandbox Sidecars -// ============================================================================= - -export async function listSidecars( - deps: KagentiApiDeps, - namespace: string, - contextId: string, -): Promise<{ sidecars: KagentiSidecar[] }> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}/sidecars`, - ); -} - -export async function enableSidecar( - deps: KagentiApiDeps, - namespace: string, - contextId: string, - sidecarType: string, - autoApprove?: boolean, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}/sidecars/${e(sidecarType)}/enable`, - jsonBody({ auto_approve: autoApprove }), - ); -} - -export async function disableSidecar( - deps: KagentiApiDeps, - namespace: string, - contextId: string, - sidecarType: string, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}/sidecars/${e(sidecarType)}/disable`, - jsonBody({}), - ); -} - -export async function updateSidecarConfig( - deps: KagentiApiDeps, - namespace: string, - contextId: string, - sidecarType: string, - config: Record, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}/sidecars/${e(sidecarType)}/config`, - jsonBody(config, 'PUT'), - ); -} - -export async function resetSidecar( - deps: KagentiApiDeps, - namespace: string, - contextId: string, - sidecarType: string, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}/sidecars/${e(sidecarType)}/reset`, - jsonBody({}), - ); -} - -export async function approveSidecar( - deps: KagentiApiDeps, - namespace: string, - contextId: string, - sidecarType: string, - msgId: string, -): Promise<{ status: string; id: string }> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}/sidecars/${e(sidecarType)}/approve/${e(msgId)}`, - jsonBody({}), - ); -} - -export async function denySidecar( - deps: KagentiApiDeps, - namespace: string, - contextId: string, - sidecarType: string, - msgId: string, -): Promise<{ status: string; id: string }> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/sessions/${e(contextId)}/sidecars/${e(sidecarType)}/deny/${e(msgId)}`, - jsonBody({}), - ); -} - -// ============================================================================= -// Token Usage -// ============================================================================= - -export async function getSessionTokenUsage( - deps: KagentiApiDeps, - namespace: string, - contextId: string, -): Promise { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/token-usage/sessions/${e(contextId)}`, - ); -} - -export async function getSessionTreeUsage( - deps: KagentiApiDeps, - namespace: string, - contextId: string, -): Promise> { - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/token-usage/sessions/${e(contextId)}/tree`, - ); -} - -// ============================================================================= -// Sandbox Events & Tasks -// ============================================================================= - -export async function getSandboxEvents( - deps: KagentiApiDeps, - namespace: string, - contextId: string, - options?: { taskId?: string; fromIndex?: number; limit?: number }, -): Promise> { - const params = new URLSearchParams(); - params.set('context_id', contextId); - if (options?.taskId) params.set('task_id', options.taskId); - if (options?.fromIndex !== undefined) - params.set('from_index', String(options.fromIndex)); - if (options?.limit) params.set('limit', String(options.limit)); - return deps.fetchJson(`/kagenti/sandbox/${e(namespace)}/events?${params}`); -} - -export async function getSandboxTasksPaginated( - deps: KagentiApiDeps, - namespace: string, - contextId: string, - options?: { limit?: number; beforeId?: string }, -): Promise> { - const params = new URLSearchParams(); - params.set('context_id', contextId); - if (options?.limit) params.set('limit', String(options.limit)); - if (options?.beforeId) params.set('before_id', options.beforeId); - return deps.fetchJson( - `/kagenti/sandbox/${e(namespace)}/tasks/paginated?${params}`, - ); -} diff --git a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/toolEndpoints.ts b/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/toolEndpoints.ts deleted file mode 100644 index beca50a26fe..00000000000 --- a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/toolEndpoints.ts +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - KagentiToolSummary, - KagentiToolDetail, - KagentiCreateToolRequest, - KagentiBuildInfo, - KagentiBuildListItem, - KagentiMcpToolSchema, - KagentiCreateToolResponse, - KagentiFinalizeToolBuildRequest, - KagentiMcpInvokeResponse, - KagentiRouteStatus, - KagentiTriggerBuildRunResponse, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { KagentiApiDeps } from './types'; -import { e, jsonBody } from './types'; - -// ============================================================================= -// Tools -// ============================================================================= - -export async function listTools( - deps: KagentiApiDeps, - namespace?: string, -): Promise<{ tools: KagentiToolSummary[] }> { - const qs = namespace ? `?namespace=${e(namespace)}` : ''; - const raw = await deps.fetchJson<{ - tools?: KagentiToolSummary[]; - items?: KagentiToolSummary[]; - }>(`/kagenti/tools${qs}`); - return { tools: raw.tools ?? raw.items ?? [] }; -} - -export async function getTool( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - return deps.fetchJson(`/kagenti/tools/${e(namespace)}/${e(name)}`); -} - -export async function createTool( - deps: KagentiApiDeps, - body: KagentiCreateToolRequest, -): Promise { - return deps.fetchJson('/kagenti/tools', jsonBody(body)); -} - -export async function deleteTool( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - return deps.fetchJson(`/kagenti/tools/${e(namespace)}/${e(name)}`, { - method: 'DELETE', - }); -} - -export async function getToolRouteStatus( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - return deps.fetchJson( - `/kagenti/tools/${e(namespace)}/${e(name)}/route-status`, - ); -} - -export async function getToolBuildInfo( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - return deps.fetchJson(`/kagenti/tools/${e(namespace)}/${e(name)}/build-info`); -} - -export async function triggerToolBuild( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise { - return deps.fetchJson( - `/kagenti/tools/${e(namespace)}/${e(name)}/buildrun`, - jsonBody({}), - ); -} - -export async function finalizeToolBuild( - deps: KagentiApiDeps, - namespace: string, - name: string, - body?: KagentiFinalizeToolBuildRequest, -): Promise { - return deps.fetchJson( - `/kagenti/tools/${e(namespace)}/${e(name)}/finalize-build`, - jsonBody(body || {}), - ); -} - -export async function connectTool( - deps: KagentiApiDeps, - namespace: string, - name: string, -): Promise<{ tools: KagentiMcpToolSchema[] }> { - return deps.fetchJson( - `/kagenti/tools/${e(namespace)}/${e(name)}/connect`, - jsonBody({}), - ); -} - -export async function invokeTool( - deps: KagentiApiDeps, - namespace: string, - name: string, - toolName: string, - args?: Record, -): Promise { - return deps.fetchJson( - `/kagenti/tools/${e(namespace)}/${e(name)}/invoke`, - jsonBody({ tool_name: toolName, arguments: args }), - ); -} - -// ============================================================================= -// Shipwright Builds -// ============================================================================= - -export async function listShipwrightBuilds( - deps: KagentiApiDeps, - options?: { namespace?: string; allNamespaces?: boolean }, -): Promise<{ builds: KagentiBuildListItem[] }> { - const params = new URLSearchParams(); - if (options?.namespace) params.set('namespace', options.namespace); - if (options?.allNamespaces) params.set('allNamespaces', 'true'); - const qs = params.toString() ? `?${params}` : ''; - return deps.fetchJson(`/kagenti/shipwright/builds${qs}`); -} - -export async function listToolBuilds( - deps: KagentiApiDeps, - namespace?: string, - allNamespaces = false, -): Promise> { - const params = new URLSearchParams(); - if (namespace) params.set('namespace', namespace); - if (allNamespaces) params.set('all_namespaces', 'true'); - const qs = params.toString() ? `?${params}` : ''; - return deps.fetchJson(`/kagenti/tools/shipwright-builds${qs}`); -} diff --git a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/types.ts b/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/types.ts deleted file mode 100644 index 185f2babbdc..00000000000 --- a/workspaces/augment/plugins/augment/src/api/kagentiEndpoints/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { jsonBody } from '../fetchHelpers'; - -export interface KagentiApiDeps { - fetchJson: (path: string, init?: RequestInit) => Promise; -} - -export const e = encodeURIComponent; diff --git a/workspaces/augment/plugins/augment/src/api/sessionEndpoints.test.ts b/workspaces/augment/plugins/augment/src/api/sessionEndpoints.test.ts deleted file mode 100644 index 1bf337c795d..00000000000 --- a/workspaces/augment/plugins/augment/src/api/sessionEndpoints.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { SessionApiDeps } from './sessionEndpoints'; -import { - listSessions, - createSession, - deleteSession, - getSessionMessages, - listAllSessions, - getAdminSessionMessages, -} from './sessionEndpoints'; -import { createMockResponse } from '../test-utils/factories'; - -describe('sessionEndpoints', () => { - const baseUrl = 'http://localhost:7007/api/augment'; - - function createDeps(overrides: Partial = {}): SessionApiDeps { - return { - fetchJson: jest.fn(), - fetchJsonSafe: jest.fn(), - discoveryApi: { - getBaseUrl: jest.fn().mockResolvedValue(baseUrl), - } as unknown as SessionApiDeps['discoveryApi'], - fetchApi: { - fetch: jest.fn(), - } as unknown as SessionApiDeps['fetchApi'], - ...overrides, - }; - } - - describe('Chat Sessions', () => { - describe('listSessions', () => { - it('should fetch sessions with default params', async () => { - const deps = createDeps(); - const mockSessions = [{ id: 's1', title: 'Session 1' }]; - (deps.fetchJson as jest.Mock).mockResolvedValue({ - sessions: mockSessions, - }); - - const result = await listSessions(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith('/sessions', undefined); - expect(result).toEqual(mockSessions); - }); - - it('should include limit and offset in query', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({ sessions: [] }); - - await listSessions(deps, 10, 5); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/sessions?limit=10&offset=5', - undefined, - ); - }); - - it('should return empty array when sessions missing', async () => { - const deps = createDeps(); - (deps.fetchJson as jest.Mock).mockResolvedValue({}); - - const result = await listSessions(deps); - - expect(result).toEqual([]); - }); - }); - - describe('createSession', () => { - it('should create session with title', async () => { - const deps = createDeps(); - const mockSession = { id: 's1', title: 'New Session' }; - (deps.fetchJson as jest.Mock).mockResolvedValue({ - session: mockSession, - }); - - const result = await createSession(deps, 'New Session'); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/sessions', - expect.objectContaining({ - body: JSON.stringify({ title: 'New Session' }), - }), - ); - expect(result).toEqual(mockSession); - }); - - it('should create session without title', async () => { - const deps = createDeps(); - const mockSession = { id: 's1', title: '' }; - (deps.fetchJson as jest.Mock).mockResolvedValue({ - session: mockSession, - }); - - await createSession(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith( - '/sessions', - expect.objectContaining({ - body: JSON.stringify({ title: undefined }), - }), - ); - }); - }); - - describe('deleteSession', () => { - it('should DELETE session and return true on success', async () => { - const deps = createDeps(); - (deps.fetchJsonSafe as jest.Mock).mockResolvedValue({ success: true }); - - const result = await deleteSession(deps, 'session-1'); - - expect(deps.fetchJsonSafe).toHaveBeenCalledWith( - '/sessions/session-1', - { success: false }, - { method: 'DELETE' }, - ); - expect(result).toBe(true); - }); - - it('should return false when success is not true', async () => { - const deps = createDeps(); - (deps.fetchJsonSafe as jest.Mock).mockResolvedValue({ success: false }); - - const result = await deleteSession(deps, 'session-1'); - - expect(result).toBe(false); - }); - }); - - describe('getSessionMessages', () => { - it('should fetch session messages', async () => { - const deps = createDeps(); - const mockData = { - messages: [{ role: 'user', text: 'Hi' }], - sessionCreatedAt: '2025-01-15', - }; - (deps.fetchApi.fetch as jest.Mock).mockResolvedValue( - createMockResponse({ - ok: true, - json: jest.fn().mockResolvedValue(mockData), - }), - ); - - const result = await getSessionMessages(deps, 'session-1'); - - expect(deps.fetchApi.fetch).toHaveBeenCalledWith( - `${baseUrl}/sessions/session-1/messages`, - undefined, - ); - expect(result.messages).toEqual(mockData.messages); - expect(result.sessionCreatedAt).toBe('2025-01-15'); - }); - - it('should return empty messages on 404', async () => { - const deps = createDeps(); - (deps.fetchApi.fetch as jest.Mock).mockResolvedValue( - createMockResponse({ ok: false, status: 404 }), - ); - - const result = await getSessionMessages(deps, 'nonexistent'); - - expect(result.messages).toEqual([]); - }); - }); - }); - - describe('Admin Sessions', () => { - describe('listAllSessions', () => { - it('should fetch all sessions', async () => { - const deps = createDeps(); - const mockSessions = [{ id: 's1', title: 'Session 1' }]; - (deps.fetchJson as jest.Mock).mockResolvedValue({ - sessions: mockSessions, - }); - - const result = await listAllSessions(deps); - - expect(deps.fetchJson).toHaveBeenCalledWith('/admin/sessions'); - expect(result).toEqual(mockSessions); - }); - }); - - describe('getAdminSessionMessages', () => { - it('should fetch session messages', async () => { - const deps = createDeps(); - const mockData = { - messages: [{ role: 'user', text: 'Hi' }], - sessionCreatedAt: '2025-01-15', - }; - (deps.fetchApi.fetch as jest.Mock).mockResolvedValue( - createMockResponse({ - ok: true, - json: jest.fn().mockResolvedValue(mockData), - }), - ); - - const result = await getAdminSessionMessages(deps, 'session-1'); - - expect(deps.fetchApi.fetch).toHaveBeenCalledWith( - `${baseUrl}/admin/sessions/session-1/messages`, - ); - expect(result.messages).toEqual(mockData.messages); - expect(result.sessionCreatedAt).toBe('2025-01-15'); - }); - - it('should return empty messages on 404', async () => { - const deps = createDeps(); - (deps.fetchApi.fetch as jest.Mock).mockResolvedValue( - createMockResponse({ ok: false, status: 404 }), - ); - - const result = await getAdminSessionMessages(deps, 'nonexistent'); - - expect(result.messages).toEqual([]); - }); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/api/sessionEndpoints.ts b/workspaces/augment/plugins/augment/src/api/sessionEndpoints.ts deleted file mode 100644 index 1c16e6392e7..00000000000 --- a/workspaces/augment/plugins/augment/src/api/sessionEndpoints.ts +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; -import { ResponseError } from '@backstage/errors'; -import type { ChatSessionSummary, SessionMessagesResponse } from '../types'; -import { jsonBody } from './fetchHelpers'; - -export interface SessionApiDeps { - fetchJson: (path: string, init?: RequestInit) => Promise; - fetchJsonSafe: ( - path: string, - fallback: T, - init?: RequestInit, - ) => Promise; - discoveryApi: DiscoveryApi; - fetchApi: FetchApi; -} - -// --------------------------------------------------------------------------- -// Chat Sessions (local DB) -// --------------------------------------------------------------------------- - -export async function listSessions( - deps: SessionApiDeps, - limit?: number, - offset?: number, - providerId?: string, - signal?: AbortSignal, -): Promise { - const params = new URLSearchParams(); - if (limit !== undefined) params.set('limit', limit.toString()); - if (offset !== undefined) params.set('offset', offset.toString()); - if (providerId) params.set('providerId', providerId); - const qs = params.toString(); - const path = qs ? `/sessions?${qs}` : '/sessions'; - const data = await deps.fetchJson<{ sessions?: ChatSessionSummary[] }>( - path, - signal ? { signal } : undefined, - ); - return data.sessions || []; -} - -export async function createSession( - deps: SessionApiDeps, - title?: string, - model?: string, - providerId?: string, - signal?: AbortSignal, -): Promise { - const data = await deps.fetchJson<{ session: ChatSessionSummary }>( - '/sessions', - { - ...jsonBody({ title, model, providerId }), - ...(signal ? { signal } : {}), - }, - ); - return data.session; -} - -export async function deleteSession( - deps: SessionApiDeps, - sessionId: string, - signal?: AbortSignal, -): Promise { - const data = await deps.fetchJsonSafe<{ success?: boolean }>( - `/sessions/${sessionId}`, - { success: false }, - { method: 'DELETE', ...(signal ? { signal } : {}) }, - ); - return data.success === true; -} - -export async function getSessionMessages( - deps: SessionApiDeps, - sessionId: string, - signal?: AbortSignal, -): Promise { - const baseUrl = await deps.discoveryApi.getBaseUrl('augment'); - const response = await deps.fetchApi.fetch( - `${baseUrl}/sessions/${sessionId}/messages`, - signal ? { signal } : undefined, - ); - if (!response.ok) { - if (response.status === 404) return { messages: [] }; - throw await ResponseError.fromResponse(response); - } - const data = await response.json(); - if (data.error) { - throw new Error(data.error); - } - return { - messages: data.messages || [], - sessionCreatedAt: data.sessionCreatedAt, - hasConversationId: data.hasConversationId ?? false, - }; -} - -// --------------------------------------------------------------------------- -// Session State (debug) -// --------------------------------------------------------------------------- - -export async function getSessionState( - deps: SessionApiDeps, - sessionId: string, - signal?: AbortSignal, -): Promise> { - return deps.fetchJsonSafe>( - `/sessions/${sessionId}/state`, - {}, - signal ? { signal } : undefined, - ); -} - -// --------------------------------------------------------------------------- -// Message Feedback -// --------------------------------------------------------------------------- - -export async function submitMessageFeedback( - deps: SessionApiDeps, - payload: { - messageId: string; - sessionId?: string; - direction: 'positive' | 'negative'; - reasons?: string[]; - comment?: string; - }, - signal?: AbortSignal, -): Promise { - const data = await deps.fetchJsonSafe<{ success?: boolean }>( - '/feedback', - { success: false }, - { - ...jsonBody(payload), - ...(signal ? { signal } : {}), - }, - ); - return data.success === true; -} - -// --------------------------------------------------------------------------- -// Admin Sessions -// --------------------------------------------------------------------------- - -export async function listAllSessions( - deps: SessionApiDeps, - limit?: number, - offset?: number, -): Promise { - const params = new URLSearchParams(); - if (limit !== undefined) params.set('limit', limit.toString()); - if (offset !== undefined) params.set('offset', offset.toString()); - const qs = params.toString(); - const path = qs ? `/admin/sessions?${qs}` : '/admin/sessions'; - const data = await deps.fetchJson<{ sessions?: ChatSessionSummary[] }>(path); - return data.sessions || []; -} - -export async function getAdminSessionMessages( - deps: SessionApiDeps, - sessionId: string, -): Promise { - const baseUrl = await deps.discoveryApi.getBaseUrl('augment'); - const response = await deps.fetchApi.fetch( - `${baseUrl}/admin/sessions/${sessionId}/messages`, - ); - if (!response.ok) { - if (response.status === 404) return { messages: [] }; - throw await ResponseError.fromResponse(response); - } - const data = await response.json(); - if (data.error) { - throw new Error(data.error); - } - return { - messages: data.messages || [], - sessionCreatedAt: data.sessionCreatedAt, - hasConversationId: data.hasConversationId ?? false, - }; -} diff --git a/workspaces/augment/plugins/augment/src/api/sseStreaming.ts b/workspaces/augment/plugins/augment/src/api/sseStreaming.ts deleted file mode 100644 index bb9494e4b20..00000000000 --- a/workspaces/augment/plugins/augment/src/api/sseStreaming.ts +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { StreamingEvent, StreamingEventCallback } from '../types'; -import { debugError } from '../utils'; - -function isValidStreamEvent(event: unknown): event is StreamingEvent { - return ( - typeof event === 'object' && - event !== null && - typeof (event as Record).type === 'string' - ); -} - -/** - * Parse SSE events from a ReadableStream and invoke the callback for each event. - * Handles line buffering, "data: " prefix, [DONE] sentinel, and JSON parsing. - * - * @param reader - The stream reader (from response.body.getReader()) - * @param onEvent - Callback for each parsed streaming event - * @param signal - Optional AbortSignal for cancellation - */ -const MAX_BUFFER_SIZE = 1024 * 1024; // 1 MB - -export async function parseSSEStream( - reader: ReadableStreamDefaultReader, - onEvent: StreamingEventCallback, - signal?: AbortSignal, -): Promise { - const decoder = new TextDecoder(); - let buffer = ''; - - try { - let reading = true; - while (reading) { - if (signal?.aborted) { - await reader.cancel(); - break; - } - - const { done, value } = await reader.read(); - if (done) { - reading = false; - continue; - } - - if (signal?.aborted) { - await reader.cancel(); - break; - } - - buffer += decoder.decode(value, { stream: true }); - - if (buffer.length > MAX_BUFFER_SIZE) { - await reader.cancel(); - onEvent({ - type: 'stream.error', - error: 'Stream buffer exceeded maximum size', - code: 'buffer_overflow', - } as StreamingEvent); - return; - } - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const rawLine of lines) { - if (signal?.aborted) break; - const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; - if (line.startsWith('data: ')) { - const data = line.slice(6).trim(); - if (data && data !== '[DONE]') { - try { - const parsed: unknown = JSON.parse(data); - if (isValidStreamEvent(parsed)) { - try { - onEvent(parsed); - } catch (handlerErr) { - debugError('SSE onEvent handler threw:', handlerErr); - } - } else { - debugError( - 'SSE event missing required "type" field:', - data.slice(0, 120), - ); - } - } catch (parseErr) { - debugError('Malformed SSE event:', parseErr, data.slice(0, 120)); - } - } - } - } - } - - const tail = buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer; - if (!signal?.aborted && tail.startsWith('data: ')) { - const data = tail.slice(6).trim(); - if (data && data !== '[DONE]') { - try { - const parsed: unknown = JSON.parse(data); - if (isValidStreamEvent(parsed)) { - try { - onEvent(parsed); - } catch (handlerErr) { - debugError( - 'SSE onEvent handler threw (buffer tail):', - handlerErr, - ); - } - } else { - debugError( - 'SSE event missing required "type" field (buffer tail):', - data.slice(0, 120), - ); - } - } catch (parseErr) { - debugError( - 'Malformed SSE event (buffer tail):', - parseErr, - data.slice(0, 120), - ); - } - } - } - } finally { - reader.releaseLock(); - } -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentRegistryPanel/AgentRegistryPanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentRegistryPanel/AgentRegistryPanel.tsx deleted file mode 100644 index 44c5b35b5f2..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentRegistryPanel/AgentRegistryPanel.tsx +++ /dev/null @@ -1,2800 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -/* eslint-disable @typescript-eslint/no-use-before-define, no-nested-ternary, react/forbid-elements */ - -import { - useState, - useCallback, - useEffect, - useMemo, - useRef, - type FC, -} from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Chip from '@mui/material/Chip'; -import IconButton from '@mui/material/IconButton'; -import Alert from '@mui/material/Alert'; -import Snackbar from '@mui/material/Snackbar'; -import CircularProgress from '@mui/material/CircularProgress'; -import Collapse from '@mui/material/Collapse'; -import Tooltip from '@mui/material/Tooltip'; -import InputAdornment from '@mui/material/InputAdornment'; -import Checkbox from '@mui/material/Checkbox'; -import FormControl from '@mui/material/FormControl'; -import Select from '@mui/material/Select'; -import MenuItem from '@mui/material/MenuItem'; -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import DialogContent from '@mui/material/DialogContent'; -import DialogActions from '@mui/material/DialogActions'; -import Divider from '@mui/material/Divider'; -import { useTheme, alpha } from '@mui/material/styles'; -import SearchIcon from '@mui/icons-material/Search'; -import StarIcon from '@mui/icons-material/Star'; -import StarBorderIcon from '@mui/icons-material/StarBorder'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import CloudOffIcon from '@mui/icons-material/CloudOff'; -import StorefrontIcon from '@mui/icons-material/Storefront'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import SaveIcon from '@mui/icons-material/Save'; -import SmartToyIcon from '@mui/icons-material/SmartToy'; -import HubIcon from '@mui/icons-material/Hub'; -import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'; -import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import HistoryIcon from '@mui/icons-material/History'; -import PersonIcon from '@mui/icons-material/Person'; -import VerifiedIcon from '@mui/icons-material/Verified'; -import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; -import EditNoteIcon from '@mui/icons-material/EditNote'; -import LinkIcon from '@mui/icons-material/Link'; -import LinkOffIcon from '@mui/icons-material/LinkOff'; -import StreamIcon from '@mui/icons-material/Stream'; -import DnsIcon from '@mui/icons-material/Dns'; -import PsychologyIcon from '@mui/icons-material/Psychology'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import { useApi } from '@backstage/core-plugin-api'; -import { augmentApiRef } from '../../../api'; -import type { - ChatAgent, - ChatAgentConfig, - AgentLifecycleStage, - KagentiAgentCard, - KagentiAgentDetail, - KagentiRouteStatus, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { - LIFECYCLE_STAGE_ORDER, - getAvailableTransitions, - normalizeLifecycleStage, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { useChatAgentConfig } from '../../../hooks/useChatAgentConfig'; -import { getFrameworkLabel } from '../../Marketplace/marketplace.constants'; - -const MAX_FEATURED = 4; -const PAGE_SIZE = 20; - -type SourceFilter = 'all' | 'kagenti' | 'orchestration' | 'skills'; -type StageFilter = 'all' | 'unregistered' | AgentLifecycleStage; - -interface RegistryRow { - agent: ChatAgent; - config: ChatAgentConfig; - /** True when a chatAgents entry exists (agent is in governance). */ - governanceRegistered: boolean; - dirty: boolean; -} - -interface EnrichmentData { - agentCard?: KagentiAgentCard; - routeStatus?: KagentiRouteStatus; - detail?: KagentiAgentDetail; - error?: string; -} - -function parseKagentiId( - agentId: string, -): { namespace: string; name: string } | null { - const slash = agentId.indexOf('/'); - if (slash <= 0 || slash === agentId.length - 1) return null; - return { namespace: agentId.slice(0, slash), name: agentId.slice(slash + 1) }; -} - -const LIFECYCLE_STAGES: { - key: AgentLifecycleStage; - label: string; - description: string; -}[] = [ - { key: 'draft', label: 'Draft', description: 'Under development' }, - { - key: 'pending', - label: 'Pending', - description: 'Submitted for admin review', - }, - { - key: 'published', - label: 'Published', - description: 'Live in end-user catalog', - }, - { key: 'archived', label: 'Archived', description: 'Archived from catalog' }, -]; - -function getStatusColor(status: string): string { - const s = status?.toLowerCase(); - if (s === 'ready' || s === 'running') return '#22c55e'; - if (s === 'pending' || s === 'building') return '#f59e0b'; - if (s === 'config') return '#8b5cf6'; - if (s === 'error' || s === 'failed') return '#ef4444'; - return '#94a3b8'; -} - -function getStageColor(stage: AgentLifecycleStage, isDark: boolean): string { - switch (stage) { - case 'published': - return isDark ? '#86efac' : '#15803d'; - case 'pending': - return isDark ? '#93c5fd' : '#1d4ed8'; - case 'archived': - return isDark ? '#f87171' : '#b91c1c'; - default: - return isDark ? '#94a3b8' : '#64748b'; - } -} - -function getStageIcon(stage: AgentLifecycleStage) { - switch (stage) { - case 'published': - return ; - case 'pending': - return ; - case 'archived': - return ; - default: - return ; - } -} - -function getStageBg(stage: AgentLifecycleStage, isDark: boolean): string { - switch (stage) { - case 'published': - return isDark ? 'rgba(34,197,94,0.12)' : 'rgba(34,197,94,0.08)'; - case 'pending': - return isDark ? 'rgba(37,99,235,0.12)' : 'rgba(37,99,235,0.08)'; - case 'archived': - return isDark ? 'rgba(248,113,113,0.12)' : 'rgba(248,113,113,0.08)'; - default: - return isDark ? 'rgba(148,163,184,0.1)' : 'rgba(148,163,184,0.07)'; - } -} - -function getSourceLabel(source?: string): string { - if (source === 'orchestration') return 'Workflow Builder'; - if (source === 'skills') return 'Skill Agent'; - return 'BYO Agent'; -} - -function getSourceColor(source?: string, isDark = false): string { - if (source === 'orchestration') return isDark ? '#a78bfa' : '#7c3aed'; - if (source === 'skills') return isDark ? '#2dd4bf' : '#009596'; - return isDark ? '#60a5fa' : '#2563eb'; -} - -function getAvatarColor(name: string): string { - const colors = [ - '#2563eb', - '#7c3aed', - '#059669', - '#d97706', - '#dc2626', - '#0891b2', - '#4f46e5', - '#b91c1c', - ]; - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - } - return colors[Math.abs(hash) % colors.length]; -} - -function timeAgo(dateStr?: string): string { - if (!dateStr) return ''; - const diff = Date.now() - new Date(dateStr).getTime(); - if (diff < 0) return 'just now'; - const mins = Math.floor(diff / 60_000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; -} - -interface AgentRegistryPanelProps { - initialStageFilter?: StageFilter; -} - -export const AgentRegistryPanel: FC = ({ - initialStageFilter, -}) => { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const api = useApi(augmentApiRef); - const { - configs, - loading: configLoading, - saving, - save: saveConfigs, - refresh: refreshConfigs, - } = useChatAgentConfig(); - - const [agents, setAgents] = useState([]); - const [agentsLoading, setAgentsLoading] = useState(true); - const [approvalMode, setApprovalMode] = useState('built-in'); - const [rows, setRows] = useState([]); - const [expandedId, setExpandedId] = useState(null); - const [toast, setToast] = useState(null); - const [dirty, setDirty] = useState(false); - const [search, setSearch] = useState(''); - const [sourceFilter, setSourceFilter] = useState('all'); - const [stageFilter, setStageFilter] = useState( - initialStageFilter ?? 'all', - ); - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [promoting, setPromoting] = useState(null); - const [registering, setRegistering] = useState(null); - const [promoteDialog, setPromoteDialog] = useState<{ - agentId: string; - action: 'promote' | 'demote'; - fromStage: AgentLifecycleStage; - toStage: AgentLifecycleStage; - } | null>(null); - const [page, setPage] = useState(0); - useEffect(() => { - setPage(0); - }, [search, stageFilter, sourceFilter]); - const enrichmentCacheRef = useRef>(new Map()); - const [enrichmentLoading, setEnrichmentLoading] = useState>( - new Set(), - ); - const [, setEnrichmentVersion] = useState(0); - - const fetchEnrichment = useCallback( - async (agentId: string) => { - if (enrichmentCacheRef.current.has(agentId)) return; - const parsed = parseKagentiId(agentId); - if (!parsed) return; - - setEnrichmentLoading(prev => new Set(prev).add(agentId)); - try { - const [detailResult, routeResult] = await Promise.allSettled([ - api.getKagentiAgent(parsed.namespace, parsed.name), - api.getKagentiAgentRouteStatus(parsed.namespace, parsed.name), - ]); - - const data: EnrichmentData = {}; - if (detailResult.status === 'fulfilled') { - const { agentCard, ...detail } = - detailResult.value as KagentiAgentDetail & { - agentCard?: KagentiAgentCard; - }; - data.detail = detail; - data.agentCard = agentCard; - } - if (routeResult.status === 'fulfilled') { - data.routeStatus = routeResult.value; - } - if ( - detailResult.status === 'rejected' && - routeResult.status === 'rejected' - ) { - data.error = 'Failed to fetch runtime info'; - } - enrichmentCacheRef.current.set(agentId, data); - } catch { - enrichmentCacheRef.current.set(agentId, { - error: 'Failed to fetch runtime info', - }); - } finally { - setEnrichmentLoading(prev => { - const next = new Set(prev); - next.delete(agentId); - return next; - }); - setEnrichmentVersion(v => v + 1); - } - }, - [api], - ); - - useEffect(() => { - if (!expandedId) return; - const row = rows.find(r => r.agent.id === expandedId); - if (row?.agent.source === 'kagenti') { - fetchEnrichment(expandedId); - } - }, [expandedId, rows, fetchEnrichment]); - - const fetchAgents = useCallback(async () => { - try { - setAgentsLoading(true); - const timeout = new Promise((_, reject) => - setTimeout( - () => - reject( - new Error('Request timed out — the backend may be unreachable'), - ), - 15_000, - ), - ); - const [result, status] = await Promise.race([ - Promise.all([api.listAgents(), api.getStatus()]), - timeout.then(() => { - throw new Error('Request timed out'); - }), - ]); - setAgents(result); - setApprovalMode( - ((status as unknown as Record) - .approvalMode as string) ?? 'built-in', - ); - } catch (err) { - setToast( - `Failed to load agents: ${err instanceof Error ? err.message : 'Unknown error'}`, - ); - } finally { - setAgentsLoading(false); - } - }, [api]); - - useEffect(() => { - fetchAgents(); - }, [fetchAgents]); - - useEffect(() => { - if (agentsLoading || configLoading) return; - - const configMap = new Map(configs.map(c => [c.agentId, c])); - const newRows: RegistryRow[] = agents.map(agent => { - const existing = configMap.get(agent.id); - const governanceRegistered = - agent.governanceRegistered ?? existing !== undefined; - const stage = governanceRegistered - ? normalizeLifecycleStage( - existing?.lifecycleStage ?? agent.lifecycleStage, - ) - : 'draft'; - return { - agent: { - ...agent, - lifecycleStage: stage, - governanceRegistered, - }, - config: existing - ? { ...existing, lifecycleStage: stage } - : { - agentId: agent.id, - published: false, - visible: false, - featured: false, - lifecycleStage: 'draft', - }, - governanceRegistered, - dirty: false, - }; - }); - - const stageRank: Record = { - published: 0, - production: 0, // legacy compat - deployed: 0, // legacy compat - pending: 1, - review: 1, // legacy compat - staging: 1, // legacy compat - registered: 1, // legacy compat - draft: 2, - archived: 3, - retired: 3, // legacy compat - }; - newRows.sort((a, b) => { - const sa = stageRank[a.config.lifecycleStage ?? 'draft'] ?? 5; - const sb = stageRank[b.config.lifecycleStage ?? 'draft'] ?? 5; - if (sa !== sb) return sa - sb; - if (a.config.featured !== b.config.featured) - return a.config.featured ? -1 : 1; - return (a.agent.name || '').localeCompare(b.agent.name || ''); - }); - - setRows(newRows); - setPage(0); - }, [agents, configs, agentsLoading, configLoading]); - - const filteredRows = useMemo(() => { - return rows.filter(r => { - if (search) { - const q = search.toLowerCase(); - const matchesName = r.agent.name?.toLowerCase().includes(q); - const matchesDesc = r.agent.description?.toLowerCase().includes(q); - const matchesId = r.agent.id.toLowerCase().includes(q); - if (!matchesName && !matchesDesc && !matchesId) return false; - } - if (sourceFilter !== 'all') { - const src = r.agent.source || 'kagenti'; - if (sourceFilter !== src) return false; - } - if (stageFilter === 'unregistered') { - if (r.governanceRegistered) return false; - } else if (stageFilter !== 'all') { - if (!r.governanceRegistered) return false; - const stage = r.config.lifecycleStage ?? 'draft'; - if (stageFilter !== stage) return false; - } - return true; - }); - }, [rows, search, sourceFilter, stageFilter]); - - const handleRegisterForGovernance = useCallback( - async (agentId: string) => { - setRegistering(agentId); - try { - const result = await api.registerAgentForGovernance(agentId); - await refreshConfigs(); - await fetchAgents(); - setToast(`Registered ${result.agentId} for governance (draft)`); - } catch (err) { - setToast( - `Registration failed: ${err instanceof Error ? err.message : 'Unknown error'}`, - ); - } finally { - setRegistering(null); - } - }, - [api, refreshConfigs, fetchAgents], - ); - - const [deleteAgentId, setDeleteAgentId] = useState(null); - const [deleting, setDeleting] = useState(null); - - const handleDeleteAgent = useCallback( - async (agentId: string) => { - setDeleting(agentId); - setDeleteAgentId(null); - try { - await api.deleteAgentConfig(agentId); - await refreshConfigs(); - await fetchAgents(); - setToast(`Deleted: ${agentId}`); - } catch (err) { - setToast( - `Delete failed: ${err instanceof Error ? err.message : 'Unknown'}`, - ); - } finally { - setDeleting(null); - } - }, - [api, refreshConfigs, fetchAgents], - ); - - const stats = useMemo(() => { - const unregistered = rows.filter(r => !r.governanceRegistered).length; - const draft = rows.filter( - r => - r.governanceRegistered && - (r.config.lifecycleStage ?? 'draft') === 'draft', - ).length; - const pending = rows.filter( - r => r.governanceRegistered && r.config.lifecycleStage === 'pending', - ).length; - const published = rows.filter( - r => r.governanceRegistered && r.config.lifecycleStage === 'published', - ).length; - const archived = rows.filter( - r => r.governanceRegistered && r.config.lifecycleStage === 'archived', - ).length; - const kagenti = rows.filter( - r => (r.agent.source || 'kagenti') === 'kagenti', - ).length; - const orchestration = rows.filter( - r => r.agent.source === 'orchestration', - ).length; - const featured = rows.filter(r => r.config.featured).length; - return { - total: rows.length, - unregistered, - draft, - pending, - published, - archived, - kagenti, - orchestration, - featured, - }; - }, [rows]); - - const updateRowConfig = useCallback( - (agentId: string, patch: Partial) => { - setRows(prev => - prev.map(r => - r.agent.id === agentId - ? { ...r, config: { ...r.config, ...patch }, dirty: true } - : r, - ), - ); - setDirty(true); - }, - [], - ); - - const handlePromote = useCallback( - async (agentId: string, targetStage: AgentLifecycleStage) => { - setPromoting(agentId); - try { - const result = await api.promoteAgent(agentId, targetStage); - updateRowConfig(agentId, { - lifecycleStage: result.lifecycleStage as AgentLifecycleStage, - published: result.lifecycleStage === 'published', - visible: result.lifecycleStage === 'published', - version: result.version, - promotedAt: new Date().toISOString(), - }); - await refreshConfigs(); - await fetchAgents(); - setToast( - `Agent promoted to ${result.lifecycleStage} (v${result.version})`, - ); - } catch (err) { - setToast( - `Promotion failed: ${err instanceof Error ? err.message : 'Unknown error'}`, - ); - } finally { - setPromoting(null); - setPromoteDialog(null); - } - }, - [api, updateRowConfig, refreshConfigs, fetchAgents], - ); - - const handleDemote = useCallback( - async (agentId: string, targetStage: AgentLifecycleStage) => { - setPromoting(agentId); - try { - const result = await api.demoteAgent(agentId, targetStage); - updateRowConfig(agentId, { - lifecycleStage: result.lifecycleStage as AgentLifecycleStage, - published: result.lifecycleStage === 'published', - visible: result.lifecycleStage === 'published', - featured: result.lifecycleStage === 'published' ? undefined : false, - }); - await refreshConfigs(); - await fetchAgents(); - setToast(`Agent moved to ${result.lifecycleStage}`); - } catch (err) { - setToast( - `Failed: ${err instanceof Error ? err.message : 'Unknown error'}`, - ); - } finally { - setPromoting(null); - setPromoteDialog(null); - } - }, - [api, updateRowConfig, refreshConfigs, fetchAgents], - ); - - const handleBulkPublish = useCallback( - async (publish: boolean) => { - const ids = Array.from(selectedIds).filter(id => { - const row = rows.find(r => r.agent.id === id); - return row?.governanceRegistered; - }); - const skipped = selectedIds.size - ids.length; - if (ids.length === 0) { - setToast( - skipped > 0 - ? 'Selected agents must be registered for governance first' - : 'No agents selected', - ); - return; - } - setPromoting('bulk'); - try { - await api.bulkPublishAgents(ids, publish); - for (const id of ids) { - updateRowConfig(id, { - lifecycleStage: publish ? 'published' : 'pending', - published: publish, - visible: publish, - }); - } - await refreshConfigs(); - await fetchAgents(); - setSelectedIds(new Set()); - const suffix = - skipped > 0 ? ` (${skipped} skipped — not registered)` : ''; - setToast( - `${ids.length} agents ${publish ? 'promoted to published' : 'rolled back to pending'}${suffix}`, - ); - } catch (err) { - setToast( - `Failed: ${err instanceof Error ? err.message : 'Unknown error'}`, - ); - } finally { - setPromoting(null); - } - }, - [selectedIds, rows, api, updateRowConfig, refreshConfigs, fetchAgents], - ); - - const handleToggleFeatured = useCallback( - (agentId: string, featured: boolean) => { - if (featured && stats.featured >= MAX_FEATURED) { - setToast(`Maximum ${MAX_FEATURED} featured agents`); - return; - } - updateRowConfig(agentId, { featured }); - }, - [updateRowConfig, stats.featured], - ); - - const handleSaveAll = useCallback(async () => { - const allConfigs: ChatAgentConfig[] = rows - .filter( - r => - r.dirty || - r.config.published || - r.config.featured || - r.config.displayName || - r.config.description || - r.config.avatarUrl || - r.config.accentColor || - r.config.greeting || - (r.config.conversationStarters && - r.config.conversationStarters.length > 0), - ) - .map(r => ({ - ...r.config, - conversationStarters: (r.config.conversationStarters || []).filter( - s => s.trim() !== '', - ), - })); - try { - await saveConfigs(allConfigs); - setDirty(false); - setRows(prev => prev.map(r => ({ ...r, dirty: false }))); - setToast('Registry saved'); - } catch { - setToast('Failed to save'); - } - }, [rows, saveConfigs]); - - const handleSelectToggle = useCallback((agentId: string) => { - setSelectedIds(prev => { - const next = new Set(prev); - if (next.has(agentId)) next.delete(agentId); - else next.add(agentId); - return next; - }); - }, []); - - const handleSelectAll = useCallback(() => { - if (selectedIds.size === filteredRows.length) { - setSelectedIds(new Set()); - } else { - setSelectedIds(new Set(filteredRows.map(r => r.agent.id))); - } - }, [selectedIds.size, filteredRows]); - - if (agentsLoading || configLoading) { - return ( - - - - Loading agents... - - - ); - } - - return ( - - {/* ── Header (fixed) ── */} - - - - - - - - - Agent Registry - - - Agent lifecycle management - - - - - - { - fetchAgents(); - refreshConfigs(); - }} - sx={{ - width: 32, - height: 32, - border: `1px solid ${theme.palette.divider}`, - borderRadius: 1.5, - }} - > - - - - - - - - - {/* ── Lifecycle Pipeline (fixed) ── */} - - {stats.unregistered > 0 && ( - setStageFilter('unregistered')} - sx={{ textTransform: 'none', fontWeight: 600 }} - > - Show {stats.unregistered} - - } - > - {stats.unregistered} agent{stats.unregistered === 1 ? '' : 's'}{' '} - discovered from runtime. Register to manage{' '} - {stats.unregistered === 1 ? 'its' : 'their'} lifecycle. - - )} - - - - {/* ── Filters + Count (sticky) ── */} - - setSearch(e.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - }} - sx={{ - minWidth: 200, - '& .MuiOutlinedInput-root': { - fontSize: '0.8rem', - borderRadius: 1.5, - height: 32, - }, - }} - /> - - - - - {selectedIds.size > 0 && - (() => { - const selectedRows = [...selectedIds] - .map(id => rows.find(r => r.agent.id === id)) - .filter(Boolean); - const allPending = selectedRows.every( - r => r!.config.lifecycleStage === 'pending', - ); - const allPublished = selectedRows.every( - r => r!.config.lifecycleStage === 'published', - ); - const publishDisabled = - promoting === 'bulk' || - approvalMode === 'workflow' || - !allPending; - const unpublishDisabled = - promoting === 'bulk' || - approvalMode === 'workflow' || - !allPublished; - const publishTip = - approvalMode === 'workflow' - ? 'Bulk actions disabled when approval workflow is active.' - : !allPending - ? 'Select only Pending agents to publish.' - : ''; - const unpublishTip = - approvalMode === 'workflow' - ? 'Bulk actions disabled when approval workflow is active.' - : !allPublished - ? 'Select only Published agents to unpublish.' - : ''; - return ( - - - - - - - - - - - - - - ); - })()} - - - {/* ── Select All bar ── */} - {filteredRows.length > 0 && ( - - 0 - } - indeterminate={ - selectedIds.size > 0 && selectedIds.size < filteredRows.length - } - onChange={handleSelectAll} - sx={{ p: 0.25 }} - /> - - Select all ({filteredRows.length}) - - - )} - - {/* ── Agent List (scrollable with pagination) ── */} - - {filteredRows.length === 0 ? ( - - {rows.length === 0 - ? 'No agents found. Deploy agents via the Agents panel or configure orchestration to get started.' - : 'No agents match the current filters.'} - - ) : ( - - {filteredRows - .slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) - .map(row => ( - - setExpandedId( - expandedId === row.agent.id ? null : row.agent.id, - ) - } - onToggleSelect={() => handleSelectToggle(row.agent.id)} - onRequestPromote={(action, from, to) => - setPromoteDialog({ - agentId: row.agent.id, - action, - fromStage: from, - toStage: to, - }) - } - onRegisterForGovernance={() => - handleRegisterForGovernance(row.agent.id) - } - onToggleFeatured={handleToggleFeatured} - onUpdateConfig={updateRowConfig} - onDeleteAgent={id => setDeleteAgentId(id)} - isDeleting={deleting === row.agent.id} - /> - ))} - - )} - - {/* Pagination */} - {filteredRows.length > PAGE_SIZE && ( - - - - {page * PAGE_SIZE + 1}– - {Math.min((page + 1) * PAGE_SIZE, filteredRows.length)} of{' '} - {filteredRows.length} - - - - )} - - - {/* Promotion Confirmation Dialog */} - { - if (!promoteDialog) return; - if (promoteDialog.action === 'promote') { - handlePromote(promoteDialog.agentId, promoteDialog.toStage); - } else { - handleDemote(promoteDialog.agentId, promoteDialog.toStage); - } - }} - onCancel={() => setPromoteDialog(null)} - agentName={ - promoteDialog - ? (rows.find(r => r.agent.id === promoteDialog.agentId)?.agent - .name ?? promoteDialog.agentId) - : '' - } - isDark={isDark} - /> - - {/* Delete confirm dialog */} -

    setDeleteAgentId(null)} - maxWidth="xs" - fullWidth - > - Delete Agent - - - Permanently delete {deleteAgentId}? This action - cannot be undone. - - - - - - - - - setToast(null)} - message={toast} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> - - ); -}; - -// --------------------------------------------------------------------------- -// Lifecycle Pipeline Visualization -// --------------------------------------------------------------------------- - -interface LifecyclePipelineProps { - stats: { - total: number; - unregistered: number; - draft: number; - pending: number; - published: number; - archived: number; - featured: number; - }; - isDark: boolean; - onFilterStage: (stage: StageFilter) => void; - activeFilter: StageFilter; -} - -const LifecyclePipeline: FC = ({ - stats, - isDark, - onFilterStage, - activeFilter, -}) => { - const theme = useTheme(); - - const stages = [ - ...(stats.unregistered > 0 - ? [ - { - key: 'unregistered' as StageFilter, - label: 'Unregistered', - count: stats.unregistered, - icon: , - color: isDark ? '#fb923c' : '#c2410c', - }, - ] - : []), - { - key: 'draft' as StageFilter, - label: 'Draft', - count: stats.draft, - icon: , - color: isDark ? '#94a3b8' : '#64748b', - }, - { - key: 'pending' as StageFilter, - label: 'Pending', - count: stats.pending, - icon: , - color: isDark ? '#fbbf24' : '#b45309', - }, - { - key: 'published' as StageFilter, - label: 'Published', - count: stats.published, - icon: , - color: isDark ? '#86efac' : '#15803d', - }, - { - key: 'archived' as StageFilter, - label: 'Archived', - count: stats.archived, - icon: , - color: isDark ? '#f87171' : '#b91c1c', - }, - ]; - - return ( - - - {stages.map((stage, i) => { - const isActive = activeFilter === stage.key; - return ( - - onFilterStage(isActive ? 'all' : stage.key)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onFilterStage(isActive ? 'all' : stage.key); - } - }} - sx={{ - flex: 1, - display: 'flex', - alignItems: 'center', - gap: 1.25, - px: 1.75, - py: 1.25, - borderRadius: 2, - cursor: 'pointer', - position: 'relative', - overflow: 'hidden', - border: `1px solid ${isActive ? alpha(stage.color, 0.5) : theme.palette.divider}`, - bgcolor: isActive - ? alpha(stage.color, isDark ? 0.12 : 0.06) - : isDark - ? alpha(theme.palette.background.paper, 0.4) - : theme.palette.background.paper, - boxShadow: isActive - ? `0 0 0 1px ${alpha(stage.color, 0.15)}` - : isDark - ? 'none' - : '0 1px 2px rgba(0,0,0,0.04)', - transition: 'all 0.2s cubic-bezier(0.4,0,0.2,1)', - '&:hover': { - bgcolor: alpha(stage.color, isDark ? 0.1 : 0.05), - borderColor: alpha(stage.color, 0.35), - transform: 'translateY(-1px)', - boxShadow: `0 2px 8px ${alpha(stage.color, 0.12)}`, - }, - '&::before': isActive - ? { - content: '""', - position: 'absolute', - top: 0, - left: 0, - right: 0, - height: 2, - background: `linear-gradient(90deg, ${stage.color}, ${alpha(stage.color, 0.3)})`, - borderRadius: '2px 2px 0 0', - } - : {}, - }} - > - - {stage.icon} - - - - {stage.label} - - - {stage.count} - - - - {i < stages.length - 1 && ( - - )} - - ); - })} - - - - } - label={`${stats.featured}/${MAX_FEATURED} Featured`} - sx={{ - height: 20, - fontWeight: 600, - fontSize: '0.65rem', - bgcolor: alpha('#f59e0b', isDark ? 0.15 : 0.1), - color: isDark ? '#fcd34d' : '#b45309', - }} - /> - {activeFilter !== 'all' && ( - onFilterStage('all')} - sx={{ height: 20, fontWeight: 600, fontSize: '0.65rem' }} - /> - )} - - - ); -}; - -// --------------------------------------------------------------------------- -// Promotion Confirmation Dialog -// --------------------------------------------------------------------------- - -interface PromoteConfirmDialogProps { - dialog: { - agentId: string; - action: 'promote' | 'demote'; - fromStage: AgentLifecycleStage; - toStage: AgentLifecycleStage; - } | null; - isPromoting: boolean; - onConfirm: () => void; - onCancel: () => void; - agentName: string; - isDark: boolean; -} - -const PromoteConfirmDialog: FC = ({ - dialog, - isPromoting, - onConfirm, - onCancel, - agentName, - isDark, -}) => { - if (!dialog) return null; - - const isPromoteAction = dialog.action === 'promote'; - const fromLabel = - LIFECYCLE_STAGES.find(s => s.key === dialog.fromStage)?.label ?? - dialog.fromStage; - const toLabel = - LIFECYCLE_STAGES.find(s => s.key === dialog.toStage)?.label ?? - dialog.toStage; - const toColor = getStageColor(dialog.toStage, isDark); - - return ( - - - {isPromoteAction ? 'Promote Agent' : 'Withdraw Agent'} - - - - - - - - - {dialog.toStage === 'pending' && isPromoteAction && ( - <> - Submit {agentName} for admin review. It will - appear in the Review Queue. - - )} - {dialog.toStage === 'pending' && !isPromoteAction && ( - <> - Roll back {agentName} from published to pending. - It will no longer be visible to end users. - - )} - {dialog.toStage === 'published' && ( - <> - Promote {agentName} to published. Users will be - able to discover and chat with this agent. - - )} - {dialog.toStage === 'archived' && ( - <> - Archive {agentName}. It will be archived and no - longer available to users. - - )} - {dialog.toStage === 'draft' && ( - <> - Move {agentName} back to draft. This resets its - lifecycle for further development. - - )} - - - - - - - - ); -}; - -// --------------------------------------------------------------------------- -// AgentRow sub-component -// --------------------------------------------------------------------------- - -interface AgentRowProps { - row: RegistryRow; - isDark: boolean; - isExpanded: boolean; - isSelected: boolean; - isPromoting: boolean; - isRegistering: boolean; - featuredCount: number; - enrichment?: EnrichmentData; - enrichmentLoading?: boolean; - onToggleExpand: () => void; - onToggleSelect: () => void; - onRequestPromote: ( - action: 'promote' | 'demote', - from: AgentLifecycleStage, - to: AgentLifecycleStage, - ) => void; - onRegisterForGovernance: () => void; - onToggleFeatured: (agentId: string, featured: boolean) => void; - onUpdateConfig: (agentId: string, patch: Partial) => void; - onDeleteAgent?: (agentId: string) => void; - isDeleting?: boolean; -} - -const AgentRow: FC = ({ - row, - isDark, - isExpanded, - isSelected, - isPromoting, - isRegistering, - enrichment, - enrichmentLoading, - onToggleExpand, - onToggleSelect, - onRequestPromote, - onRegisterForGovernance, - onToggleFeatured, - onDeleteAgent, - isDeleting, -}) => { - const theme = useTheme(); - const { agent, config, governanceRegistered } = row; - const stage = normalizeLifecycleStage(config.lifecycleStage); - const displayName = config.displayName || agent.name || agent.id; - const avatarColor = config.accentColor || getAvatarColor(displayName); - const sourceColor = getSourceColor(agent.source, isDark); - const statusColor = getStatusColor(agent.status); - const stageColor = getStageColor(stage, isDark); - const transitions = getAvailableTransitions(stage); - const forwardTransitions = transitions.filter( - t => - LIFECYCLE_STAGE_ORDER.indexOf(t.to) > - LIFECYCLE_STAGE_ORDER.indexOf(t.from), - ); - const backwardTransitions = transitions.filter( - t => - LIFECYCLE_STAGE_ORDER.indexOf(t.to) < - LIFECYCLE_STAGE_ORDER.indexOf(t.from), - ); - - return ( - - - - - {/* Avatar */} - - {displayName.charAt(0).toUpperCase()} - - - {/* Name + meta */} - - - - {displayName} - - {!governanceRegistered ? ( - } - sx={{ - height: 18, - fontSize: '0.6rem', - fontWeight: 600, - bgcolor: alpha( - isDark ? '#fb923c' : '#c2410c', - isDark ? 0.2 : 0.1, - ), - color: isDark ? '#fdba74' : '#c2410c', - '& .MuiChip-icon': { - color: isDark ? '#fdba74' : '#c2410c', - ml: '3px', - }, - '& .MuiChip-label': { px: '5px' }, - }} - /> - ) : ( - s.key === stage)?.label ?? stage - } - icon={getStageIcon(stage)} - sx={{ - height: 18, - fontSize: '0.6rem', - fontWeight: 600, - bgcolor: getStageBg(stage, isDark), - color: stageColor, - '& .MuiChip-icon': { color: stageColor, ml: '3px' }, - '& .MuiChip-label': { px: '5px' }, - }} - /> - )} - - ) : ( - - ) - } - sx={{ - height: 18, - fontSize: '0.6rem', - fontWeight: 600, - bgcolor: alpha(sourceColor, isDark ? 0.12 : 0.08), - color: sourceColor, - '& .MuiChip-icon': { color: sourceColor, ml: '3px' }, - '& .MuiChip-label': { px: '5px' }, - }} - /> - - - - {enrichment?.routeStatus && ( - - {enrichment.routeStatus.hasRoute ? ( - - ) : ( - - )} - - )} - {(agent.version ?? 0) > 0 && ( - - v{agent.version} - - )} - - - - {agent.id} - {agent.framework - ? ` · ${getFrameworkLabel(agent.framework)}` - : ''} - - {agent.promotedAt && ( - - - {timeAgo(agent.promotedAt)} - - )} - {agent.promotedBy && ( - - - {agent.promotedBy.replace(/^user:default\//, '')} - - )} - - - - {/* Promotion Actions */} - - {isPromoting || isRegistering ? ( - - ) : !governanceRegistered ? ( - - ) : ( - <> - {backwardTransitions.length > 0 && ( - - - onRequestPromote( - 'demote', - stage, - backwardTransitions[0].to, - ) - } - sx={{ - color: 'text.disabled', - width: 26, - height: 26, - '&:hover': { color: 'text.secondary' }, - }} - > - - - - )} - {stage === 'pending' ? ( - - ) : forwardTransitions.length > 0 ? ( - - - - ) : null} - {stage === 'published' && ( - - } - label="Live" - sx={{ - height: 22, - fontWeight: 600, - fontSize: '0.65rem', - bgcolor: alpha('#22c55e', isDark ? 0.15 : 0.1), - color: isDark ? '#86efac' : '#15803d', - '& .MuiChip-icon': { - color: isDark ? '#86efac' : '#15803d', - }, - border: `1px solid ${alpha('#22c55e', isDark ? 0.2 : 0.15)}`, - }} - /> - )} - - )} - - - {/* Featured star */} - - - onToggleFeatured(agent.id, !config.featured)} - sx={{ - width: 26, - height: 26, - color: config.featured ? '#f59e0b' : 'text.disabled', - transition: 'color 0.15s ease', - }} - > - {config.featured ? ( - - ) : ( - - )} - - - - - {/* Delete (draft only) */} - {stage === 'draft' && onDeleteAgent && ( - - { - e.stopPropagation(); - onDeleteAgent(agent.id); - }} - sx={{ - width: 26, - height: 26, - color: 'error.main', - opacity: 0.6, - '&:hover': { opacity: 1 }, - }} - > - - - - )} - - {/* Expand */} - - - - - - {/* Expanded Detail */} - - - {/* Lifecycle Pipeline (mini for this agent) */} - - {LIFECYCLE_STAGES.map((s, i) => { - const isActive = s.key === stage; - const stageIdx = LIFECYCLE_STAGES.findIndex( - ls => ls.key === stage, - ); - const isPast = stageIdx > i && stage !== 'archived'; - const sc = getStageColor(s.key, isDark); - return ( - - - - {isPast ? ( - - ) : ( - getStageIcon(s.key) - )} - - - - {s.label} - - - - {i < LIFECYCLE_STAGES.length - 1 && ( - - )} - - ); - })} - - - {/* Agent Runtime Info (lazy-loaded) */} - {agent.source === 'kagenti' && ( - - )} - - {/* Skill Agent Runtime Info */} - {(agent.source === 'skills' || agent.chatEndpoint) && - agent.source !== 'kagenti' && ( - - - Skill Agent Runtime - - - {agent.chatEndpoint && ( - <> - - Endpoint - - - {agent.chatEndpoint} - - - )} - {'namespace' in agent && agent.namespace && ( - <> - - Namespace - - - {String(agent.namespace)} - - - )} - - Framework - - - {getFrameworkLabel(agent.framework)} - - - - )} - - {/* Two-Column: Dev Info | Ops Info */} - - {/* Development Info */} - - - Development - - - - - - - {agent.namespace && ( - - )} - - - - - {/* Operations Info */} - - - Operations - - - - s.key === stage)?.label ?? - String(stage) - } - chipColor={stageColor} - /> - - - - - - - - - {/* Description */} - {agent.description && ( - <> - - - {agent.description} - - - )} - {/* Display Overrides and Conversation Starters removed -- admins - configure these from the agent detail view, not the registry. - To restore, look at git history for this section. */} - - - - ); -}; - -// --------------------------------------------------------------------------- -// Agent Runtime Info (lazy-loaded enrichment) -// --------------------------------------------------------------------------- - -interface KagentiRuntimeInfoProps { - enrichment?: EnrichmentData; - loading: boolean; - isDark: boolean; -} - -const KagentiRuntimeInfo: FC = ({ - enrichment, - loading, - isDark, -}) => { - const theme = useTheme(); - - if (loading) { - return ( - - - - Loading runtime info... - - - ); - } - - if (!enrichment) return null; - - if ( - enrichment.error && - !enrichment.agentCard && - !enrichment.routeStatus && - !enrichment.detail - ) { - return ( - - - {enrichment.error} - - - ); - } - - const { agentCard, routeStatus, detail } = enrichment; - const workloadType = detail?.workloadType || 'Unknown'; - const readyStatus = detail?.readyStatus || 'Unknown'; - const replicas = (detail?.spec as Record)?.replicas; - const containers = ( - (detail?.spec as Record)?.template as Record< - string, - unknown - > - )?.spec as Record | undefined; - const containerList = (containers?.containers ?? []) as Array<{ - image?: string; - name?: string; - }>; - const containerImage = containerList[0]?.image; - - return ( - - - Agent Runtime - - - {/* Status badges row */} - - {/* Route status */} - {routeStatus && ( - - ) : ( - - ) - } - label={routeStatus.hasRoute ? 'Routable' : 'No Route'} - sx={{ - height: 20, - fontSize: '0.6rem', - fontWeight: 600, - bgcolor: routeStatus.hasRoute - ? alpha('#22c55e', isDark ? 0.15 : 0.1) - : alpha(theme.palette.text.disabled, 0.08), - color: routeStatus.hasRoute - ? isDark - ? '#86efac' - : '#15803d' - : 'text.disabled', - '& .MuiChip-icon': { - color: routeStatus.hasRoute - ? isDark - ? '#86efac' - : '#15803d' - : 'text.disabled', - }, - }} - /> - )} - - {/* Streaming */} - {agentCard && ( - } - label={agentCard.streaming ? 'Streaming' : 'Request/Response'} - sx={{ - height: 20, - fontSize: '0.6rem', - fontWeight: 600, - bgcolor: agentCard.streaming - ? alpha('#3b82f6', isDark ? 0.15 : 0.1) - : alpha(theme.palette.text.disabled, 0.08), - color: agentCard.streaming - ? isDark - ? '#93c5fd' - : '#1d4ed8' - : 'text.disabled', - '& .MuiChip-icon': { - color: agentCard.streaming - ? isDark - ? '#93c5fd' - : '#1d4ed8' - : 'text.disabled', - }, - }} - /> - )} - - {/* Workload type */} - {detail && ( - } - label={workloadType} - sx={{ - height: 20, - fontSize: '0.6rem', - fontWeight: 600, - bgcolor: alpha(theme.palette.text.primary, isDark ? 0.08 : 0.05), - color: 'text.secondary', - '& .MuiChip-icon': { color: 'text.secondary' }, - }} - /> - )} - - {/* Ready status */} - {detail && ( - - )} - - {/* Replicas */} - {replicas !== undefined && ( - - {String(replicas)} replica{Number(replicas) !== 1 ? 's' : ''} - - )} - - - {/* Detail rows */} - - {agentCard?.url && ( - - - A2A URL - - - {agentCard.url} - - - )} - - {routeStatus?.hasRoute && routeStatus.url && ( - - - Route URL - - - {routeStatus.url as string} - - - - )} - - {containerImage && ( - - - Image - - - {containerImage} - - - )} - - {agentCard?.version && ( - - - Card Version - - - {agentCard.version} - - - )} - - - {/* Skills */} - {agentCard?.skills && agentCard.skills.length > 0 && ( - - - Skills - - - {agentCard.skills.map((skill, i) => ( - - } - label={skill.name || skill.id || `Skill ${i + 1}`} - sx={{ - height: 18, - fontSize: '0.575rem', - fontWeight: 600, - bgcolor: alpha('#8b5cf6', isDark ? 0.12 : 0.08), - color: isDark ? '#c4b5fd' : '#6d28d9', - '& .MuiChip-icon': { - color: isDark ? '#c4b5fd' : '#6d28d9', - }, - }} - /> - - ))} - - - )} - - ); -}; - -// --------------------------------------------------------------------------- -// DetailRow helper -// --------------------------------------------------------------------------- - -const DetailRow: FC<{ label: string; value: string; chipColor?: string }> = ({ - label, - value, - chipColor, -}) => ( - - - {label} - - {chipColor ? ( - - - - {value} - - - ) : ( - - {value} - - )} - -); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentRegistryPanel/index.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentRegistryPanel/index.ts deleted file mode 100644 index 18ab51237ec..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentRegistryPanel/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { AgentRegistryPanel } from './AgentRegistryPanel'; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AdvancedTab.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AdvancedTab.tsx deleted file mode 100644 index 23441947d16..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AdvancedTab.tsx +++ /dev/null @@ -1,287 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { memo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import TextField from '@mui/material/TextField'; -import Select from '@mui/material/Select'; -import MenuItem from '@mui/material/MenuItem'; -import InputLabel from '@mui/material/InputLabel'; -import FormControl from '@mui/material/FormControl'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import { useTheme } from '@mui/material/styles'; -import { ToggleSwitch } from '../shared/ToggleSwitch'; -import { SELECT_MENU_PROPS } from '../shared/selectMenuProps'; -import { FieldHelpButton } from './FieldHelpPopover'; -import type { AgentFormData } from './agentValidation'; - -interface AdvancedTabProps { - agent: AgentFormData; - agentKey: string; - onUpdateAgent: ( - key: string, - field: keyof AgentFormData, - value: unknown, - ) => void; -} - -export const AdvancedTab = memo(function AdvancedTab({ - agent, - agentKey, - onUpdateAgent, -}: AdvancedTabProps) { - const theme = useTheme(); - - return ( - - - - - Tool Choice - - - - - - - Reasoning - - - - - - - - - { - const v = parseFloat(e.target.value); - onUpdateAgent( - agentKey, - 'temperature', - e.target.value === '' || isNaN(v) ? undefined : v, - ); - }} - inputProps={{ min: 0, max: 2, step: 0.1 }} - /> - - - - - - { - const v = parseInt(e.target.value, 10); - onUpdateAgent( - agentKey, - 'maxOutputTokens', - e.target.value === '' || isNaN(v) ? undefined : v, - ); - }} - inputProps={{ min: 1 }} - /> - - - - - - { - const v = parseInt(e.target.value, 10); - onUpdateAgent( - agentKey, - 'maxToolCalls', - e.target.value === '' || isNaN(v) ? undefined : v, - ); - }} - inputProps={{ min: 1 }} - /> - - - - - - { - const v = e.target.value; - onUpdateAgent( - agentKey, - 'guardrails', - v - ? v - .split(',') - .map(s => s.trim()) - .filter(Boolean) - : undefined, - ); - }} - placeholder="shield-id-1, shield-id-2" - /> - - - - - onUpdateAgent( - agentKey, - 'resetToolChoice', - e.target.checked || undefined, - ) - } - /> - } - label={ - - - Reset Tool Choice After Use - - - After a tool call, reset to "auto" - - - } - /> - - onUpdateAgent( - agentKey, - 'nestHandoffHistory', - e.target.checked || undefined, - ) - } - /> - } - label={ - - - Summarize History on Handoff - - - Compress conversation on transfer - - - } - /> - - - ); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentEditorHeader.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentEditorHeader.tsx deleted file mode 100644 index 9a97c9364e4..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentEditorHeader.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { memo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; -import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined'; -import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; -import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'; -import { useTheme, alpha } from '@mui/material/styles'; - -interface AgentEditorHeaderProps { - isSingleAgentMode: boolean; - autoCreate?: boolean; - agentName?: string; - agentCount: number; - isDirty: boolean; - showPreview: boolean; - onTogglePreview: () => void; -} - -export const AgentEditorHeader = memo(function AgentEditorHeader({ - isSingleAgentMode, - autoCreate, - agentName, - agentCount, - isDirty, - showPreview, - onTogglePreview, -}: AgentEditorHeaderProps) { - const theme = useTheme(); - - return ( - - - {(() => { - if (isSingleAgentMode) - return ( - - ); - if (autoCreate) - return ( - - ); - return ( - - ); - })()} - - - - {(() => { - if (isSingleAgentMode) return 'Create Agent'; - if (autoCreate) return 'Agent Team Editor'; - if (agentName) return `Editing ${agentName}`; - return 'Agent Configuration'; - })()} - - - {(() => { - if (isSingleAgentMode) - return "Define your agent's capabilities and instructions"; - if (autoCreate) - return `${agentCount} agent${agentCount !== 1 ? 's' : ''} in team`; - return 'Configure agent behavior and tools'; - })()} - - - {!isSingleAgentMode && ( - - - - - - )} - {isDirty && ( - - )} - - ); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentEditorToolbar.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentEditorToolbar.tsx deleted file mode 100644 index 7b645f4b6ca..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentEditorToolbar.tsx +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { memo } from 'react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Select from '@mui/material/Select'; -import MenuItem from '@mui/material/MenuItem'; -import InputLabel from '@mui/material/InputLabel'; -import FormControl from '@mui/material/FormControl'; -import CircularProgress from '@mui/material/CircularProgress'; -import Tooltip from '@mui/material/Tooltip'; -import AddIcon from '@mui/icons-material/Add'; -import SaveIcon from '@mui/icons-material/Save'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import { useTheme } from '@mui/material/styles'; -import { SELECT_MENU_PROPS } from '../shared/selectMenuProps'; -import type { AgentFormData } from './agentValidation'; -import { MIN_TURNS, MAX_TURNS } from './useAgentEditor'; - -interface AgentEditorToolbarProps { - agents: Record; - agentKeys: string[]; - defaultAgentKey: string; - maxTurns: number; - agentsSource: string; - agentsSaving: boolean; - resetting: boolean; - saving: boolean; - saveSuccess: boolean; - validationErrors: string[]; - onSetDefaultAgentKey: (key: string) => void; - onSetMaxTurns: (turns: number) => void; - onReset: () => void; - onNewAgent: () => void; - onSave: () => void; -} - -export const AgentEditorToolbar = memo(function AgentEditorToolbar({ - agents, - agentKeys, - defaultAgentKey, - maxTurns, - agentsSource, - agentsSaving, - resetting, - saving, - saveSuccess, - validationErrors, - onSetDefaultAgentKey, - onSetMaxTurns, - onReset, - onNewAgent, - onSave, -}: AgentEditorToolbarProps) { - const theme = useTheme(); - - return ( - - {agentKeys.length > 1 && ( - <> - - Starting Agent - - - - onSetMaxTurns( - Math.max(MIN_TURNS, parseInt(e.target.value, 10) || MIN_TURNS), - ) - } - inputProps={{ min: MIN_TURNS, max: MAX_TURNS }} - sx={{ width: 85, '& input': { fontSize: '0.8rem' } }} - /> - - )} - - - - {agentsSource === 'database' && ( - - )} - - 0 - ? `Fix errors before saving: ${validationErrors[0]}` - : '' - } - > - - - - - - ); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentListItem.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentListItem.tsx deleted file mode 100644 index 824a5ed12eb..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentListItem.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { memo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Tooltip from '@mui/material/Tooltip'; -import Chip from '@mui/material/Chip'; -import ExtensionIcon from '@mui/icons-material/Extension'; -import MenuBookIcon from '@mui/icons-material/MenuBook'; -import PublicIcon from '@mui/icons-material/Public'; -import CodeIcon from '@mui/icons-material/Code'; -import StarIcon from '@mui/icons-material/Star'; -import { useTheme, alpha } from '@mui/material/styles'; -import type { AgentFormData, PublishAsRole } from './agentValidation'; - -interface AgentListItemProps { - agentKey: string; - agent: AgentFormData; - isSelected: boolean; - isDefault: boolean; - isSingleAgent: boolean; - outCount: number; - inCount: number; - effectiveRole: PublishAsRole; - onSelect: (key: string) => void; -} - -const ROLE_CHIP_CONFIG: Record< - PublishAsRole, - { label: string; color: 'primary' | 'default' | 'secondary' } -> = { - router: { label: 'Router', color: 'primary' }, - specialist: { label: 'Specialist', color: 'secondary' }, - standalone: { label: 'Standalone', color: 'default' }, -}; - -export const AgentListItem = memo(function AgentListItem({ - agentKey, - agent, - isSelected, - isDefault, - isSingleAgent, - outCount, - inCount, - effectiveRole, - onSelect, -}: AgentListItemProps) { - const theme = useTheme(); - const hasCapabilities = - agent.mcpServers.length > 0 || - agent.enableRAG || - agent.enableWebSearch || - agent.enableCodeInterpreter; - - return ( - onSelect(agentKey)} - role="button" - tabIndex={0} - aria-label={`Select agent ${agent.name || agentKey}${isDefault ? ', starting agent' : ''}`} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onSelect(agentKey); - } - }} - sx={{ - px: 1.5, - py: 1, - cursor: 'pointer', - borderRadius: 1, - mx: 0.75, - mb: 0.25, - backgroundColor: isSelected - ? alpha(theme.palette.primary.main, 0.08) - : 'transparent', - borderLeft: isSelected - ? `3px solid ${theme.palette.primary.main}` - : '3px solid transparent', - transition: 'background-color 0.1s', - '&:hover': { - backgroundColor: isSelected - ? alpha(theme.palette.primary.main, 0.08) - : alpha(theme.palette.action.hover, 0.4), - }, - }} - > - - {isDefault && !isSingleAgent && ( - - )} - - {agent.name || agentKey} - - - - {(hasCapabilities || outCount > 0 || inCount > 0) && ( - - {agent.mcpServers.length > 0 && ( - - - - )} - {agent.enableRAG && ( - - - - )} - {agent.enableWebSearch && ( - - - - )} - {agent.enableCodeInterpreter && ( - - - - )} - {(outCount > 0 || inCount > 0) && ( - - {outCount > 0 && `${outCount} out`} - {outCount > 0 && inCount > 0 && ' \u00b7 '} - {inCount > 0 && `${inCount} in`} - - )} - - )} - - ); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentPreviewCard.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentPreviewCard.tsx deleted file mode 100644 index 9d1b76a8826..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentPreviewCard.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useMemo, memo, type ReactElement } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; -import Avatar from '@mui/material/Avatar'; -import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'; -import MenuBookIcon from '@mui/icons-material/MenuBook'; -import PublicIcon from '@mui/icons-material/Public'; -import CodeIcon from '@mui/icons-material/Code'; -import ExtensionIcon from '@mui/icons-material/Extension'; -import { useTheme, alpha } from '@mui/material/styles'; -import { SECTION_LABEL_SX } from '../shared/commandCenterStyles'; -import type { AgentFormData } from './agentValidation'; - -interface AgentPreviewCardProps { - agent: AgentFormData; -} - -export const AgentPreviewCard = memo(function AgentPreviewCard({ - agent, -}: AgentPreviewCardProps) { - const theme = useTheme(); - - const caps = useMemo(() => { - const items: Array<{ icon: ReactElement; label: string }> = []; - if (agent.enableRAG) - items.push({ - icon: , - label: 'RAG', - }); - if (agent.enableWebSearch) - items.push({ icon: , label: 'Web' }); - if (agent.enableCodeInterpreter) - items.push({ icon: , label: 'Code' }); - if (agent.mcpServers.length > 0) - items.push({ - icon: , - label: `${agent.mcpServers.length} MCP`, - }); - return items; - }, [ - agent.enableRAG, - agent.enableWebSearch, - agent.enableCodeInterpreter, - agent.mcpServers, - ]); - - return ( - - - - - Preview - - - - - {agent.name ? agent.name.charAt(0).toUpperCase() : '?'} - - - {agent.name || 'Unnamed Agent'} - - - {agent.instructions.trim() - ? agent.instructions.substring(0, 100) - : 'No instructions yet'} - - {caps.length > 0 && ( - - {caps.map(c => ( - - ))} - - )} - - - How users see this agent - - - ); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentReviewStep.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentReviewStep.tsx deleted file mode 100644 index 8dd718d587e..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentReviewStep.tsx +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useMemo, memo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import Avatar from '@mui/material/Avatar'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import SaveIcon from '@mui/icons-material/Save'; -import { useTheme, alpha } from '@mui/material/styles'; -import { SECTION_LABEL_SX, sectionCardSx } from '../shared/commandCenterStyles'; -import type { AgentFormData } from './agentValidation'; - -interface AgentReviewStepProps { - agent: AgentFormData; - agentKey: string; - validationErrors: string[]; - saving: boolean; - saveSuccess: boolean; - availableMcpServers?: { id: string; name: string }[]; - onSave: () => void; - onDone?: () => void; - onBack?: () => void; -} - -export const AgentReviewStep = memo(function AgentReviewStep({ - agent, - agentKey, - validationErrors, - saving, - saveSuccess, - availableMcpServers = [], - onSave, - onDone, - onBack, -}: AgentReviewStepProps) { - const theme = useTheme(); - - const caps = useMemo(() => { - const items: string[] = []; - if (agent.enableRAG) items.push('Knowledge Base'); - if (agent.enableWebSearch) items.push('Web Search'); - if (agent.enableCodeInterpreter) items.push('Code Interpreter'); - return items; - }, [agent.enableRAG, agent.enableWebSearch, agent.enableCodeInterpreter]); - - const mcpServerNames = useMemo(() => { - return agent.mcpServers.map( - id => availableMcpServers.find(s => s.id === id)?.name || id, - ); - }, [agent.mcpServers, availableMcpServers]); - - return ( - - - Review & Save - - - Review your agent configuration before saving. You can always edit it - later. - - - {validationErrors.length > 0 && ( - - {validationErrors.map(err => ( - - • {err} - - ))} - - )} - - - - - {agent.name ? agent.name.charAt(0).toUpperCase() : '?'} - - - - {agent.name || 'Unnamed Agent'} - - - {agentKey} - - - - - - - - Model - - - {agent.model || 'Global default'} - - - - - Tools - - - {caps.length > 0 ? ( - caps.map(c => ( - - )) - ) : ( - - None configured - - )} - - - - - MCP Servers - - - {mcpServerNames.length > 0 ? ( - mcpServerNames.map(name => ( - - )) - ) : ( - - None selected - - )} - - - - - Instructions - - - {agent.instructions.trim() - ? `${agent.instructions.trim().substring(0, 120)}${agent.instructions.length > 120 ? '...' : ''}` - : 'Not set \u2014 required'} - - - - - - - {onBack && ( - - )} - - {saveSuccess && onDone && ( - - )} - - - ); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentsPanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentsPanel.tsx deleted file mode 100644 index 73568eeeba6..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/AgentsPanel.tsx +++ /dev/null @@ -1,1776 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Alert from '@mui/material/Alert'; -import Chip from '@mui/material/Chip'; -import IconButton from '@mui/material/IconButton'; -import { ToggleSwitch } from '../shared/ToggleSwitch'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import Select from '@mui/material/Select'; -import MenuItem from '@mui/material/MenuItem'; -import InputLabel from '@mui/material/InputLabel'; -import FormControl from '@mui/material/FormControl'; -import Autocomplete from '@mui/material/Autocomplete'; -import CircularProgress from '@mui/material/CircularProgress'; -import Tooltip from '@mui/material/Tooltip'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import Snackbar from '@mui/material/Snackbar'; -import { useTheme, alpha } from '@mui/material/styles'; -import { Progress } from '@backstage/core-components'; -import AddIcon from '@mui/icons-material/Add'; -import DeleteIcon from '@mui/icons-material/Delete'; -import SaveIcon from '@mui/icons-material/Save'; -import WarningAmberIcon from '@mui/icons-material/WarningAmber'; -import HubIcon from '@mui/icons-material/Hub'; -import BuildIcon from '@mui/icons-material/Build'; -import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; - -import { useEffectiveConfig } from '../../../hooks/useEffectiveConfig'; -import { - useAdminConfig, - useModels, - useGeneratePrompt, - useVectorStores, -} from '../../../hooks'; -import { SELECT_MENU_PROPS } from '../shared/selectMenuProps'; -import { - type AgentFormData, - type PublishAsRole, - createDefaultAgent, - agentFromConfig, - agentToConfig, - validateAgents, - deriveAgentRole, - buildAgentContext, -} from './agentValidation'; -import { ConfirmDialog } from '../shared/ConfirmDialog'; -import { AgentListItem } from './AgentListItem'; -import { CreateAgentModal } from './CreateAgentModal'; -import { InstructionsTab } from './InstructionsTab'; - -const LEFT_PANEL_WIDTH = 280; -const RIGHT_MAX_WIDTH = 780; -const SAVE_SUCCESS_TIMEOUT_MS = 3000; -const DEFAULT_MAX_TURNS = 10; -const MIN_TURNS = 1; -const MAX_TURNS = 50; - -export interface AgentsPanelProps { - focusAgentKey?: string; - autoCreate?: boolean; - createType?: 'single' | 'multi' | null; - onSaved?: () => void; -} - -export const AgentsPanel = ({ - focusAgentKey, - autoCreate, - createType, - onSaved, -}: AgentsPanelProps = {}) => { - const theme = useTheme(); - const { - config: effectiveConfig, - loading: configLoading, - error: configError, - refresh: refreshConfig, - } = useEffectiveConfig(); - - const { - source: agentsSource, - saving: agentsSaving, - error: agentsError, - save: saveAgents, - reset: resetAgents, - } = useAdminConfig('agents'); - const { save: saveDefaultAgent } = useAdminConfig('defaultAgent'); - const { save: saveMaxTurns } = useAdminConfig('maxAgentTurns'); - const { - models, - loading: modelsLoading, - refresh: refreshModels, - } = useModels(); - const modelOptions = useMemo( - () => models.map(m => m.id).filter(Boolean) as string[], - [models], - ); - const { generate, generating, error: generateError } = useGeneratePrompt(); - const { stores: vectorStores } = useVectorStores(); - - // ── State ────────────────────────────────────────────────────────────── - - const [agents, setAgents] = useState>({}); - const [selectedAgentKey, setSelectedAgentKey] = useState(null); - const [defaultAgentKey, setDefaultAgentKey] = useState(''); - const [maxTurns, setMaxTurns] = useState(DEFAULT_MAX_TURNS); - - const [saveError, setSaveError] = useState(null); - const [saveSuccess, setSaveSuccess] = useState(false); - const [initialized, setInitialized] = useState(false); - const [resetting, setResetting] = useState(false); - const [activeTab, setActiveTab] = useState(0); - - const [generateToast, setGenerateToast] = useState(null); - - const [createModalOpen, setCreateModalOpen] = useState(false); - - const [confirmRemoveKey, setConfirmRemoveKey] = useState(null); - const [confirmReset, setConfirmReset] = useState(false); - - const saveTimerRef = useRef>(); - - // ── Effects ──────────────────────────────────────────────────────────── - - const isSingleAgentMode = createType === 'single' && autoCreate; - - useEffect( - () => () => { - if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - }, - [], - ); - - useEffect(() => { - if (!effectiveConfig || initialized) return; - if (isSingleAgentMode) { - setAgents({}); - setDefaultAgentKey(''); - } else { - const rawAgents = - (effectiveConfig.agents as Record>) || - {}; - const parsed: Record = {}; - for (const [key, cfg] of Object.entries(rawAgents)) - parsed[key] = agentFromConfig(cfg); - setAgents(parsed); - setDefaultAgentKey((effectiveConfig.defaultAgent as string) || ''); - } - setMaxTurns((effectiveConfig.maxAgentTurns as number) || DEFAULT_MAX_TURNS); - setInitialized(true); - }, [effectiveConfig, initialized, isSingleAgentMode]); - - // Auto-select focused agent or first agent when agents load - const focusAppliedRef = useRef(false); - useEffect(() => { - focusAppliedRef.current = false; - }, [focusAgentKey]); - useEffect(() => { - if (!initialized) return; - const keys = Object.keys(agents); - if (keys.length === 0) { - setSelectedAgentKey(null); - return; - } - if (focusAgentKey && agents[focusAgentKey] && !focusAppliedRef.current) { - focusAppliedRef.current = true; - setSelectedAgentKey(focusAgentKey); - return; - } - setSelectedAgentKey(prev => (!prev || !agents[prev] ? keys[0] : prev)); - }, [initialized, agents, focusAgentKey]); - - const autoCreateAppliedRef = useRef(false); - useEffect(() => { - if (autoCreate && initialized && !autoCreateAppliedRef.current) { - autoCreateAppliedRef.current = true; - if (isSingleAgentMode) { - const key = 'agent'; - const agent = createDefaultAgent(); - agent.name = 'Agent'; - setAgents({ [key]: agent }); - setDefaultAgentKey(key); - setSelectedAgentKey(key); - } else { - setCreateModalOpen(true); - } - } - }, [autoCreate, initialized, isSingleAgentMode]); - - // ── Memos ────────────────────────────────────────────────────────────── - - const agentKeys = useMemo(() => Object.keys(agents), [agents]); - const availableMcpServers = useMemo(() => { - if (!effectiveConfig) return []; - const servers = - (effectiveConfig.mcpServers as Array<{ id: string; name: string }>) || []; - return servers.map(s => ({ id: s.id, name: s.name || s.id })); - }, [effectiveConfig]); - const selectedAgent = selectedAgentKey - ? (agents[selectedAgentKey] ?? null) - : null; - const selectedAgentRole: PublishAsRole = useMemo( - () => - selectedAgentKey - ? deriveAgentRole(selectedAgentKey, agents) - : 'standalone', - [selectedAgentKey, agents], - ); - const showConnections = selectedAgentRole !== 'standalone'; - const validation = useMemo( - () => validateAgents(agents, defaultAgentKey), - [agents, defaultAgentKey], - ); - - const topologyEdges = useMemo(() => { - const edges: Array<{ - from: string; - to: string; - type: 'handoff' | 'subtask'; - }> = []; - for (const key of agentKeys) { - const a = agents[key]; - for (const h of a.handoffs) - if (agents[h]) edges.push({ from: key, to: h, type: 'handoff' }); - for (const t of a.asTools) - if (agents[t]) edges.push({ from: key, to: t, type: 'subtask' }); - } - return edges; - }, [agents, agentKeys]); - - const agentRoles = useMemo(() => { - const roles: Record = {}; - for (const key of agentKeys) { - roles[key] = deriveAgentRole(key, agents); - } - return roles; - }, [agents, agentKeys]); - - const edgeCounts = useMemo(() => { - const counts: Record = {}; - for (const key of agentKeys) { - const a = agents[key]; - counts[key] = { - out: - a.handoffs.filter(h => agents[h]).length + - a.asTools.filter(t => agents[t]).length, - in: 0, - }; - } - for (const key of agentKeys) { - const a = agents[key]; - for (const h of a.handoffs) if (counts[h]) counts[h].in++; - for (const t of a.asTools) if (counts[t]) counts[t].in++; - } - return counts; - }, [agents, agentKeys]); - - // ── Callbacks ────────────────────────────────────────────────────────── - - const handleSelectAgent = useCallback((key: string) => { - setSelectedAgentKey(key); - setActiveTab(0); - }, []); - - const handleCreateFromModal = useCallback( - (name: string, key: string) => { - if (!key || agents[key]) return; - const agent = createDefaultAgent(); - agent.name = name || key.charAt(0).toUpperCase() + key.slice(1); - setAgents(prev => ({ ...prev, [key]: agent })); - if (agentKeys.length === 0) setDefaultAgentKey(key); - setSelectedAgentKey(key); - setCreateModalOpen(false); - }, - [agents, agentKeys.length], - ); - - const executeRemoveAgent = useCallback((key: string) => { - setAgents(prev => { - const next: Record = {}; - for (const [k, a] of Object.entries(prev)) { - if (k === key) continue; - next[k] = { - ...a, - handoffs: a.handoffs.filter(h => h !== key), - asTools: a.asTools.filter(t => t !== key), - }; - } - const remaining = Object.keys(next); - setSelectedAgentKey(sel => (sel === key ? remaining[0] || null : sel)); - setDefaultAgentKey(def => (def === key ? '' : def)); - return next; - }); - }, []); - - const handleRemoveAgent = useCallback((key: string) => { - setConfirmRemoveKey(key); - }, []); - const handleConfirmRemove = useCallback(() => { - if (confirmRemoveKey) { - executeRemoveAgent(confirmRemoveKey); - setConfirmRemoveKey(null); - } - }, [confirmRemoveKey, executeRemoveAgent]); - - const updateAgent = useCallback( - (key: string, field: keyof AgentFormData, value: unknown) => { - setAgents(prev => ({ ...prev, [key]: { ...prev[key], [field]: value } })); - }, - [], - ); - - const handleSave = useCallback(async () => { - if (validation.errors.length > 0) return; - setSaveError(null); - setSaveSuccess(false); - try { - const newAgents: Record> = {}; - for (const [key, agent] of Object.entries(agents)) { - newAgents[key] = agentToConfig(agent); - } - let payload = newAgents; - if (isSingleAgentMode && effectiveConfig) { - const existing = - (effectiveConfig.agents as Record>) || - {}; - payload = { ...existing, ...newAgents }; - } - for (const step of [ - { label: 'agents', fn: () => saveAgents(payload) }, - { label: 'defaultAgent', fn: () => saveDefaultAgent(defaultAgentKey) }, - { label: 'maxAgentTurns', fn: () => saveMaxTurns(maxTurns) }, - ]) { - try { - await step.fn(); - } catch (e) { - throw new Error( - `Failed to save ${step.label}: ${e instanceof Error ? e.message : String(e)}`, - ); - } - } - setSaveSuccess(true); - if (onSaved) { - onSaved(); - } else { - saveTimerRef.current = setTimeout( - () => setSaveSuccess(false), - SAVE_SUCCESS_TIMEOUT_MS, - ); - } - } catch (err) { - let msg = 'Failed to save agent config'; - if (err instanceof Error) msg = err.message; - const body = (err as Record)?.body; - if ( - body && - typeof body === 'object' && - typeof (body as Record).message === 'string' - ) { - msg = (body as Record).message as string; - } - setSaveError(msg); - } - }, [ - agents, - defaultAgentKey, - maxTurns, - validation, - saveAgents, - saveDefaultAgent, - saveMaxTurns, - onSaved, - isSingleAgentMode, - effectiveConfig, - ]); - - const executeReset = useCallback(async () => { - setResetting(true); - try { - await resetAgents(); - await refreshConfig(); - setInitialized(false); - setSelectedAgentKey(null); - } catch { - setSaveError('Failed to reset agents'); - } finally { - setResetting(false); - } - }, [resetAgents, refreshConfig]); - - const handleConfirmReset = useCallback(async () => { - try { - await executeReset(); - } finally { - setConfirmReset(false); - } - }, [executeReset]); - - const handleUpdateInstructions = useCallback( - (value: string) => { - if (selectedAgentKey) - updateAgent(selectedAgentKey, 'instructions', value); - }, - [selectedAgentKey, updateAgent], - ); - - const handleGenerateForTab = useCallback( - async (description: string, model: string | undefined) => { - if (!selectedAgentKey || !selectedAgent) return; - const context = buildAgentContext( - selectedAgent, - agents, - availableMcpServers, - ); - const capabilities = { - enableWebSearch: selectedAgent.enableWebSearch, - enableCodeInterpreter: selectedAgent.enableCodeInterpreter, - ragEnabled: selectedAgent.enableRAG, - }; - const resolvedModel = model || selectedAgent.model || undefined; - const prompt = await generate( - `${description.trim()}\n\nAGENT CONTEXT:\n${context}`, - resolvedModel, - capabilities, - ); - updateAgent(selectedAgentKey, 'instructions', prompt); - setGenerateToast('Instructions generated — review and save when ready'); - }, - [ - selectedAgentKey, - selectedAgent, - agents, - availableMcpServers, - generate, - updateAgent, - ], - ); - - // ── Guards ───────────────────────────────────────────────────────────── - - if (configLoading) return ; - if (configError) - return ( - - Failed to load: {configError} - - ); - - // ── JSX ──────────────────────────────────────────────────────────────── - - return ( - - {/* ── Top bar ───────────────────────────────────────────────────── */} - - {!isSingleAgentMode && agentKeys.length > 1 && ( - <> - - - Starting Agent - - - - - setMaxTurns( - Math.max( - MIN_TURNS, - parseInt(e.target.value, 10) || MIN_TURNS, - ), - ) - } - inputProps={{ min: MIN_TURNS, max: MAX_TURNS }} - sx={{ width: 85, '& input': { fontSize: '0.8rem' } }} - /> - - )} - - - - {!isSingleAgentMode && agentsSource === 'database' && ( - - )} - {!isSingleAgentMode && ( - - )} - - - - {/* ── Feedback ──────────────────────────────────────────────────── */} - {saveSuccess && ( - - Configuration saved. - - )} - {(saveError || agentsError) && ( - setSaveError(null) : undefined} - > - {saveError || agentsError} - - )} - - {/* ── Main content ──────────────────────────────────────────────── */} - {!isSingleAgentMode && agentKeys.length === 0 ? ( - - - - No agents yet - - - Start by creating your first agent. Give it tools and instructions - — a single well-configured agent can handle many tasks. Add - more agents later when your workflow needs routing or - specialization. - - - - - ) : ( - - {/* ── Left panel: Agent list + Topology (hidden in single-agent mode) */} - {!isSingleAgentMode && ( - - - {agentKeys.map(key => ( - - ))} - - - )} - - {/* ── Right panel: Agent config form ─────────────────────────── */} - - {selectedAgentKey && selectedAgent ? ( - - {/* Identity — always visible above tabs */} - - - - updateAgent(selectedAgentKey, 'name', e.target.value) - } - variant="standard" - placeholder="Agent name" - InputProps={{ - sx: { fontSize: '1.1rem', fontWeight: 600 }, - disableUnderline: true, - }} - sx={{ flex: 1 }} - /> - - {agentsSource === 'database' && ( - - )} - {!isSingleAgentMode && ( - - handleRemoveAgent(selectedAgentKey)} - sx={{ - color: theme.palette.error.main, - opacity: 0.35, - '&:hover': { opacity: 1 }, - }} - > - - - - )} - - {!isSingleAgentMode && ( - - { - if (selectedAgentRole === 'router') - return ; - if (selectedAgentRole === 'specialist') - return ; - return ; - })()} - label={ - selectedAgentRole.charAt(0).toUpperCase() + - selectedAgentRole.slice(1) - } - color={(() => { - if (selectedAgentRole === 'router') return 'primary'; - if (selectedAgentRole === 'specialist') - return 'default'; - return 'success'; - })()} - variant="outlined" - sx={{ fontWeight: 600, fontSize: '0.75rem' }} - /> - - {selectedAgentRole === 'router' && - 'Visible in gallery — routes to other agents'} - {selectedAgentRole === 'specialist' && - 'Hidden — only reachable via handoffs'} - {selectedAgentRole === 'standalone' && - 'Visible in gallery — independent agent'} - - - )} - {showConnections && ( - - updateAgent( - selectedAgentKey, - 'handoffDescription', - e.target.value, - ) - } - variant="standard" - fullWidth - placeholder="Add a description \u2014 other agents read this when deciding to route here" - InputProps={{ - sx: { - fontSize: '0.8rem', - color: theme.palette.text.secondary, - borderBottom: `1px dashed ${alpha(theme.palette.text.secondary, 0.25)}`, - '&:hover': { - borderBottom: `1px dashed ${alpha(theme.palette.text.secondary, 0.5)}`, - }, - '&.Mui-focused': { - borderBottom: `1px solid ${theme.palette.primary.main}`, - }, - }, - disableUnderline: true, - }} - /> - )} - - - {/* Validation — always visible above tabs */} - {validation.errors.length > 0 && ( - } - sx={{ mb: 1, py: 0.25, flexShrink: 0 }} - > - {validation.errors.map(err => ( - - • {err} - - ))} - - )} - {validation.warnings.length > 0 && ( - - {validation.warnings.map(w => ( - - • {w} - - ))} - - )} - - {/* Tabs */} - - setActiveTab(v)} - aria-label="Agent configuration tabs" - sx={{ - minHeight: 36, - '& .MuiTab-root': { - textTransform: 'none', - fontWeight: 500, - fontSize: '0.8125rem', - minHeight: 36, - px: 2, - }, - }} - > - - {showConnections && ( - - )} - - - - - - {/* Tab content — this area scrolls independently */} - {(() => { - const connectionsTab = showConnections ? 1 : -1; - const advancedTab = showConnections ? 2 : 1; - const instructionsTab = showConnections ? 3 : 2; - return ( - - {/* Tab: Capabilities */} - {activeTab === 0 && ( - - - - - updateAgent( - selectedAgentKey, - 'model', - newValue, - ) - } - getOptionLabel={opt => - typeof opt === 'string' ? opt : '' - } - loading={modelsLoading} - renderInput={params => ( - - {modelsLoading ? ( - - ) : null} - {params.InputProps.endAdornment} - - ), - }} - /> - )} - sx={{ flex: 1 }} - /> - - - - - - - {availableMcpServers.length > 0 && ( - - MCP Servers - - - )} - - - Built-in Tools - - - - updateAgent( - selectedAgentKey, - 'enableRAG', - e.target.checked, - ) - } - /> - } - label={ - - - Knowledge Base - - - Search uploaded documents for context - - - } - /> - {selectedAgent.enableRAG && - vectorStores.length > 0 && ( - - - Vector Stores - - - - Leave empty to use global vector stores - - - )} - - updateAgent( - selectedAgentKey, - 'enableWebSearch', - e.target.checked, - ) - } - /> - } - label={ - - - Web Search - - - Search the web for current information - - - } - /> - - updateAgent( - selectedAgentKey, - 'enableCodeInterpreter', - e.target.checked, - ) - } - /> - } - label={ - - - Code Interpreter - - - Execute code to answer questions - - - } - /> - - - Global models, MCP servers, and RAG settings are - managed in Platform Config from the - sidebar. - - - )} - - {/* Tab: Connections (team mode only) */} - {activeTab === connectionsTab && ( - - {agentKeys.length <= 1 ? ( - - - No other agents to connect to yet. - - - Create additional agents to enable handoffs - (transfer conversation control) and delegation - (run as sub-tasks). - - - ) : ( - <> - - Can Transfer To - - - - Target agent takes over the conversation. This - agent stops responding. - - - - Can Delegate To - - - - Sub-agent runs in the background and returns - results. This agent stays in control. - - - {/* Topology for this agent */} - {topologyEdges.length > 0 && ( - - - Agent Topology - - - {topologyEdges.map(edge => { - const isCurrent = - edge.from === selectedAgentKey || - edge.to === selectedAgentKey; - return ( - - {agents[edge.from]?.name || edge.from}{' '} - {edge.type === 'handoff' - ? '\u2192' - : '\u21E2'}{' '} - {agents[edge.to]?.name || edge.to} - - {edge.type === 'handoff' - ? 'transfers' - : 'delegates'} - - - ); - })} - - - )} - - )} - - )} - - {/* Tab: Advanced */} - {activeTab === advancedTab && ( - - - - Tool Choice - - - - Reasoning - - - { - const v = parseFloat(e.target.value); - updateAgent( - selectedAgentKey, - 'temperature', - e.target.value === '' || isNaN(v) - ? undefined - : v, - ); - }} - inputProps={{ min: 0, max: 2, step: 0.1 }} - /> - { - const v = parseInt(e.target.value, 10); - updateAgent( - selectedAgentKey, - 'maxOutputTokens', - e.target.value === '' || isNaN(v) - ? undefined - : v, - ); - }} - inputProps={{ min: 1 }} - /> - { - const v = parseInt(e.target.value, 10); - updateAgent( - selectedAgentKey, - 'maxToolCalls', - e.target.value === '' || isNaN(v) - ? undefined - : v, - ); - }} - inputProps={{ min: 1 }} - /> - { - const v = e.target.value; - updateAgent( - selectedAgentKey, - 'guardrails', - v - ? v - .split(',') - .map(s => s.trim()) - .filter(Boolean) - : undefined, - ); - }} - placeholder="shield-id-1, shield-id-2" - /> - - - - updateAgent( - selectedAgentKey, - 'resetToolChoice', - e.target.checked || undefined, - ) - } - /> - } - label={ - - - Reset Tool Choice After Use - - - After a tool call, reset to "auto" - - - } - /> - - updateAgent( - selectedAgentKey, - 'nestHandoffHistory', - e.target.checked || undefined, - ) - } - /> - } - label={ - - - Summarize History on Handoff - - - Compress conversation on transfer - - - } - /> - - - )} - - {/* Tab: Instructions */} - {activeTab === instructionsTab && ( - - - - )} - - ); - })()} - - ) : ( - - Select an agent from the list - - )} - - - )} - - {/* ── Create Agent modal ────────────────────────────────────────── */} - setCreateModalOpen(false)} - onCreate={handleCreateFromModal} - /> - - {/* ── Confirm dialogs ───────────────────────────────────────────── */} - setConfirmRemoveKey(null)} - /> - setConfirmReset(false)} - /> - - setGenerateToast(null)} - message={generateToast} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/CapabilitiesTab.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/CapabilitiesTab.tsx deleted file mode 100644 index 410f748a83e..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/CapabilitiesTab.tsx +++ /dev/null @@ -1,382 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useMemo, memo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import TextField from '@mui/material/TextField'; -import Chip from '@mui/material/Chip'; -import IconButton from '@mui/material/IconButton'; -import Alert from '@mui/material/Alert'; -import { ToggleSwitch } from '../shared/ToggleSwitch'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import Select from '@mui/material/Select'; -import MenuItem from '@mui/material/MenuItem'; -import InputLabel from '@mui/material/InputLabel'; -import FormControl from '@mui/material/FormControl'; -import Autocomplete from '@mui/material/Autocomplete'; -import CircularProgress from '@mui/material/CircularProgress'; -import Tooltip from '@mui/material/Tooltip'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import WarningAmberIcon from '@mui/icons-material/WarningAmber'; -import DnsIcon from '@mui/icons-material/Dns'; -import { useTheme } from '@mui/material/styles'; -import { SELECT_MENU_PROPS } from '../shared/selectMenuProps'; -import { SECTION_LABEL_SX } from '../shared/commandCenterStyles'; -import type { AgentFormData } from './agentValidation'; - -interface VectorStore { - id: string; - name?: string; -} - -interface CapabilitiesTabProps { - agent: AgentFormData; - agentKey: string; - modelOptions: string[]; - modelsLoading: boolean; - availableMcpServers: { id: string; name: string }[]; - vectorStores: VectorStore[]; - onUpdateAgent: ( - key: string, - field: keyof AgentFormData, - value: unknown, - ) => void; - onRefreshModels: () => void; -} - -export const CapabilitiesTab = memo(function CapabilitiesTab({ - agent, - agentKey, - modelOptions, - modelsLoading, - availableMcpServers, - vectorStores, - onUpdateAgent, - onRefreshModels, -}: CapabilitiesTabProps) { - const theme = useTheme(); - - const staleMcpIds = useMemo(() => { - const availableIds = new Set(availableMcpServers.map(s => s.id)); - return agent.mcpServers.filter(id => !availableIds.has(id)); - }, [agent.mcpServers, availableMcpServers]); - - const cleanMcpSelection = () => { - const availableIds = new Set(availableMcpServers.map(s => s.id)); - onUpdateAgent( - agentKey, - 'mcpServers', - agent.mcpServers.filter(id => availableIds.has(id)), - ); - }; - - return ( - - {/* Model Override */} - Model - - - onUpdateAgent(agentKey, 'model', newValue) - } - getOptionLabel={opt => (typeof opt === 'string' ? opt : '')} - loading={modelsLoading} - renderInput={params => ( - - {modelsLoading ? : null} - {params.InputProps.endAdornment} - - ), - }} - /> - )} - sx={{ flex: 1, maxWidth: 480 }} - /> - - - - - - - - {/* MCP Servers */} - - - MCP Servers - - - Select which MCP tool servers this agent can access. Servers are - configured in Platform Config. - - - {availableMcpServers.length > 0 ? ( - - - MCP Servers - - - {agent.mcpServers.length === 0 && ( - - No servers selected — agent will not have access to external MCP - tools - - )} - {staleMcpIds.length > 0 && ( - } - action={ - - } - sx={{ mt: 1, py: 0.25, fontSize: '0.75rem' }} - > - {staleMcpIds.length === 1 - ? `Server "${staleMcpIds[0]}" is no longer available in Platform Config.` - : `${staleMcpIds.length} servers are no longer available: ${staleMcpIds.join(', ')}`} - - )} - - ) : ( - - No MCP servers configured yet. Add servers in{' '} - Platform Config → Model & Tools to enable external - tool access for this agent. - - )} - - {/* Built-in Tools */} - - Built-in Tools - - - - onUpdateAgent(agentKey, 'enableRAG', e.target.checked) - } - /> - } - label={ - - - Knowledge Base - - - Search uploaded documents for context - - - } - /> - {agent.enableRAG && vectorStores.length > 0 && ( - - - Vector Stores - - - - Leave empty to use global vector stores - - - )} - - onUpdateAgent(agentKey, 'enableWebSearch', e.target.checked) - } - /> - } - label={ - - Web Search - - Search the web for current information - - - } - /> - - onUpdateAgent( - agentKey, - 'enableCodeInterpreter', - e.target.checked, - ) - } - /> - } - label={ - - - Code Interpreter - - - Execute code to answer questions - - - } - /> - - - ); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/ConnectionsTab.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/ConnectionsTab.tsx deleted file mode 100644 index 977826b2a81..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/ConnectionsTab.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { memo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; -import Select from '@mui/material/Select'; -import MenuItem from '@mui/material/MenuItem'; -import InputLabel from '@mui/material/InputLabel'; -import FormControl from '@mui/material/FormControl'; -import { useTheme } from '@mui/material/styles'; -import { SELECT_MENU_PROPS } from '../shared/selectMenuProps'; -import type { AgentFormData, PublishAsRole } from './agentValidation'; -import { TopologyGraph, type TopologyEdge } from './TopologyGraph'; - -interface ConnectionsTabProps { - agents: Record; - agentKeys: string[]; - selectedAgentKey: string; - selectedAgent: AgentFormData; - topologyEdges: TopologyEdge[]; - agentRoles: Record; - onUpdateAgent: ( - key: string, - field: keyof AgentFormData, - value: unknown, - ) => void; - onSelectAgent: (key: string) => void; -} - -export const ConnectionsTab = memo(function ConnectionsTab({ - agents, - agentKeys, - selectedAgentKey, - selectedAgent, - topologyEdges, - agentRoles, - onUpdateAgent, - onSelectAgent, -}: ConnectionsTabProps) { - const theme = useTheme(); - - if (agentKeys.length <= 1) { - return ( - - - No other agents to connect to yet. - - - Create additional agents to enable handoffs (transfer conversation - control) and delegation (run as sub-tasks). - - - ); - } - - return ( - - - Can Transfer To - - - - Target agent takes over the conversation. This agent stops responding. - - - - Can Delegate To - - - - Sub-agent runs in the background and returns results. This agent stays - in control. - - - - - ); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/CreateAgentModal.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/CreateAgentModal.tsx deleted file mode 100644 index 54d3c03a5ba..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/CreateAgentModal.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useEffect, useCallback, memo } from 'react'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import DialogContent from '@mui/material/DialogContent'; -import DialogActions from '@mui/material/DialogActions'; -import type { AgentFormData } from './agentValidation'; - -function normalizeAgentId(raw: string): string { - return raw - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); -} - -interface CreateAgentModalProps { - open: boolean; - agents: Record; - isFirstAgent: boolean; - onClose: () => void; - onCreate: (name: string, id: string) => void; -} - -export const CreateAgentModal = memo(function CreateAgentModal({ - open, - agents, - isFirstAgent, - onClose, - onCreate, -}: CreateAgentModalProps) { - const [name, setName] = useState(''); - const [id, setId] = useState(''); - const [idManual, setIdManual] = useState(false); - - useEffect(() => { - if (!idManual) setId(normalizeAgentId(name)); - }, [name, idManual]); - - const handleClose = useCallback(() => { - onClose(); - setName(''); - setId(''); - setIdManual(false); - }, [onClose]); - - const handleCreate = useCallback(() => { - const normalizedId = normalizeAgentId(id); - if (!normalizedId || agents[normalizedId]) return; - onCreate(name.trim(), normalizedId); - setName(''); - setId(''); - setIdManual(false); - }, [id, name, agents, onCreate]); - - const normalizedId = normalizeAgentId(id); - const isDuplicate = Boolean(normalizedId && agents[normalizedId]); - - return ( - - New Agent - - - {isFirstAgent - ? 'Create your first agent. Its role will be determined automatically from its connections.' - : 'Add another agent. Its role (Router, Specialist, or Standalone) will be determined automatically based on how you connect it to other agents.'} - - setName(e.target.value)} - fullWidth - size="small" - // eslint-disable-next-line jsx-a11y/no-autofocus - autoFocus - placeholder="e.g. Support Agent" - sx={{ mb: 2 }} - /> - { - setId(e.target.value); - setIdManual(true); - }} - fullWidth - size="small" - helperText={ - isDuplicate - ? 'This ID already exists.' - : 'Auto-generated from name.' - } - error={isDuplicate} - /> - - - - - - - ); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/FieldHelpPopover.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/FieldHelpPopover.tsx deleted file mode 100644 index 9f519f108ea..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/FieldHelpPopover.tsx +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, memo, type MouseEvent } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import Popover from '@mui/material/Popover'; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import { useTheme } from '@mui/material/styles'; - -interface FieldHelpEntry { - title: string; - description: string; - recommended?: string; -} - -const FIELD_HELP: Record = { - toolChoice: { - title: 'Tool Choice', - description: - 'Controls when the model invokes tools. "auto" lets the model decide, "required" forces tool use on every turn, "none" disables tools entirely.', - recommended: - 'Use "auto" for most agents. Use "required" for pure routing agents that should always delegate.', - }, - reasoning: { - title: 'Reasoning Effort', - description: - 'Controls how much computation the model spends on chain-of-thought reasoning before responding.', - recommended: - '"medium" works well for most tasks. Use "high" for complex multi-step problems.', - }, - temperature: { - title: 'Temperature', - description: - "Controls randomness in the model's output. Lower values (0.0-0.3) produce more focused, deterministic responses. Higher values (0.7-1.5) produce more creative, varied responses.", - recommended: - '0.0-0.2 for factual/code tasks, 0.5-0.8 for creative writing.', - }, - maxOutputTokens: { - title: 'Max Output Tokens', - description: - 'The maximum number of tokens the model can generate in a single response. One token is roughly 4 characters.', - recommended: - "Leave empty to use the model's default. Set to 4096 for long-form content.", - }, - maxToolCalls: { - title: 'Max Tool Calls', - description: - 'Limits the number of tool invocations the model can make in a single turn to prevent runaway loops.', - recommended: - 'Leave empty for default. Set to 5-10 for agents with many tools.', - }, - guardrails: { - title: 'Guardrails', - description: - "Comma-separated list of LlamaGuard shield IDs to apply to this agent's inputs and outputs for content safety filtering.", - recommended: - 'Add shields for user-facing agents that handle sensitive topics.', - }, -}; - -interface FieldHelpButtonProps { - field: string; -} - -export const FieldHelpButton = memo(function FieldHelpButton({ - field, -}: FieldHelpButtonProps) { - const theme = useTheme(); - const [anchor, setAnchor] = useState(null); - const info = FIELD_HELP[field]; - - const handleOpen = useCallback( - (e: MouseEvent) => setAnchor(e.currentTarget), - [], - ); - const handleClose = useCallback(() => setAnchor(null), []); - - if (!info) return null; - - return ( - <> - - - - - - - - - {info.title} - - - {info.description} - - {info.recommended && ( - - {info.recommended} - - )} - - - - ); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/InstructionsTab.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/InstructionsTab.tsx deleted file mode 100644 index f47bd827606..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/InstructionsTab.tsx +++ /dev/null @@ -1,303 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useMemo, useCallback, memo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Alert from '@mui/material/Alert'; -import Chip from '@mui/material/Chip'; -import IconButton from '@mui/material/IconButton'; -import Autocomplete from '@mui/material/Autocomplete'; -import CircularProgress from '@mui/material/CircularProgress'; -import Tooltip from '@mui/material/Tooltip'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; -import EditNoteIcon from '@mui/icons-material/EditNote'; -import { useTheme } from '@mui/material/styles'; -import type { AgentFormData } from './agentValidation'; - -interface InstructionsTabProps { - agent: AgentFormData; - agents: Record; - availableMcpServers: Array<{ id: string; name: string }>; - modelOptions: string[]; - modelsLoading: boolean; - effectiveModel: string; - generating: boolean; - generateError: string | null; - onUpdateInstructions: (value: string) => void; - onGenerate: (description: string, model: string | undefined) => Promise; - onRefreshModels: () => void; -} - -export const InstructionsTab = memo(function InstructionsTab({ - agent, - agents, - availableMcpServers, - modelOptions, - modelsLoading, - effectiveModel, - generating, - generateError, - onUpdateInstructions, - onGenerate, - onRefreshModels, -}: InstructionsTabProps) { - const theme = useTheme(); - const [mode, setMode] = useState<'write' | 'generate'>('write'); - const [description, setDescription] = useState(''); - const [model, setModel] = useState(''); - - const configSummary = useMemo(() => { - const caps: string[] = []; - if (agent.model) caps.push(`Model: ${agent.model}`); - if (agent.enableRAG) caps.push('Knowledge Base'); - if (agent.enableWebSearch) caps.push('Web Search'); - if (agent.enableCodeInterpreter) caps.push('Code Interpreter'); - agent.mcpServers.forEach(id => { - caps.push(availableMcpServers.find(s => s.id === id)?.name || id); - }); - agent.handoffs - .filter(k => agents[k]) - .forEach(k => caps.push(`→ ${agents[k].name || k}`)); - agent.asTools - .filter(k => agents[k]) - .forEach(k => caps.push(`⇢ ${agents[k].name || k}`)); - return caps; - }, [agent, agents, availableMcpServers]); - - const handleGenerate = useCallback(async () => { - if (!description.trim()) return; - try { - await onGenerate(description, model || undefined); - setMode('write'); - setDescription(''); - } catch { - // error surfaced via generateError prop - } - }, [description, model, onGenerate]); - - return ( - - - setMode(v)} - aria-label="Instructions input mode" - sx={{ - minHeight: 32, - '& .MuiTab-root': { - minHeight: 32, - textTransform: 'none', - fontSize: '0.8125rem', - px: 2, - mr: 0.5, - }, - }} - > - } - iconPosition="start" - label="Write" - value="write" - /> - } - iconPosition="start" - label="Generate" - value="generate" - /> - - - - {mode === 'write' && ( - - - The complete prompt sent to the LLM for this agent. What you write - here is exactly what the model receives. - - onUpdateInstructions(e.target.value)} - fullWidth - multiline - minRows={6} - maxRows={18} - size="small" - required - /> - - )} - - {mode === 'generate' && ( - - - Describe what this agent should do. Instructions will be generated - based on the capabilities, connections, and tools you've configured. - - - setDescription(e.target.value)} - placeholder="e.g. Help developers troubleshoot Kubernetes deployments, check pod logs, and suggest fixes..." - disabled={generating} - inputProps={{ maxLength: 2000 }} - sx={{ mb: 2 }} - /> - - {configSummary.length > 0 && ( - - - Context included from configuration - - - {configSummary.map((c, i) => ( - - ))} - - - )} - - - - setModel(v)} - getOptionLabel={opt => (typeof opt === 'string' ? opt : '')} - loading={modelsLoading} - disabled={generating} - renderInput={params => ( - - {modelsLoading ? ( - - ) : null} - {params.InputProps.endAdornment} - - ), - }} - /> - )} - sx={{ flex: 1 }} - /> - - - - - - - - - - {description.length}/2000 - - - - - {generateError && ( - - {generateError} - - )} - - )} - - ); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/MultiAgentEditor.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/MultiAgentEditor.tsx deleted file mode 100644 index b11c22a8e44..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/MultiAgentEditor.tsx +++ /dev/null @@ -1,460 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useMemo, memo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import TextField from '@mui/material/TextField'; -import Alert from '@mui/material/Alert'; -import Chip from '@mui/material/Chip'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import DeleteIcon from '@mui/icons-material/Delete'; -import WarningAmberIcon from '@mui/icons-material/WarningAmber'; -import HubIcon from '@mui/icons-material/Hub'; -import BuildIcon from '@mui/icons-material/Build'; -import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; -import { useTheme, alpha } from '@mui/material/styles'; -import { AgentListItem } from './AgentListItem'; -import { AgentPreviewCard } from './AgentPreviewCard'; -import { CapabilitiesTab } from './CapabilitiesTab'; -import { ConnectionsTab } from './ConnectionsTab'; -import { AdvancedTab } from './AdvancedTab'; -import { InstructionsTab } from './InstructionsTab'; -import type { AgentEditorState } from './useAgentEditor'; - -const LEFT_PANEL_WIDTH = 280; -const RIGHT_MAX_WIDTH = 780; - -interface MultiAgentEditorProps { - editor: AgentEditorState; -} - -export const MultiAgentEditor = memo(function MultiAgentEditor({ - editor, -}: MultiAgentEditorProps) { - const theme = useTheme(); - const { - agents, - agentKeys, - selectedAgentKey, - selectedAgent, - defaultAgentKey, - agentRoles, - selectedAgentRole, - showConnections, - edgeCounts, - isDirty, - agentsSource, - validation, - topologyEdges, - tabCompletion, - activeTab, - setActiveTab, - showPreview, - modelOptions, - modelsLoading, - generating, - generateError, - effectiveModel, - availableMcpServers, - vectorStores, - handleSelectAgent, - handleRemoveAgent, - updateAgent, - handleUpdateInstructions, - handleGenerateForTab, - refreshModels, - } = editor; - - const connectionsTab = showConnections ? 1 : -1; - const advancedTab = showConnections ? 2 : 1; - const instructionsTab = showConnections ? 3 : 2; - - const tabHasError = (tabIndex: number): boolean => { - if (!selectedAgentKey || !selectedAgent) return false; - const nameErr = !selectedAgent.name.trim(); - const instrErr = !selectedAgent.instructions.trim(); - if (tabIndex === 0) return nameErr; - if (tabIndex === instructionsTab) return instrErr; - return false; - }; - - const renderTabLabel = ( - label: string, - tabIndex: number, - completionKey?: keyof NonNullable, - ) => { - const hasError = tabHasError(tabIndex); - const isComplete = - completionKey && tabCompletion ? tabCompletion[completionKey] : false; - return ( - - {label} - {hasError && ( - - )} - {!hasError && isComplete && ( - - )} - - ); - }; - - const roleDescription = useMemo(() => { - if (selectedAgentRole === 'router') - return 'Visible in gallery \u2014 routes to other agents'; - if (selectedAgentRole === 'specialist') - return 'Hidden \u2014 only reachable via handoffs'; - return 'Visible in gallery \u2014 independent agent'; - }, [selectedAgentRole]); - - return ( - - - - {agentKeys.map(key => ( - - ))} - - - - - - {selectedAgentKey && selectedAgent ? ( - - {/* Identity */} - - - - updateAgent(selectedAgentKey, 'name', e.target.value) - } - variant="standard" - placeholder="Agent name" - error={!selectedAgent.name.trim() && isDirty} - helperText={ - !selectedAgent.name.trim() && isDirty - ? 'Name is required' - : undefined - } - InputProps={{ - sx: { fontSize: '1.1rem', fontWeight: 600 }, - disableUnderline: true, - }} - sx={{ flex: 1 }} - /> - - {agentsSource === 'database' && ( - - )} - - handleRemoveAgent(selectedAgentKey)} - sx={{ - color: theme.palette.error.main, - opacity: 0.35, - '&:hover': { opacity: 1 }, - }} - > - - - - - - { - if (selectedAgentRole === 'router') - return ; - if (selectedAgentRole === 'specialist') - return ; - return ; - })()} - label={ - selectedAgentRole.charAt(0).toUpperCase() + - selectedAgentRole.slice(1) - } - color={(() => { - if (selectedAgentRole === 'router') return 'primary'; - if (selectedAgentRole === 'specialist') return 'default'; - return 'success'; - })()} - variant="outlined" - sx={{ fontWeight: 600, fontSize: '0.75rem' }} - /> - - {roleDescription} - - - {showConnections && ( - - updateAgent( - selectedAgentKey, - 'handoffDescription', - e.target.value, - ) - } - variant="standard" - fullWidth - placeholder="Add a description \u2014 other agents read this when deciding to route here" - InputProps={{ - sx: { - fontSize: '0.8rem', - color: theme.palette.text.secondary, - borderBottom: `1px dashed ${alpha(theme.palette.text.secondary, 0.25)}`, - '&:hover': { - borderBottom: `1px dashed ${alpha(theme.palette.text.secondary, 0.5)}`, - }, - '&.Mui-focused': { - borderBottom: `1px solid ${theme.palette.primary.main}`, - }, - }, - disableUnderline: true, - }} - /> - )} - - - {/* Validation */} - {validation.errors.length > 0 && ( - } - sx={{ mb: 1, py: 0.25, flexShrink: 0 }} - > - {validation.errors.map(err => ( - - • {err} - - ))} - - )} - {validation.warnings.length > 0 && ( - - {validation.warnings.map(w => ( - - • {w} - - ))} - - )} - - {/* Tabs */} - - setActiveTab(v)} - aria-label="Agent configuration tabs" - sx={{ - minHeight: 36, - '& .MuiTab-root': { - textTransform: 'none', - fontWeight: 500, - fontSize: '0.8125rem', - minHeight: 36, - px: 2, - }, - }} - > - - {showConnections && ( - - )} - - - - - - {/* Tab content */} - - {activeTab === 0 && ( - - )} - {activeTab === connectionsTab && ( - - )} - {activeTab === advancedTab && ( - - )} - {activeTab === instructionsTab && ( - - - - )} - - - ) : ( - - Select an agent from the list - - )} - - - {showPreview && selectedAgent && ( - - )} - - - ); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/SingleAgentStepper.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/SingleAgentStepper.tsx deleted file mode 100644 index 4b1004e346b..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/SingleAgentStepper.tsx +++ /dev/null @@ -1,313 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { memo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Tooltip from '@mui/material/Tooltip'; -import Stepper from '@mui/material/Stepper'; -import Step from '@mui/material/Step'; -import StepLabel from '@mui/material/StepLabel'; -import StepButton from '@mui/material/StepButton'; -import { useTheme, alpha } from '@mui/material/styles'; -import { - SECTION_LABEL_SX, - HOVER_TRANSITION, -} from '../shared/commandCenterStyles'; -import { AGENT_TEMPLATES } from './agentTemplates'; -import { InstructionsTab } from './InstructionsTab'; -import { CapabilitiesTab } from './CapabilitiesTab'; -import { AgentReviewStep } from './AgentReviewStep'; -import type { AgentEditorState } from './useAgentEditor'; - -const LEFT_PANEL_WIDTH = 280; -const RIGHT_MAX_WIDTH = 780; -const SINGLE_STEPS = ['Name', 'Instructions', 'Capabilities', 'Review']; - -interface SingleAgentStepperProps { - editor: AgentEditorState; -} - -export const SingleAgentStepper = memo(function SingleAgentStepper({ - editor, -}: SingleAgentStepperProps) { - const theme = useTheme(); - const { - selectedAgentKey, - selectedAgent, - stepperStep, - setStepperStep, - selectedTemplate, - isDirty, - tabCompletion, - saveSuccess, - saving, - validation, - agents, - availableMcpServers, - modelOptions, - modelsLoading, - generating, - generateError, - effectiveModel, - vectorStores, - handleSave, - handleUpdateInstructions, - handleGenerateForTab, - refreshModels, - updateAgent, - applyTemplate, - onSaved, - } = editor; - - if (!selectedAgentKey || !selectedAgent) return null; - - let canContinue = true; - if (stepperStep === 0) canContinue = !!selectedAgent.name.trim(); - else if (stepperStep === 1) canContinue = !!selectedAgent.instructions.trim(); - - const renderStepContent = () => { - switch (stepperStep) { - case 0: - return ( - - - Name Your Agent - - - Give your agent a clear, descriptive name so users know what it - does. - - - updateAgent(selectedAgentKey, 'name', e.target.value) - } - onKeyDown={e => { - if (e.key === 'Enter' && selectedAgent.name.trim()) { - e.preventDefault(); - setStepperStep(1); - } - }} - label="Agent Name" - placeholder="e.g. Code Review Assistant" - fullWidth - size="small" - error={selectedAgent.name.trim() === '' && isDirty} - helperText={ - selectedAgent.name.trim() === '' && isDirty - ? 'Name is required' - : undefined - } - sx={{ mb: 3, maxWidth: 480 }} - /> - - Start from a template - - - {AGENT_TEMPLATES.map(tpl => ( - applyTemplate(tpl.id)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - applyTemplate(tpl.id); - } - }} - sx={{ - p: 2, - borderRadius: 2, - border: - selectedTemplate === tpl.id - ? `2px solid ${theme.palette.primary.main}` - : `1px solid ${theme.palette.divider}`, - bgcolor: - selectedTemplate === tpl.id - ? alpha(theme.palette.primary.main, 0.04) - : 'transparent', - cursor: 'pointer', - width: 180, - transition: HOVER_TRANSITION, - '&:hover': { - borderColor: theme.palette.primary.main, - bgcolor: alpha(theme.palette.primary.main, 0.02), - }, - }} - > - - {tpl.icon} - - - {tpl.name} - - - {tpl.description} - - - ))} - - - ); - case 1: - return ( - - - Write Instructions - - - Define exactly what your agent should do. This is the system - prompt sent to the model. - - - - ); - case 2: - return ( - - ); - case 3: - return ( - setStepperStep(2)} - /> - ); - default: - return null; - } - }; - - return ( - - - - {SINGLE_STEPS.map((label, idx) => { - let completed = false; - if (idx === 0) completed = !!selectedAgent.name.trim(); - if (idx === 1) completed = !!selectedAgent.instructions.trim(); - if (idx === 2) completed = !!tabCompletion?.capabilities; - if (idx === 3) completed = saveSuccess; - return ( - - setStepperStep(idx)}> - - {label} - - - - ); - })} - - - - - {renderStepContent()} - {stepperStep < 3 && ( - - {stepperStep > 0 && ( - - )} - { - if (!canContinue) { - if (stepperStep === 0) - return 'Enter an agent name to continue'; - return 'Add instructions to continue'; - } - return ''; - })()} - > - - - - - - )} - - - - ); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/TopologyGraph.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/TopologyGraph.tsx deleted file mode 100644 index 5a77ff13b01..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/TopologyGraph.tsx +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useMemo, memo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; -import { useTheme, alpha } from '@mui/material/styles'; -import { - SECTION_LABEL_SX, - HOVER_TRANSITION, - subtleBorder, -} from '../shared/commandCenterStyles'; -import type { AgentFormData, PublishAsRole } from './agentValidation'; - -export interface TopologyEdge { - from: string; - to: string; - type: 'handoff' | 'subtask'; -} - -interface TopologyGraphProps { - agents: Record; - edges: TopologyEdge[]; - agentRoles: Record; - selectedAgentKey: string | null; - onSelectAgent: (key: string) => void; -} - -export const TopologyGraph = memo(function TopologyGraph({ - agents, - edges, - agentRoles, - selectedAgentKey, - onSelectAgent, -}: TopologyGraphProps) { - const theme = useTheme(); - - const nodes = useMemo(() => { - const set = new Set(); - edges.forEach(e => { - set.add(e.from); - set.add(e.to); - }); - return Array.from(set); - }, [edges]); - - if (edges.length === 0) return null; - - return ( - - Agent Topology - - - {nodes.map(nodeKey => { - const isCurrent = nodeKey === selectedAgentKey; - const role = agentRoles[nodeKey] ?? 'standalone'; - return ( - onSelectAgent(nodeKey)} - sx={{ - p: 1, - px: 1.5, - borderRadius: 1.5, - border: isCurrent - ? `2px solid ${theme.palette.primary.main}` - : `1px solid ${theme.palette.divider}`, - bgcolor: isCurrent - ? alpha(theme.palette.primary.main, 0.06) - : theme.palette.background.paper, - cursor: 'pointer', - transition: HOVER_TRANSITION, - '&:hover': { borderColor: theme.palette.primary.main }, - display: 'flex', - alignItems: 'center', - gap: 0.75, - minWidth: 100, - }} - > - - {agents[nodeKey]?.name || nodeKey} - - { - if (role === 'router') return 'primary'; - if (role === 'specialist') return 'secondary'; - return 'default'; - })()} - sx={{ - height: 16, - fontSize: '0.55rem', - '& .MuiChip-label': { px: 0.5 }, - }} - /> - - ); - })} - - - {edges.map(edge => { - const isCurrent = - edge.from === selectedAgentKey || edge.to === selectedAgentKey; - return ( - - - {agents[edge.from]?.name || edge.from} - - - {edge.type === 'handoff' - ? '\u2192 transfers to' - : '\u21E2 delegates to'} - - - {agents[edge.to]?.name || edge.to} - - - ); - })} - - - - ); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/agentTemplates.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/agentTemplates.tsx deleted file mode 100644 index 01440ca0a7d..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/agentTemplates.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; -import CodeIcon from '@mui/icons-material/Code'; -import PublicIcon from '@mui/icons-material/Public'; -import MenuBookIcon from '@mui/icons-material/MenuBook'; -import type { AgentFormData } from './agentValidation'; - -export interface AgentTemplate { - id: string; - name: string; - icon: React.ReactElement; - description: string; - defaults: Partial; -} - -export const AGENT_TEMPLATES: AgentTemplate[] = [ - { - id: 'blank', - name: 'Blank Agent', - icon: , - description: 'Start from scratch with a clean slate', - defaults: {}, - }, - { - id: 'code-assistant', - name: 'Code Assistant', - icon: , - description: 'Helps write, review, and debug code', - defaults: { - enableCodeInterpreter: true, - enableWebSearch: true, - instructions: - 'You are a skilled software engineering assistant. Help users write clean, efficient code. You can execute code to test solutions and search the web for documentation and best practices. Always explain your reasoning and suggest improvements.', - }, - }, - { - id: 'research-agent', - name: 'Research Agent', - icon: , - description: 'Finds and synthesizes information from documents and the web', - defaults: { - enableRAG: true, - enableWebSearch: true, - instructions: - 'You are a research assistant. Search the knowledge base and the web to find accurate, up-to-date information. Synthesize findings into clear, well-organized responses. Always cite your sources and distinguish between verified facts and inferences.', - }, - }, - { - id: 'knowledge-expert', - name: 'Knowledge Expert', - icon: , - description: 'Answers questions using your uploaded documents', - defaults: { - enableRAG: true, - instructions: - 'You are a knowledgeable assistant with access to an internal knowledge base. Answer questions accurately using the available documents. If the answer is not in the knowledge base, clearly state that and suggest where the user might find the information.', - }, - }, -]; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/agentValidation.test.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/agentValidation.test.ts deleted file mode 100644 index 4150fb24704..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/agentValidation.test.ts +++ /dev/null @@ -1,511 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - createDefaultAgent, - agentFromConfig, - detectCircularHandoffs, - validateAgents, - buildAgentContext, - type AgentFormData, -} from './agentValidation'; - -describe('agentValidation', () => { - describe('createDefaultAgent', () => { - it('returns blank agent form data', () => { - const agent = createDefaultAgent(); - expect(agent.name).toBe(''); - expect(agent.instructions).toBe(''); - expect(agent.handoffs).toEqual([]); - expect(agent.asTools).toEqual([]); - expect(agent.mcpServers).toEqual([]); - expect(agent.enableRAG).toBe(false); - expect(agent.enableWebSearch).toBe(false); - expect(agent.enableCodeInterpreter).toBe(false); - }); - }); - - describe('agentFromConfig', () => { - it('parses a full agent config', () => { - const cfg = { - name: 'Triage Agent', - instructions: 'Route the user', - handoffDescription: 'Routes incoming requests', - model: 'llama-3.3', - handoffs: ['billing', 'support'], - asTools: ['lookup'], - mcpServers: ['ocp-server'], - enableRAG: true, - enableWebSearch: false, - enableCodeInterpreter: true, - }; - - const result = agentFromConfig(cfg); - - expect(result.name).toBe('Triage Agent'); - expect(result.instructions).toBe('Route the user'); - expect(result.handoffDescription).toBe('Routes incoming requests'); - expect(result.model).toBe('llama-3.3'); - expect(result.handoffs).toEqual(['billing', 'support']); - expect(result.asTools).toEqual(['lookup']); - expect(result.mcpServers).toEqual(['ocp-server']); - expect(result.enableRAG).toBe(true); - expect(result.enableWebSearch).toBe(false); - expect(result.enableCodeInterpreter).toBe(true); - }); - - it('handles missing fields gracefully', () => { - const result = agentFromConfig({}); - expect(result.name).toBe(''); - expect(result.instructions).toBe(''); - expect(result.handoffs).toEqual([]); - expect(result.enableRAG).toBe(false); - }); - - it('handles non-array handoffs gracefully', () => { - const result = agentFromConfig({ handoffs: 'not-an-array' }); - expect(result.handoffs).toEqual([]); - }); - - it('handles non-array asTools gracefully', () => { - const result = agentFromConfig({ asTools: 42 }); - expect(result.asTools).toEqual([]); - }); - - it('handles non-array mcpServers gracefully', () => { - const result = agentFromConfig({ mcpServers: null }); - expect(result.mcpServers).toEqual([]); - }); - - it('filters non-string elements from arrays', () => { - const result = agentFromConfig({ - handoffs: ['valid', 123, null, 'also-valid'], - }); - expect(result.handoffs).toEqual(['valid', 'also-valid']); - }); - - it('handles null/undefined string fields', () => { - const result = agentFromConfig({ name: null, instructions: undefined }); - expect(result.name).toBe(''); - expect(result.instructions).toBe(''); - }); - }); - - describe('detectCircularHandoffs', () => { - function agent(overrides: Partial = {}): AgentFormData { - return { - ...createDefaultAgent(), - name: 'Test', - instructions: 'test', - ...overrides, - }; - } - - it('returns empty for agents with no handoffs', () => { - const agents = { - triage: agent(), - billing: agent(), - }; - expect(detectCircularHandoffs(agents)).toEqual([]); - }); - - it('returns empty for linear handoff chain', () => { - const agents = { - triage: agent({ handoffs: ['billing'] }), - billing: agent({ handoffs: ['support'] }), - support: agent(), - }; - expect(detectCircularHandoffs(agents)).toEqual([]); - }); - - it('allows A → B → A routing pattern (standard handoff-back)', () => { - const agents = { - a: agent({ handoffs: ['b'] }), - b: agent({ handoffs: ['a'] }), - }; - expect(detectCircularHandoffs(agents)).toEqual([]); - }); - - it('allows A → B → C → A routing pattern (standard multi-hop)', () => { - const agents = { - a: agent({ handoffs: ['b'] }), - b: agent({ handoffs: ['c'] }), - c: agent({ handoffs: ['a'] }), - }; - expect(detectCircularHandoffs(agents)).toEqual([]); - }); - - it('detects self-referencing handoff', () => { - const agents = { - a: agent({ handoffs: ['a'] }), - }; - const warnings = detectCircularHandoffs(agents); - expect(warnings.length).toBe(1); - expect(warnings[0]).toContain('hands off to itself'); - }); - - it('returns empty for empty agent map', () => { - expect(detectCircularHandoffs({})).toEqual([]); - }); - }); - - describe('validateAgents', () => { - function agent(overrides: Partial = {}): AgentFormData { - return { - ...createDefaultAgent(), - name: 'Agent', - instructions: 'Do something', - ...overrides, - }; - } - - it('returns no errors for a valid single agent', () => { - const agents = { triage: agent({ name: 'Triage' }) }; - const result = validateAgents(agents, 'triage'); - expect(result.errors).toEqual([]); - expect(result.warnings).toEqual([]); - }); - - it('returns no errors for a valid multi-agent setup', () => { - const agents = { - triage: agent({ name: 'Triage', handoffs: ['billing'] }), - billing: agent({ name: 'Billing' }), - }; - const result = validateAgents(agents, 'triage'); - expect(result.errors).toEqual([]); - }); - - it('reports missing name', () => { - const agents = { triage: agent({ name: '' }) }; - const result = validateAgents(agents, 'triage'); - expect(result.errors).toContain('Agent "triage" has no name'); - }); - - it('reports missing instructions', () => { - const agents = { triage: agent({ instructions: '' }) }; - const result = validateAgents(agents, 'triage'); - expect(result.errors).toContain('Agent "triage" has no instructions'); - }); - - it('reports handoff to unknown agent', () => { - const agents = { - triage: agent({ name: 'Triage', handoffs: ['nonexistent'] }), - }; - const result = validateAgents(agents, 'triage'); - expect(result.errors).toContain( - 'Agent "triage" hands off to unknown "nonexistent"', - ); - }); - - it('reports asTools referencing unknown agent', () => { - const agents = { - triage: agent({ name: 'Triage', asTools: ['ghost'] }), - }; - const result = validateAgents(agents, 'triage'); - expect(result.errors).toContain( - 'Agent "triage" calls unknown agent "ghost" as tool', - ); - }); - - it('reports invalid default agent', () => { - const agents = { - triage: agent({ name: 'Triage', handoffs: ['support'] }), - support: agent({ name: 'Support' }), - }; - const result = validateAgents(agents, 'billing'); - expect(result.errors).toContain('Default agent "billing" not found'); - }); - - it('allows standard handoff-back pattern (A → B → A) without circular warnings', () => { - const agents = { - a: agent({ name: 'A', handoffs: ['b'], handoffDescription: 'Agent A' }), - b: agent({ name: 'B', handoffs: ['a'], handoffDescription: 'Agent B' }), - }; - const result = validateAgents(agents, 'a'); - expect(result.errors).toEqual([]); - expect(result.warnings).toEqual([]); - }); - - it('returns no errors for empty agent map with empty default', () => { - const result = validateAgents({}, ''); - expect(result.errors).toEqual([]); - expect(result.warnings).toEqual([]); - }); - - it('accumulates multiple errors', () => { - const agents = { - triage: agent({ name: '', instructions: '', handoffs: ['unknown'] }), - }; - const result = validateAgents(agents, 'bad-default'); - expect(result.errors.length).toBeGreaterThanOrEqual(3); - }); - - it('reports whitespace-only name as missing', () => { - const agents = { a: agent({ name: ' ' }) }; - const result = validateAgents(agents, 'a'); - expect(result.errors).toContain('Agent "a" has no name'); - }); - - it('reports whitespace-only instructions as missing', () => { - const agents = { a: agent({ instructions: ' \n ' }) }; - const result = validateAgents(agents, 'a'); - expect(result.errors).toContain('Agent "a" has no instructions'); - }); - - it('does not block save for standard handoff-back-to-entry pattern', () => { - const agents = { - router: agent({ name: 'Router', handoffs: ['ops', 'support'] }), - ops: agent({ name: 'Ops', handoffs: ['router'] }), - support: agent({ name: 'Support', handoffs: ['router'] }), - }; - const result = validateAgents(agents, 'router'); - expect(result.errors).toEqual([]); - expect(result.warnings.length).toBeGreaterThan(0); - }); - - it('warns when default agent has no handoffs or delegates in multi-agent setup', () => { - const agents = { - router: agent({ - name: 'Router', - handoffs: [], - handoffDescription: 'Routes', - }), - support: agent({ name: 'Support', handoffDescription: 'Supports' }), - }; - const result = validateAgents(agents, 'router'); - expect( - result.warnings.some( - w => - w.includes('starting agent') && - w.includes('no handoffs or delegates'), - ), - ).toBe(true); - }); - - it('warns when handoff target has no handoff description', () => { - const agents = { - a: agent({ name: 'A', handoffs: ['b'], handoffDescription: 'Agent A' }), - b: agent({ name: 'B', handoffDescription: '' }), - }; - const result = validateAgents(agents, 'a'); - expect( - result.warnings.some( - w => - w.includes('handoff target') && - w.includes('no handoff description'), - ), - ).toBe(true); - }); - - it('warns when toolChoice=required with handoffs and tools', () => { - const agents = { - a: agent({ - name: 'A', - handoffs: ['b'], - toolChoice: 'required', - enableRAG: true, - handoffDescription: 'A desc', - }), - b: agent({ name: 'B', handoffDescription: 'B desc' }), - }; - const result = validateAgents(agents, 'a'); - expect( - result.warnings.some( - w => w.includes('toolChoice=required') && w.includes('handoffs'), - ), - ).toBe(true); - }); - - it('reports multiple unknown handoffs', () => { - const agents = { - triage: agent({ name: 'Triage', handoffs: ['x', 'y', 'z'] }), - }; - const result = validateAgents(agents, 'triage'); - expect(result.errors).toContain( - 'Agent "triage" hands off to unknown "x"', - ); - expect(result.errors).toContain( - 'Agent "triage" hands off to unknown "y"', - ); - expect(result.errors).toContain( - 'Agent "triage" hands off to unknown "z"', - ); - }); - }); - - describe('agentFromConfig — optional fields', () => { - it('parses valid toolChoice values', () => { - expect(agentFromConfig({ toolChoice: 'auto' }).toolChoice).toBe('auto'); - expect(agentFromConfig({ toolChoice: 'required' }).toolChoice).toBe( - 'required', - ); - expect(agentFromConfig({ toolChoice: 'none' }).toolChoice).toBe('none'); - }); - - it('omits toolChoice when invalid', () => { - expect( - agentFromConfig({ toolChoice: 'invalid' }).toolChoice, - ).toBeUndefined(); - expect(agentFromConfig({ toolChoice: 42 }).toolChoice).toBeUndefined(); - }); - - it('parses temperature, maxOutputTokens, maxToolCalls', () => { - const r = agentFromConfig({ - temperature: 0.7, - maxOutputTokens: 2048, - maxToolCalls: 10, - }); - expect(r.temperature).toBe(0.7); - expect(r.maxOutputTokens).toBe(2048); - expect(r.maxToolCalls).toBe(10); - }); - - it('omits NaN numeric fields', () => { - expect(agentFromConfig({ temperature: NaN }).temperature).toBeUndefined(); - expect( - agentFromConfig({ maxOutputTokens: NaN }).maxOutputTokens, - ).toBeUndefined(); - expect( - agentFromConfig({ maxToolCalls: NaN }).maxToolCalls, - ).toBeUndefined(); - }); - - it('parses guardrails array and filters non-strings', () => { - const r = agentFromConfig({ guardrails: ['g1', 1, 'g2'] }); - expect(r.guardrails).toEqual(['g1', 'g2']); - }); - - it('parses valid reasoning effort', () => { - expect( - agentFromConfig({ reasoning: { effort: 'high' } }).reasoning, - ).toEqual({ effort: 'high' }); - expect( - agentFromConfig({ reasoning: { effort: 'low' } }).reasoning, - ).toEqual({ effort: 'low' }); - expect( - agentFromConfig({ reasoning: { effort: 'medium' } }).reasoning, - ).toEqual({ effort: 'medium' }); - }); - - it('omits reasoning when effort is invalid', () => { - expect( - agentFromConfig({ reasoning: { effort: 'invalid' } }).reasoning, - ).toBeUndefined(); - expect( - agentFromConfig({ reasoning: 'not-object' }).reasoning, - ).toBeUndefined(); - }); - - it('parses resetToolChoice and nestHandoffHistory', () => { - const r = agentFromConfig({ - resetToolChoice: true, - nestHandoffHistory: false, - }); - expect(r.resetToolChoice).toBe(true); - expect(r.nestHandoffHistory).toBe(false); - }); - - it('omits resetToolChoice and nestHandoffHistory when not boolean', () => { - const r = agentFromConfig({ - resetToolChoice: 'yes', - nestHandoffHistory: 1, - }); - expect(r.resetToolChoice).toBeUndefined(); - expect(r.nestHandoffHistory).toBeUndefined(); - }); - }); - - describe('buildAgentContext', () => { - const emptyMcp: Array<{ id: string; name: string }> = []; - - it('includes model or global default when empty', () => { - const a = createDefaultAgent(); - const ctx = buildAgentContext(a, {}, emptyMcp); - expect(ctx).toContain('Model: global default'); - }); - - it('includes model name when set', () => { - const a = agentFromConfig({ model: 'gpt-4' }); - const ctx = buildAgentContext(a, {}, emptyMcp); - expect(ctx).toContain('Model: gpt-4'); - }); - - it('includes built-in tools when enabled', () => { - const a = agentFromConfig({ - enableRAG: true, - enableWebSearch: true, - enableCodeInterpreter: true, - }); - const ctx = buildAgentContext(a, {}, emptyMcp); - expect(ctx).toContain('Knowledge Base (RAG)'); - expect(ctx).toContain('Web Search'); - expect(ctx).toContain('Code Interpreter'); - }); - - it('shows "none" when no built-in tools enabled', () => { - const a = createDefaultAgent(); - const ctx = buildAgentContext(a, {}, emptyMcp); - expect(ctx).toContain('Built-in tools: none'); - }); - - it('includes MCP server names', () => { - const a = agentFromConfig({ mcpServers: ['mcp1'] }); - const mcps = [{ id: 'mcp1', name: 'My MCP Server' }]; - const ctx = buildAgentContext(a, {}, mcps); - expect(ctx).toContain('My MCP Server'); - }); - - it('falls back to MCP server ID when name not found', () => { - const a = agentFromConfig({ mcpServers: ['unknown-id'] }); - const ctx = buildAgentContext(a, {}, emptyMcp); - expect(ctx).toContain('unknown-id'); - }); - - it('includes handoff targets with descriptions', () => { - const allAgents: Record = { - a: agentFromConfig({ - name: 'AgentA', - handoffDescription: 'Handles billing', - }), - b: agentFromConfig({ name: 'AgentB' }), - }; - const agent = agentFromConfig({ handoffs: ['a', 'b'] }); - const ctx = buildAgentContext(agent, allAgents, emptyMcp); - expect(ctx).toContain('AgentA (Handles billing)'); - expect(ctx).toContain('AgentB'); - }); - - it('includes delegates with descriptions', () => { - const allAgents: Record = { - worker: agentFromConfig({ - name: 'Worker', - handoffDescription: 'Does work', - }), - }; - const agent = agentFromConfig({ asTools: ['worker'] }); - const ctx = buildAgentContext(agent, allAgents, emptyMcp); - expect(ctx).toContain('Can delegate to:'); - expect(ctx).toContain('Worker (Does work)'); - }); - - it('includes toolChoice and temperature when set', () => { - const a = agentFromConfig({ toolChoice: 'required', temperature: 0.5 }); - const ctx = buildAgentContext(a, {}, emptyMcp); - expect(ctx).toContain('Tool choice: required'); - expect(ctx).toContain('Temperature: 0.5'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/agentValidation.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/agentValidation.ts deleted file mode 100644 index 1e1b903efc8..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/agentValidation.ts +++ /dev/null @@ -1,376 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - type AgentRole, - deriveRoleFromTopology, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -export type PublishAsRole = AgentRole; - -export interface AgentFormData { - name: string; - instructions: string; - handoffDescription: string; - model: string; - handoffs: string[]; - asTools: string[]; - mcpServers: string[]; - enableRAG: boolean; - vectorStoreIds: string[]; - enableWebSearch: boolean; - enableCodeInterpreter: boolean; - toolChoice?: 'auto' | 'required' | 'none'; - temperature?: number; - maxOutputTokens?: number; - maxToolCalls?: number; - guardrails?: string[]; - reasoning?: { effort: 'low' | 'medium' | 'high' }; - resetToolChoice?: boolean; - nestHandoffHistory?: boolean; -} - -export function createDefaultAgent(): AgentFormData { - return { - name: '', - instructions: '', - handoffDescription: '', - model: '', - handoffs: [], - asTools: [], - mcpServers: [], - enableRAG: false, - vectorStoreIds: [], - enableWebSearch: false, - enableCodeInterpreter: false, - }; -} - -function toStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.filter((item): item is string => typeof item === 'string'); -} - -export function agentFromConfig(cfg: Record): AgentFormData { - const base: AgentFormData = { - name: typeof cfg.name === 'string' ? cfg.name : '', - instructions: typeof cfg.instructions === 'string' ? cfg.instructions : '', - handoffDescription: - typeof cfg.handoffDescription === 'string' ? cfg.handoffDescription : '', - model: typeof cfg.model === 'string' ? cfg.model : '', - handoffs: toStringArray(cfg.handoffs), - asTools: toStringArray(cfg.asTools), - mcpServers: toStringArray(cfg.mcpServers), - enableRAG: Boolean(cfg.enableRAG), - vectorStoreIds: toStringArray(cfg.vectorStoreIds), - enableWebSearch: Boolean(cfg.enableWebSearch), - enableCodeInterpreter: Boolean(cfg.enableCodeInterpreter), - }; - - if ( - typeof cfg.toolChoice === 'string' && - ['auto', 'required', 'none'].includes(cfg.toolChoice) - ) { - base.toolChoice = cfg.toolChoice as 'auto' | 'required' | 'none'; - } - if (typeof cfg.temperature === 'number' && !isNaN(cfg.temperature)) - base.temperature = cfg.temperature; - if (typeof cfg.maxOutputTokens === 'number' && !isNaN(cfg.maxOutputTokens)) - base.maxOutputTokens = cfg.maxOutputTokens; - if (typeof cfg.maxToolCalls === 'number' && !isNaN(cfg.maxToolCalls)) - base.maxToolCalls = cfg.maxToolCalls; - if (Array.isArray(cfg.guardrails)) - base.guardrails = toStringArray(cfg.guardrails); - if (cfg.reasoning && typeof cfg.reasoning === 'object') { - const r = cfg.reasoning as Record; - if ( - typeof r.effort === 'string' && - ['low', 'medium', 'high'].includes(r.effort) - ) { - base.reasoning = { effort: r.effort as 'low' | 'medium' | 'high' }; - } - } - if (typeof cfg.resetToolChoice === 'boolean') - base.resetToolChoice = cfg.resetToolChoice; - if (typeof cfg.nestHandoffHistory === 'boolean') - base.nestHandoffHistory = cfg.nestHandoffHistory; - - return base; -} - -/** - * Converts an AgentFormData into the config payload shape expected by the - * admin config API (PUT /admin/config/agents). Inverse of `agentFromConfig`. - * Only includes optional fields when they carry a meaningful value so the - * persisted config stays minimal. - */ -export function agentToConfig(agent: AgentFormData): Record { - const entry: Record = { - name: agent.name, - instructions: agent.instructions, - }; - if (agent.handoffDescription) - entry.handoffDescription = agent.handoffDescription; - if (agent.model) entry.model = agent.model; - if (agent.handoffs.length > 0) entry.handoffs = agent.handoffs; - if (agent.asTools.length > 0) entry.asTools = agent.asTools; - if (agent.mcpServers.length > 0) entry.mcpServers = agent.mcpServers; - if (agent.enableRAG) entry.enableRAG = true; - if (agent.vectorStoreIds.length > 0) - entry.vectorStoreIds = agent.vectorStoreIds; - if (agent.enableWebSearch) entry.enableWebSearch = true; - if (agent.enableCodeInterpreter) entry.enableCodeInterpreter = true; - if (agent.toolChoice) entry.toolChoice = agent.toolChoice; - if (agent.temperature !== undefined) entry.temperature = agent.temperature; - if (agent.maxOutputTokens !== undefined) - entry.maxOutputTokens = agent.maxOutputTokens; - if (agent.maxToolCalls !== undefined) entry.maxToolCalls = agent.maxToolCalls; - if (agent.guardrails && agent.guardrails.length > 0) - entry.guardrails = agent.guardrails; - if (agent.reasoning) entry.reasoning = agent.reasoning; - if (agent.resetToolChoice !== undefined) - entry.resetToolChoice = agent.resetToolChoice; - if (agent.nestHandoffHistory !== undefined) - entry.nestHandoffHistory = agent.nestHandoffHistory; - return entry; -} - -/** - * Derives the effective role for an agent purely from topology. - * Delegates to the shared `deriveRoleFromTopology` utility in augment-common. - */ -export function deriveAgentRole( - agentKey: string, - allAgents: Record, -): PublishAsRole { - return deriveRoleFromTopology(agentKey, allAgents); -} - -export function detectCircularHandoffs( - agents: Record, -): string[] { - const warnings: string[] = []; - - for (const [key, agent] of Object.entries(agents)) { - if (agent.handoffs.includes(key)) { - warnings.push( - `"${agent.name || key}" hands off to itself — this will cause an infinite loop.`, - ); - } - } - - return warnings; -} - -export interface ValidationResult { - errors: string[]; - warnings: string[]; -} - -export function validateAgents( - agents: Record, - defaultAgentKey: string, - _availableMcpServerIds?: string[], -): ValidationResult { - const errors: string[] = []; - const agentKeys = Object.keys(agents); - - for (const [key, agent] of Object.entries(agents)) { - if (!agent.name.trim()) errors.push(`Agent "${key}" has no name`); - if (!agent.instructions.trim()) - errors.push(`Agent "${key}" has no instructions`); - for (const h of agent.handoffs) { - if (!agents[h]) errors.push(`Agent "${key}" hands off to unknown "${h}"`); - } - for (const t of agent.asTools) { - if (!agents[t]) - errors.push(`Agent "${key}" calls unknown agent "${t}" as tool`); - } - } - - const hasAnyRouterOrSpecialist = agentKeys.some(k => { - const r = deriveAgentRole(k, agents); - return r === 'router' || r === 'specialist'; - }); - if ( - hasAnyRouterOrSpecialist && - agentKeys.length > 0 && - !agents[defaultAgentKey] - ) { - errors.push(`Default agent "${defaultAgentKey}" not found`); - } - - const warnings: string[] = []; - warnings.push(...detectCircularHandoffs(agents)); - warnings.push(...detectSmartWarnings(agents, defaultAgentKey)); - - for (const [key] of Object.entries(agents)) { - const role = deriveAgentRole(key, agents); - if (role === 'router') { - const a = agents[key]; - if (a.handoffs.length === 0 && a.asTools.length === 0) { - warnings.push( - `"${a.name || key}" is configured as Router but has no handoffs or delegates.`, - ); - } - } - if (role === 'specialist') { - const isTarget = agentKeys.some( - k => - k !== key && - (agents[k].handoffs.includes(key) || agents[k].asTools.includes(key)), - ); - if (!isTarget) { - warnings.push( - `"${agents[key].name || key}" is a Specialist but no other agent routes to it.`, - ); - } - } - } - - return { errors, warnings }; -} - -/** - * Summarize an agent's configured capabilities, connections, and settings - * into a text block suitable for inclusion in a prompt-generation meta-prompt. - */ -export function buildAgentContext( - agent: AgentFormData, - allAgents: Record, - availableMcpServers: Array<{ id: string; name: string }>, -): string { - const lines: string[] = []; - - lines.push(`Model: ${agent.model || 'global default'}`); - - const tools: string[] = []; - if (agent.enableRAG) tools.push('Knowledge Base (RAG)'); - if (agent.enableWebSearch) tools.push('Web Search'); - if (agent.enableCodeInterpreter) tools.push('Code Interpreter'); - lines.push(`Built-in tools: ${tools.length > 0 ? tools.join(', ') : 'none'}`); - - if (agent.mcpServers.length > 0) { - const names = agent.mcpServers.map( - id => availableMcpServers.find(s => s.id === id)?.name || id, - ); - lines.push(`MCP Servers: ${names.join(', ')}`); - } - - if (agent.handoffs.length > 0) { - const targets = agent.handoffs - .filter(k => allAgents[k]) - .map(k => { - const desc = allAgents[k].handoffDescription; - return desc - ? `${allAgents[k].name || k} (${desc})` - : allAgents[k].name || k; - }); - if (targets.length > 0) - lines.push(`Can transfer to: ${targets.join(', ')}`); - } - - if (agent.asTools.length > 0) { - const workers = agent.asTools - .filter(k => allAgents[k]) - .map(k => { - const desc = allAgents[k].handoffDescription; - return desc - ? `${allAgents[k].name || k} (${desc})` - : allAgents[k].name || k; - }); - if (workers.length > 0) - lines.push(`Can delegate to: ${workers.join(', ')}`); - } - - if (agent.toolChoice) lines.push(`Tool choice: ${agent.toolChoice}`); - if (agent.temperature !== undefined) - lines.push(`Temperature: ${agent.temperature}`); - - return lines.join('\n'); -} - -export function getTabCompletionStatus( - agent: AgentFormData, -): Record { - return { - basics: Boolean(agent.name.trim() && agent.instructions.trim()), - tools: - agent.mcpServers.length > 0 || - agent.enableRAG || - agent.enableWebSearch || - agent.enableCodeInterpreter, - connections: agent.handoffs.length > 0 || agent.asTools.length > 0, - }; -} - -export function generateUniqueAgentKey(existingKeys: string[]): string { - const base = 'agent'; - let idx = 1; - let key = base; - while (existingKeys.includes(key)) { - idx++; - key = `${base}_${idx}`; - } - return key; -} - -function detectSmartWarnings( - agents: Record, - defaultAgentKey: string, -): string[] { - const warnings: string[] = []; - const agentKeys = Object.keys(agents); - - for (const [key, agent] of Object.entries(agents)) { - const hasTools = - agent.mcpServers.length > 0 || - agent.enableRAG || - agent.enableWebSearch || - agent.enableCodeInterpreter; - const hasHandoffs = agent.handoffs.length > 0; - - if (agent.toolChoice === 'required' && hasHandoffs && hasTools) { - warnings.push( - `"${agent.name || key}" has toolChoice=required and handoffs but also has tools enabled. Consider removing tools if this agent only routes.`, - ); - } - - const isHandoffTarget = agentKeys.some( - k => - k !== key && - (agents[k].handoffs.includes(key) || agents[k].asTools.includes(key)), - ); - if (isHandoffTarget && !agent.handoffDescription.trim()) { - warnings.push( - `"${agent.name || key}" is a handoff target but has no handoff description. Other agents won't know when to route here.`, - ); - } - - if ( - key === defaultAgentKey && - agentKeys.length > 1 && - !hasHandoffs && - agent.asTools.length === 0 - ) { - warnings.push( - `"${agent.name || key}" is the starting agent but has no handoffs or delegates. In a multi-agent setup, users may get stuck.`, - ); - } - } - - return warnings; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/index.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/index.ts deleted file mode 100644 index aea369d457b..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { - type AgentFormData, - type ValidationResult, - createDefaultAgent, - agentFromConfig, - validateAgents, - detectCircularHandoffs, -} from './agentValidation'; -export { AgentsPanel } from './AgentsPanel'; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/useAgentActions.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/useAgentActions.ts deleted file mode 100644 index 05a1a5d907e..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/useAgentActions.ts +++ /dev/null @@ -1,364 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useCallback, type MutableRefObject } from 'react'; -import { - type AgentFormData, - createDefaultAgent, - agentToConfig, - buildAgentContext, -} from './agentValidation'; -import { AGENT_TEMPLATES } from './agentTemplates'; - -const SAVE_SUCCESS_TIMEOUT_MS = 3000; - -interface SaveDeps { - saveAgents: (v: unknown) => Promise; - saveDefaultAgent: (v: unknown) => Promise; - saveMaxTurns: (v: unknown) => Promise; -} - -interface AgentActionDeps { - agents: Record; - agentKeys: string[]; - defaultAgentKey: string; - maxTurns: number; - selectedAgentKey: string | null; - selectedAgent: AgentFormData | null; - availableMcpServers: { id: string; name: string }[]; - isSingleAgentMode: boolean | '' | null | undefined; - effectiveConfig: Record | null; - validationErrors: string[]; - saveDeps: SaveDeps; - generate: ( - prompt: string, - model?: string, - caps?: Record, - ) => Promise; - onSaved?: () => void; - setAgents: React.Dispatch< - React.SetStateAction> - >; - setSelectedAgentKey: React.Dispatch>; - setDefaultAgentKey: React.Dispatch>; - setActiveTab: React.Dispatch>; - setCreateModalOpen: React.Dispatch>; - setConfirmRemoveKey: React.Dispatch>; - setConfirmReset: React.Dispatch>; - setSaveError: React.Dispatch>; - setSaveSuccess: React.Dispatch>; - setSaving: React.Dispatch>; - setResetting: React.Dispatch>; - setInitialized: React.Dispatch>; - setGenerateToast: React.Dispatch>; - setSelectedTemplate: React.Dispatch>; - setStepperStep: React.Dispatch>; - initialSnapshotRef: MutableRefObject; - saveTimerRef: MutableRefObject | undefined>; - resetAgents: () => Promise; - refreshConfig: () => Promise; - confirmRemoveKey: string | null; -} - -export function useAgentActions(deps: AgentActionDeps) { - const { - agents, - agentKeys, - defaultAgentKey, - maxTurns, - selectedAgentKey, - selectedAgent, - availableMcpServers, - isSingleAgentMode, - effectiveConfig, - validationErrors, - saveDeps, - generate, - onSaved, - setAgents, - setSelectedAgentKey, - setDefaultAgentKey, - setActiveTab, - setCreateModalOpen, - setConfirmRemoveKey, - setConfirmReset, - setSaveError, - setSaveSuccess, - setSaving, - setResetting, - setInitialized, - setGenerateToast, - setSelectedTemplate, - setStepperStep, - initialSnapshotRef, - saveTimerRef, - resetAgents, - refreshConfig, - confirmRemoveKey, - } = deps; - - const handleSelectAgent = useCallback( - (key: string) => { - setSelectedAgentKey(key); - setActiveTab(0); - }, - [setSelectedAgentKey, setActiveTab], - ); - - const handleCreateFromModal = useCallback( - (name: string, key: string) => { - if (!key || agents[key]) return; - const agent = createDefaultAgent(); - agent.name = name || key.charAt(0).toUpperCase() + key.slice(1); - setAgents(prev => ({ ...prev, [key]: agent })); - if (agentKeys.length === 0) setDefaultAgentKey(key); - setSelectedAgentKey(key); - setCreateModalOpen(false); - }, - [ - agents, - agentKeys.length, - setAgents, - setDefaultAgentKey, - setSelectedAgentKey, - setCreateModalOpen, - ], - ); - - const executeRemoveAgent = useCallback( - (key: string) => { - setAgents(prev => { - const next: Record = {}; - for (const [k, a] of Object.entries(prev)) { - if (k === key) continue; - next[k] = { - ...a, - handoffs: a.handoffs.filter(h => h !== key), - asTools: a.asTools.filter(t => t !== key), - }; - } - const remaining = Object.keys(next); - setSelectedAgentKey(sel => (sel === key ? remaining[0] || null : sel)); - setDefaultAgentKey(def => (def === key ? '' : def)); - return next; - }); - }, - [setAgents, setSelectedAgentKey, setDefaultAgentKey], - ); - - const handleRemoveAgent = useCallback( - (key: string) => { - setConfirmRemoveKey(key); - }, - [setConfirmRemoveKey], - ); - - const handleConfirmRemove = useCallback(() => { - if (confirmRemoveKey) { - executeRemoveAgent(confirmRemoveKey); - setConfirmRemoveKey(null); - } - }, [confirmRemoveKey, executeRemoveAgent, setConfirmRemoveKey]); - - const updateAgent = useCallback( - (key: string, field: keyof AgentFormData, value: unknown) => { - setAgents(prev => ({ ...prev, [key]: { ...prev[key], [field]: value } })); - }, - [setAgents], - ); - - const handleSave = useCallback(async () => { - if (validationErrors.length > 0) return; - setSaveError(null); - setSaveSuccess(false); - setSaving(true); - try { - const newAgents: Record> = {}; - for (const [key, agent] of Object.entries(agents)) { - newAgents[key] = agentToConfig(agent); - } - let payload = newAgents; - if (isSingleAgentMode && effectiveConfig) { - const existing = - (effectiveConfig.agents as Record>) || - {}; - payload = { ...existing, ...newAgents }; - } - for (const step of [ - { label: 'agents', fn: () => saveDeps.saveAgents(payload) }, - { - label: 'defaultAgent', - fn: () => saveDeps.saveDefaultAgent(defaultAgentKey), - }, - { label: 'maxAgentTurns', fn: () => saveDeps.saveMaxTurns(maxTurns) }, - ]) { - try { - await step.fn(); - } catch (e) { - throw new Error( - `Failed to save ${step.label}: ${e instanceof Error ? e.message : String(e)}`, - ); - } - } - initialSnapshotRef.current = JSON.stringify(agents); - setSaveSuccess(true); - if (onSaved) { - onSaved(); - } else { - saveTimerRef.current = setTimeout( - () => setSaveSuccess(false), - SAVE_SUCCESS_TIMEOUT_MS, - ); - } - } catch (err) { - let msg = 'Failed to save agent config'; - if (err instanceof Error) msg = err.message; - const body = (err as Record)?.body; - if ( - body && - typeof body === 'object' && - typeof (body as Record).message === 'string' - ) { - msg = (body as Record).message as string; - } - setSaveError(msg); - } finally { - setSaving(false); - } - }, [ - agents, - defaultAgentKey, - maxTurns, - validationErrors, - saveDeps, - onSaved, - isSingleAgentMode, - effectiveConfig, - initialSnapshotRef, - saveTimerRef, - setSaveError, - setSaveSuccess, - setSaving, - ]); - - const executeReset = useCallback(async () => { - setResetting(true); - try { - await resetAgents(); - await refreshConfig(); - setInitialized(false); - setSelectedAgentKey(null); - } catch { - setSaveError('Failed to reset agents'); - } finally { - setResetting(false); - } - }, [ - resetAgents, - refreshConfig, - setResetting, - setInitialized, - setSelectedAgentKey, - setSaveError, - ]); - - const handleConfirmReset = useCallback(async () => { - try { - await executeReset(); - } finally { - setConfirmReset(false); - } - }, [executeReset, setConfirmReset]); - - const handleUpdateInstructions = useCallback( - (value: string) => { - if (selectedAgentKey) - updateAgent(selectedAgentKey, 'instructions', value); - }, - [selectedAgentKey, updateAgent], - ); - - const handleGenerateForTab = useCallback( - async (description: string, model: string | undefined) => { - if (!selectedAgentKey || !selectedAgent) return; - const context = buildAgentContext( - selectedAgent, - agents, - availableMcpServers, - ); - const capabilities = { - enableWebSearch: selectedAgent.enableWebSearch, - enableCodeInterpreter: selectedAgent.enableCodeInterpreter, - ragEnabled: selectedAgent.enableRAG, - }; - const resolvedModel = model || selectedAgent.model || undefined; - const prompt = await generate( - `${description.trim()}\n\nAGENT CONTEXT:\n${context}`, - resolvedModel, - capabilities, - ); - updateAgent(selectedAgentKey, 'instructions', prompt); - setGenerateToast('Instructions generated — review and save when ready'); - }, - [ - selectedAgentKey, - selectedAgent, - agents, - availableMcpServers, - generate, - updateAgent, - setGenerateToast, - ], - ); - - const applyTemplate = useCallback( - (templateId: string) => { - if (!selectedAgentKey) return; - setSelectedTemplate(templateId); - const tpl = AGENT_TEMPLATES.find(t => t.id === templateId); - if (!tpl || templateId === 'blank') return; - setAgents(prev => ({ - ...prev, - [selectedAgentKey]: { - ...prev[selectedAgentKey], - ...tpl.defaults, - }, - })); - if (isSingleAgentMode) { - setStepperStep(1); - } - }, - [ - selectedAgentKey, - isSingleAgentMode, - setSelectedTemplate, - setAgents, - setStepperStep, - ], - ); - - return { - handleSelectAgent, - handleCreateFromModal, - handleRemoveAgent, - handleConfirmRemove, - updateAgent, - handleSave, - handleConfirmReset, - handleUpdateInstructions, - handleGenerateForTab, - applyTemplate, - }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/useAgentDerived.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/useAgentDerived.ts deleted file mode 100644 index a2cada68701..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/useAgentDerived.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useMemo } from 'react'; -import { - type AgentFormData, - type PublishAsRole, - deriveAgentRole, - validateAgents, - getTabCompletionStatus, -} from './agentValidation'; -import type { TopologyEdge } from './TopologyGraph'; - -export function useAgentDerived( - agents: Record, - selectedAgentKey: string | null, - defaultAgentKey: string, - availableMcpServerIds?: string[], -) { - const agentKeys = useMemo(() => Object.keys(agents), [agents]); - - const selectedAgent = selectedAgentKey - ? (agents[selectedAgentKey] ?? null) - : null; - - const selectedAgentRole: PublishAsRole = useMemo( - () => - selectedAgentKey - ? deriveAgentRole(selectedAgentKey, agents) - : 'standalone', - [selectedAgentKey, agents], - ); - - const showConnections = selectedAgentRole !== 'standalone'; - - const validation = useMemo( - () => validateAgents(agents, defaultAgentKey, availableMcpServerIds), - [agents, defaultAgentKey, availableMcpServerIds], - ); - - const tabCompletion = useMemo( - () => (selectedAgent ? getTabCompletionStatus(selectedAgent) : null), - [selectedAgent], - ); - - const topologyEdges = useMemo((): TopologyEdge[] => { - const edges: TopologyEdge[] = []; - for (const key of agentKeys) { - const a = agents[key]; - for (const h of a.handoffs) - if (agents[h]) edges.push({ from: key, to: h, type: 'handoff' }); - for (const t of a.asTools) - if (agents[t]) edges.push({ from: key, to: t, type: 'subtask' }); - } - return edges; - }, [agents, agentKeys]); - - const agentRoles = useMemo(() => { - const roles: Record = {}; - for (const key of agentKeys) { - roles[key] = deriveAgentRole(key, agents); - } - return roles; - }, [agents, agentKeys]); - - const edgeCounts = useMemo(() => { - const counts: Record = {}; - for (const key of agentKeys) { - const a = agents[key]; - counts[key] = { - out: - a.handoffs.filter(h => agents[h]).length + - a.asTools.filter(t => agents[t]).length, - in: 0, - }; - } - for (const key of agentKeys) { - const a = agents[key]; - for (const h of a.handoffs) if (counts[h]) counts[h].in++; - for (const t of a.asTools) if (counts[t]) counts[t].in++; - } - return counts; - }, [agents, agentKeys]); - - return { - agentKeys, - selectedAgent, - selectedAgentRole, - showConnections, - validation, - tabCompletion, - topologyEdges, - agentRoles, - edgeCounts, - }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/useAgentEditor.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/useAgentEditor.ts deleted file mode 100644 index 50d82418202..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AgentsPanel/useAgentEditor.ts +++ /dev/null @@ -1,306 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useMemo, useEffect, useRef } from 'react'; -import { useEffectiveConfig } from '../../../hooks/useEffectiveConfig'; -import { - useAdminConfig, - useModels, - useGeneratePrompt, - useVectorStores, -} from '../../../hooks'; -import { - type AgentFormData, - createDefaultAgent, - agentFromConfig, - generateUniqueAgentKey, -} from './agentValidation'; -import { useAgentDerived } from './useAgentDerived'; -import { useAgentActions } from './useAgentActions'; - -const DEFAULT_MAX_TURNS = 10; -const MIN_TURNS = 1; -const MAX_TURNS = 50; - -export { MIN_TURNS, MAX_TURNS }; - -export interface UseAgentEditorProps { - focusAgentKey?: string; - autoCreate?: boolean; - createType?: 'single' | 'multi' | null; - onSaved?: () => void; - onDirtyChange?: (dirty: boolean) => void; -} - -export function useAgentEditor({ - focusAgentKey, - autoCreate, - createType, - onSaved, - onDirtyChange, -}: UseAgentEditorProps) { - // ── External hooks ──────────────────────────────────────────────────── - - const { - config: effectiveConfig, - loading: configLoading, - error: configError, - refresh: refreshConfig, - } = useEffectiveConfig(); - - const { - source: agentsSource, - saving: agentsSaving, - error: agentsError, - save: saveAgents, - reset: resetAgents, - } = useAdminConfig('agents'); - const { save: saveDefaultAgent } = useAdminConfig('defaultAgent'); - const { save: saveMaxTurns } = useAdminConfig('maxAgentTurns'); - const { - models, - loading: modelsLoading, - refresh: refreshModels, - } = useModels(); - const modelOptions = useMemo( - () => models.map(m => m.id).filter(Boolean) as string[], - [models], - ); - const { generate, generating, error: generateError } = useGeneratePrompt(); - const { stores: vectorStores } = useVectorStores(); - - // ── State ───────────────────────────────────────────────────────────── - - const [agents, setAgents] = useState>({}); - const [selectedAgentKey, setSelectedAgentKey] = useState(null); - const [defaultAgentKey, setDefaultAgentKey] = useState(''); - const [maxTurns, setMaxTurns] = useState(DEFAULT_MAX_TURNS); - - const [saveError, setSaveError] = useState(null); - const [saveSuccess, setSaveSuccess] = useState(false); - const [saving, setSaving] = useState(false); - const [initialized, setInitialized] = useState(false); - const [resetting, setResetting] = useState(false); - const [activeTab, setActiveTab] = useState(0); - const [generateToast, setGenerateToast] = useState(null); - const [createModalOpen, setCreateModalOpen] = useState(false); - const [confirmRemoveKey, setConfirmRemoveKey] = useState(null); - const [confirmReset, setConfirmReset] = useState(false); - const [showPreview, setShowPreview] = useState(false); - const [selectedTemplate, setSelectedTemplate] = useState('blank'); - const [stepperStep, setStepperStep] = useState(0); - - const saveTimerRef = useRef>(); - const initialSnapshotRef = useRef(''); - - const isSingleAgentMode = createType === 'single' && autoCreate; - - // ── Effects ─────────────────────────────────────────────────────────── - - useEffect( - () => () => { - if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - }, - [], - ); - - useEffect(() => { - if (!effectiveConfig || initialized) return; - if (isSingleAgentMode) { - setAgents({}); - setDefaultAgentKey(''); - } else { - const rawAgents = - (effectiveConfig.agents as Record>) || - {}; - const parsed: Record = {}; - for (const [key, cfg] of Object.entries(rawAgents)) - parsed[key] = agentFromConfig(cfg); - setAgents(parsed); - setDefaultAgentKey((effectiveConfig.defaultAgent as string) || ''); - } - setMaxTurns((effectiveConfig.maxAgentTurns as number) || DEFAULT_MAX_TURNS); - setInitialized(true); - }, [effectiveConfig, initialized, isSingleAgentMode]); - - const focusAppliedRef = useRef(false); - useEffect(() => { - focusAppliedRef.current = false; - }, [focusAgentKey]); - useEffect(() => { - if (!initialized) return; - const keys = Object.keys(agents); - if (keys.length === 0) { - setSelectedAgentKey(null); - return; - } - if (focusAgentKey && agents[focusAgentKey] && !focusAppliedRef.current) { - focusAppliedRef.current = true; - setSelectedAgentKey(focusAgentKey); - return; - } - setSelectedAgentKey(prev => (!prev || !agents[prev] ? keys[0] : prev)); - }, [initialized, agents, focusAgentKey]); - - const autoCreateAppliedRef = useRef(false); - useEffect(() => { - if (autoCreate && initialized && !autoCreateAppliedRef.current) { - autoCreateAppliedRef.current = true; - if (isSingleAgentMode) { - const existingKeys = effectiveConfig - ? Object.keys( - (effectiveConfig.agents as Record) || {}, - ) - : []; - const key = generateUniqueAgentKey(existingKeys); - const agent = createDefaultAgent(); - agent.name = ''; - setAgents({ [key]: agent }); - setDefaultAgentKey(key); - setSelectedAgentKey(key); - setActiveTab(0); - setStepperStep(0); - initialSnapshotRef.current = JSON.stringify({ [key]: agent }); - } else { - setCreateModalOpen(true); - } - } - }, [autoCreate, initialized, isSingleAgentMode, effectiveConfig]); - - useEffect(() => { - if (!initialized || initialSnapshotRef.current !== '') return; - initialSnapshotRef.current = JSON.stringify(agents); - }, [initialized, agents]); - - const isDirty = useMemo(() => { - if (!initialized || initialSnapshotRef.current === '') return false; - return JSON.stringify(agents) !== initialSnapshotRef.current; - }, [agents, initialized]); - - useEffect(() => { - onDirtyChange?.(isDirty); - }, [isDirty, onDirtyChange]); - - // ── Derived ─────────────────────────────────────────────────────────── - - const availableMcpServers = useMemo(() => { - if (!effectiveConfig) return []; - const servers = - (effectiveConfig.mcpServers as Array<{ id: string; name: string }>) || []; - return servers.map(s => ({ id: s.id, name: s.name || s.id })); - }, [effectiveConfig]); - - const availableMcpServerIds = useMemo( - () => availableMcpServers.map(s => s.id), - [availableMcpServers], - ); - - const derived = useAgentDerived( - agents, - selectedAgentKey, - defaultAgentKey, - availableMcpServerIds, - ); - - const effectiveModel = - derived.selectedAgent?.model || (effectiveConfig?.model as string) || ''; - - // ── Actions ─────────────────────────────────────────────────────────── - - const actions = useAgentActions({ - agents, - agentKeys: derived.agentKeys, - defaultAgentKey, - maxTurns, - selectedAgentKey, - selectedAgent: derived.selectedAgent, - availableMcpServers, - isSingleAgentMode, - effectiveConfig: effectiveConfig as Record | null, - validationErrors: derived.validation.errors, - saveDeps: { saveAgents, saveDefaultAgent, saveMaxTurns }, - generate, - onSaved, - setAgents, - setSelectedAgentKey, - setDefaultAgentKey, - setActiveTab, - setCreateModalOpen, - setConfirmRemoveKey, - setConfirmReset, - setSaveError, - setSaveSuccess, - setSaving, - setResetting, - setInitialized, - setGenerateToast, - setSelectedTemplate, - setStepperStep, - initialSnapshotRef, - saveTimerRef, - resetAgents, - refreshConfig, - confirmRemoveKey, - }); - - return { - configLoading, - configError, - agents, - ...derived, - selectedAgentKey, - availableMcpServers, - defaultAgentKey, - setDefaultAgentKey, - maxTurns, - setMaxTurns, - isDirty, - isSingleAgentMode, - saving, - saveSuccess, - saveError, - setSaveError, - agentsSource, - agentsSaving, - agentsError, - resetting, - activeTab, - setActiveTab, - stepperStep, - setStepperStep, - selectedTemplate, - showPreview, - setShowPreview, - createModalOpen, - setCreateModalOpen, - confirmRemoveKey, - setConfirmRemoveKey, - confirmReset, - setConfirmReset, - generateToast, - setGenerateToast, - modelOptions, - modelsLoading, - refreshModels, - generating, - generateError, - vectorStores, - effectiveModel, - onSaved, - ...actions, - }; -} - -export type AgentEditorState = ReturnType; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/AppearanceSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/AppearanceSection.tsx deleted file mode 100644 index 3a01fa3acc1..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/AppearanceSection.tsx +++ /dev/null @@ -1,253 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useMemo, useEffect } from 'react'; -import Box from '@mui/material/Box'; -import TextField from '@mui/material/TextField'; -import Typography from '@mui/material/Typography'; -import Snackbar from '@mui/material/Snackbar'; -import { - useAdminConfig, - useBranding, - dispatchBrandingRefresh, -} from '../../hooks'; -import { AdminSection } from './shared/AdminSection'; -import { ColorInput } from './shared/ColorInput'; -import { - DEFAULT_BRANDING, - type BrandingConfig, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -const HEX_RE = /^#[0-9a-fA-F]{6}$/; - -// --------------------------------------------------------------------------- -// Field group header -// --------------------------------------------------------------------------- - -const FieldGroup = ({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) => ( - - - {title} - - {children} - -); - -// --------------------------------------------------------------------------- -// Main component -// --------------------------------------------------------------------------- - -interface Props { - effectiveConfig?: Record | null; -} - -export const AppearanceSection = ({ - effectiveConfig: _effectiveConfig, -}: Props) => { - const config = useAdminConfig('branding'); - const { branding: liveBranding, loading: brandingLoading } = useBranding(); - - const [local, setLocal] = useState({ ...DEFAULT_BRANDING }); - const [toast, setToast] = useState(null); - const [initialized, setInitialized] = useState(false); - - const baseline = useMemo( - () => ({ ...DEFAULT_BRANDING, ...liveBranding }), - [liveBranding], - ); - - const dbOverrides = useMemo>(() => { - if (!config.entry) return {}; - return (config.entry.configValue as Partial) ?? {}; - }, [config.entry]); - - useEffect(() => { - if (!initialized && !config.loading && !brandingLoading) { - const merged = { ...baseline, ...dbOverrides }; - setLocal(merged); - setInitialized(true); - } - }, [initialized, config.loading, brandingLoading, baseline, dbOverrides]); - - const set = useCallback( - (key: K, value: BrandingConfig[K]) => { - setLocal(prev => ({ ...prev, [key]: value })); - }, - [], - ); - - // Validate all color fields - const colorFields: (keyof BrandingConfig)[] = [ - 'primaryColor', - 'secondaryColor', - 'successColor', - 'warningColor', - 'errorColor', - 'infoColor', - ]; - const hasInvalidColor = colorFields.some(f => { - const v = local[f] as string; - return v && !HEX_RE.test(v); - }); - - const handleSave = useCallback(async () => { - if (hasInvalidColor) { - setToast('Fix invalid color values before saving'); - return; - } - try { - await config.save(local); - } catch { - return; - } - dispatchBrandingRefresh(); - setToast('Branding saved'); - }, [local, config, hasInvalidColor]); - - const handleReset = useCallback(async () => { - try { - await config.reset(); - } catch { - return; - } - setLocal({ ...baseline }); - setInitialized(false); - dispatchBrandingRefresh(); - setToast('Reset to YAML defaults'); - }, [config, baseline]); - - if (config.loading) return null; - - return ( - <> - - {/* Identity */} - - - set('appName', e.target.value)} - /> - set('inputPlaceholder', e.target.value)} - /> - set('faviconUrl', e.target.value || undefined)} - placeholder="https://example.com/favicon.ico" - helperText="Tab icon URL" - /> - set('botAvatarUrl', e.target.value || undefined)} - placeholder="https://example.com/avatar.png" - helperText="Assistant avatar in chat" - /> - - - - {/* Colors */} - - - set('primaryColor', v)} - defaultValue={DEFAULT_BRANDING.primaryColor} - /> - set('secondaryColor', v)} - defaultValue={DEFAULT_BRANDING.secondaryColor} - /> - set('successColor', v)} - defaultValue={DEFAULT_BRANDING.successColor} - /> - set('warningColor', v)} - defaultValue={DEFAULT_BRANDING.warningColor} - /> - set('errorColor', v)} - defaultValue={DEFAULT_BRANDING.errorColor} - /> - set('infoColor', v)} - defaultValue={DEFAULT_BRANDING.infoColor} - /> - - - - - setToast(null)} - message={toast} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/BrandingPanel.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/BrandingPanel.test.tsx deleted file mode 100644 index 3b4a3d33c93..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/BrandingPanel.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { TestApiProvider } from '@backstage/test-utils'; -import { augmentApiRef } from '../../api'; -import { createAdminMockApi } from '../../test-utils/factories'; -import { BrandingPanel } from './BrandingPanel'; - -const theme = createTheme(); - -function renderPanel(api = createAdminMockApi()) { - return render( - - - - - , - ); -} - -describe('BrandingPanel', () => { - it('renders panel title', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Branding')).toBeInTheDocument(); - }); - }); - - it('shows branding fields', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByLabelText('App Name')).toBeInTheDocument(); - expect(screen.getByLabelText('Primary Color')).toBeInTheDocument(); - expect(screen.getByLabelText('Secondary Color')).toBeInTheDocument(); - }); - }); - - it('calls setAdminConfig on save from Appearance', async () => { - const api = createAdminMockApi(); - renderPanel(api); - - await waitFor(() => { - expect(screen.getByText('Save')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Save')); - - await waitFor(() => { - expect(api.setAdminConfig).toHaveBeenCalledWith( - 'branding', - expect.any(Object), - ); - }); - }); - - it('pre-populates Appearance fields from branding config', async () => { - const api = createAdminMockApi(); - renderPanel(api); - - await waitFor(() => { - const appNameInput = screen.getByLabelText( - 'App Name', - ) as HTMLInputElement; - expect(appNameInput.value).toBe('AI Chat'); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/BrandingPanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/BrandingPanel.tsx deleted file mode 100644 index aee59fc54cb..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/BrandingPanel.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import { Progress } from '@backstage/core-components'; -import { useEffectiveConfig } from '../../hooks'; -import { AppearanceSection } from './AppearanceSection'; - -export const BrandingPanel = () => { - const { config: effectiveConfig, loading: ecLoading } = useEffectiveConfig(); - - if (ecLoading) { - return ; - } - - return ( - - - - Branding - - - Customize the chat interface identity and colors. - - - - - - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/BrandingSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/BrandingSection.tsx deleted file mode 100644 index 97271508586..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/BrandingSection.tsx +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useTheme } from '@mui/material/styles'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Paper from '@mui/material/Paper'; -import TextField from '@mui/material/TextField'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import Chip from '@mui/material/Chip'; -import SaveIcon from '@mui/icons-material/Save'; -import RestoreIcon from '@mui/icons-material/Restore'; - -const HEX_RE = /^#[0-9a-fA-F]{6}$/; - -export interface BrandingState { - appName: string; - tagline: string; - primaryColor: string; - secondaryColor: string; -} - -export interface BrandingSectionProps { - values: BrandingState; - onFieldChange: ( - field: K, - value: BrandingState[K], - ) => void; - onSave: () => void; - onReset: () => void; - configSource: 'database' | 'default'; - saving: boolean; - error: string | null; -} - -export const BrandingSection = ({ - values, - onFieldChange, - onSave, - onReset, - configSource, - saving, - error, -}: BrandingSectionProps) => { - const theme = useTheme(); - - return ( - - - Branding - - {configSource === 'database' && ( - - )} - - - - - onFieldChange('appName', e.target.value)} - sx={{ flex: 1, minWidth: 200 }} - /> - onFieldChange('tagline', e.target.value)} - sx={{ flex: 1, minWidth: 200 }} - /> - - - onFieldChange('primaryColor', e.target.value)} - placeholder={theme.palette.primary.main} - sx={{ width: 150 }} - error={!!values.primaryColor && !HEX_RE.test(values.primaryColor)} - helperText={ - values.primaryColor && !HEX_RE.test(values.primaryColor) - ? 'Invalid hex' - : '' - } - /> - onFieldChange('secondaryColor', e.target.value)} - placeholder={theme.palette.text.secondary} - sx={{ width: 150 }} - error={!!values.secondaryColor && !HEX_RE.test(values.secondaryColor)} - helperText={ - values.secondaryColor && !HEX_RE.test(values.secondaryColor) - ? 'Invalid hex' - : '' - } - /> - {values.primaryColor && HEX_RE.test(values.primaryColor) && ( - - )} - - - {error && ( - - {error} - - )} - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ChatExperiencePanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ChatExperiencePanel.tsx deleted file mode 100644 index 6d2f4a620cc..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ChatExperiencePanel.tsx +++ /dev/null @@ -1,605 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState, useCallback, useEffect, useMemo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Chip from '@mui/material/Chip'; -import IconButton from '@mui/material/IconButton'; -import Alert from '@mui/material/Alert'; -import Snackbar from '@mui/material/Snackbar'; -import CircularProgress from '@mui/material/CircularProgress'; -import Collapse from '@mui/material/Collapse'; -import Tooltip from '@mui/material/Tooltip'; -import { useTheme, alpha } from '@mui/material/styles'; -import SaveIcon from '@mui/icons-material/Save'; -import StarIcon from '@mui/icons-material/Star'; -import StarBorderIcon from '@mui/icons-material/StarBorder'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import ExpandLessIcon from '@mui/icons-material/ExpandLess'; -import AddIcon from '@mui/icons-material/Add'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import VisibilityIcon from '@mui/icons-material/Visibility'; -import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; -import { useApi } from '@backstage/core-plugin-api'; -import { augmentApiRef } from '../../api'; -import type { ChatAgentConfig } from '../../types'; -import { useChatAgentConfig } from '../../hooks/useChatAgentConfig'; -import { useAgentGalleryData } from '../WelcomeScreen/useAgentGalleryData'; -import { getAgentAvatarColor, isAgentReady } from '../WelcomeScreen/agentUtils'; -import type { AgentWithCard } from '../WelcomeScreen/agentUtils'; - -const MAX_FEATURED = 4; -const MAX_STARTERS = 6; - -interface AgentConfigRow extends ChatAgentConfig { - _agent?: AgentWithCard; - _dirty?: boolean; -} - -function mergeAgentsWithConfigs( - agents: AgentWithCard[], - configs: ChatAgentConfig[], -): AgentConfigRow[] { - const configMap = new Map(configs.map(c => [c.agentId, c])); - const rows: AgentConfigRow[] = []; - - for (const agent of agents) { - const agentId = `${agent.namespace}/${agent.name}`; - const existing = configMap.get(agentId); - rows.push({ - agentId, - published: existing?.published ?? false, - visible: existing?.visible ?? true, - featured: existing?.featured ?? false, - order: existing?.order, - displayName: existing?.displayName, - description: existing?.description, - avatarUrl: existing?.avatarUrl, - accentColor: existing?.accentColor, - greeting: existing?.greeting, - conversationStarters: existing?.conversationStarters ?? [], - _agent: agent, - }); - } - - return rows.sort((a, b) => { - if (a.featured !== b.featured) return a.featured ? -1 : 1; - if (a.visible !== b.visible) return a.visible ? -1 : 1; - return (a.order ?? 999) - (b.order ?? 999); - }); -} - -export const ChatExperiencePanel = () => { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const api = useApi(augmentApiRef); - const { agents, loading: agentsLoading } = useAgentGalleryData(api); - const { - configs, - loading: configLoading, - saving, - save, - } = useChatAgentConfig(); - - const [rows, setRows] = useState([]); - const [expandedId, setExpandedId] = useState(null); - const [toast, setToast] = useState(null); - const [dirty, setDirty] = useState(false); - - useEffect(() => { - if (!agentsLoading && !configLoading) { - setRows(mergeAgentsWithConfigs(agents, configs)); - } - }, [agents, configs, agentsLoading, configLoading]); - - const featuredCount = useMemo( - () => rows.filter(r => r.featured).length, - [rows], - ); - - const updateRow = useCallback( - (agentId: string, patch: Partial) => { - setRows(prev => - prev.map(r => - r.agentId === agentId ? { ...r, ...patch, _dirty: true } : r, - ), - ); - setDirty(true); - }, - [], - ); - - const handleToggleVisible = useCallback( - (agentId: string, visible: boolean) => { - updateRow(agentId, { visible, featured: visible ? undefined : false }); - }, - [updateRow], - ); - - const handleToggleFeatured = useCallback( - (agentId: string, featured: boolean) => { - if (featured && featuredCount >= MAX_FEATURED) { - setToast(`Maximum ${MAX_FEATURED} featured agents allowed`); - return; - } - updateRow(agentId, { featured }); - }, - [updateRow, featuredCount], - ); - - const handleAddStarter = useCallback( - (agentId: string) => { - const row = rows.find(r => r.agentId === agentId); - if (!row) return; - const starters = [...(row.conversationStarters || [])]; - if (starters.length >= MAX_STARTERS) { - setToast(`Maximum ${MAX_STARTERS} conversation starters`); - return; - } - starters.push(''); - updateRow(agentId, { conversationStarters: starters }); - }, - [rows, updateRow], - ); - - const handleUpdateStarter = useCallback( - (agentId: string, index: number, value: string) => { - const row = rows.find(r => r.agentId === agentId); - if (!row) return; - const starters = [...(row.conversationStarters || [])]; - starters[index] = value; - updateRow(agentId, { conversationStarters: starters }); - }, - [rows, updateRow], - ); - - const handleRemoveStarter = useCallback( - (agentId: string, index: number) => { - const row = rows.find(r => r.agentId === agentId); - if (!row) return; - const starters = [...(row.conversationStarters || [])]; - starters.splice(index, 1); - updateRow(agentId, { conversationStarters: starters }); - }, - [rows, updateRow], - ); - - const handleSave = useCallback(async () => { - const configsToSave: ChatAgentConfig[] = rows - .filter( - r => - r._dirty || - r.featured || - !r.visible || - r.displayName || - r.description || - r.avatarUrl || - r.accentColor || - r.greeting || - (r.conversationStarters && r.conversationStarters.length > 0), - ) - .map(({ _agent: _a, _dirty: _d, ...cfg }) => ({ - ...cfg, - conversationStarters: (cfg.conversationStarters || []).filter( - s => s.trim() !== '', - ), - })); - try { - await save(configsToSave); - setDirty(false); - setToast('Chat experience saved'); - } catch { - setToast('Failed to save'); - } - }, [rows, save]); - - if (agentsLoading || configLoading) { - return ( - - - - ); - } - - return ( - - - - - Chat Experience - - - Control which agents end users see and how they appear in chat. - - - - - - - {featuredCount} / {MAX_FEATURED} featured ·{' '} - {rows.filter(r => r.visible).length} / {rows.length} visible - - - {rows.length === 0 ? ( - - No agents found. Deploy agents via the Agents panel first. - - ) : ( - - {rows.map(row => { - const agent = row._agent; - const displayName = - row.displayName || - agent?.agentCard?.name || - agent?.name || - row.agentId; - const avatarColor = - row.accentColor || getAgentAvatarColor(displayName); - const ready = agent ? isAgentReady(agent.status) : false; - const isExpanded = expandedId === row.agentId; - - return ( - - - {/* Avatar */} - - {displayName.charAt(0).toUpperCase()} - - - {/* Name + status */} - - - {displayName} - - - {row.agentId} - {ready ? '' : ` · ${agent?.status || 'unknown'}`} - - - - {/* Visible toggle */} - - - handleToggleVisible(row.agentId, !row.visible) - } - sx={{ - color: row.visible - ? theme.palette.success.main - : theme.palette.text.disabled, - }} - > - {row.visible ? ( - - ) : ( - - )} - - - - {/* Featured toggle */} - - - handleToggleFeatured(row.agentId, !row.featured) - } - sx={{ - color: row.featured - ? theme.palette.warning.main - : theme.palette.text.disabled, - }} - > - {row.featured ? ( - - ) : ( - - )} - - - - {/* Expand */} - - setExpandedId(isExpanded ? null : row.agentId) - } - > - {isExpanded ? ( - - ) : ( - - )} - - - - - - {/* Display overrides */} - - - updateRow(row.agentId, { - displayName: e.target.value || undefined, - }) - } - placeholder={ - agent?.agentCard?.name || agent?.name || '' - } - /> - - updateRow(row.agentId, { - accentColor: e.target.value || undefined, - }) - } - placeholder="#1e40af" - /> - - - updateRow(row.agentId, { - description: e.target.value || undefined, - }) - } - placeholder={ - agent?.agentCard?.description || - agent?.description || - '' - } - /> - - updateRow(row.agentId, { - avatarUrl: e.target.value || undefined, - }) - } - placeholder="https://example.com/avatar.png" - /> - - updateRow(row.agentId, { - greeting: e.target.value || undefined, - }) - } - placeholder="Hi! I'm your weather assistant. Ask me about any city's forecast." - helperText="Shown as the first bot message when a user starts a new conversation" - /> - - {/* Conversation starters */} - - - Conversation Starters - - - Suggested prompts shown on the featured agent card and - below the chat input - - {(row.conversationStarters || []).map((starter, si) => ( - - - - handleUpdateStarter( - row.agentId, - si, - e.target.value, - ) - } - placeholder="What's the weather in London?" - sx={{ - '& .MuiOutlinedInput-root': { - fontSize: '0.8rem', - }, - }} - /> - handleRemoveStarter(row.agentId, si)} - sx={{ color: theme.palette.text.disabled }} - > - - - - ))} - - - - - - ); - })} - - )} - - setToast(null)} - message={toast} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ChunkCard.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ChunkCard.tsx deleted file mode 100644 index 93855212943..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ChunkCard.tsx +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Paper from '@mui/material/Paper'; -import Chip from '@mui/material/Chip'; -import Tooltip from '@mui/material/Tooltip'; -import IconButton from '@mui/material/IconButton'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import ExpandLessIcon from '@mui/icons-material/ExpandLess'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import CheckIcon from '@mui/icons-material/Check'; -import type { RagTestResult } from '../../types'; -import type { Theme } from '@mui/material/styles'; -import { ScoreBar } from './ScoreBar'; -import { highlightQueryTerms } from './ragResultsUtils'; - -const CHUNK_PREVIEW_LENGTH = 300; - -export interface ChunkCardProps { - chunk: RagTestResult['chunks'][number]; - index: number; - query: string; - expanded: boolean; - onToggle: () => void; - storeName?: string; - showStore: boolean; - theme: Theme; - belowThreshold?: boolean; -} - -export const ChunkCard = ({ - chunk, - index, - query, - expanded, - onToggle, - storeName, - showStore, - theme, - belowThreshold, -}: ChunkCardProps) => { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(async () => { - try { - await window.navigator.clipboard.writeText(chunk.text); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - // Clipboard API may not be available in all contexts - } - }, [chunk.text]); - - const isLong = chunk.text.length > CHUNK_PREVIEW_LENGTH; - const displayText = - expanded || !isLong - ? chunk.text - : `${chunk.text.slice(0, CHUNK_PREVIEW_LENGTH)}\u2026`; - - const estimatedTokens = Math.ceil(chunk.text.length / 4); - - return ( - - {/* Card header: file info + actions + score */} - - - - #{index + 1} - - {chunk.fileName && ( - - {chunk.fileName} - - )} - {showStore && storeName && ( - - )} - - - - - - {copied ? ( - - ) : ( - - )} - - - {chunk.score !== undefined && ( - - )} - - - - {/* Chunk metadata */} - - - {chunk.text.length.toLocaleString()} chars / ~ - {estimatedTokens.toLocaleString()} tokens - - {chunk.fileId && ( - - 12 ? `${chunk.fileId.slice(0, 12)}\u2026` : chunk.fileId}`} - size="small" - variant="outlined" - sx={{ fontSize: '0.6rem', height: 16 }} - /> - - )} - - - {/* Chunk text with highlighting */} - - {highlightQueryTerms(displayText, query, theme.palette.warning.light)} - - - {/* Expand/collapse toggle */} - {isLong && ( - - - - )} - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/CreateStoreForm.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/CreateStoreForm.tsx deleted file mode 100644 index cdc556a1248..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/CreateStoreForm.tsx +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import TextField from '@mui/material/TextField'; -import Autocomplete from '@mui/material/Autocomplete'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import CircularProgress from '@mui/material/CircularProgress'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import type { VectorStoreConfig } from '../../types'; - -export interface CreateStoreFormProps { - localConfig: Partial; - embeddingModels: string[]; - modelsLoading: boolean; - defaultEmbeddingModel?: string; - onUpdate: ( - key: K, - value: VectorStoreConfig[K], - ) => void; - onRefreshModels: () => void; -} - -/** - * Form fields for vector store name, embedding model, and dimension. - */ -export function CreateStoreForm({ - localConfig, - embeddingModels, - modelsLoading, - defaultEmbeddingModel, - onUpdate, - onRefreshModels, -}: Readonly) { - const nameValid = (localConfig.vectorStoreName ?? '').trim().length > 0; - - return ( - - onUpdate('vectorStoreName', e.target.value)} - error={!nameValid} - helperText={nameValid ? 'Unique name' : 'Name is required'} - sx={{ minWidth: 0 }} - /> - - onUpdate('embeddingModel', newValue)} - getOptionLabel={opt => (typeof opt === 'string' ? opt : '')} - loading={modelsLoading} - renderOption={(props, option) => ( -
  1. - - - {option} - - {option === defaultEmbeddingModel && ( - - default - - )} - -
  2. - )} - renderInput={params => ( - 0 - ? 'Select an embedding model or type a custom identifier' - : 'No embedding models found on server — type a model identifier' - } - InputProps={{ - ...params.InputProps, - endAdornment: ( - <> - {modelsLoading ? : null} - {params.InputProps.endAdornment} - - ), - }} - /> - )} - sx={{ flex: 1 }} - /> - - - - - -
    - { - const parsed = Number.parseInt(e.target.value, 10); - if (Number.isFinite(parsed) && parsed > 0) { - onUpdate('embeddingDimension', parsed); - } - }} - error={ - localConfig.embeddingDimension !== undefined && - !Number.isFinite(localConfig.embeddingDimension) - } - helperText="384, 768, etc." - /> -
    - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/DeleteStoreDialog.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/DeleteStoreDialog.tsx deleted file mode 100644 index cc2c9ad5cca..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/DeleteStoreDialog.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import DialogContent from '@mui/material/DialogContent'; -import DialogActions from '@mui/material/DialogActions'; -import Button from '@mui/material/Button'; -import Typography from '@mui/material/Typography'; - -export interface DeleteStoreDialogProps { - deleteConfirm: { - storeId: string; - storeName: string; - storeStatus?: string; - } | null; - onConfirm: () => void; - onCancel: () => void; -} - -export const DeleteStoreDialog = ({ - deleteConfirm, - onConfirm, - onCancel, -}: DeleteStoreDialogProps) => { - const isStale = deleteConfirm?.storeStatus === 'unknown'; - - return ( - - - {isStale ? 'Remove Stale Store' : 'Delete Vector Store'} - - - - {isStale ? ( - <> - {deleteConfirm?.storeName} no longer exists on - the server. Remove it from the active list? - - ) : ( - <> - Delete {deleteConfirm?.storeName} and all its - files permanently? - - )} - - - - - - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/DocsPanel/DocsPanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/DocsPanel/DocsPanel.tsx deleted file mode 100644 index b0c375d06b9..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/DocsPanel/DocsPanel.tsx +++ /dev/null @@ -1,477 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState, useMemo, useCallback } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import TextField from '@mui/material/TextField'; -import Chip from '@mui/material/Chip'; -import InputAdornment from '@mui/material/InputAdornment'; -import SearchIcon from '@mui/icons-material/Search'; -import { useTheme, alpha } from '@mui/material/styles'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import { useBranding } from '../../../hooks/useBranding'; -import { DOCS, DOC_CATEGORIES, type DocSection } from './docsContent'; - -const TOC_WIDTH = 240; - -function replaceAppName(content: string, appName: string): string { - return content.replace(/\{\{appName\}\}/g, appName); -} - -export function DocsPanel() { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const { branding } = useBranding(); - const appName = branding?.appName || 'Augment'; - - const [activeDocId, setActiveDocId] = useState(DOCS[0]?.id ?? ''); - const [search, setSearch] = useState(''); - - const filteredDocs = useMemo(() => { - if (!search.trim()) return DOCS; - const q = search.toLowerCase(); - return DOCS.filter( - doc => - doc.title.toLowerCase().includes(q) || - doc.category.toLowerCase().includes(q) || - doc.content.toLowerCase().includes(q), - ); - }, [search]); - - const groupedDocs = useMemo(() => { - const groups: Record = {}; - for (const cat of DOC_CATEGORIES) { - const items = filteredDocs.filter(d => d.category === cat); - if (items.length > 0) groups[cat] = items; - } - return groups; - }, [filteredDocs]); - - const activeDoc = DOCS.find(d => d.id === activeDocId); - const activeCategory = activeDoc?.category ?? ''; - const renderedContent = activeDoc - ? replaceAppName(activeDoc.content, appName) - : ''; - - const handleDocClick = useCallback((id: string) => { - setActiveDocId(id); - }, []); - - const docCount = DOCS.length; - - return ( - - {/* Table of Contents */} - - - - - Documentation - - - - setSearch(e.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - }} - sx={{ - '& .MuiOutlinedInput-root': { - fontSize: '0.75rem', - height: 32, - borderRadius: 1, - bgcolor: alpha(theme.palette.action.hover, isDark ? 0.3 : 0.4), - '& fieldset': { border: 'none' }, - '&:hover fieldset': { - border: `1px solid ${alpha(theme.palette.divider, 0.5)}`, - }, - '&.Mui-focused fieldset': { - border: `1px solid ${theme.palette.primary.main}`, - }, - }, - }} - /> - - - - {Object.entries(groupedDocs).map(([category, docs]) => ( - - - {category} - - {docs.map(doc => { - const isActive = doc.id === activeDocId; - return ( - handleDocClick(doc.id)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleDocClick(doc.id); - } - }} - sx={{ - px: 1, - py: 0.5, - mx: 0.25, - borderRadius: 0.75, - cursor: 'pointer', - fontSize: '0.8rem', - lineHeight: 1.4, - fontWeight: isActive ? 600 : 400, - color: isActive - ? theme.palette.text.primary - : theme.palette.text.secondary, - bgcolor: isActive - ? alpha(theme.palette.primary.main, isDark ? 0.1 : 0.07) - : 'transparent', - borderLeft: isActive - ? `2px solid ${theme.palette.primary.main}` - : '2px solid transparent', - '&:hover': { - bgcolor: isActive - ? alpha( - theme.palette.primary.main, - isDark ? 0.13 : 0.09, - ) - : alpha(theme.palette.action.hover, 0.4), - color: theme.palette.text.primary, - }, - transition: 'all 0.12s ease', - }} - > - {doc.title} - - ); - })} - - ))} - - {filteredDocs.length === 0 && ( - - No docs match "{search}" - - )} - - - - {/* Document Content */} - - {activeDoc ? ( - <> - {/* Breadcrumb */} - - {activeCategory} - - - - - {renderedContent} - - - - ) : ( - - Select a document from the sidebar - - )} - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/DocsPanel/docsContent.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/DocsPanel/docsContent.ts deleted file mode 100644 index b4845ba9ae5..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/DocsPanel/docsContent.ts +++ /dev/null @@ -1,1168 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export interface DocSection { - id: string; - title: string; - category: string; - content: string; -} - -export const DOC_CATEGORIES = [ - 'Getting Started', - 'Agents', - 'Tools', - 'Platform', - 'Administration', - 'Reference', -]; - -export const DOCS: DocSection[] = [ - // ========================================================================= - // GETTING STARTED - // ========================================================================= - { - id: 'overview', - title: 'Overview', - category: 'Getting Started', - content: `# {{appName}} Overview - -{{appName}} is an enterprise AI agent management platform integrated into Red Hat Developer Hub. It provides a unified interface for deploying, orchestrating, and monitoring AI agents and MCP tools across your infrastructure. - -## Key Concepts - -### Agents -AI agents are autonomous programs that can understand natural language, use tools, and collaborate with other agents. {{appName}} supports deploying agents as container workloads on Kubernetes/OpenShift with full lifecycle management. - -### MCP Tools -Model Context Protocol (MCP) tools extend agent capabilities — web search, database access, API calls, code execution, and more. Tools are deployed as separate services and connected to agents through the platform configuration. - -### Orchestration -Multi-agent orchestration lets you define teams of specialized agents that can hand off conversations and delegate tasks to each other. A starting agent receives user requests and routes them to the right specialist. - -### Namespaces -All agents and tools are deployed into Kubernetes namespaces. The namespace picker in the sidebar lets you scope your view to a specific namespace. - -### Communication Protocols -Agents communicate using one of three protocols: -- **A2A** (Agent-to-Agent) — native agent communication protocol -- **MCP** (Model Context Protocol) — for tool-oriented agents -- **HTTP** — standard REST endpoints - -## Platform Architecture - -The platform consists of three layers: - -1. **Frontend Plugin** — The UI you are using now, running inside Red Hat Developer Hub -2. **Backend Plugin** — Handles API routing, configuration management, and provider integration -3. **Kagenti Runtime** — The Kubernetes-native agent runtime that manages agent and tool workloads - -## What You Can Do - -| Area | Capabilities | -|------|-------------| -| **Agents** | Deploy from container images, build from source, develop with templates, configure orchestration | -| **Tools** | Deploy MCP tool servers, connect tools to agents, invoke and test tools | -| **Builds** | Trigger Shipwright container image builds, monitor build status | -| **Sandbox** | Create isolated testing sessions, manage agent lifecycle, browse files, monitor pods | -| **Platform Config** | Manage models, tools, MCP servers, RAG knowledge bases, safety guardrails, evaluation | -| **Observability** | Access distributed tracing, network monitoring, MCP inspector, and MCP proxy dashboards | -| **Administration** | Manage identity (Keycloak), namespaces, agent migration, API keys, LLM teams, integrations, DevSpaces, and sandbox triggers | -`, - }, - - { - id: 'quick-start', - title: 'Quick Start', - category: 'Getting Started', - content: `# Quick Start Guide - -Get up and running with {{appName}} in three steps. - -## Step 1: Navigate to Command Center - -Click **Command Center** in the sidebar to enter the admin interface. You'll see the Home Dashboard with: - -- **Stat Cards** — Overview of agents, tools, and namespace health -- **Quick Actions** — Shortcuts to create agents, tools, or launch the Guided Experience -- **Health Monitor** — Status of all deployed agents and tools - -## Step 2: Create Your First Agent - -1. Click **New Agent** (from Quick Actions or the Agents panel) -2. Choose how to get started: - - **Develop** — Scaffold a new agent from a template or launch a DevSpace - - **Import** — Deploy an existing agent from a container image or Git source - - **Configure** — Set up multi-agent orchestration -3. Fill in the wizard fields (name, namespace, protocol, framework, deployment method) -4. Click **Create** to deploy (or **Start Build** for source-based deployments) - -## Step 3: Chat With Your Agent - -Once your agent shows "Ready" in the Health Monitor: -1. Click the agent row to view its details -2. Click the **Chat** button to start a conversation -3. Type a message and press Enter - -## What's Next? - -- **Add MCP Tools** — Go to the Tools panel to deploy tool servers that give your agents new capabilities -- **Set Up Orchestration** — Use Configure to create multi-agent routing with handoffs and delegation -- **Explore Platform Config** — Configure models, RAG knowledge bases, and safety guardrails -- **Run the Guided Tours** — Click the Guided Experience button on the Home dashboard for interactive walkthroughs -`, - }, - - // ========================================================================= - // AGENTS - // ========================================================================= - { - id: 'creating-agents', - title: 'Creating Agents', - category: 'Agents', - content: `# Creating Agents - -{{appName}} provides four paths to get an agent running on your platform. - -## Import: Container Image - -Deploy a pre-built agent from any container registry. - -1. Go to **Agents** → **New Agent** → **Import** -2. **Basics** — Enter agent name, select namespace, choose protocol (A2A, MCP, or HTTP), and select framework (LangGraph, CrewAI, Google ADK, LlamaIndex, Semantic Kernel, Autogen, or Custom) -3. **Deployment** — Select "Container Image" and provide: - - Image URL (e.g., \`ghcr.io/org/my-agent:latest\`) - - Image pull secret (if your registry requires authentication) -4. **Runtime** — Configure: - - Workload type (Deployment, StatefulSet, or Job) - - Environment variables (direct values, from Secret, or from ConfigMap) - - Service ports (name, port, target port, protocol) - - Security settings (HTTP routes, auth bridge, SPIRE identity) -5. Click **Create** to deploy - -## Import: Source from Git - -Build an agent from source code using Shipwright pipelines. - -1. Go to **Agents** → **New Agent** → **Import** -2. **Basics** — Enter agent name, namespace, protocol, and framework -3. **Deployment** — Select "Source from Git" and provide: - - Git repository URL - - Branch and subdirectory (optional) - - Registry URL and registry secret for the built image - - Image tag (optional) - - Start command (optional) - - Build strategy, Dockerfile path, and build timeout - - Build arguments (key-value pairs, optional) -4. **Runtime** — Configure workload type, env vars, ports, and security -5. Click **Start Build** to trigger the Shipwright pipeline - -The build progress appears in the Build Pipelines panel. Your agent is deployed automatically when the build completes. - -## Develop: From Template - -Scaffold a new agent project using a pre-configured software template. - -1. Go to **Agents** → **New Agent** → **Develop** → **From Template** -2. Browse available templates (e.g., LangGraph agent, A2A agent) -3. Select a template and follow the scaffolding wizard -4. After development, return to Import to deploy from image or source - -## Develop: Agent DevSpace - -Launch a cloud IDE with your agent repo pre-configured. - -1. Go to **Agents** → **New Agent** → **Develop** → **Agent DevSpace** -2. Provide the Git repository URL and select a namespace -3. Configure resource limits (memory, CPU) -4. Click **Create** to launch the DevSpace -5. Use the cloud IDE terminal to build and push your container image -6. Return to Import to deploy from the built image -`, - }, - - { - id: 'agent-registry', - title: 'Agent Registry & Lifecycle', - category: 'Agents', - content: `# Agent Registry & Lifecycle - -The Agent Registry is the central staging area where administrators manage the full agent lifecycle — from initial creation through governance review to production deployment. Every agent, regardless of source (Kagenti, orchestration, or external), appears in the registry. - -## Agent Lifecycle - -Agents follow a five-stage promotion pipeline: - -### 1. Draft -Agents start as **Draft** when first created or discovered. Draft agents are visible only to their creator and administrators. They have not been submitted for review. Draft agents can be deleted by their creator. - -### 2. Review -When the creator submits an agent for review, it enters the **Review** stage. Review agents are visible to administrators who can evaluate the agent's configuration, instructions, and capabilities before deciding whether to approve it. - -### 3. Staging -Approved agents move to **Staging** — a pre-production environment for internal testing. Staging agents can be tested by administrators but are not visible to end users. This stage is for quality assurance and final validation. - -### 4. Production -When a staging agent is promoted to **Production**, it goes live in the end-user catalog. Users can discover and chat with production agents. Each promotion increments the agent's version number. - -### 5. Retired -When a production agent is no longer needed, it can be retired. **Retired** agents are removed from the end-user catalog but preserved in the registry for audit and history purposes. Retired agents can be reactivated back to production if needed. - -## Core Concepts - -### Promotion Pipeline -The registry header shows a visual pipeline of all five lifecycle stages with counts. Click any stage to filter the list. Promoting an agent requires confirmation — no accidental deployments. - -### Valid Transitions -Agents can only move forward or backward one stage at a time: -- Draft → Review (submit for review) -- Review → Staging (approve) -- Staging → Production (publish) -- Production → Retired (retire) -- Review → Draft (reject / send back) -- Staging → Review (withdraw from staging) -- Production → Staging (unpublish) -- Retired → Production (reactivate) - -### Versioning -Each promotion increments the agent's version number. The registry tracks who promoted the agent and when, providing an audit trail. - -### Featured Agents -Production agents can be marked as **featured** to appear prominently on the chat welcome screen. Up to 4 agents can be featured simultaneously. - -### Dev & Ops Views -Expanding an agent row shows two information sections: -- **Development** — source, framework, protocols, namespace, creation date, creator -- **Operations** — runtime status, lifecycle stage, version, promotion history - -## Agent Sources - -| Source | Description | Status | -|--------|-------------|--------| -| **Kagenti** | Container workloads deployed on OpenShift | Runtime status (Ready, Pending, Error) | -| **Orchestration** | Multi-agent workflow configurations | Config-based (always "config") | - -## Using the Registry - -### Promoting an Agent -1. Navigate to **Agent Registry** under Agent Ops in the sidebar -2. Find the agent in the list (use search or filters) -3. Click **Submit for Review** to promote from Draft → Review -4. Click **Approve** to promote from Review → Staging -5. Click **Publish** to promote from Staging → Production -6. Confirm the promotion in the dialog - -### Withdrawing an Agent -Click the back arrow on any agent to withdraw it to the previous lifecycle stage. Withdrawing a production agent removes it from the end-user catalog immediately and moves it back to staging. - -### Deleting a Draft Agent -Draft agents can be deleted by their creator or by an administrator. This permanently removes the agent from the registry. - -### Configuring Display -Expand any agent row to customize how it appears to end users: -- **Display Name** — override the agent's technical name -- **Description** — custom description for the catalog -- **Avatar URL** — custom avatar image -- **Accent Color** — hex color for the agent's avatar -- **Greeting Message** — first message when a user starts a conversation -- **Conversation Starters** — suggested prompts shown on the welcome screen - -### Bulk Operations -1. Select multiple agents using checkboxes -2. Use **Publish All** or **Unpublish All** in the toolbar -3. All selected agents are updated simultaneously (agents that don't meet the required lifecycle stage are skipped) - -### Quick Promote from Agent Detail -When viewing an agent's detail page in the Agents panel, use the **Publish** / **Unpublish** button in the header. -`, - }, - - { - id: 'agent-orchestration', - title: 'Agent Orchestration', - category: 'Agents', - content: `# Agent Orchestration - -Multi-agent orchestration lets you create teams of specialized agents that collaborate to handle complex workflows. - -## Accessing Orchestration - -Go to **Agents** → **New Agent** → **Configure** to open the orchestration panel. - -## The Orchestration Panel - -### Toolbar -- **Starting Agent** — Which agent receives incoming requests first -- **Max Turns** — Maximum routing turns before the system stops (1–50, prevents infinite loops) -- **New Agent** — Add a new orchestrated agent -- **Save** — Persist the configuration - -### Agent List -The left panel shows all orchestrated agents with: -- Agent name and key identifier -- Connection counts (inbound and outbound) -- Default/starting agent indicator (star icon) - -### Agent Identity -Each agent has: -- **Display Name** — Human-readable name shown in the UI -- **Key** — Unique machine identifier (DNS-safe) -- **Handoff Description** — Critical text that other agents read when deciding whether to route to this agent - -## Configuration Tabs - -### Capabilities -- **Model Override** — Assign a specific model to this agent (or use the global default) -- **MCP Servers** — Connect MCP tool servers for this agent to use -- **Built-in Tools**: - - **Knowledge Base** — Enable RAG for document search (with optional vector store selection) - - **Web Search** — Allow the agent to search the web - - **Code Interpreter** — Let the agent execute code - -### Connections -- **Can Transfer To** (Handoffs) — Select agents this one can transfer the conversation to. The target agent takes full control; this agent stops responding. -- **Can Delegate To** (Delegation) — Select agents to run as background sub-tasks. This agent stays in control and receives results back. -- **Agent Topology** — Visual map of all routing paths between agents - -### Advanced -- **Tool Choice** — How the model decides to use tools (auto, required, none) -- **Reasoning** — Effort level for chain-of-thought reasoning (low, medium, high) -- **Temperature** — Controls randomness in responses (0 = deterministic, 2 = creative) -- **Max Output Tokens** — Limit response length -- **Max Tool Calls** — Limit the number of tool invocations per turn -- **Guardrails** — Safety shield IDs to apply to this agent -- **Reset Tool Choice After Use** — Revert to "auto" after each tool call -- **Summarize History on Handoff** — Compress conversation history when transferring - -### Instructions -- **Write** — Manually author the agent's system prompt -- **Generate** — Provide a description and let AI auto-generate instructions based on the agent's capabilities, connections, and tools -`, - }, - - { - id: 'agent-lifecycle', - title: 'Agent Lifecycle', - category: 'Agents', - content: `# Agent Lifecycle - -## Monitoring Agent Status - -The **Home Dashboard** Health Monitor and the **Agents** panel show the status of all deployed agents: - -| Status | Meaning | -|--------|---------| -| **Ready** | Agent is deployed and accepting requests | -| **Pending** | Agent is being deployed or waiting for resources | -| **Building** | A Shipwright build is in progress | -| **Error** | Deployment failed — check agent details for more information | - -## Agent Detail View - -Click any agent in the Agents panel to see its detail view with four tabs: - -### Details -- Agent name, namespace, description -- Workload type and replica status -- Creation timestamp and UID - -### Agent Card -- A2A agent card information (name, description, capabilities, skills) -- Protocol and framework metadata - -### Status -- Current pod/replica status -- Shipwright build status (if applicable) -- Build triggers and rebuild options - -### Resource -- Raw Kubernetes resource YAML -- Copy-to-clipboard for debugging - -## Rebuilding an Agent - -If your agent was deployed from source, you can trigger a rebuild: -1. Open the agent detail view -2. Click **Rebuild** in the top-right -3. Monitor progress in the Build Pipelines panel - -## Deleting an Agent - -1. Open the Agents panel -2. Locate the agent in the table -3. Click the delete action (trash icon) -4. Confirm the deletion - -## Chatting with an Agent - -1. Open the agent detail view -2. Click the **Chat** button -3. The interface switches to chat mode with the selected agent -`, - }, - - // ========================================================================= - // TOOLS - // ========================================================================= - { - id: 'managing-tools', - title: 'Managing MCP Tools', - category: 'Tools', - content: `# Managing MCP Tools - -MCP (Model Context Protocol) tools extend your agents' capabilities. Tools run as separate services and are connected to agents through the platform configuration. - -## Creating a Tool - -1. Go to **Tools** → **New Tool** -2. Choose your path: - - **Develop** — Scaffold from a template or launch a Tool DevSpace - - **Deploy** — Bring an existing tool onto the platform - -### Develop Path -- **From Template** — Scaffold a new MCP tool project from a pre-configured software template -- **Tool DevSpace** — Launch a cloud IDE workspace to develop your MCP tool with full tooling and runtime support - -### Deploy Path - -#### Basics -- **Tool Name** — Unique name for the tool (DNS-1123 label) -- **Namespace** — Target Kubernetes namespace -- **Protocol** — MCP protocol variant -- **Description** — What this tool does (agents read this to decide when to use it) - -#### Deployment Method -- **Container Image** — Deploy from a pre-built image -- **Source from Git** — Build from a Git repository using Shipwright - -#### Runtime Configuration -- **Workload Type** — Deployment, StatefulSet, or Job -- **Environment Variables** — Key-value pairs for tool configuration -- **Ports** — Service ports the tool exposes -- **Security** — HTTP routes, auth bridge, SPIRE identity - -## Connecting Tools to Agents - -Tools deployed through {{appName}} are managed as Kubernetes workloads. To make external MCP servers available to orchestrated agents: -1. Go to **Platform Config** → **MCP Servers** -2. Register the external server's URL and connection type -3. In **Agent Orchestration**, enable the MCP server in the agent's Capabilities tab - -## Tool Discovery - -Use the **MCP Tool Discovery** feature to automatically detect available tools from an MCP server: -1. Open the tool detail drawer (click a tool row in the Tools panel) -2. Click **Connect** to establish a connection -3. Browse the list of discovered tools and their schemas -4. Click **Invoke** to test a tool with sample parameters - -## Tool Health - -The Home Dashboard's Health Monitor shows tool status alongside agents. Tools follow the same status model (Ready, Pending, Building, Error). -`, - }, - - // ========================================================================= - // PLATFORM - // ========================================================================= - { - id: 'platform-config', - title: 'Platform Configuration', - category: 'Platform', - content: `# Platform Configuration - -Platform Config manages the shared AI infrastructure used by all agents. Access it from the **Platform Config** sidebar item. - -## Model - -Set the default LLM model used by agents: -- Select from available models via the model dropdown -- Test model connectivity with the test button -- Agents can override the global model in their individual Capabilities tab - -## Tools - -Manage the built-in tool registry available to agents. This tab lists tools registered at the platform level and allows you to configure tool-level settings. - -## MCP Servers - -Register external MCP tool servers: -- **Add Server** — Provide server name, URL, and connection type (SSE or Streamable HTTP) -- **Test Connection** — Verify the server is reachable -- **Enable/Disable** — Toggle individual servers without removing them -- Servers registered here appear in the Capabilities tab of orchestrated agents - -## RAG / Knowledge Base - -Configure Retrieval-Augmented Generation: -- **Vector Stores** — Create and manage vector stores for document search -- **Document Ingestion** — Upload documents for agents to reference -- **Sync** — Trigger re-indexing of ingested documents -- Agents enable Knowledge Base access per-agent in their Capabilities tab - -## Safety - -Configure safety shields that filter agent inputs and outputs: -- Define guardrail IDs -- Agents reference these IDs in their Advanced settings tab -- Guardrails run as pre/post processing steps in the agent pipeline - -## Evaluation - -Monitor model evaluation metrics and configure evaluation pipelines for quality assurance. - -> **Note**: Tab visibility depends on provider capabilities. Some tabs may be hidden if the active provider does not support a feature. -`, - }, - - { - id: 'build-pipelines', - title: 'Build Pipelines', - category: 'Platform', - content: `# Build Pipelines - -Build Pipelines provides visibility into Shipwright container image builds for agents and tools. - -## Viewing Builds - -The Build Pipelines panel shows all builds across namespaces with: -- **Name** — Build or BuildRun name -- **Type** — Whether the build is for an Agent or a Tool -- **Workspace** — The namespace where the build runs -- **Strategy** — The Shipwright build strategy used -- **Git URL** — Source repository for the build -- **Status** — Succeeded, Failed, Running, Pending -- **Created** — When the build was triggered -- **Actions** — Trigger a rebuild - -## Build Strategies - -Shipwright supports multiple build strategies. Common examples include: -- **buildah** — Build images using Buildah -- **kaniko** — Build images using Kaniko -- **source-to-image** — OpenShift S2I builds -- Custom strategies registered in your cluster - -The available strategies depend on the ClusterBuildStrategies installed on your cluster. View them in **Administration** → **Build Strategies**. - -## Triggering Builds - -Builds are triggered automatically when you: -1. Create an agent or tool with "Source from Git" deployment method -2. Click **Rebuild** from an agent or tool detail view - -## Monitoring Build Progress - -1. Navigate to **Build Pipelines** in the sidebar -2. Find your build in the list -3. Check the status column for current progress -4. Click a build row for detailed information - -Build status also appears in the **Home Dashboard** under the "Recent Builds" tab. -`, - }, - - { - id: 'sandbox-testing', - title: 'Sandbox Testing', - category: 'Platform', - content: `# Sandbox Testing - -The Sandbox provides isolated testing environments for agents before production deployment. - -## Prerequisites - -Select a **namespace** from the sidebar dropdown before accessing the Sandbox panel. - -## Tabs - -The Sandbox panel has six tabs: - -### Sessions -Create and manage sandbox sessions: -- **Create Session** — Start a new sandbox session for an agent -- **Search & Filter** — Find sessions by name or status -- **Session Actions** — Rename, approve/deny, delete, or kill sessions -- **Token Usage** — View token consumption per session -- **Session Detail** — Click a session to view its sub-tabs: - - **Detail** — Full session metadata (JSON) - - **Chain** — Conversation routing chain - - **History** — Complete conversation history - -### Agent Lifecycle -Manage the lifecycle of agents within the sandbox: -- Deploy sandbox-specific agent instances -- Monitor agent readiness and health -- View agent pod information - -### Pod Observability -Monitor pods running in the sandbox namespace: -- View pod status, resource usage, and events -- Inspect running containers - -### File Browser -Browse the file system of sandbox agents: -- Navigate directory structures within agent containers -- View file contents for debugging - -### Sidecars -Manage sidecar containers attached to sandbox agents: -- View sidecar status and logs -- Observe sidecar events via SSE streams - -### Events & Tasks -Monitor events and background tasks in the sandbox: -- Track sandbox lifecycle events -- View scheduled and completed tasks -`, - }, - - { - id: 'observability', - title: 'Observability', - category: 'Platform', - content: `# Observability - -The Observability panel provides links to external monitoring dashboards configured for your platform. - -## Available Dashboards - -Dashboards are organized into two categories: - -### Observability -| Dashboard | Purpose | -|-----------|---------| -| **Traces** | Distributed tracing for agent requests and A2A calls (e.g., Jaeger) | -| **Network** | Network topology and service mesh visualization (e.g., Kiali) | - -### Development -| Dashboard | Purpose | -|-----------|---------| -| **MCP Inspector** | Inspect and debug MCP tool connections | -| **MCP Proxy** | Manage MCP proxy routing and endpoints | - -## Configuring Dashboard URLs - -Dashboard URLs are loaded from the Kagenti platform configuration. Administrators can configure them through the \`app-config.yaml\` under \`augment.kagenti.dashboards\`. - -Dashboard entries include: -- **Key** — Dashboard identifier (e.g., \`traces\`, \`network\`, \`mcpInspector\`, \`mcpProxy\`) -- **URL** — Full URL to the external dashboard - -## Namespace Scoping - -When a namespace is selected in the sidebar, dashboard links automatically append a \`?namespace=\` query parameter to pre-filter their views. -`, - }, - - // ========================================================================= - // ADMINISTRATION - // ========================================================================= - { - id: 'administration', - title: 'Administration', - category: 'Administration', - content: `# Administration - -The Administration panel provides access to platform management features. Availability depends on your feature flags and role. - -## Identity Management - -Access the Keycloak console for user, role, and authentication policy management. Requires the Keycloak console URL to be configured in the platform dashboard settings. - -## Namespace Management - -View all enabled Kubernetes namespaces where agents and tools can be deployed. The namespace picker in the sidebar scopes all views to the selected namespace. - -## Agent Migration - -Migrate agents from the legacy Agent CRD workload type to the Deployment workload type: -- View migratable agents with their current status -- Migrate agents individually or use **Migrate All** -- Check whether each agent already has a Deployment resource - -## Build Strategies - -View available ClusterBuildStrategies installed on your cluster. These strategies are used by Shipwright when building agent and tool container images from source. - -## Dev Spaces - -Configure Red Hat Dev Spaces for cloud-based agent development: -- **Dev Spaces API URL** — Endpoint for the Dev Spaces API -- **OpenShift Token** — Optional bearer token for Kubernetes API authentication (used if the cluster does not use Keycloak as an identity provider) - -## LLM Models - -View available LLM models from the Kagenti platform as raw JSON data. - -## LLM Teams - -Organize model access by team: -- View all configured LLM teams -- **Create Team** — Specify namespace and optional team name - -## LLM API Keys - -Manage API keys for agent model access: -- View all configured API keys (by namespace and agent) -- **Create Key** — Specify namespace and agent name -- Delete individual keys - -## Integrations - -Configure external service integrations: -- View all integrations (namespace, name, type) -- **Create** — Add a new integration with namespace, name, type, and config JSON -- **Test** — Verify connectivity to an integration -- **Delete** — Remove an integration - -## Sandbox Trigger - -Manually trigger a sandbox session for an agent: -- **Namespace** — Target namespace (required) -- **Agent Name** — Specific agent to target (optional) -- **Type** — Trigger type: webhook, cron, or alert -- **Message** — Trigger payload message -`, - }, - - { - id: 'branding', - title: 'Branding & Appearance', - category: 'Administration', - content: `# Branding & Appearance - -Customize the look and feel of {{appName}} through the Branding panel. - -## Appearance - -### Identity -- **App Name** — The product name displayed throughout the UI (currently "{{appName}}") -- **Tagline** — Subtitle shown on the welcome screen -- **Input Placeholder** — Default text in the chat input field -- **Logo URL** — Custom logo image (must be https:// or http://) -- **Favicon URL** — Browser tab icon (must be https:// or http://) - -### Colors -- **Primary Color** — Main accent color -- **Secondary Color** — Supporting color -- **Success Color** — Color for success states -- **Warning Color** — Color for warning states -- **Error Color** — Color for error states -- **Info Color** — Color for informational states - -### Theme -- **Theme Preset** — Choose from built-in color themes that apply a coordinated set of colors - -## Prompt Groups - -Organize conversation starters into groups: -- Create and edit prompt groups -- Add prompts with titles and descriptions -- Reorder prompts within groups -- Prompts appear on the chat welcome screen - -## Chat Experience - -Configure the chat interface behavior and features. This tab is available when the Kagenti provider is active. - -## Configuration Precedence - -Branding follows the standard configuration precedence: -1. Code defaults (built into the plugin) -2. YAML configuration (\`augment.branding\` in \`app-config.yaml\`) -3. Admin UI overrides (saved to the database via this panel) - -Admin UI values override YAML, which overrides defaults. -`, - }, - - { - id: 'security', - title: 'Security & Access', - category: 'Administration', - content: `# Security & Access - -{{appName}} supports three security modes to control who can access the plugin and its features. - -## Security Modes - -| Mode | Description | Setup Required | -|------|-------------|----------------| -| **none** | No authentication required | Just set \`security.mode: 'none'\` | -| **plugin-only** | Keycloak + Backstage RBAC | Keycloak configuration + RBAC policies | -| **full** | Keycloak + RBAC + MCP OAuth | Full security stack (experimental) | - -## Plugin Access Control - -Access to {{appName}} is controlled by the \`augment.access\` Backstage permission: -- Configure in your RBAC policies who can access the plugin -- Users without the permission see a "Not authorized" message - -## Admin Access - -Admin features (Command Center, configuration changes, agent/tool management) require: -- \`augment.admin\` permission, OR -- Username listed in \`augment.security.adminUsers\` configuration - -## Keycloak Setup - -For \`plugin-only\` and \`full\` modes: -1. Configure a Keycloak realm with an OIDC client for Backstage -2. Set up user groups for access control -3. Configure Backstage to use Keycloak as the auth provider -4. Define RBAC policies for the \`augment.access\` and \`augment.admin\` permissions - -## Configuration Example - -\`\`\`yaml -augment: - security: - mode: 'plugin-only' - adminUsers: - - admin@example.com - - platform-admin@example.com -\`\`\` -`, - }, - - // ========================================================================= - // REFERENCE - // ========================================================================= - { - id: 'config-reference', - title: 'Configuration Reference', - category: 'Reference', - content: `# Configuration Reference - -All configuration for {{appName}} lives under the \`augment\` key in \`app-config.yaml\`. - -## Minimum Required Configuration - -\`\`\`yaml -augment: - llamaStack: - baseUrl: 'https://your-llama-stack-server.com' - model: 'meta-llama/Llama-3.3-70B-Instruct' -\`\`\` - -## Configuration Precedence - -\`\`\` -EffectiveConfig = YAML baseline + DB admin overrides -\`\`\` - -1. **YAML** — Loaded at startup from \`app-config.yaml\` -2. **Database** — Admin UI saves override YAML per-key -3. **Cache** — Merged result cached for 5 seconds; invalidated on admin save -4. **Fallback** — If DB is unreachable, YAML-only mode with warning - -### Special Merge Rules - -| Key | Behavior | -|-----|----------| -| \`branding\` | Shallow merge — DB fields win, YAML-only fields survive | -| \`mcpServers\` | Smart merge — matching IDs override, new servers appended | -| \`agents\` | Full replacement — DB agents completely replace YAML agents | - -## Key Configuration Sections - -### Provider -\`\`\`yaml -augment: - provider: 'kagenti' # or 'llamastack' -\`\`\` - -### Kagenti Runtime -\`\`\`yaml -augment: - kagenti: - baseUrl: 'https://kagenti-api.example.com' - token: '\${KAGENTI_TOKEN}' -\`\`\` - -### Branding -\`\`\`yaml -augment: - branding: - appName: 'My AI Platform' - tagline: 'Your enterprise AI assistant' - primaryColor: '#1e40af' -\`\`\` - -### Security -\`\`\`yaml -augment: - security: - mode: 'plugin-only' # 'none' | 'plugin-only' | 'full' - adminUsers: - - admin@example.com -\`\`\` - -### MCP Servers -\`\`\`yaml -augment: - mcpServers: - - id: 'kubernetes' - name: 'Kubernetes MCP' - url: 'http://mcp-server:8080/sse' - type: 'sse' -\`\`\` - -### Agents (YAML-defined) -\`\`\`yaml -augment: - agents: - router: - name: Router - instructions: 'Route requests to specialist agents' - handoffs: - - specialist - specialist: - name: Specialist - instructions: 'Handle domain-specific tasks' - mcpServers: - - kubernetes -\`\`\` - -## YAML Changes Require Restart - -The plugin does not watch for file changes. Any modification to \`app-config.yaml\` requires a backend restart to take effect. -`, - }, - - { - id: 'troubleshooting', - title: 'Troubleshooting', - category: 'Reference', - content: `# Troubleshooting - -Common issues and solutions for {{appName}}. - -## "Setup Needed" or "Failed to connect to backend" - -**Cause**: The backend plugin is not reachable or not configured. - -**Solutions**: -1. Verify \`augment.llamaStack.baseUrl\` and \`augment.llamaStack.model\` are set in \`app-config.yaml\` -2. Check that the backend plugin is installed and loaded (look for "Augment configuration validated successfully" in pod logs) -3. Test \`GET /api/augment/health\` — if it returns 404, the plugin never loaded -4. For Kagenti provider, verify \`augment.kagenti.baseUrl\` is reachable - -## 400 Error Saving Agent Configuration - -**Cause**: Validation failure in the agent payload. - -**Common errors**: -| Error | Cause | -|-------|-------| -| "Agent must have a non-empty name" | Missing name field | -| "Agent must have non-empty instructions" | Missing instructions | -| "Handoff to X which does not exist" | Referenced agent not in the save payload | -| "agents must be a non-null object" | Wrong payload structure | - -**Resolution**: Ensure every agent has at minimum a \`name\` and \`instructions\` field. All handoff and delegation targets must exist in the same save. - -## Agent Stuck in "Pending" - -**Possible causes**: -- Insufficient cluster resources (CPU/memory) -- Image pull errors (wrong URL or missing pull secret) -- Namespace quotas exceeded - -**Diagnosis**: -1. Open the agent detail view → Status tab -2. Check replica status and pod events -3. Look at the Resource tab for the full Kubernetes spec - -## Build Failed - -**Diagnosis**: -1. Go to **Build Pipelines** panel -2. Find the failed build -3. Check the build status and error messages -4. Verify the Git repository URL, Dockerfile path, and registry URL - -**Common causes**: -- Invalid Git repository URL -- Dockerfile not found at the specified path -- Registry authentication failure -- Build strategy not available in the cluster - -## MCP Tool Connection Failed - -**Solutions**: -1. Verify the MCP server URL is correct and reachable from the cluster -2. Check the connection type (SSE vs Streamable HTTP) -3. Use **Platform Config → MCP Servers → Test Connection** to diagnose -4. Check that the MCP server is running and healthy - -## Dark Mode Text Invisible - -If text appears invisible in dark mode, this is a known theming issue. Try: -1. Toggle between light and dark mode -2. Refresh the page -3. Report the specific panel/component where the issue occurs -`, - }, - - { - id: 'api-reference', - title: 'API Reference', - category: 'Reference', - content: `# API Reference - -{{appName}} exposes a REST API under \`/api/augment\`. All endpoints require authentication unless noted. - -## Health & Status - -| Method | Path | Description | -|--------|------|-------------| -| GET | \`/health\` | Liveness check (503 if provider init failed) — no auth required | -| GET | \`/status\` | Provider status, MCP status, admin flag | -| GET | \`/branding\` | Merged branding configuration | - -## Chat - -| Method | Path | Description | -|--------|------|-------------| -| POST | \`/chat\` | Non-streaming chat response | -| POST | \`/chat/stream\` | Server-Sent Events streaming chat | -| POST | \`/chat/approve\` | Approve pending tool calls (rate-limited) | - -## Sessions - -| Method | Path | Description | -|--------|------|-------------| -| GET | \`/sessions\` | List chat sessions | -| POST | \`/sessions\` | Create a new session | -| GET | \`/sessions/:id\` | Get session details | -| DELETE | \`/sessions/:id\` | Delete a session | -| PATCH | \`/sessions/:id\` | Update session (rename) | -| GET | \`/sessions/:id/messages\` | Get session messages | -| GET | \`/sessions/:id/state\` | Get session state | -| POST | \`/feedback\` | Submit feedback for a message | - -## Documents & RAG - -| Method | Path | Description | -|--------|------|-------------| -| GET | \`/documents\` | List ingested documents | -| POST | \`/sync\` | Trigger document re-indexing | -| GET | \`/vector-stores\` | List vector stores | -| GET | \`/safety/status\` | Safety guardrail status | -| GET | \`/evaluation/status\` | Evaluation pipeline status | - -## Admin Configuration - -All admin endpoints require admin access. - -| Method | Path | Description | -|--------|------|-------------| -| GET | \`/admin/effective-config\` | Merged YAML + DB configuration | -| GET | \`/admin/config\` | All config keys | -| GET | \`/admin/config/:key\` | Get a specific config key | -| PUT | \`/admin/config/:key\` | Set a config key (body: \`{ value: ... }\`) | -| DELETE | \`/admin/config/:key\` | Reset a key to YAML defaults | -| GET | \`/admin/providers\` | List available providers | -| GET | \`/admin/active-provider\` | Get active provider | -| PUT | \`/admin/active-provider\` | Switch active provider | -| GET | \`/admin/models\` | List available models | -| POST | \`/admin/test-model\` | Test model connectivity | -| POST | \`/admin/generate-system-prompt\` | AI-generate agent instructions | -| POST | \`/admin/mcp/test-connection\` | Test MCP server connectivity | - -### Admin Documents & Vector Stores - -| Method | Path | Description | -|--------|------|-------------| -| POST | \`/admin/documents\` | Upload a document | -| DELETE | \`/admin/documents/:id\` | Delete a document | -| GET | \`/admin/vector-stores\` | List vector stores | -| POST | \`/admin/vector-store/create\` | Create a vector store | -| GET | \`/admin/vector-store/status\` | Vector store status | -| POST | \`/admin/vector-stores/connect\` | Connect to a vector store | -| DELETE | \`/admin/vector-stores/:id\` | Delete a vector store | - -### Admin Sessions - -| Method | Path | Description | -|--------|------|-------------| -| GET | \`/admin/sessions\` | List all sessions (admin view) | -| GET | \`/admin/sessions/:id/messages\` | Get messages for any session | - -## Dev Spaces - -| Method | Path | Description | -|--------|------|-------------| -| POST | \`/devspaces/workspaces\` | Create a DevSpaces workspace (admin) | - -## Kagenti Agents - -Available when using the Kagenti provider. - -| Method | Path | Description | -|--------|------|-------------| -| GET | \`/kagenti/agents\` | List agents (query: namespace, include=cards) | -| POST | \`/kagenti/agents\` | Create an agent (admin) | -| GET | \`/kagenti/agents/:ns/:name\` | Get agent details | -| DELETE | \`/kagenti/agents/:ns/:name\` | Delete an agent (admin) | -| GET | \`/kagenti/agents/:ns/:name/route-status\` | Agent route/endpoint status | -| GET | \`/kagenti/agents/:ns/:name/build-info\` | Build information | -| POST | \`/kagenti/agents/:ns/:name/buildrun\` | Trigger a build (admin) | -| POST | \`/kagenti/agents/:ns/:name/finalize-build\` | Finalize a build (admin) | -| POST | \`/kagenti/agents/:ns/:name/migrate\` | Migrate agent CRD (admin) | -| POST | \`/kagenti/agents/migration/migrate-all\` | Migrate all agents (admin) | -| GET | \`/kagenti/agents/migration/migratable\` | List migratable agents | -| GET | \`/kagenti/agents/build-strategies\` | List build strategies | -| GET | \`/kagenti/agents/shipwright-builds\` | List Shipwright builds | - -## Kagenti Tools - -| Method | Path | Description | -|--------|------|-------------| -| GET | \`/kagenti/tools\` | List tools (query: namespace) | -| POST | \`/kagenti/tools\` | Create a tool (admin) | -| GET | \`/kagenti/tools/:ns/:name\` | Get tool details | -| DELETE | \`/kagenti/tools/:ns/:name\` | Delete a tool (admin) | -| GET | \`/kagenti/tools/:ns/:name/route-status\` | Tool route/endpoint status | -| POST | \`/kagenti/tools/:ns/:name/connect\` | Connect to MCP server | -| POST | \`/kagenti/tools/:ns/:name/invoke\` | Invoke a tool | -| GET | \`/kagenti/tools/:ns/:name/build-info\` | Tool build information | -| POST | \`/kagenti/tools/:ns/:name/buildrun\` | Trigger tool build (admin) | - -## Kagenti Sandbox - -| Method | Path | Description | -|--------|------|-------------| -| GET | \`/kagenti/sandbox/defaults\` | Default sandbox configuration | -| POST | \`/kagenti/sandbox/:ns/sessions\` | Create sandbox session | -| GET | \`/kagenti/sandbox/:ns/sessions\` | List sandbox sessions | -| POST | \`/kagenti/sandbox/:ns/chat\` | Chat in sandbox | -| POST | \`/kagenti/sandbox/:ns/chat/stream\` | Streaming sandbox chat (SSE) | - -## Kagenti Config & Admin - -| Method | Path | Description | -|--------|------|-------------| -| GET | \`/kagenti/health\` | Kagenti runtime health | -| GET | \`/kagenti/config/features\` | Feature flags | -| GET | \`/kagenti/config/dashboards\` | Dashboard URLs | -| GET | \`/kagenti/namespaces\` | List enabled namespaces | -| GET | \`/kagenti/models\` | List LLM models | -`, - }, -]; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/DocsPanel/index.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/DocsPanel/index.ts deleted file mode 100644 index 7c280a2b288..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/DocsPanel/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { DocsPanel } from './DocsPanel'; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/DocumentsTable.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/DocumentsTable.tsx deleted file mode 100644 index 4d9e246bf07..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/DocumentsTable.tsx +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState } from 'react'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; -import IconButton from '@mui/material/IconButton'; -import Button from '@mui/material/Button'; -import CircularProgress from '@mui/material/CircularProgress'; -import Tooltip from '@mui/material/Tooltip'; -import Typography from '@mui/material/Typography'; -import DeleteIcon from '@mui/icons-material/Delete'; -import type { DocumentInfo } from '../../types'; - -export interface DocumentsTableProps { - documents: DocumentInfo[]; - deleteInProgress: string | null; - onDelete: (fileId: string) => void; -} - -function DeleteCell({ - doc, - isDeleting, - isConfirming, - onConfirmStart, - onConfirmCancel, - onDelete, -}: { - doc: DocumentInfo; - isDeleting: boolean; - isConfirming: boolean; - onConfirmStart: () => void; - onConfirmCancel: () => void; - onDelete: () => void; -}) { - if (isDeleting) { - return ; - } - if (isConfirming) { - return ( - <> - - - - ); - } - return ( - - - - - - ); -} - -export function DocumentsTable({ - documents, - deleteInProgress, - onDelete, -}: DocumentsTableProps) { - const [confirmId, setConfirmId] = useState(null); - - return ( - - - - - File Name - Format - Size - Status - Actions - - - - {documents.map(doc => ( - - - - {doc.fileName} - - - {doc.format} - - {doc.fileSize > 0 - ? `${(doc.fileSize / 1024).toFixed(1)} KB` - : '—'} - - {doc.status} - - setConfirmId(doc.id)} - onConfirmCancel={() => setConfirmId(null)} - onDelete={() => { - setConfirmId(null); - onDelete(doc.id); - }} - /> - - - ))} - -
    -
    - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/GeneratedAnswerCard.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/GeneratedAnswerCard.tsx deleted file mode 100644 index 2dce5ee01fb..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/GeneratedAnswerCard.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback } from 'react'; -import Box from '@mui/material/Box'; -import Paper from '@mui/material/Paper'; -import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; -import Tooltip from '@mui/material/Tooltip'; -import IconButton from '@mui/material/IconButton'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import CheckIcon from '@mui/icons-material/Check'; -import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; -import AccessTimeIcon from '@mui/icons-material/AccessTime'; - -interface GeneratedAnswerCardProps { - answer: string; - model: string; - timeMs: number | null; -} - -export function GeneratedAnswerCard({ - answer, - model, - timeMs, -}: GeneratedAnswerCardProps) { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(async () => { - try { - await window.navigator.clipboard.writeText(answer); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - // Clipboard API may not be available - } - }, [answer]); - - return ( - - - - - - Generated Answer - - - - - {timeMs !== null && ( - - - - {timeMs < 1000 - ? `${timeMs}ms` - : `${(timeMs / 1000).toFixed(2)}s`} - - - )} - - - {copied ? ( - - ) : ( - - )} - - - - - - - {answer} - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/HybridSearchConfig.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/HybridSearchConfig.tsx deleted file mode 100644 index 78b482c7779..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/HybridSearchConfig.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Slider from '@mui/material/Slider'; - -export interface HybridSearchConfigProps { - bm25Weight: number; - semanticWeight: number; - onBm25WeightChange: (value: number) => void; - onSemanticWeightChange: (value: number) => void; -} - -/** - * BM25 and semantic weight sliders for hybrid search mode - */ -export function HybridSearchConfig({ - bm25Weight, - semanticWeight, - onBm25WeightChange, - onSemanticWeightChange, -}: HybridSearchConfigProps) { - return ( - - - - BM25 Weight: {bm25Weight} - - onBm25WeightChange(v as number)} - /> - - - - Semantic Weight: {semanticWeight} - - onSemanticWeightChange(v as number)} - /> - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/IngestDropZone.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/IngestDropZone.tsx deleted file mode 100644 index 3cd1c741ddd..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/IngestDropZone.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Paper from '@mui/material/Paper'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import CloudUploadIcon from '@mui/icons-material/CloudUpload'; -import { useIngestDropZone } from './useIngestDropZone'; - -export interface IngestDropZoneProps { - vectorStoreId: string | null; - onUploadComplete?: (message: string) => void; -} - -export const IngestDropZone = ({ - vectorStoreId, - onUploadComplete, -}: IngestDropZoneProps) => { - const { - onFileUpload, - uploading, - error, - onClearError, - dragOver, - onDragOver, - onDragLeave, - onDrop, - fileInputRef, - } = useIngestDropZone({ vectorStoreId, onUploadComplete }); - - return ( - <> - { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - fileInputRef.current?.click(); - } - }} - sx={{ - p: 2, - textAlign: 'center', - border: dragOver ? '2px dashed' : '1px dashed', - borderColor: dragOver ? 'primary.main' : 'divider', - bgcolor: dragOver ? 'action.hover' : 'transparent', - cursor: 'pointer', - '&:hover': { - borderColor: 'primary.light', - bgcolor: 'action.hover', - }, - '&:focus-visible': { - outline: '2px solid', - outlineColor: 'primary.main', - outlineOffset: 2, - }, - }} - onClick={() => fileInputRef.current?.click()} - > - onFileUpload(e.target.files)} - /> - - - Drag & drop files or click to browse - - - .md, .txt, .pdf, .json, .yaml, .yml (max 10 MB) - - {uploading && ( - - - Ingesting... - - )} - - {error && ( - - {error} - - )} - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KBCreateStore.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KBCreateStore.test.tsx deleted file mode 100644 index a280fc378c4..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KBCreateStore.test.tsx +++ /dev/null @@ -1,275 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ComponentProps } from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { KBCreateStore } from './KBCreateStore'; - -type VsConfigHandle = ComponentProps['vsConfig']; -import { createApiTestWrapper, createAdminMockApi } from '../../test-utils'; - -const theme = createTheme(); - -interface VsConfigOverrides { - config?: Record | null; - configSource?: string; - saving?: boolean; - creating?: boolean; - save?: jest.Mock; - reset?: jest.Mock; - create?: jest.Mock; -} - -function createVsConfigHandle(overrides: VsConfigOverrides = {}) { - const base = { - config: { - vectorStoreName: 'Test Store', - embeddingModel: 'model-1', - embeddingDimension: 384, - searchMode: 'semantic' as const, - chunkingStrategy: 'auto' as const, - }, - configSource: 'database', - saving: false, - creating: false, - save: jest.fn().mockResolvedValue(undefined), - reset: jest.fn().mockResolvedValue(undefined), - create: jest.fn().mockResolvedValue(null), - }; - return { ...base, ...overrides } as VsConfigHandle; -} - -function createApiWithModels() { - const api = createAdminMockApi(); - (api.listModels as jest.Mock).mockResolvedValue([ - { id: 'model-1', model_type: 'embedding' }, - { id: 'model-2', model_type: 'embedding' }, - ]); - return api; -} - -function createWrapper(api: ReturnType) { - const Wrapper = createApiTestWrapper(api); - return ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); -} - -describe('KBCreateStore', () => { - const onCreated = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('returns null when vsConfig has no config', () => { - const vsConfig = createVsConfigHandle({ config: null }); - const api = createApiWithModels(); - - const { container } = render( - , - { wrapper: createWrapper(api) }, - ); - - expect(container.firstChild).toBeNull(); - }); - - it('renders form fields when config is available', async () => { - const vsConfig = createVsConfigHandle(); - const api = createApiWithModels(); - - render(, { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(screen.getByLabelText(/Vector Store Name/i)).toBeInTheDocument(); - }); - - expect(screen.getByLabelText(/Embedding Model/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/Dimension/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/Search Mode/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/Chunking/i)).toBeInTheDocument(); - }); - - it('Create Store button is disabled when name is empty', async () => { - const vsConfig = createVsConfigHandle({ - config: { - vectorStoreName: '', - embeddingModel: 'model-1', - embeddingDimension: 384, - }, - }); - const api = createApiWithModels(); - - render(, { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect( - screen.getByRole('button', { name: /Create Store/i }), - ).toBeDisabled(); - }); - }); - - it('Create Store button is disabled when creating', async () => { - const vsConfig = createVsConfigHandle({ creating: true }); - const api = createApiWithModels(); - - render(, { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - const createBtn = screen.getByRole('button', { name: /Creating/i }); - expect(createBtn).toBeDisabled(); - }); - }); - - it('handleCreate calls vsConfig.create and onCreated on success', async () => { - const createMock = jest.fn().mockResolvedValue({ - vectorStoreId: 'vs-123', - vectorStoreName: 'My Store', - }); - const vsConfig = createVsConfigHandle({ create: createMock }); - const api = createApiWithModels(); - - render(, { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect( - screen.getByRole('button', { name: /Create Store/i }), - ).toBeInTheDocument(); - }); - - const createBtn = screen.getByRole('button', { name: /Create Store/i }); - fireEvent.click(createBtn); - - await waitFor(() => { - expect(createMock).toHaveBeenCalled(); - expect(onCreated).toHaveBeenCalledWith('vs-123', 'My Store'); - }); - - expect(screen.getByText(/My Store.*vs-123/)).toBeInTheDocument(); - }); - - it('Save as Defaults calls vsConfig.save', async () => { - const saveMock = jest.fn().mockResolvedValue(undefined); - const vsConfig = createVsConfigHandle({ save: saveMock }); - const api = createApiWithModels(); - - render(, { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect( - screen.getByRole('button', { name: /Save as Defaults/i }), - ).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', { name: /Save as Defaults/i })); - - await waitFor(() => { - expect(saveMock).toHaveBeenCalled(); - }); - }); - - it('Reset calls vsConfig.reset', async () => { - const resetMock = jest.fn().mockResolvedValue(undefined); - const vsConfig = createVsConfigHandle({ - reset: resetMock, - configSource: 'database', - }); - const api = createApiWithModels(); - - render(, { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect( - screen.getByRole('button', { name: /^Reset$/i }), - ).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', { name: /^Reset$/i })); - - await waitFor(() => { - expect(resetMock).toHaveBeenCalled(); - }); - }); - - it('Reset is disabled when configSource is yaml', async () => { - const vsConfig = createVsConfigHandle({ configSource: 'yaml' }); - const api = createApiWithModels(); - - render(, { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(screen.getByRole('button', { name: /^Reset$/i })).toBeDisabled(); - }); - }); - - it('updates local config when user changes vector store name', async () => { - const vsConfig = createVsConfigHandle(); - const api = createApiWithModels(); - - render(, { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(screen.getByLabelText(/Vector Store Name/i)).toBeInTheDocument(); - }); - - const nameInput = screen.getByLabelText(/Vector Store Name/i); - fireEvent.change(nameInput, { target: { value: 'Updated Name' } }); - - expect(nameInput).toHaveValue('Updated Name'); - }); - - it('shows HybridSearchConfig when searchMode is hybrid', async () => { - const vsConfig = createVsConfigHandle({ - config: { - vectorStoreName: 'Test', - embeddingModel: 'model-1', - embeddingDimension: 384, - searchMode: 'hybrid', - chunkingStrategy: 'auto', - }, - }); - const api = createApiWithModels(); - - render(, { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(screen.getByLabelText(/Vector Store Name/i)).toBeInTheDocument(); - }); - - expect(screen.getByLabelText(/Search Mode/i)).toBeInTheDocument(); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KBCreateStore.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KBCreateStore.tsx deleted file mode 100644 index 97f8c76a5ae..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KBCreateStore.tsx +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useEffect, useMemo } from 'react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import AddIcon from '@mui/icons-material/Add'; -import { useModels } from '../../hooks'; -import type { VectorStoreConfig } from '../../types'; -import { CreateStoreForm } from './CreateStoreForm'; -import { StoreConfigFields } from './StoreConfigFields'; - -interface VsConfigHandle { - config: VectorStoreConfig | null; - configSource: string; - saving: boolean; - creating: boolean; - save: (overrides: Partial) => Promise; - reset: () => Promise; - create: ( - overrides?: Record, - ) => Promise<{ vectorStoreId: string; vectorStoreName: string } | null>; -} - -interface Props { - vsConfig: VsConfigHandle; - onCreated: (id: string, name: string) => void; -} - -export const KBCreateStore = ({ vsConfig, onCreated }: Props) => { - const { - models, - loading: modelsLoading, - refresh: refreshModels, - } = useModels(); - const [localConfig, setLocalConfig] = - useState | null>(null); - const [createResult, setCreateResult] = useState(null); - - const embeddingModels = useMemo( - () => - models - .filter(m => m.model_type === 'embedding') - .map(m => m.id) - .filter(Boolean) as string[], - [models], - ); - - useEffect(() => { - if (vsConfig.config && !localConfig) { - setLocalConfig({ ...vsConfig.config }); - } - }, [vsConfig.config]); // eslint-disable-line react-hooks/exhaustive-deps - - const update = useCallback( - ( - key: K, - value: VectorStoreConfig[K], - ) => { - setLocalConfig(prev => - prev ? { ...prev, [key]: value } : { [key]: value }, - ); - }, - [], - ); - - const handleCreate = useCallback(async () => { - setCreateResult(null); - const result = await vsConfig.create(localConfig ?? undefined); - if (result) { - setCreateResult( - `"${result.vectorStoreName}" created (ID: ${result.vectorStoreId})`, - ); - onCreated(result.vectorStoreId, result.vectorStoreName); - } - }, [vsConfig, localConfig, onCreated]); - - const handleSave = useCallback(async () => { - if (localConfig) await vsConfig.save(localConfig); - }, [localConfig, vsConfig]); - - const handleReset = useCallback(async () => { - await vsConfig.reset(); - setLocalConfig(null); - }, [vsConfig]); - - if (!localConfig) return null; - - const nameValid = (localConfig.vectorStoreName ?? '').trim().length > 0; - const modelValid = (localConfig.embeddingModel ?? '').trim().length > 0; - const dimValid = - typeof localConfig.embeddingDimension === 'number' && - localConfig.embeddingDimension > 0; - const canCreate = nameValid && modelValid && dimValid && !vsConfig.creating; - - return ( - <> - - - - - - - - - - - {createResult && ( - setCreateResult(null)} - > - {createResult} - - )} - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KBManageStores.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KBManageStores.tsx deleted file mode 100644 index a2e81041ce0..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KBManageStores.tsx +++ /dev/null @@ -1,627 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { type ReactElement, useState, useCallback, useMemo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Paper from '@mui/material/Paper'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import IconButton from '@mui/material/IconButton'; -import Chip from '@mui/material/Chip'; -import Tooltip from '@mui/material/Tooltip'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import Snackbar from '@mui/material/Snackbar'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import ExpandLessIcon from '@mui/icons-material/ExpandLess'; -import CloudUploadIcon from '@mui/icons-material/CloudUpload'; -import DescriptionIcon from '@mui/icons-material/Description'; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import LinkIcon from '@mui/icons-material/Link'; -import StorageIcon from '@mui/icons-material/Storage'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; -import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; -import { StoresTable } from './StoresTable'; -import { KBStoreDetail } from './KBStoreDetail'; -import { IngestDropZone } from './IngestDropZone'; -import { DocumentsTable } from './DocumentsTable'; -import { DeleteStoreDialog } from './DeleteStoreDialog'; -import { useStoreDocuments } from './useStoreDocuments'; -import type { ActiveVectorStore } from '../../hooks'; - -interface Props { - stores: ActiveVectorStore[]; - unconnectedStores?: ActiveVectorStore[]; - selectedStoreId: string | null; - onSelectStore: (id: string) => void; - onRefresh: () => void; - onRemoveStore: (id: string, permanent?: boolean) => Promise; - onConnectStore?: (id: string) => Promise; - onUpdateStore?: ( - id: string, - updates: { name?: string; metadata?: Record }, - ) => Promise; -} - -const STATUS_ICON: Record = { - completed: , - in_progress: ( - - ), - failed: , -}; - -function StoreDetailHeader({ - store, - fileCount, -}: { - store: ActiveVectorStore; - fileCount: number; -}) { - const displayName = store.name !== store.id ? store.name : store.id; - const shortId = - store.id.length > 20 - ? `${store.id.slice(0, 8)}...${store.id.slice(-8)}` - : store.id; - - return ( - - - - {displayName} - - {store.name !== store.id && ( - - - {shortId} - - - )} - - - - - {store.embeddingModel && ( - - - - )} - {store.providerType && ( - } - label={store.providerType} - size="small" - variant="outlined" - sx={{ - height: 22, - fontSize: '0.6875rem', - '& .MuiChip-icon': { fontSize: 12 }, - }} - /> - )} - - - ); -} - -const COLLAPSED_LIMIT = 5; - -function UnconnectedStoresSection({ - stores, - connectInProgress, - onConnect, -}: { - stores: ActiveVectorStore[]; - connectInProgress: string | null; - onConnect: (id: string) => Promise; -}) { - const [expanded, setExpanded] = useState(false); - const [connectError, setConnectError] = useState(null); - const hasMore = stores.length > COLLAPSED_LIMIT; - const visibleStores = useMemo( - () => (expanded ? stores : stores.slice(0, COLLAPSED_LIMIT)), - [stores, expanded], - ); - const hiddenCount = stores.length - COLLAPSED_LIMIT; - - return ( - - - - Available Stores on Server - - - - - These vector stores exist on the server. Connect one to start managing - files and using it for RAG queries. - - {connectError && ( - setConnectError(null)} - > - {connectError} - - )} - - - - - ID - Model - Provider - Files - - - - - {visibleStores.map(s => ( - - - - {s.name !== s.id ? s.name : s.id} - - {s.name !== s.id && ( - - {s.id} - - )} - - - {s.embeddingModel ? ( - - - {s.embeddingModel.split('/').pop()} - - - ) : ( - - - - - )} - {s.embeddingDimension && ( - - {s.embeddingDimension}d - - )} - - - {s.providerType ? ( - } - label={s.providerType} - size="small" - variant="outlined" - sx={{ - height: 18, - fontSize: '0.625rem', - '& .MuiChip-icon': { fontSize: 12 }, - }} - /> - ) : ( - - - - - )} - - - {s.fileCount} - - - - { - setConnectError(null); - onConnect(s.id).catch(err => { - setConnectError( - err instanceof Error - ? err.message - : 'Failed to connect store', - ); - }); - }} - > - {connectInProgress === s.id ? ( - - ) : ( - - )} - - - - - ))} - -
    -
    - {hasMore && ( - - - - )} -
    - ); -} - -export const KBManageStores = ({ - stores, - unconnectedStores = [], - selectedStoreId, - onSelectStore, - onRefresh, - onRemoveStore, - onConnectStore, - onUpdateStore, -}: Props) => { - const selectedStore = stores.find(s => s.id === selectedStoreId) ?? null; - const [removeInProgress, setRemoveInProgress] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ - storeId: string; - storeName: string; - storeStatus?: string; - } | null>(null); - const [detailTab, setDetailTab] = useState(0); - const [ingestResult, setIngestResult] = useState(null); - const [connectInProgress, setConnectInProgress] = useState( - null, - ); - - const { - documents, - docsLoading, - docsError, - refreshDocs, - deleteInProgress, - deleteError, - handleDelete, - } = useStoreDocuments({ - selectedStoreId, - onRefresh, - }); - - const confirmDeleteStore = useCallback(async () => { - if (!deleteConfirm) return; - const { storeId, storeStatus } = deleteConfirm; - const permanent = storeStatus !== 'unknown'; - setDeleteConfirm(null); - setRemoveInProgress(storeId); - try { - await onRemoveStore(storeId, permanent); - refreshDocs(); - } finally { - setRemoveInProgress(null); - } - }, [deleteConfirm, onRemoveStore, refreshDocs]); - - const handleRenameStore = useCallback( - async (storeId: string, newName: string) => { - if (onUpdateStore) { - await onUpdateStore(storeId, { name: newName }); - } - }, - [onUpdateStore], - ); - - const hasStores = stores.length > 0; - - return ( - <> - setIngestResult(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - setIngestResult(null)} - > - {ingestResult} - - - - - - {deleteError} - - - - { - onSelectStore(id); - setDetailTab(0); - }} - onDeleteStore={store => - setDeleteConfirm({ - storeId: store.id, - storeName: store.name !== store.id ? store.name : store.id, - storeStatus: store.status, - }) - } - onRenameStore={onUpdateStore ? handleRenameStore : undefined} - deleteInProgress={removeInProgress} - /> - - {selectedStore && ( - - - - setDetailTab(v)} - sx={{ - px: 1.5, - borderBottom: 1, - borderColor: 'divider', - minHeight: 36, - '& .MuiTab-root': { - minHeight: 36, - textTransform: 'none', - fontSize: '0.8125rem', - px: 2, - mr: 0.5, - }, - }} - > - } - iconPosition="start" - /> - } - iconPosition="start" - /> - } - iconPosition="start" - /> - - - - {detailTab === 0 && } - - {detailTab === 1 && ( - <> - {docsError && ( - - Could not load documents: {docsError} - - )} - {/* eslint-disable-next-line no-nested-ternary */} - {docsLoading ? ( - - - - ) : documents.length === 0 && !docsError ? ( - - - No documents yet. - - - - ) : ( - - )} - - )} - - {detailTab === 2 && ( - { - setIngestResult(message); - setDetailTab(1); - refreshDocs(); - onRefresh(); - }} - /> - )} - - - )} - - {hasStores && !selectedStore && ( - - Select a store above to view or ingest documents. - - )} - - {unconnectedStores.length > 0 && onConnectStore && ( - { - setConnectInProgress(id); - try { - await onConnectStore(id); - } finally { - setConnectInProgress(null); - } - }} - /> - )} - - - - - - - - - - setDeleteConfirm(null)} - /> - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KBRagTest.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KBRagTest.tsx deleted file mode 100644 index 23230b38c14..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KBRagTest.tsx +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useEffect } from 'react'; -import Box from '@mui/material/Box'; -import Alert from '@mui/material/Alert'; -import Chip from '@mui/material/Chip'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import { ToggleSwitch as Switch } from './shared/ToggleSwitch'; -import Typography from '@mui/material/Typography'; -import { - useRagTest, - useRagGenerate, - type ActiveVectorStore, -} from '../../hooks'; -import type { RagTestResult } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { RagQueryForm } from './RagQueryForm'; -import { RagResultsTable } from './RagResultsTable'; -import { GeneratedAnswerCard } from './GeneratedAnswerCard'; - -const MAX_HISTORY = 8; - -export type RagTestMode = 'search' | 'generate'; - -interface Props { - stores: ActiveVectorStore[]; - selectedStoreId: string | null; - onSelectStore?: (storeId: string) => void; -} - -export const KBRagTest = ({ - stores, - selectedStoreId, - onSelectStore, -}: Props) => { - const { - search, - loading: searchLoading, - error: searchError, - result: searchResult, - searchTimeMs, - clearResult: clearSearchResult, - clearError: clearSearchError, - } = useRagTest(); - - const { - generate, - loading: generateLoading, - error: generateError, - result: generateResult, - generateTimeMs, - clearResult: clearGenerateResult, - clearError: clearGenerateError, - } = useRagGenerate(); - - const [ragQuery, setRagQuery] = useState(''); - const [maxResults, setMaxResults] = useState(5); - const [searchAllStores, setSearchAllStores] = useState(false); - const [queryHistory, setQueryHistory] = useState([]); - const [expandedChunks, setExpandedChunks] = useState>(new Set()); - const [scoreThreshold, setScoreThreshold] = useState(0); - const [mode, setMode] = useState('search'); - - const loading = mode === 'search' ? searchLoading : generateLoading; - const error = mode === 'search' ? searchError : generateError; - const clearError = mode === 'search' ? clearSearchError : clearGenerateError; - const result = mode === 'search' ? searchResult : generateResult; - const timeMs = mode === 'search' ? searchTimeMs : generateTimeMs; - - const selectedStore = stores.find(s => s.id === selectedStoreId) ?? null; - const hasStores = stores.length > 0; - - const addToHistory = useCallback((query: string) => { - setQueryHistory(prev => { - const filtered = prev.filter(q => q !== query); - return [query, ...filtered].slice(0, MAX_HISTORY); - }); - }, []); - - const handleSearch = useCallback(async () => { - const trimmed = ragQuery.trim(); - if (!trimmed) return; - clearError(); - setExpandedChunks(new Set()); - addToHistory(trimmed); - - const storeIds = searchAllStores ? stores.map(s => s.id) : undefined; - const storeId = searchAllStores - ? undefined - : (selectedStoreId ?? undefined); - - if (mode === 'generate') { - await generate(trimmed, maxResults, storeId, storeIds).catch(() => {}); - } else { - await search(trimmed, maxResults, storeId, storeIds).catch(() => {}); - } - }, [ - ragQuery, - maxResults, - search, - generate, - clearError, - selectedStoreId, - searchAllStores, - stores, - addToHistory, - mode, - ]); - - const handleHistoryClick = useCallback( - (query: string) => { - setRagQuery(query); - clearError(); - setExpandedChunks(new Set()); - addToHistory(query); - - const storeIds = searchAllStores ? stores.map(s => s.id) : undefined; - const storeId = searchAllStores - ? undefined - : (selectedStoreId ?? undefined); - - if (mode === 'generate') { - void generate(query, maxResults, storeId, storeIds).catch(() => {}); - } else { - void search(query, maxResults, storeId, storeIds).catch(() => {}); - } - }, - [ - search, - generate, - maxResults, - selectedStoreId, - searchAllStores, - stores, - clearError, - addToHistory, - mode, - ], - ); - - const toggleChunk = useCallback((index: number) => { - setExpandedChunks(prev => { - const next = new Set(prev); - if (next.has(index)) next.delete(index); - else next.add(index); - return next; - }); - }, []); - - const handleExpandCollapseAll = useCallback(() => { - if (!result) return; - const CHUNK_PREVIEW_LENGTH = 300; - const longIndices = result.chunks - .map((c, i) => (c.text.length > CHUNK_PREVIEW_LENGTH ? i : -1)) - .filter(i => i >= 0); - const allExpanded = longIndices.every(i => expandedChunks.has(i)); - setExpandedChunks(allExpanded ? new Set() : new Set(longIndices)); - }, [result, expandedChunks]); - - useEffect(() => { - clearSearchResult(); - clearGenerateResult(); - clearSearchError(); - clearGenerateError(); - setExpandedChunks(new Set()); - }, [ - selectedStoreId, - searchAllStores, - clearSearchResult, - clearGenerateResult, - clearSearchError, - clearGenerateError, - ]); - - // Clear results when switching mode - useEffect(() => { - setExpandedChunks(new Set()); - }, [mode]); - - if (!hasStores) { - return ( - - Create a vector store and ingest documents before testing RAG queries. - - ); - } - - if (!searchAllStores && !selectedStore) { - return ( - - Select a store below, or enable Search all stores. - {stores.length > 0 && ( - - {stores.map(s => ( - onSelectStore?.(s.id)} - sx={{ - cursor: 'pointer', - '&:hover': { - bgcolor: 'action.hover', - borderColor: 'primary.main', - }, - }} - /> - ))} - {stores.length > 1 && ( - setSearchAllStores(v)} - /> - } - label={ - - Search all {stores.length} stores - - } - /> - )} - - )} - - ); - } - - return ( - <> - - - {!searchAllStores && selectedStore && selectedStore.fileCount === 0 && ( - - This store has no documents. Ingest files first to test retrieval. - - )} - - {error && ( - - {error} - - )} - - {mode === 'generate' && generateResult && ( - - )} - - {result && ( - - )} - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KBStoreDetail.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KBStoreDetail.tsx deleted file mode 100644 index de577a94aab..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KBStoreDetail.tsx +++ /dev/null @@ -1,538 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Paper from '@mui/material/Paper'; -import Chip from '@mui/material/Chip'; -import Divider from '@mui/material/Divider'; -import LinearProgress from '@mui/material/LinearProgress'; -import TextField from '@mui/material/TextField'; -import Button from '@mui/material/Button'; -import CircularProgress from '@mui/material/CircularProgress'; -import Alert from '@mui/material/Alert'; -import Collapse from '@mui/material/Collapse'; -import Tooltip from '@mui/material/Tooltip'; -import IconButton from '@mui/material/IconButton'; -import DescriptionIcon from '@mui/icons-material/Description'; -import StorageIcon from '@mui/icons-material/Storage'; -import MemoryIcon from '@mui/icons-material/Memory'; -import SearchIcon from '@mui/icons-material/Search'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; -import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; -import type { ActiveVectorStore } from '../../hooks'; -import { useRagTest } from '../../hooks'; - -function formatBytes(bytes: number | undefined): string { - if (bytes === undefined || bytes === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB']; - let unitIndex = 0; - let value = bytes; - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex++; - } - return `${value.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`; -} - -function formatTimestamp(epochSeconds: number | undefined): string { - if (!epochSeconds) return 'N/A'; - return new Date(epochSeconds * 1000).toLocaleString(); -} - -function MetadataRow({ - label, - value, - mono, - copyable, -}: { - label: string; - value: string | undefined; - mono?: boolean; - copyable?: boolean; -}) { - const [copied, setCopied] = useState(false); - if (!value) return null; - - const handleCopy = () => { - window.navigator.clipboard.writeText(value).then( - () => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }, - () => { - /* clipboard may not be available in all contexts */ - }, - ); - }; - - return ( - - - {label} - - - - {value} - - {copyable && ( - - - {copied ? ( - - ) : ( - - )} - - - )} - - - ); -} - -function FileCountsBreakdown({ - fileCounts, -}: { - fileCounts: NonNullable; -}) { - const { completed, inProgress, failed, cancelled, total } = fileCounts; - if (total === 0) return null; - - const completedPct = (completed / total) * 100; - let barColor: 'error' | 'warning' | 'success' = 'success'; - if (failed > 0) barColor = 'error'; - else if (inProgress > 0) barColor = 'warning'; - - return ( - - - - - {completed} completed - - {inProgress > 0 && ( - - - {inProgress} processing - - )} - {failed > 0 && ( - - - {failed} failed - - )} - {cancelled > 0 && ( - - {cancelled} cancelled - - )} - - - - ); -} - -interface QuickSearchProps { - vectorStoreId: string; -} - -function QuickSearch({ vectorStoreId }: QuickSearchProps) { - const { - search, - loading, - error, - result, - searchTimeMs, - clearError, - clearResult, - } = useRagTest(); - const [query, setQuery] = useState(''); - - const handleSearch = useCallback(async () => { - const trimmed = query.trim(); - if (!trimmed) return; - clearError(); - await search(trimmed, 3, vectorStoreId).catch(() => {}); - }, [query, vectorStoreId, search, clearError]); - - return ( - - - Quick Search - - - setQuery(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') void handleSearch(); - }} - fullWidth - sx={{ '& .MuiInputBase-input': { fontSize: '0.8125rem' } }} - /> - - - - {error && ( - - {error} - - )} - - {result !== null && result.chunks.length === 0 && !error && ( - - - No results found for “{query}” - - - )} - - 0}> - {result && ( - - - - {result.totalResults} chunk - {result.totalResults !== 1 ? 's' : ''} found - - {searchTimeMs !== null && ( - - in {searchTimeMs}ms - - )} - - - - {result.chunks.slice(0, 3).map((chunk, i) => ( - - - {chunk.score !== undefined && ( - { - if (chunk.score > 0.7) return 'success'; - if (chunk.score > 0.4) return 'warning'; - return 'default'; - })()} - sx={{ height: 18, fontSize: '0.625rem' }} - /> - )} - {chunk.fileName && ( - - {chunk.fileName} - - )} - - - {chunk.text} - - - ))} - - )} - - - ); -} - -interface KBStoreDetailProps { - store: ActiveVectorStore; -} - -export const KBStoreDetail = ({ store }: KBStoreDetailProps) => { - const displayName = store.name !== store.id ? store.name : null; - - return ( - - - {/* Left column: Identity & Configuration */} - - - Store Configuration - - - - {displayName && } - - - - - - - - - - {/* Right column: Storage & Files */} - - - Storage & Files - - - - - - - - - {store.fileCount} - - - Total Files - - - - - - - - - {formatBytes(store.usageBytes)} - - - Storage Used - - - - - {store.fileCounts && store.fileCounts.total > 0 && ( - <> - - - Processing Status - - - - )} - - {store.embeddingModel && ( - <> - - - - - - {store.embeddingModel} - - {store.embeddingDimension && ( - - {store.embeddingDimension} dimensions - - )} - - - - )} - - - - {/* Quick Search */} - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentCardTab.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentCardTab.tsx deleted file mode 100644 index deaeb9bb4a1..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentCardTab.tsx +++ /dev/null @@ -1,264 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; -import Card from '@mui/material/Card'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableRow from '@mui/material/TableRow'; -import Skeleton from '@mui/material/Skeleton'; -import { useTheme } from '@mui/material/styles'; -import StreamIcon from '@mui/icons-material/Stream'; -import type { KagentiAgentCard } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -export interface AgentCardTabProps { - agentCard: KagentiAgentCard | null; - loading: boolean; -} - -export function AgentCardTab({ agentCard, loading }: AgentCardTabProps) { - const theme = useTheme(); - - if (loading) { - return ( - - - - - ); - } - - if (!agentCard) { - return ( - - - No agent card available. The agent may not expose an A2A agent card - endpoint, or it has not been fetched yet. - - - ); - } - - return ( - - - - Basic Information - - - - - - Name - - {agentCard.name} - - - - Version - - - - - - - - URL - - - {agentCard.url || '\u2014'} - - - -
    -
    - - - - Capabilities - - - - Streaming - - } - label={agentCard.streaming ? 'Enabled' : 'Disabled'} - size="small" - color={agentCard.streaming ? 'success' : 'default'} - sx={{ height: 24 }} - /> - - {agentCard.defaultInputModes && - agentCard.defaultInputModes.length > 0 && ( - - - Input modes - - - {agentCard.defaultInputModes.map((mode: string) => ( - - ))} - - - )} - - - {agentCard.description && ( - - - Description - - - {agentCard.description} - - - )} - - {agentCard.skills && agentCard.skills.length > 0 && ( - - - Skills ({agentCard.skills.length}) - - - {agentCard.skills.map( - ( - skill: { - id?: string; - name?: string; - description?: string; - tags?: string[]; - }, - idx: number, - ) => ( - - - {skill.name || skill.id || `Skill ${idx + 1}`} - - {skill.description && ( - - {skill.description} - - )} - {skill.tags && skill.tags.length > 0 && ( - - {skill.tags.map((tag: string) => ( - - ))} - - )} - - ), - )} - - - )} -
    - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentCatalogCard.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentCatalogCard.tsx deleted file mode 100644 index 6000eff4bb1..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentCatalogCard.tsx +++ /dev/null @@ -1,315 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import { useTheme, alpha } from '@mui/material/styles'; -import { - glassSurface, - glassHoverGlow, - borderRadius, - transitions, - typeScale, - animations, - staggerDelay, - reducedMotion, -} from '../../../theme/tokens'; -import type { KagentiAgentSummary } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -interface AgentCatalogCardProps { - id: string; - name: string; - namespace?: string; - description: string; - status: string; - labels: { protocol?: string | string[]; framework?: string }; - source: 'kagenti' | 'orchestration'; - agentRole?: string; - createdAt?: string; - kagentiAgent?: KagentiAgentSummary; - index: number; - onClick: () => void; - onChat?: () => void; - onDelete?: () => void; -} - -function statusDot( - status: string, - palette: { - success: { main: string }; - warning: { main: string }; - error: { main: string }; - text: { disabled: string }; - }, -) { - const s = status?.toLowerCase() ?? ''; - if (s === 'ready' || s === 'running') return palette.success.main; - if (s === 'pending' || s === 'building' || s === 'not ready') - return palette.warning.main; - if (s === 'failed' || s === 'error') return palette.error.main; - return palette.text.disabled; -} - -function timeAgo(dateStr?: string): string { - if (!dateStr) return ''; - const diff = Date.now() - new Date(dateStr).getTime(); - if (diff < 0) return 'just now'; - const mins = Math.floor(diff / 60_000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; -} - -/** - * A visually rich agent card for the catalog grid view. - * Shows status, framework, protocol badges, and quick actions. - */ -export function AgentCatalogCard({ - name, - namespace, - description, - status, - labels, - source, - agentRole, - createdAt, - index, - onClick, - onChat, - onDelete, -}: AgentCatalogCardProps) { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const glass = glassSurface(theme, 6, isDark ? 0.55 : 0.8); - const hoverGlow = glassHoverGlow(theme); - const dotColor = statusDot(status, theme.palette); - const isReady = status?.toLowerCase() === 'ready'; - - return ( - { - if (e.key === 'Enter') onClick(); - }} - sx={{ - ...glass, - ...animations.fadeSlideIn, - animationDelay: staggerDelay(index, 40), - animationFillMode: 'both', - '@media (prefers-reduced-motion: reduce)': reducedMotion, - borderRadius: borderRadius.lg, - p: 2.5, - cursor: 'pointer', - transition: transitions.normal, - display: 'flex', - flexDirection: 'column', - gap: 1.5, - position: 'relative', - overflow: 'hidden', - '&:hover': { - ...hoverGlow, - transform: 'translateY(-2px)', - }, - '&:focus-visible': { - outline: `2px solid ${theme.palette.primary.main}`, - outlineOffset: -2, - }, - }} - > - {/* Top row: status dot + name + quick actions */} - - {/* Status indicator */} - - - {/* Name and namespace */} - - - {name} - - {namespace && ( - - {namespace} - - )} - - - {/* Quick actions (stop propagation so card click doesn't fire) */} - e.stopPropagation()} - > - {onChat && ( - - - - - - )} - {source === 'kagenti' && onDelete && ( - - - - - - )} - - - - {/* Description */} - - {description || 'No description'} - - - {/* Badges row */} - - - {labels?.framework && ( - - )} - {labels?.protocol && ( - - )} - {agentRole && ( - - )} - - - {/* Footer: source + time */} - - - {source === 'orchestration' ? 'Responses API' : 'Platform Agent'} - - {createdAt && ( - - {timeAgo(createdAt)} - - )} - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentCreateIntentDialog.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentCreateIntentDialog.tsx deleted file mode 100644 index a523cc34cc6..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentCreateIntentDialog.tsx +++ /dev/null @@ -1,868 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useCallback, useId, useState, useRef } from 'react'; -import { - useApi, - discoveryApiRef, - fetchApiRef, -} from '@backstage/core-plugin-api'; -import Box from '@mui/material/Box'; -import ButtonBase from '@mui/material/ButtonBase'; -import Dialog from '@mui/material/Dialog'; -import DialogActions from '@mui/material/DialogActions'; -import DialogContent from '@mui/material/DialogContent'; -import Alert from '@mui/material/Alert'; -import Button from '@mui/material/Button'; -import LinearProgress from '@mui/material/LinearProgress'; -import Typography from '@mui/material/Typography'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; -import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; -import CloudUploadOutlinedIcon from '@mui/icons-material/CloudUploadOutlined'; -import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined'; -import DashboardCustomizeOutlinedIcon from '@mui/icons-material/DashboardCustomizeOutlined'; -import CodeIcon from '@mui/icons-material/Code'; -import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; -import { alpha, useTheme } from '@mui/material/styles'; -import type { DeploymentMethod } from './agentWizardTypes'; -import { AgentTemplateBrowser } from './AgentTemplateBrowser'; -import { - DevSpacesFrameworkPicker, - type FrameworkOption, -} from './DevSpacesFrameworkPicker'; -import { DevSpacesLaunchForm } from './DevSpacesLaunchForm'; -import { DialogHeader } from './IntentDialogParts'; -import { RuntimePicker } from '../../SkillsAgentCreation/RuntimePicker'; -import { - SkillBrowser, - type SkillDefinition, -} from '../../SkillsAgentCreation/SkillBrowser'; -import { SkillAgentConfigForm } from '../../SkillsAgentCreation/SkillAgentConfigForm'; -import { - glassSurface, - borderRadius, - transitions, - typeScale, - animations, - staggerDelay, - reducedMotion, -} from '../../../theme/tokens'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface AgentCreateIntentDialogProps { - readonly open: boolean; - readonly onClose: () => void; - readonly onSelectDeploy: (method?: DeploymentMethod) => void; - readonly onSelectConfigure?: () => void; - readonly onCreated?: () => void; -} - -type SkillsStep = 'runtime' | 'skills' | 'config' | 'deploying'; -type View = 'intent' | 'create-sub' | 'templates' | 'devspaces'; - -type DeployPhase = 'applying' | 'waiting-for-pod' | 'ready' | 'failed'; - -interface DeployResult { - phase: DeployPhase; - deployed: boolean; - agentName: string; - namespace: string; - chatEndpoint: string; - skillCount: number; - error?: string; -} - -interface SubOption { - readonly id: string; - readonly icon: React.ReactElement; - readonly title: string; - readonly subtitle: string; - readonly color: string; -} - -// --------------------------------------------------------------------------- -// AccentCard — reusable clickable card with an accent color strip -// --------------------------------------------------------------------------- - -function AccentCard({ - icon, - title, - subtitle, - accentColor, - gradient, - onClick, - tourId, - animIndex, -}: { - readonly icon: React.ReactElement; - readonly title: string; - readonly subtitle: string; - readonly accentColor: string; - readonly gradient?: boolean; - readonly onClick: () => void; - readonly tourId?: string; - readonly animIndex: number; -}) { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - - return ( - - - {icon} - - - {title} - - - {subtitle} - - - ); -} - -// --------------------------------------------------------------------------- -// Main Component -// --------------------------------------------------------------------------- - -export function AgentCreateIntentDialog({ - open, - onClose, - onSelectDeploy, - onSelectConfigure, - onCreated, -}: AgentCreateIntentDialogProps) { - const theme = useTheme(); - const titleId = useId(); - const discoveryApi = useApi(discoveryApiRef); - const fetchApi = useApi(fetchApiRef); - const deployAbortRef = useRef(); - const [view, setView] = useState('intent'); - const [devSpacesGitRepo, setDevSpacesGitRepo] = useState( - undefined, - ); - const [devSpacesFramework, setDevSpacesFramework] = - useState(null); - const [skillsStep, setSkillsStep] = useState(null); - const [skillsRuntimeId, setSkillsRuntimeId] = useState(''); - const [skillsSelected, setSkillsSelected] = useState([]); - const [skillsDeploying, setSkillsDeploying] = useState(false); - const [deployResult, setDeployResult] = useState(null); - - const handleClose = useCallback(() => { - setView('intent'); - setDevSpacesGitRepo(undefined); - setDevSpacesFramework(null); - setSkillsStep(null); - setSkillsRuntimeId(''); - setSkillsSelected([]); - setDeployResult(null); - onClose(); - }, [onClose]); - - const handleOpenInDevSpace = useCallback((gitRepoUrl: string) => { - setDevSpacesGitRepo(gitRepoUrl); - setView('devspaces'); - }, []); - - const SUB_OPTIONS: readonly SubOption[] = [ - { - id: 'skills', - icon: , - title: 'Create using Skills', - subtitle: 'Compose an agent from reusable skills', - color: theme.palette.success.main, - }, - { - id: 'configure', - icon: , - title: 'Create with Visual Canvas', - subtitle: 'Design with the no-code workflow builder', - color: theme.palette.info.dark, - }, - { - id: 'code', - icon: , - title: 'Code Your Agent', - subtitle: 'Scaffold from a template or cloud workspace', - color: theme.palette.primary.dark, - }, - { - id: 'template', - icon: , - title: 'Create from Template', - subtitle: 'Start from a pre-configured software template', - color: theme.palette.warning.main, - }, - ]; - - const handleSkillsDeploy = useCallback( - async (config: { name: string; systemPrompt: string }) => { - setSkillsDeploying(true); - setDeployResult(null); - setSkillsStep('deploying'); - deployAbortRef.current?.abort(); - deployAbortRef.current = new AbortController(); - - const makeResult = ( - phase: DeployPhase, - overrides?: Partial, - ): DeployResult => ({ - phase, - deployed: false, - agentName: config.name, - namespace: '', - chatEndpoint: '', - skillCount: skillsSelected.length, - ...overrides, - }); - - try { - const baseUrl = await discoveryApi.getBaseUrl('augment'); - setDeployResult(makeResult('applying')); - - const resp = await fetchApi.fetch(`${baseUrl}/agents/from-skills`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Backstage-Request': 'augment', - }, - body: JSON.stringify({ - name: config.name, - systemPrompt: config.systemPrompt, - runtime: skillsRuntimeId, - skills: skillsSelected.map(s => ({ - slug: s.slug ?? s.name, - name: s.name, - skillName: s.skillName ?? s.name, - pluginName: s.pluginName, - gitPath: s.gitPath ?? '', - })), - }), - signal: deployAbortRef.current.signal, - }); - - if (!resp.ok) { - const detail = await resp.text().catch(() => ''); - setDeployResult( - makeResult('failed', { - error: `Deploy failed: ${resp.status} ${detail.slice(0, 200)}`, - }), - ); - return; - } - - const data = (await resp.json()) as { - deployed?: boolean; - agentName?: string; - namespace?: string; - chatEndpoint?: string; - skillCount?: number; - }; - - const agentName = data.agentName ?? config.name; - const partial = { - deployed: data.deployed ?? false, - agentName, - namespace: data.namespace ?? '', - chatEndpoint: data.chatEndpoint ?? '', - skillCount: data.skillCount ?? skillsSelected.length, - }; - - onCreated?.(); - - if (!data.deployed) { - setDeployResult(makeResult('ready', partial)); - return; - } - - setDeployResult(makeResult('waiting-for-pod', partial)); - - const pollHealth = async (): Promise => { - try { - const infoResp = await fetchApi.fetch( - `${baseUrl}/skills/agents/${encodeURIComponent(agentName)}/info`, - { headers: { 'X-Backstage-Request': 'augment' } }, - ); - if (!infoResp.ok) return false; - const info = (await infoResp.json()) as { - health?: { status?: string }; - }; - return info.health?.status === 'healthy'; - } catch { - return false; - } - }; - - for (let i = 0; i < 12; i++) { - if (deployAbortRef.current.signal.aborted) return; - await new Promise(r => setTimeout(r, 5000)); - if (await pollHealth()) { - setDeployResult(makeResult('ready', partial)); - return; - } - } - - setDeployResult(makeResult('ready', partial)); - } catch (err) { - if ((err as Error).name !== 'AbortError') { - setDeployResult( - makeResult('failed', { - error: err instanceof Error ? err.message : 'Unknown error', - }), - ); - } - } finally { - setSkillsDeploying(false); - } - }, - [discoveryApi, fetchApi, skillsRuntimeId, skillsSelected, onCreated], - ); - - const handleSubOptionClick = useCallback( - (id: string) => { - setDevSpacesFramework(null); - if (id === 'skills') { - setSkillsStep('runtime'); - } else if (id === 'configure') { - handleClose(); - onSelectConfigure?.(); - } else if (id === 'code') { - setView('devspaces'); - } else if (id === 'template') { - setView('templates'); - } - }, - [handleClose, onSelectConfigure], - ); - - return ( - - } - > - {/* --- Top-level intent selection --- */} - {view === 'intent' && !skillsStep && ( - <> - - - - } - title="Create Agent" - subtitle="Build a new agent from scratch using skills, visual canvas, code, or templates." - accentColor={theme.palette.primary.main} - gradient - onClick={() => setView('create-sub')} - animIndex={0} - /> - } - title="BYO Agent" - subtitle="Import an existing agent from a container image or Git repository." - accentColor={theme.palette.warning.dark} - onClick={() => onSelectDeploy()} - animIndex={1} - /> - - - - - - - )} - - {/* --- Create sub-options (2×2 grid) --- */} - {view === 'create-sub' && !skillsStep && ( - <> - setView('intent')} - /> - - - {SUB_OPTIONS.map((opt, idx) => ( - handleSubOptionClick(opt.id)} - animIndex={idx} - /> - ))} - - - - - - - )} - - {/* --- Template browser --- */} - {view === 'templates' && ( - - setView('create-sub')} - onOpenInDevSpace={handleOpenInDevSpace} - onDone={handleClose} - specType="agent" - /> - - )} - - {/* --- DevSpaces framework picker --- */} - {view === 'devspaces' && devSpacesFramework === null && ( - <> - setView('create-sub')} - /> - - setDevSpacesFramework(fw)} - /> - - - - - - )} - - {/* --- DevSpaces launch form --- */} - {view === 'devspaces' && devSpacesFramework !== null && ( - - setDevSpacesFramework(null)} - onDone={handleClose} - initialGitRepo={devSpacesGitRepo ?? devSpacesFramework?.starterRepo} - frameworkName={devSpacesFramework?.name} - /> - - )} - - {/* --- Skills wizard (3-step) --- */} - {skillsStep === 'runtime' && ( - <> - { - setSkillsStep(null); - setView('create-sub'); - }} - /> - - { - setSkillsRuntimeId(id); - setSkillsStep('skills'); - }} - /> - - - - - - )} - - {skillsStep === 'skills' && ( - <> - setSkillsStep('runtime')} - /> - - - - - - - - - )} - - {skillsStep === 'config' && ( - <> - setSkillsStep('skills')} - /> - - - - - )} - - {skillsStep === 'deploying' && ( - <> - - - {/* Phase: applying manifests or waiting for pod */} - {deployResult && - (deployResult.phase === 'applying' || - deployResult.phase === 'waiting-for-pod') && ( - - - - {deployResult.phase === 'applying' - ? 'Creating namespace, applying manifests...' - : `Waiting for ${deployResult.agentName} pod to become ready...`} - - {deployResult.phase === 'waiting-for-pod' && ( - - - Agent - - - {deployResult.agentName} - - - Namespace - - - {deployResult.namespace} - - - Skills - - - {deployResult.skillCount} skill - {deployResult.skillCount !== 1 ? 's' : ''} - - - )} - - )} - - {/* Phase: ready */} - {deployResult?.phase === 'ready' && ( - - } - sx={{ mb: 3 }} - > - Agent {deployResult.agentName} is{' '} - {deployResult.deployed ? 'deployed and ready' : 'registered'}. - - - - Agent - - - {deployResult.agentName} - - - Namespace - - - {deployResult.namespace} - - {deployResult.chatEndpoint && ( - <> - - Endpoint - - - {deployResult.chatEndpoint} - - - )} - - Skills - - - {deployResult.skillCount} skill - {deployResult.skillCount !== 1 ? 's' : ''} mounted - - - - )} - - {/* Phase: failed */} - {deployResult?.phase === 'failed' && ( - } - sx={{ mb: 2 }} - > - {deployResult.error ?? 'Deployment failed. Please try again.'} - - )} - - {/* Initial state (before first API response) */} - {!deployResult && ( - - - - Preparing deployment... - - - )} - - - {deployResult?.phase === 'ready' && ( - <> - - - - )} - {deployResult?.phase === 'failed' && ( - <> - - - - )} - - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentDetailsTab.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentDetailsTab.tsx deleted file mode 100644 index e8962c005c1..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentDetailsTab.tsx +++ /dev/null @@ -1,317 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useMemo, type ReactNode } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; -import Card from '@mui/material/Card'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableRow from '@mui/material/TableRow'; -import Skeleton from '@mui/material/Skeleton'; -import IconButton from '@mui/material/IconButton'; -import { useTheme, alpha } from '@mui/material/styles'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import type { - KagentiAgentCard, - KagentiAgentDetail, - KagentiAgentSummary, - KagentiRouteStatus, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -export interface AgentDetailsTabProps { - agent: KagentiAgentSummary; - agentDetail: (KagentiAgentDetail & { agentCard?: KagentiAgentCard }) | null; - loading: boolean; - routeStatus: KagentiRouteStatus | null; - copied: boolean; - onCopy: (text: string) => void; -} - -export function AgentDetailsTab({ - agent, - agentDetail, - loading, - routeStatus, - copied, - onCopy, -}: AgentDetailsTabProps) { - const theme = useTheme(); - const agentUrl = agentDetail?.agentCard?.url || routeStatus?.url; - - const infoRows = useMemo(() => { - const rows: Array<{ label: string; value: ReactNode }> = [ - { label: 'Name', value: agent.name }, - { label: 'Namespace', value: agent.namespace }, - { label: 'Description', value: agent.description || '—' }, - { - label: 'Workload Type', - value: ( - - ), - }, - ]; - const meta = agentDetail?.metadata as Record | undefined; - const statusObj = agentDetail?.status as - | Record - | undefined; - if (statusObj?.replicas !== undefined) { - const avail = statusObj.availableReplicas ?? statusObj.readyReplicas ?? 0; - rows.push({ - label: 'Replicas', - value: `${avail}/${statusObj.replicas} ready (${avail} available)`, - }); - } - if (agent.createdAt) { - rows.push({ - label: 'Created', - value: new Date(agent.createdAt).toLocaleString(), - }); - } - if (meta?.uid) { - rows.push({ - label: 'UID', - value: ( - - {String(meta.uid)} - - ), - }); - } - return rows; - }, [agent, agentDetail]); - - const endpointRows = useMemo(() => { - const rows: Array<{ label: string; value: ReactNode }> = []; - if (agentUrl) { - rows.push({ - label: 'Agent URL', - value: ( - - - {agentUrl} - - onCopy(agentUrl)} - title={copied ? 'Copied!' : 'Copy'} - > - - - - ), - }); - } - const svc = agentDetail?.service as Record | undefined; - if (svc) { - if (svc.type) - rows.push({ label: 'Service', value: `${agent.name} (${svc.type})` }); - if (svc.clusterIP) { - rows.push({ - label: 'Cluster IP', - value: ( - - {String(svc.clusterIP)} - - ), - }); - } - const ports = svc.ports as - | Array<{ port?: number; protocol?: string }> - | undefined; - if (ports && ports.length > 0) { - rows.push({ - label: 'Ports', - value: ( - - {ports.map((p, i) => ( - - ))} - - ), - }); - } - } - if (routeStatus) { - Object.entries(routeStatus).forEach(([key, val]) => { - if (key === 'url') return; - if (val !== undefined && val !== null) { - rows.push({ - label: key.charAt(0).toUpperCase() + key.slice(1), - value: typeof val === 'object' ? JSON.stringify(val) : String(val), - }); - } - }); - } - return rows; - }, [agentUrl, agentDetail, routeStatus, copied, theme, agent.name, onCopy]); - - return ( - - - - Agent Information - - {loading ? ( - - {Array.from({ length: 5 }).map((_, i) => ( - - ))} - - ) : ( - - - {infoRows.map(row => ( - - - {row.label} - - - {row.value} - - - ))} - -
    - )} -
    - - - - Endpoint - - {loading && ( - - - - - )} - {!loading && endpointRows.length > 0 && ( - - - {endpointRows.map(row => ( - - - {row.label} - - - {row.value} - - - ))} - -
    - )} - {!loading && endpointRows.length === 0 && ( - - No endpoint information available - - )} -
    -
    - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentLifecycleDetail.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentLifecycleDetail.tsx deleted file mode 100644 index 3e0b5e4d659..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentLifecycleDetail.tsx +++ /dev/null @@ -1,454 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState, useCallback, useEffect, useMemo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import Snackbar from '@mui/material/Snackbar'; -import Tooltip from '@mui/material/Tooltip'; -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import DialogContent from '@mui/material/DialogContent'; -import DialogContentText from '@mui/material/DialogContentText'; -import DialogActions from '@mui/material/DialogActions'; -import ChatIcon from '@mui/icons-material/Chat'; -import PublishIcon from '@mui/icons-material/Publish'; -import CloudOffIcon from '@mui/icons-material/CloudOff'; -import DeleteIcon from '@mui/icons-material/Delete'; -import { - getLifecycleTransition, - getLifecycleStep, -} from './lifecycleTransitions'; -import { - LifecycleActionButtons, - useWithdrawHandler, - useLifecycleAction, -} from './LifecycleActionButtons'; -import { LifecycleDetailShell } from './LifecycleDetailShell'; -import PlayArrowIcon from '@mui/icons-material/PlayArrow'; -import { useApi } from '@backstage/core-plugin-api'; -import type { KagentiAgentSummary } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { normalizeLifecycleStage } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { augmentApiRef } from '../../../api'; -import { statusChipColor as statusColor } from './kagentiDisplayUtils'; -import { AgentDetailsTab } from './AgentDetailsTab'; -import { AgentStatusTab } from './AgentStatusTab'; -import { AgentResourceTab } from './AgentResourceTab'; -import { AgentCardTab } from './AgentCardTab'; -import { useKagentiAgentDetail } from './useKagentiAgentDetail'; -import { - borderRadius, - typeScale, - animations, - reducedMotion, -} from '../../../theme/tokens'; -import { CONTENT_MAX_WIDTH } from '../shared/commandCenterStyles'; -import { InlineAgentChat } from '../shared/InlineAgentChat'; - -type LifecycleTab = 'overview' | 'design' | 'test' | 'build' | 'card'; - -export interface AgentLifecycleDetailProps { - agent: KagentiAgentSummary; - onBack: () => void; - onChatWithAgent?: (agentId: string) => void; - onDeleted?: () => void; - isAdmin?: boolean; -} - -/** - * Agent Detail view structured around the development lifecycle. - * Tabs: Overview | Design | Test | Build | Agent Card - */ -export function AgentLifecycleDetail({ - agent, - onBack, - onChatWithAgent, - onDeleted, - isAdmin = false, -}: AgentLifecycleDetailProps) { - const api = useApi(augmentApiRef); - const [activeTab, setActiveTab] = useState('overview'); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deleteLoading, setDeleteLoading] = useState(false); - - const { - agentCard, - agentDetail, - buildInfo, - routeStatus, - loading, - error, - setError, - buildTriggering, - copied, - hasBuild, - loadBuildInfo, - handleTriggerBuild, - handleCopy, - } = useKagentiAgentDetail(api, agent); - - const agentId = `${agent.namespace}/${agent.name}`; - const displayName = agentCard?.name || agent.name; - - const [lifecycleStage, setLifecycleStage] = useState('draft'); - const [publishLoading, setPublishLoading] = useState(false); - const [publishToast, setPublishToast] = useState(null); - const [rejectionReason, setRejectionReason] = useState(); - const [rejectedBy, setRejectedBy] = useState(); - - useEffect(() => { - let cancelled = false; - api - .listAgents() - .then(agents => { - if (cancelled) return; - const match = agents.find(a => a.id === agentId); - setLifecycleStage(normalizeLifecycleStage(match?.lifecycleStage)); - setRejectionReason(match?.rejectionReason); - setRejectedBy(match?.rejectedBy); - }) - .catch(() => setLifecycleStage('draft')); - return () => { - cancelled = true; - }; - }, [api, agentId]); - - const nextTransition = useMemo(() => { - const t = getLifecycleTransition(lifecycleStage); - return { - ...t, - icon: t.iconType === 'promote' ? : , - }; - }, [lifecycleStage]); - - const { handleAction: handleLifecycleAction } = useLifecycleAction({ - api, - agentId, - action: nextTransition.action, - target: nextTransition.target, - setLoading: setPublishLoading, - setToast: setPublishToast, - onStageChange: setLifecycleStage, - }); - - const handleDelete = useCallback(async () => { - setDeleteLoading(true); - try { - await api.deleteKagentiAgent(agent.namespace, agent.name); - await api.deleteAgentConfig(agentId).catch(() => {}); - setPublishToast('Agent deleted'); - onDeleted?.(); - } catch (err) { - setPublishToast( - `Delete failed: ${err instanceof Error ? err.message : 'Unknown'}`, - ); - } finally { - setDeleteLoading(false); - setDeleteDialogOpen(false); - } - }, [api, agent.namespace, agent.name, agentId, onDeleted]); - - const { handleWithdraw } = useWithdrawHandler({ - api, - agentId, - onSuccess: () => setLifecycleStage('draft'), - setToast: setPublishToast, - setLoading: setPublishLoading, - }); - - const canDelete = lifecycleStage === 'draft'; - - const currentStep = getLifecycleStep(lifecycleStage); - - return ( - - - {/* Top: Name + Status + Actions */} - - - - - {displayName} - - - {lifecycleStage && ( - - )} - - - - {agent.namespace} - - {agent.labels?.framework && ( - - )} - {agent.labels?.protocol && ( - - )} - - - - {/* Action buttons */} - - - {isAdmin && hasBuild && ( - - - - )} - {onChatWithAgent && ( - - )} - {canDelete && ( - - - - )} - - - - - {lifecycleStage === 'draft' && rejectionReason && ( - - - Rejected{rejectedBy ? ` by ${rejectedBy}` : ''} - - {rejectionReason} - - )} - - {error && ( - setError(null)}> - {error} - - )} - - {/* Lifecycle Tabs */} - - setActiveTab(v)} - sx={{ - minHeight: 40, - '& .MuiTab-root': { - minHeight: 40, - minWidth: 'auto', - px: 2.5, - textTransform: 'none', - fontSize: typeScale.body.fontSize, - fontWeight: 500, - }, - }} - > - - - - - - - - - {/* Tab Content */} - {activeTab === 'overview' && ( - - )} - - {activeTab === 'design' && ( - - )} - - {activeTab === 'test' && ( - - )} - - {activeTab === 'build' && ( - void loadBuildInfo()} - onTriggerBuild={() => void handleTriggerBuild()} - /> - )} - - {activeTab === 'card' && ( - - )} - - setDeleteDialogOpen(false)} - > - Delete Agent - - - Delete agent {displayName} ({agent.namespace}/ - {agent.name})? This will remove the Kagenti deployment and its - lifecycle config entry. This action cannot be undone. - - - - - - - - - setPublishToast(null)} - message={publishToast} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentResourceTab.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentResourceTab.tsx deleted file mode 100644 index f15f661695e..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentResourceTab.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useMemo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Card from '@mui/material/Card'; -import Skeleton from '@mui/material/Skeleton'; -import IconButton from '@mui/material/IconButton'; -import { useTheme, alpha } from '@mui/material/styles'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import type { KagentiAgentDetail } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -export interface AgentResourceTabProps { - agentDetail: KagentiAgentDetail | null; - loading: boolean; - copied: boolean; - onCopy: (text: string) => void; -} - -export function AgentResourceTab({ - agentDetail, - loading, - copied, - onCopy, -}: AgentResourceTabProps) { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - - const yamlContent = useMemo(() => { - if (!agentDetail) return null; - const resource: Record = {}; - if (agentDetail.metadata) resource.metadata = agentDetail.metadata; - if (agentDetail.spec) resource.spec = agentDetail.spec; - if (agentDetail.status) resource.status = agentDetail.status; - try { - return JSON.stringify(resource, null, 2); - } catch { - return null; - } - }, [agentDetail]); - - return ( - - {loading && ( - - {Array.from({ length: 15 }).map((_, i) => ( - - ))} - - )} - {!loading && yamlContent && ( - - onCopy(yamlContent)} - title={copied ? 'Copied!' : 'Copy'} - sx={{ - position: 'absolute', - top: 8, - right: 8, - zIndex: 1, - bgcolor: 'background.paper', - }} - > - - - - {yamlContent} - - - )} - {!loading && !yamlContent && ( - - - No resource data available - - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentStatusTab.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentStatusTab.tsx deleted file mode 100644 index 0dbd46cbc73..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentStatusTab.tsx +++ /dev/null @@ -1,471 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import Card from '@mui/material/Card'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; -import CircularProgress from '@mui/material/CircularProgress'; -import IconButton from '@mui/material/IconButton'; -import Alert from '@mui/material/Alert'; -import { useTheme, alpha } from '@mui/material/styles'; -import PlayArrowIcon from '@mui/icons-material/PlayArrow'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import ErrorIcon from '@mui/icons-material/Error'; -import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; -import type { - KagentiAgentDetail, - KagentiAgentSummary, - KagentiBuildInfo, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { statusChipColor as statusColor } from './kagentiDisplayUtils'; - -/* ── helpers ─────────────────────────────────────── */ - -interface KubeCondition { - type: string; - status: string; - reason?: string; - message?: string; - last_transition_time?: string; - lastTransitionTime?: string; - last_update_time?: string; -} - -function getTransitionTime(c: KubeCondition): string | undefined { - return c.last_transition_time ?? c.lastTransitionTime; -} - -function buildPhaseToStatus(phase: string): string { - if (phase === 'Succeeded') return 'ready'; - if (phase === 'Failed') return 'error'; - return 'pending'; -} - -function interpretConditions(conditions: KubeCondition[]): { - severity: 'success' | 'info' | 'warning' | 'error'; - summary: string; -} { - const available = conditions.find(c => c.type === 'Available'); - const progressing = conditions.find(c => c.type === 'Progressing'); - - const isAvailable = available?.status === 'True'; - const isProgressing = progressing?.status === 'True'; - - if (isAvailable && isProgressing) { - return { - severity: 'success', - summary: - 'Agent is running and healthy. All replicas are available and the latest version has been rolled out.', - }; - } - if (isAvailable && !isProgressing) { - return { - severity: 'warning', - summary: - 'Agent is available but a rollout may be stalled or the deployment has not met its progress deadline.', - }; - } - if (!isAvailable && isProgressing) { - return { - severity: 'info', - summary: - 'Agent is starting up. A new version is being rolled out and replicas are coming online.', - }; - } - return { - severity: 'error', - summary: - 'Agent is unavailable. The deployment may have failed or has insufficient replicas.', - }; -} - -function conditionStatusIcon(status: string) { - if (status === 'True') - return ; - if (status === 'False') - return ; - return ; -} - -function formatTimestamp(iso: string | undefined): string { - if (!iso) return '—'; - const d = new Date(iso); - if (isNaN(d.getTime())) return iso; - return d.toLocaleString(); -} - -/* ── component ───────────────────────────────────── */ - -export interface AgentStatusTabProps { - agent: KagentiAgentSummary; - agentDetail: KagentiAgentDetail | null; - buildInfo: KagentiBuildInfo | null; - loading: boolean; - buildTriggering: boolean; - hasBuild: boolean | null; - onRefreshBuild: () => void; - onTriggerBuild: () => void; -} - -export function AgentStatusTab({ - agent, - agentDetail, - buildInfo, - loading, - buildTriggering, - hasBuild, - onRefreshBuild, - onTriggerBuild, -}: AgentStatusTabProps) { - const theme = useTheme(); - const conditions = (agentDetail?.status as Record) - ?.conditions as KubeCondition[] | undefined; - - const interpretation = conditions ? interpretConditions(conditions) : null; - - return ( - - {/* ── Agent Status ── */} - - - - Agent Status - - - Auto-refreshes every 15 s - - - - {loading && } - - {!loading && conditions && ( - <> - {interpretation && ( - - {interpretation.summary} - - )} - - - - - Condition - Value - Reason - Message - Last Transition - - - - {conditions.map(c => ( - - - {conditionStatusIcon(c.status)} - - {c.type} - - - - - {c.reason || '—'} - - - {c.message || '—'} - - - {formatTimestamp(getTransitionTime(c))} - - - ))} - -
    - - )} - - {!loading && !conditions && ( - - )} -
    - - {/* ── Shipwright Build Status (only for source-built agents) ── */} - {hasBuild !== false && ( - - - - Shipwright Build Status - - - - - - - - - - {buildInfo ? ( - - - - {[ - { label: 'Build Name', value: buildInfo.name }, - { - label: 'Build Registered', - value: buildInfo.buildRegistered ? ( - - ) : ( - - ), - }, - { - label: 'Build Strategy', - value: buildInfo.strategy ? ( - - ) : ( - '—' - ), - }, - { - label: 'Output Image', - value: buildInfo.outputImage ? ( - - {buildInfo.outputImage} - - ) : ( - '—' - ), - }, - { - label: 'Git URL', - value: buildInfo.gitUrl ? ( - - {buildInfo.gitUrl} - - ) : ( - '—' - ), - }, - { - label: 'Git Revision', - value: buildInfo.gitRevision || '—', - }, - { - label: 'Context Directory', - value: buildInfo.contextDir || '—', - }, - ] - .filter(r => r.value) - .map(row => ( - - - {row.label} - - - {row.value} - - - ))} - -
    - - {buildInfo.buildRunName && ( - - - Latest BuildRun - - - - {[ - { - label: 'BuildRun Name', - value: buildInfo.buildRunName, - }, - { - label: 'Phase', - value: buildInfo.buildRunPhase ? ( - - ) : ( - '—' - ), - }, - { - label: 'Message', - value: buildInfo.buildMessage || '—', - }, - ] - .filter(r => r.value) - .map(row => ( - - - {row.label} - - - {row.value} - - - ))} - -
    -
    - )} -
    - ) : ( - - - No build information available. Click “Trigger - Build” to start a build. - - - )} -
    - )} -
    - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentTemplateBrowser.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentTemplateBrowser.tsx deleted file mode 100644 index f07edadf5b8..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentTemplateBrowser.tsx +++ /dev/null @@ -1,510 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useMemo, useState } from 'react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import Chip from '@mui/material/Chip'; -import IconButton from '@mui/material/IconButton'; -import InputAdornment from '@mui/material/InputAdornment'; -import Skeleton from '@mui/material/Skeleton'; -import TextField from '@mui/material/TextField'; -import Tooltip from '@mui/material/Tooltip'; -import Typography from '@mui/material/Typography'; -import Alert from '@mui/material/Alert'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import LaptopMacOutlinedIcon from '@mui/icons-material/LaptopMacOutlined'; -import SearchIcon from '@mui/icons-material/Search'; -import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; - -import RefreshIcon from '@mui/icons-material/Refresh'; -import { type Theme, alpha, useTheme } from '@mui/material/styles'; -import type { Entity } from '@backstage/catalog-model'; -import { useAgentTemplates } from './useAgentTemplates'; - -export interface AgentTemplateBrowserProps { - readonly onBack: () => void; - readonly onDone?: () => void; - readonly tag?: string; - /** Filter templates by `spec.type` (e.g. "agent" or "tool"). */ - readonly specType?: string; - /** Callback to open a template's source repo in a DevSpace. */ - readonly onOpenInDevSpace?: (gitRepoUrl: string) => void; - /** Header title. Defaults to "Agent Templates". */ - readonly title?: string; - /** Descriptive blurb below the header. */ - readonly description?: string; - /** Title shown when no templates match. Defaults to "No agent templates found". */ - readonly emptyTitle?: string; - /** Description shown when no templates match. */ - readonly emptyDescription?: string; - /** Tooltip and aria-label for the DevSpace button. Defaults to "Open in Agent DevSpace". */ - readonly devSpaceLabel?: string; -} - -function templateTitle(entity: Entity): string { - return (entity.metadata.title as string | undefined) ?? entity.metadata.name; -} - -function templateDescription(entity: Entity): string { - return entity.metadata.description ?? ''; -} - -const MAX_VISIBLE_TAGS = 4; - -function templateTags(entity: Entity): string[] { - return (entity.metadata.tags as string[] | undefined) ?? []; -} - -function templateOwner(entity: Entity): string | undefined { - const spec = entity.spec as Record | undefined; - if (!spec) return undefined; - return typeof spec.owner === 'string' ? spec.owner : undefined; -} - -function templateType(entity: Entity): string | undefined { - const spec = entity.spec as Record | undefined; - if (!spec) return undefined; - return typeof spec.type === 'string' ? spec.type : undefined; -} - -function templateSourceRepo(entity: Entity): string | undefined { - const annotations = entity.metadata.annotations ?? {}; - const sourceUrl = - annotations['backstage.io/source-location'] ?? - annotations['backstage.io/techdocs-ref']; - if (sourceUrl && sourceUrl.startsWith('url:')) { - return sourceUrl.slice(4).replace(/\/catalog-info\.yaml$/i, ''); - } - return undefined; -} - -function buildScaffolderUrl(entity: Entity): string { - const ns = entity.metadata.namespace ?? 'default'; - const name = entity.metadata.name; - return `/create/templates/${ns}/${name}`; -} - -function defaultEmptyDescription( - specType: string | undefined, - tag: string | undefined, - theme: Theme, -): React.ReactNode { - if (specType) { - return ( - <> - Register a Backstage software template with{' '} - - spec.type: {specType} - {' '} - in the catalog and it will appear here automatically. - - ); - } - if (tag) { - return ( - <> - Templates with the tag{' '} - - {tag} - {' '} - will appear here automatically. - - ); - } - return 'All Backstage software templates (kind: Template) registered in the catalog will appear here automatically.'; -} - -export function AgentTemplateBrowser({ - onBack, - onDone, - tag, - specType, - onOpenInDevSpace, - title: titleLabel = 'Agent Templates', - description: - descriptionLabel = 'Choose a software template to scaffold a new agent project. Templates are discovered from the catalog automatically.', - emptyTitle = 'No agent templates found', - emptyDescription, - devSpaceLabel = 'Open in Agent DevSpace', -}: AgentTemplateBrowserProps) { - const theme = useTheme(); - const hookOpts = tag || specType ? { tag, specType } : undefined; - const { templates, loading, error, reload } = useAgentTemplates(hookOpts); - const [search, setSearch] = useState(''); - - const filtered = useMemo(() => { - if (!search.trim()) return templates; - const q = search.trim().toLowerCase(); - return templates.filter(t => { - const title = templateTitle(t).toLowerCase(); - const desc = templateDescription(t).toLowerCase(); - const tags = templateTags(t).join(' ').toLowerCase(); - return title.includes(q) || desc.includes(q) || tags.includes(q); - }); - }, [templates, search]); - - const handleLaunch = (entity: Entity) => { - window.open(buildScaffolderUrl(entity), '_blank', 'noopener'); - onDone?.(); - }; - - return ( - - - - - - - {titleLabel} - - - - - - - - {descriptionLabel} - - - {!loading && templates.length > 0 && ( - setSearch(e.target.value)} - fullWidth - sx={{ mb: 2 }} - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - )} - - {error && ( - - {error} - - )} - - {loading && ( - - {[0, 1, 2].map(i => ( - - - - - - - - - - - - ))} - - )} - - {!loading && filtered.length === 0 && ( - - - - {emptyTitle} - - - {emptyDescription ?? defaultEmptyDescription(specType, tag, theme)} - - - )} - - {!loading && filtered.length > 0 && ( - - {filtered.map(entity => { - const title = templateTitle(entity); - const desc = templateDescription(entity); - const tags = templateTags(entity); - const owner = templateOwner(entity); - const type = templateType(entity); - const sourceRepo = templateSourceRepo(entity); - const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS); - const hiddenCount = tags.length - visibleTags.length; - - return ( - handleLaunch(entity)} - > - - - - {title} - - {type && ( - - )} - - - {desc && ( - - {desc} - - )} - - {visibleTags.length > 0 && ( - - {visibleTags.map(t => ( - - ))} - {hiddenCount > 0 && ( - - )} - - )} - - - {owner && ( - - {owner} - - )} - - - {onOpenInDevSpace && sourceRepo && ( - - { - e.stopPropagation(); - onOpenInDevSpace(sourceRepo); - }} - sx={{ - color: theme.palette.text.secondary, - '&:hover': { - color: theme.palette.primary.main, - }, - }} - aria-label={devSpaceLabel} - > - - - - )} - - - - - ); - })} - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardBasicsStep.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardBasicsStep.tsx deleted file mode 100644 index befeb24fd29..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardBasicsStep.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { FC } from 'react'; -import Box from '@mui/material/Box'; -import FormControl from '@mui/material/FormControl'; -import FormHelperText from '@mui/material/FormHelperText'; -import InputLabel from '@mui/material/InputLabel'; -import MenuItem from '@mui/material/MenuItem'; -import Select from '@mui/material/Select'; -import Stack from '@mui/material/Stack'; -import TextField from '@mui/material/TextField'; -import { SELECT_MENU_PROPS } from '../shared/selectMenuProps'; - -interface AgentWizardBasicsStepProps { - name: string; - setName: (v: string) => void; - namespace: string; - setNamespace: (v: string) => void; - protocol: string; - setProtocol: (v: string) => void; - framework: string; - setFramework: (v: string) => void; - nameError: string | undefined; - availableNamespaces: string[]; -} - -export const AgentWizardBasicsStep: FC = ({ - name, - setName, - namespace, - setNamespace, - protocol, - setProtocol, - framework, - setFramework, - nameError, - availableNamespaces, -}) => ( - - - setName(e.target.value)} - fullWidth - required - size="small" - error={!!nameError} - helperText={ - nameError ?? 'Lowercase alphanumeric and hyphens (max 63 chars).' - } - /> - - {availableNamespaces.length > 0 ? ( - - Namespace - - - ) : ( - setNamespace(e.target.value)} - fullWidth - required - size="small" - helperText="Target Kubernetes namespace for the agent." - /> - )} - - Protocol - - - A2A = Agent-to-Agent. MCP = Model Context Protocol. HTTP = standard - REST. - - - - Framework - - - Agent framework. Defaults to LangGraph if omitted. - - - -); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardBuildStep.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardBuildStep.tsx deleted file mode 100644 index d8c800b4507..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardBuildStep.tsx +++ /dev/null @@ -1,525 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { FC, ReactNode } from 'react'; -import { useMemo, useCallback } from 'react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import CircularProgress from '@mui/material/CircularProgress'; -import IconButton from '@mui/material/IconButton'; -import LinearProgress from '@mui/material/LinearProgress'; -import Stack from '@mui/material/Stack'; -import Tooltip from '@mui/material/Tooltip'; -import Typography from '@mui/material/Typography'; -import { useTheme } from '@mui/material/styles'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import ErrorIcon from '@mui/icons-material/Error'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; -import WarningAmberIcon from '@mui/icons-material/WarningAmber'; -import type { BuildProgress } from './agentWizardTypes'; - -export interface AgentWizardBuildStepProps { - progress: BuildProgress; - onRetry: () => void; - onClose: () => void; - resourceLabel?: string; -} - -interface PhaseStep { - label: string; - state: 'pending' | 'active' | 'done' | 'error'; -} - -function formatElapsed(ms: number): string { - const totalSec = Math.floor(ms / 1000); - const min = Math.floor(totalSec / 60); - const sec = totalSec % 60; - if (min === 0) return `${sec}s`; - return `${min}m ${sec}s`; -} - -function stepLabelColor( - state: PhaseStep['state'], - palette: { - text: { primary: string; disabled: string }; - error: { main: string }; - }, -): string { - if (state === 'pending') return palette.text.disabled; - if (state === 'error') return palette.error.main; - return palette.text.primary; -} - -function derivePhaseSteps( - progress: BuildProgress, - resourceLabel: string, -): PhaseStep[] { - const { phase } = progress; - const capLabel = - resourceLabel.charAt(0).toUpperCase() + resourceLabel.slice(1); - const steps: PhaseStep[] = [ - { label: 'Build submitted', state: 'done' }, - { label: 'Building container image', state: 'pending' }, - { label: `Deploying ${resourceLabel}`, state: 'pending' }, - { label: `${capLabel} ready`, state: 'pending' }, - ]; - - if (phase === 'building') { - steps[1].state = 'active'; - } else if (phase === 'finalizing') { - steps[1].state = 'done'; - steps[2].state = 'active'; - } else if (phase === 'complete') { - steps[1].state = 'done'; - steps[2].state = 'done'; - steps[3].state = 'done'; - } else if (phase === 'failed') { - if (progress.deployFailedAfterBuild) { - steps[1].state = 'done'; - steps[2].state = 'error'; - } else { - steps[1].state = 'error'; - } - } - - return steps; -} - -export const AgentWizardBuildStep: FC = ({ - progress, - onRetry, - onClose, - resourceLabel = 'agent', -}) => { - const theme = useTheme(); - const steps = useMemo( - () => derivePhaseSteps(progress, resourceLabel), - [progress, resourceLabel], - ); - - const isDone = progress.phase === 'complete'; - const isFailed = progress.phase === 'failed'; - const isActive = !isDone && !isFailed; - const retryLabel = progress.deployFailedAfterBuild - ? 'Retry Deploy' - : 'Retry Build'; - - return ( - - {/* Phase timeline */} - - {steps.map((step, idx) => { - const isLast = idx === steps.length - 1; - return ( - - - {step.state === 'done' && ( - - )} - {step.state === 'active' && ( - - )} - {step.state === 'error' && ( - - )} - {step.state === 'pending' && ( - - )} - {!isLast && ( - - )} - - - {step.label} - - - ); - })} - - - {/* Progress bar for active builds */} - {isActive && ( - - )} - - {/* Elapsed time */} - {progress.elapsedMs > 0 && ( - - Elapsed: {formatElapsed(progress.elapsedMs)} - - )} - - {/* Poll error warning */} - {progress.pollErrorCount > 0 && isActive && ( - - - - Having trouble reaching the build service. Retrying… - - - )} - - {/* Build details */} - - - Build details - - - {progress.buildRunName && ( - - )} - {progress.buildRunPhase && ( - - } - /> - )} - {progress.strategy && ( - - )} - {progress.gitUrl && ( - - )} - {progress.contextDir && ( - - )} - {progress.outputImage && ( - - )} - - - - {/* Status message */} - {progress.message && !isFailed && ( - - {progress.message} - - )} - - {/* Failure message + kubectl hint */} - {isFailed && progress.failureMessage && ( - - )} - - {/* Success banner */} - {isDone && ( - - - - - {resourceLabel.charAt(0).toUpperCase() + resourceLabel.slice(1)}{' '} - deployed successfully - - {progress.message && ( - - {progress.message} - - )} - - - )} - - {/* Action buttons */} - - {isActive ? ( - - ) : ( - - )} - - {isFailed && ( - - )} - {(isDone || isFailed) && ( - - )} - - - - ); -}; - -function DetailRow({ - label, - value, - mono, -}: { - label: string; - value: ReactNode; - mono?: boolean; -}) { - return ( - - - {label} - - {typeof value === 'string' ? ( - - {value} - - ) : ( - value - )} - - ); -} - -const KUBECTL_RE = /kubectl\s+--namespace\s+\S+\s+logs\s+\S+\s+--container=\S+/; - -function extractKubectlCommand(msg: string): string | null { - const match = msg.match(KUBECTL_RE); - return match ? match[0] : null; -} - -function FailureBlock({ - failureMessage, - palette, -}: { - failureMessage: string; - palette: { - error: { main: string }; - background: { paper: string }; - divider: string; - text: { secondary: string }; - }; -}) { - const kubectlCmd = extractKubectlCommand(failureMessage); - const displayMsg = kubectlCmd - ? failureMessage.replace(`, for detailed information: ${kubectlCmd}`, '') - : failureMessage; - - const handleCopy = useCallback(() => { - if (kubectlCmd) { - window.navigator.clipboard.writeText(kubectlCmd).catch(() => {}); - } - }, [kubectlCmd]); - - return ( - - - {displayMsg} - - {kubectlCmd && ( - - - {kubectlCmd} - - - - - - - - )} - - ); -} - -function phaseChipColor( - phase: string | undefined, - palette: { - success: { main: string }; - warning: { main: string }; - error: { main: string }; - info: { main: string }; - }, -): string { - switch (phase?.toLowerCase()) { - case 'succeeded': - return palette.success.main; - case 'running': - case 'pending': - return palette.info.main; - case 'failed': - return palette.error.main; - default: - return palette.warning.main; - } -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardDeployStep.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardDeployStep.tsx deleted file mode 100644 index a904162835a..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardDeployStep.tsx +++ /dev/null @@ -1,334 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { FC } from 'react'; -import type { KagentiBuildStrategy } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import AddIcon from '@mui/icons-material/Add'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import Alert from '@mui/material/Alert'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import FormControl from '@mui/material/FormControl'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import FormLabel from '@mui/material/FormLabel'; -import IconButton from '@mui/material/IconButton'; -import InputLabel from '@mui/material/InputLabel'; -import MenuItem from '@mui/material/MenuItem'; -import Radio from '@mui/material/Radio'; -import RadioGroup from '@mui/material/RadioGroup'; -import Select from '@mui/material/Select'; -import Stack from '@mui/material/Stack'; -import TextField from '@mui/material/TextField'; -import Typography from '@mui/material/Typography'; -import type { BuildArgRow, DeploymentMethod } from './agentWizardTypes'; - -interface AgentWizardDeployStepProps { - deploymentMethod: DeploymentMethod; - setDeploymentMethod: (v: DeploymentMethod) => void; - containerImage: string; - setContainerImage: (v: string) => void; - imagePullSecret: string; - setImagePullSecret: (v: string) => void; - gitUrl: string; - setGitUrl: (v: string) => void; - gitBranch: string; - setGitBranch: (v: string) => void; - gitPath: string; - setGitPath: (v: string) => void; - registryUrl: string; - setRegistryUrl: (v: string) => void; - registrySecret: string; - setRegistrySecret: (v: string) => void; - imageTag: string; - setImageTag: (v: string) => void; - startCommand: string; - setStartCommand: (v: string) => void; - buildStrategy: string; - setBuildStrategy: (v: string) => void; - buildStrategies: KagentiBuildStrategy[]; - buildStrategyError: string | null; - dockerfile: string; - setDockerfile: (v: string) => void; - buildTimeout: string; - setBuildTimeout: (v: string) => void; - buildArgRows: BuildArgRow[]; - addBuildArgRow: () => void; - updateBuildArgRow: (id: number, value: string) => void; - removeBuildArgRow: (id: number) => void; -} - -export const AgentWizardDeployStep: FC = ({ - deploymentMethod, - setDeploymentMethod, - containerImage, - setContainerImage, - imagePullSecret, - setImagePullSecret, - gitUrl, - setGitUrl, - gitBranch, - setGitBranch, - gitPath, - setGitPath, - registryUrl, - setRegistryUrl, - registrySecret, - setRegistrySecret, - imageTag, - setImageTag, - startCommand, - setStartCommand, - buildStrategy, - setBuildStrategy, - buildStrategies, - buildStrategyError, - dockerfile, - setDockerfile, - buildTimeout, - setBuildTimeout, - buildArgRows, - addBuildArgRow, - updateBuildArgRow, - removeBuildArgRow, -}) => ( - - - Deployment method - setDeploymentMethod(v as DeploymentMethod)} - > - } - label={ - - - Container Image - - - Deploy from a pre-built image. Fastest option. - - - } - /> - } - label={ - - - Source from Git - - - Build the image from source via Shipwright, then deploy. Takes a - few minutes. - - - } - /> - - - - {deploymentMethod === 'image' ? ( - - setContainerImage(e.target.value)} - fullWidth - required - size="small" - helperText="e.g. quay.io/org/agent:v1" - /> - setImagePullSecret(e.target.value)} - fullWidth - size="small" - helperText="Kubernetes Secret name for private registry authentication." - /> - - ) : ( - - - setGitUrl(e.target.value)} - fullWidth - required - size="small" - helperText="Repository URL containing the agent source code." - /> - - - setGitBranch(e.target.value)} - size="small" - sx={{ flex: 1 }} - helperText="Defaults to main." - /> - setGitPath(e.target.value)} - size="small" - sx={{ flex: 1 }} - placeholder="e.g. agents/my_agent" - helperText="Subdirectory containing the Dockerfile. Leave empty for repo root." - /> - - - - setRegistryUrl(e.target.value)} - size="small" - fullWidth - placeholder="e.g. image-registry.openshift-image-registry.svc:5000/my-ns" - helperText="Container registry path including namespace (e.g. registry/namespace)." - /> - - setRegistrySecret(e.target.value)} - size="small" - sx={{ flex: 1 }} - helperText="Secret for registry push credentials." - /> - - - setImageTag(e.target.value)} - size="small" - sx={{ flex: 1 }} - helperText="Tag for the built image (default v0.0.1)." - /> - setStartCommand(e.target.value)} - size="small" - sx={{ flex: 1 }} - helperText="Override the container entrypoint." - /> - - - {/* Shipwright build config */} - - Build configuration - - {buildStrategyError && ( - - {buildStrategyError} - - )} - - - Build strategy - - - - setDockerfile(e.target.value)} - size="small" - fullWidth - helperText="Path relative to git path." - /> - - - setBuildTimeout(e.target.value)} - size="small" - fullWidth - helperText="Maximum build duration (e.g. 15m, 30m, 1h)." - /> - - - Build arguments - - - {buildArgRows.map(row => ( - - updateBuildArgRow(row.id, e.target.value)} - size="small" - fullWidth - placeholder="ARG_NAME=value" - /> - removeBuildArgRow(row.id)} - size="small" - > - - - - ))} - - - - - )} - -); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardRuntimeStep.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardRuntimeStep.tsx deleted file mode 100644 index 4abbf262037..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/AgentWizardRuntimeStep.tsx +++ /dev/null @@ -1,344 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { FC } from 'react'; -import type { SelectChangeEvent } from '@mui/material/Select'; -import AddIcon from '@mui/icons-material/Add'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import Alert from '@mui/material/Alert'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import FormControl from '@mui/material/FormControl'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import FormHelperText from '@mui/material/FormHelperText'; -import FormLabel from '@mui/material/FormLabel'; -import IconButton from '@mui/material/IconButton'; -import InputLabel from '@mui/material/InputLabel'; -import MenuItem from '@mui/material/MenuItem'; -import Radio from '@mui/material/Radio'; -import RadioGroup from '@mui/material/RadioGroup'; -import Select from '@mui/material/Select'; -import Stack from '@mui/material/Stack'; -import Switch from '@mui/material/Switch'; -import TextField from '@mui/material/TextField'; -import Tooltip from '@mui/material/Tooltip'; -import Typography from '@mui/material/Typography'; -import type { - EnvRow, - EnvSource, - PortProtocol, - ServicePortRow, - WorkloadType, -} from './agentWizardTypes'; - -interface AgentWizardRuntimeStepProps { - workloadType: WorkloadType; - setWorkloadType: (v: WorkloadType) => void; - envRows: EnvRow[]; - addEnvRow: () => void; - updateEnvRow: (id: number, patch: Partial) => void; - removeEnvRow: (id: number) => void; - duplicateEnvNames: Set; - portRows: ServicePortRow[]; - addPortRow: () => void; - updatePortRow: (id: number, patch: Partial) => void; - removePortRow: (id: number) => void; - handlePortProtocol: (id: number, e: SelectChangeEvent) => void; - portErrors: Map; - createHttpRoute: boolean; - setCreateHttpRoute: (v: boolean) => void; - authBridgeEnabled: boolean; - setAuthBridgeEnabled: (v: boolean) => void; - spireEnabled: boolean; - setSpireEnabled: (v: boolean) => void; -} - -export const AgentWizardRuntimeStep: FC = ({ - workloadType, - setWorkloadType, - envRows, - addEnvRow, - updateEnvRow, - removeEnvRow, - duplicateEnvNames, - portRows, - addPortRow, - updatePortRow, - removePortRow, - handlePortProtocol, - portErrors, - createHttpRoute, - setCreateHttpRoute, - authBridgeEnabled, - setAuthBridgeEnabled, - spireEnabled, - setSpireEnabled, -}) => ( - - - Workload type - setWorkloadType(v as WorkloadType)} - row - > - } - label="Deployment" - /> - } - label="StatefulSet" - /> - } - label="Job" - /> - - - Deployment = always running. StatefulSet = persistent state. Job = run - once and exit. - - - - {/* Environment variables */} - - - Environment variables - - {duplicateEnvNames.size > 0 && ( - - Duplicate variable names: {Array.from(duplicateEnvNames).join(', ')} - - )} - - {envRows.map(row => ( - - - updateEnvRow(row.id, { name: e.target.value })} - size="small" - sx={{ flex: 1 }} - error={duplicateEnvNames.has(row.name.trim().toLowerCase())} - /> - - Source - - - {row.source === 'direct' ? ( - - updateEnvRow(row.id, { value: e.target.value }) - } - size="small" - sx={{ flex: 1 }} - /> - ) : ( - <> - - updateEnvRow(row.id, { refName: e.target.value }) - } - size="small" - sx={{ flex: 1 }} - /> - - updateEnvRow(row.id, { refKey: e.target.value }) - } - size="small" - sx={{ flex: 1 }} - /> - - )} - removeEnvRow(row.id)} - size="small" - sx={{ mt: 0.5 }} - > - - - - - ))} - - - - - {/* Service ports */} - - - Service ports - - - {portRows.map(row => ( - - updatePortRow(row.id, { name: e.target.value })} - size="small" - sx={{ minWidth: 120, flex: '1 1 100px' }} - /> - updatePortRow(row.id, { port: e.target.value })} - size="small" - type="number" - inputProps={{ min: 1, max: 65535 }} - sx={{ width: 100 }} - error={portErrors.has(row.id)} - helperText={portErrors.get(row.id)} - /> - - updatePortRow(row.id, { targetPort: e.target.value }) - } - size="small" - type="number" - inputProps={{ min: 1, max: 65535 }} - sx={{ width: 120 }} - /> - - Protocol - - labelId={`agent-proto-${row.id}`} - label="Protocol" - value={row.protocol} - onChange={e => handlePortProtocol(row.id, e)} - > - TCP - UDP - - - removePortRow(row.id)} - size="small" - sx={{ mt: 0.5 }} - > - - - - ))} - - - - - {/* Switches */} - - - setCreateHttpRoute(e.target.checked)} - size="small" - /> - } - label="Create HTTP route" - /> - - - setAuthBridgeEnabled(e.target.checked)} - size="small" - /> - } - label="Auth bridge enabled" - /> - - - setSpireEnabled(e.target.checked)} - size="small" - /> - } - label="SPIRE enabled" - /> - - - -); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateAgentWizard.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateAgentWizard.test.tsx deleted file mode 100644 index 67a72ea05d7..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateAgentWizard.test.tsx +++ /dev/null @@ -1,444 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -// eslint-disable-next-line @backstage/no-undeclared-imports -import { TestApiProvider } from '@backstage/test-utils'; -import { augmentApiRef, type AugmentApi } from '../../../api'; -import { CreateAgentWizard } from './CreateAgentWizard'; -import type { CreateAgentWizardProps } from './agentWizardTypes'; - -const theme = createTheme(); - -function createMockApi(overrides: Partial> = {}) { - return { - listKagentiBuildStrategies: jest.fn().mockResolvedValue({ - strategies: [{ name: 'buildah' }], - }), - listKagentiNamespaces: jest.fn().mockResolvedValue({ - namespaces: ['team-a', 'team-b'], - }), - createKagentiAgent: jest.fn().mockResolvedValue({ - success: true, - name: 'test-agent', - namespace: 'team-a', - message: 'Agent created', - }), - ...overrides, - }; -} - -function renderWizard( - props: Partial = {}, - apiOverrides: Partial> = {}, -) { - const onClose = jest.fn(); - const onCreated = jest.fn(); - const api = createMockApi(apiOverrides); - const result = render( - - - - - , - ); - return { ...result, onClose, onCreated, api }; -} - -function getNameInput(): HTMLElement { - const inputs = screen.getAllByRole('textbox'); - const nameInput = inputs.find(el => { - const label = el.closest('.v5-MuiFormControl-root')?.querySelector('label'); - return ( - label?.textContent?.startsWith('Name') && - !label?.textContent?.startsWith('Namespace') - ); - }); - return nameInput ?? inputs[0]; -} - -// ----------------------------------------------------------------------------- -// Rendering -// ----------------------------------------------------------------------------- - -describe('CreateAgentWizard — rendering', () => { - it('renders dialog with title and stepper', () => { - renderWizard(); - expect(screen.getByText('Create Agent')).toBeInTheDocument(); - expect(screen.getByText('Basics')).toBeInTheDocument(); - expect(screen.getByText('Deployment')).toBeInTheDocument(); - expect(screen.getByText('Runtime')).toBeInTheDocument(); - }); - - it('shows step 0 fields by default', () => { - renderWizard(); - expect(getNameInput()).toBeInTheDocument(); - expect(screen.getAllByText('Back').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('Next')).toBeInTheDocument(); - }); - - it('does not render when open is false', () => { - renderWizard({ open: false }); - expect(screen.queryByText('Create Agent')).not.toBeInTheDocument(); - }); - - it('shows back/close button on step 0', () => { - renderWizard(); - expect(screen.getAllByText('Back').length).toBeGreaterThanOrEqual(1); - }); -}); - -// ----------------------------------------------------------------------------- -// Step 0 — Basics -// ----------------------------------------------------------------------------- - -describe('CreateAgentWizard — step 0 basics', () => { - it('shows error when clicking Next with empty fields', async () => { - const user = userEvent.setup(); - renderWizard(); - - await user.click(screen.getByText('Next')); - - await waitFor(() => { - expect( - screen.getByText('Name and namespace are required.'), - ).toBeInTheDocument(); - }); - }); - - it('shows DNS error for invalid name', async () => { - const user = userEvent.setup(); - renderWizard({ namespace: 'team-a' }); - - await user.type(getNameInput(), 'My Agent'); - await user.click(screen.getByText('Next')); - - await waitFor(() => { - expect( - screen.getByText('Agent name must be a valid DNS-1123 label.'), - ).toBeInTheDocument(); - }); - }); - - it('advances to step 1 with valid inputs', async () => { - const user = userEvent.setup(); - renderWizard({ namespace: 'team-a' }); - - await user.type(getNameInput(), 'my-agent'); - await user.click(screen.getByText('Next')); - - await waitFor(() => { - expect(screen.getByText('Container Image')).toBeInTheDocument(); - }); - }); - - it('Back button on step 0 calls onClose', async () => { - const user = userEvent.setup(); - const { onClose } = renderWizard(); - - const backButtons = screen.getAllByText('Back'); - await user.click(backButtons[0]); - expect(onClose).toHaveBeenCalledTimes(1); - }); -}); - -// ----------------------------------------------------------------------------- -// Step 1 — Deployment (image) -// ----------------------------------------------------------------------------- - -describe('CreateAgentWizard — step 1 image deployment', () => { - async function goToStep1(user: ReturnType) { - renderWizard({ namespace: 'team-a' }); - await user.type(getNameInput(), 'my-agent'); - await user.click(screen.getByText('Next')); - await waitFor(() => { - expect(screen.getByText('Container Image')).toBeInTheDocument(); - }); - } - - it('shows image fields by default', async () => { - const user = userEvent.setup(); - await goToStep1(user); - expect(screen.getByLabelText(/Container image/)).toBeInTheDocument(); - expect(screen.getByLabelText(/Image pull secret/)).toBeInTheDocument(); - }); - - it('blocks Next with empty container image', async () => { - const user = userEvent.setup(); - await goToStep1(user); - - await user.click(screen.getByText('Next')); - await waitFor(() => { - expect( - screen.getByText('Container image is required.'), - ).toBeInTheDocument(); - }); - }); - - it('advances to step 2 with valid image', async () => { - const user = userEvent.setup(); - await goToStep1(user); - - await user.type(screen.getByLabelText(/Container image/), 'quay.io/img:v1'); - await user.click(screen.getByText('Next')); - - await waitFor(() => { - expect(screen.getByText('Auth bridge enabled')).toBeInTheDocument(); - }); - }); -}); - -// ----------------------------------------------------------------------------- -// Step 1 — Deployment (source) -// ----------------------------------------------------------------------------- - -describe('CreateAgentWizard — step 1 source deployment', () => { - it('shows source fields when Source from Git is selected', async () => { - const user = userEvent.setup(); - renderWizard({ namespace: 'team-a' }); - await user.type(getNameInput(), 'my-agent'); - await user.click(screen.getByText('Next')); - await waitFor(() => { - expect(screen.getByText('Container Image')).toBeInTheDocument(); - }); - - await user.click(screen.getByText('Source from Git')); - - await waitFor(() => { - expect(screen.getByLabelText(/Git URL/)).toBeInTheDocument(); - }); - expect(screen.queryByLabelText(/Container image/)).not.toBeInTheDocument(); - expect(screen.getByText('Build configuration')).toBeInTheDocument(); - }); -}); - -// ----------------------------------------------------------------------------- -// Step 2 — Runtime -// ----------------------------------------------------------------------------- - -describe('CreateAgentWizard — step 2 runtime', () => { - async function goToStep2(user: ReturnType) { - renderWizard({ namespace: 'team-a' }); - await user.type(getNameInput(), 'my-agent'); - await user.click(screen.getByText('Next')); - await waitFor(() => - expect(screen.getByLabelText(/Container image/)).toBeInTheDocument(), - ); - await user.type(screen.getByLabelText(/Container image/), 'quay.io/img:v1'); - await user.click(screen.getByText('Next')); - await waitFor(() => - expect(screen.getByText('Auth bridge enabled')).toBeInTheDocument(), - ); - } - - it('shows workload type options', async () => { - const user = userEvent.setup(); - await goToStep2(user); - expect(screen.getByLabelText('Deployment')).toBeInTheDocument(); - expect(screen.getByLabelText('StatefulSet')).toBeInTheDocument(); - expect(screen.getByLabelText('Job')).toBeInTheDocument(); - }); - - it('auth bridge is enabled by default', async () => { - const user = userEvent.setup(); - await goToStep2(user); - const switchEl = within( - screen.getByText('Auth bridge enabled').closest('label')!, - ).getByRole('checkbox'); - expect(switchEl).toBeChecked(); - }); - - it('SPIRE is disabled by default', async () => { - const user = userEvent.setup(); - await goToStep2(user); - const switchEl = within( - screen.getByText('SPIRE enabled').closest('label')!, - ).getByRole('checkbox'); - expect(switchEl).not.toBeChecked(); - }); - - it('shows Create button instead of Next', async () => { - const user = userEvent.setup(); - await goToStep2(user); - expect(screen.getByText('Create')).toBeInTheDocument(); - expect(screen.queryByText('Next')).not.toBeInTheDocument(); - }); - - it('shows Back button', async () => { - const user = userEvent.setup(); - await goToStep2(user); - expect(screen.getByText('Back')).toBeInTheDocument(); - }); - - it('Back button returns to step 1', async () => { - const user = userEvent.setup(); - await goToStep2(user); - - await user.click(screen.getByText('Back')); - await waitFor(() => { - expect(screen.getByLabelText(/Container image/)).toBeInTheDocument(); - }); - }); - - it('adds env var row on button click', async () => { - const user = userEvent.setup(); - await goToStep2(user); - - await user.click(screen.getByText('Add variable')); - await waitFor(() => { - expect(screen.getByLabelText('Value')).toBeInTheDocument(); - }); - }); - - it('adds port row on button click', async () => { - const user = userEvent.setup(); - await goToStep2(user); - - await user.click(screen.getByText('Add port')); - await waitFor(() => { - expect(screen.getByLabelText(/^Port$/)).toBeInTheDocument(); - }); - }); -}); - -// ----------------------------------------------------------------------------- -// End-to-end creation (image) -// ----------------------------------------------------------------------------- - -describe('CreateAgentWizard — e2e image creation', () => { - it('creates agent with correct payload', async () => { - const user = userEvent.setup(); - const { api, onClose, onCreated } = renderWizard({ namespace: 'team-a' }); - - await user.type(getNameInput(), 'my-agent'); - await user.click(screen.getByText('Next')); - await waitFor(() => - expect(screen.getByLabelText(/Container image/)).toBeInTheDocument(), - ); - - await user.type(screen.getByLabelText(/Container image/), 'quay.io/img:v1'); - await user.click(screen.getByText('Next')); - await waitFor(() => expect(screen.getByText('Create')).toBeInTheDocument()); - - await user.click(screen.getByText('Create')); - - await waitFor(() => { - expect(api.createKagentiAgent).toHaveBeenCalledTimes(1); - }); - expect(api.createKagentiAgent).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'my-agent', - namespace: 'team-a', - deploymentMethod: 'image', - containerImage: 'quay.io/img:v1', - authBridgeEnabled: true, - spireEnabled: false, - }), - ); - expect(onCreated).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledTimes(1); - - await waitFor(() => { - expect( - screen.getByText('Agent created successfully.'), - ).toBeInTheDocument(); - }); - }); -}); - -// ----------------------------------------------------------------------------- -// End-to-end creation (source) -// ----------------------------------------------------------------------------- - -describe('CreateAgentWizard — e2e source creation', () => { - it('creates agent with source deployment payload', async () => { - const user = userEvent.setup(); - const { api } = renderWizard({ namespace: 'team-a' }); - - await user.type(getNameInput(), 'src-agent'); - await user.click(screen.getByText('Next')); - await waitFor(() => - expect(screen.getByText('Container Image')).toBeInTheDocument(), - ); - - await user.click(screen.getByText('Source from Git')); - await waitFor(() => - expect(screen.getByLabelText(/Git URL/)).toBeInTheDocument(), - ); - - await user.type( - screen.getByLabelText(/Git URL/), - 'https://github.com/org/repo', - ); - await user.click(screen.getByText('Next')); - await waitFor(() => - expect(screen.getByText('Start Build')).toBeInTheDocument(), - ); - - await user.click(screen.getByText('Start Build')); - - await waitFor(() => { - expect(api.createKagentiAgent).toHaveBeenCalledTimes(1); - }); - expect(api.createKagentiAgent).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'src-agent', - deploymentMethod: 'source', - gitUrl: 'https://github.com/org/repo', - }), - ); - }); -}); - -// ----------------------------------------------------------------------------- -// Error handling -// ----------------------------------------------------------------------------- - -describe('CreateAgentWizard — error handling', () => { - it('shows error alert when createKagentiAgent rejects', async () => { - const user = userEvent.setup(); - const { onClose } = renderWizard( - { namespace: 'team-a' }, - { - createKagentiAgent: jest.fn().mockRejectedValue(new Error('Conflict')), - }, - ); - - await user.type(getNameInput(), 'my-agent'); - await user.click(screen.getByText('Next')); - await waitFor(() => - expect(screen.getByLabelText(/Container image/)).toBeInTheDocument(), - ); - - await user.type(screen.getByLabelText(/Container image/), 'quay.io/img:v1'); - await user.click(screen.getByText('Next')); - await waitFor(() => expect(screen.getByText('Create')).toBeInTheDocument()); - - await user.click(screen.getByText('Create')); - - await waitFor(() => { - expect(screen.getByText('Conflict')).toBeInTheDocument(); - }); - expect(onClose).not.toHaveBeenCalled(); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateAgentWizard.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateAgentWizard.tsx deleted file mode 100644 index 915c8760e04..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateAgentWizard.tsx +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useEffect, useMemo } from 'react'; -import BuildIcon from '@mui/icons-material/Build'; -import CloudUploadIcon from '@mui/icons-material/CloudUpload'; -import SettingsIcon from '@mui/icons-material/Settings'; -import TuneIcon from '@mui/icons-material/Tune'; -import type { CreateAgentWizardProps } from './agentWizardTypes'; -import { FORM_STEPS, BUILD_STEP } from './agentWizardTypes'; -import { useAgentWizardForm } from './useAgentWizardForm'; -import { AgentWizardBasicsStep } from './AgentWizardBasicsStep'; -import { AgentWizardDeployStep } from './AgentWizardDeployStep'; -import { AgentWizardRuntimeStep } from './AgentWizardRuntimeStep'; -import { AgentWizardBuildStep } from './AgentWizardBuildStep'; -import { WizardShell } from './WizardShell'; - -export type { CreateAgentWizardProps } from './agentWizardTypes'; - -const STEP_ICONS: Record = { - Basics: , - Deployment: , - Runtime: , - [BUILD_STEP]: , -}; - -const STEP_DESCRIPTIONS: Record = { - Basics: - 'Name your agent and choose its communication protocol and framework.', - Deployment: - 'Choose how to deploy — from a pre-built container image or built from source code.', - Runtime: - 'Configure the workload type, environment variables, ports, and security settings.', - [BUILD_STEP]: - 'Monitor the build progress and deployment status of your agent.', -}; - -export function CreateAgentWizard({ - open, - namespace: namespaceProp, - initialDeploymentMethod, - onClose, - onCreated, - onStepControl, - onDeployMethodControl, -}: CreateAgentWizardProps) { - const form = useAgentWizardForm( - open, - namespaceProp, - onClose, - onCreated, - initialDeploymentMethod, - ); - const { setDeploymentMethod, setActiveStep } = form; - - useEffect(() => { - if (open && onDeployMethodControl) { - onDeployMethodControl((method: string) => { - if (method === 'image' || method === 'source') { - setDeploymentMethod(method); - } - }); - } - }, [open, onDeployMethodControl, setDeploymentMethod]); - - useEffect(() => { - if (open && onStepControl) { - onStepControl(setActiveStep); - } - }, [open, onStepControl, setActiveStep]); - - const isSourceDeploy = form.deploymentMethod === 'source'; - const isBuildStep = form.activeStep === FORM_STEPS.length; - const buildActive = - form.buildProgress.phase !== 'idle' && - form.buildProgress.phase !== 'submitting'; - - const allSteps = useMemo((): string[] => { - const base: string[] = [...FORM_STEPS]; - if (isBuildStep) { - base.push(BUILD_STEP); - } - return base; - }, [isBuildStep]); - - return ( - form.setSubmitError(null)} - submitLabel={isSourceDeploy ? 'Start Build' : 'Create'} - submittingLabel={isSourceDeploy ? 'Starting…' : 'Creating…'} - successOpen={form.successOpen} - successMessage="Agent created successfully." - onSuccessClose={() => form.setSuccessOpen(false)} - onClose={onClose} - onCloseBuild={form.handleCloseBuild} - onBack={form.handleBack} - onNext={form.handleNext} - onSubmit={form.handleSubmit} - > - {form.activeStep === 0 && ( - - )} - - {form.activeStep === 1 && ( - - )} - - {form.activeStep === 2 && ( - - )} - - {isBuildStep && ( - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateToolWizard.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateToolWizard.test.tsx deleted file mode 100644 index a604c1ba9dc..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateToolWizard.test.tsx +++ /dev/null @@ -1,305 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -// eslint-disable-next-line @backstage/no-undeclared-imports -import { TestApiProvider } from '@backstage/test-utils'; -import { augmentApiRef, type AugmentApi } from '../../../api'; -import { CreateToolWizard } from './CreateToolWizard'; -import type { CreateToolWizardProps } from './toolWizardTypes'; - -const theme = createTheme(); - -function createMockApi(overrides: Partial> = {}) { - return { - listKagentiBuildStrategies: jest.fn().mockResolvedValue({ - strategies: [{ name: 'buildah' }], - }), - listKagentiNamespaces: jest.fn().mockResolvedValue({ - namespaces: ['team-a', 'team-b'], - }), - createKagentiTool: jest.fn().mockResolvedValue({ - success: true, - name: 'test-tool', - namespace: 'team-a', - message: 'Tool created', - }), - ...overrides, - }; -} - -function renderWizard( - props: Partial = {}, - apiOverrides: Partial> = {}, -) { - const onClose = jest.fn(); - const onCreated = jest.fn(); - const api = createMockApi(apiOverrides); - const result = render( - - - - - , - ); - return { ...result, onClose, onCreated, api }; -} - -function getNameInput(): HTMLElement { - const inputs = screen.getAllByRole('textbox'); - const nameInput = inputs.find(el => { - const label = el.closest('.MuiFormControl-root')?.querySelector('label'); - return ( - label?.textContent?.startsWith('Name') && - !label?.textContent?.startsWith('Namespace') - ); - }); - return nameInput ?? inputs[0]; -} - -describe('CreateToolWizard — rendering', () => { - it('renders dialog with title and stepper', () => { - renderWizard(); - expect(screen.getByText('Create Tool')).toBeInTheDocument(); - expect(screen.getByText('Basics')).toBeInTheDocument(); - expect(screen.getByText('Deployment')).toBeInTheDocument(); - expect(screen.getByText('Runtime')).toBeInTheDocument(); - }); - - it('shows Back and Next buttons on step 0', () => { - renderWizard(); - expect( - screen.getAllByRole('button', { name: /Back/i }).length, - ).toBeGreaterThanOrEqual(1); - expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument(); - }); - - it('does not render when open is false', () => { - renderWizard({ open: false }); - expect(screen.queryByText('Create Tool')).not.toBeInTheDocument(); - }); - - it('loads namespaces from API on open', async () => { - const { api } = renderWizard(); - await waitFor(() => { - expect(api.listKagentiNamespaces).toHaveBeenCalled(); - }); - }); -}); - -describe('CreateToolWizard — step navigation', () => { - it('blocks Next when name is empty', async () => { - renderWizard(); - const user = userEvent.setup(); - await user.click(screen.getByRole('button', { name: 'Next' })); - expect( - screen.getByText(/Name and namespace are required/i), - ).toBeInTheDocument(); - }); - - it('blocks Next when name is invalid DNS-1123', async () => { - renderWizard(); - const user = userEvent.setup(); - - await waitFor(() => { - expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1); - }); - - await user.type(getNameInput(), 'INVALID_NAME'); - - const nsSelect = screen.getAllByRole('combobox')[0]; - await user.click(nsSelect); - const option = await screen.findByRole('option', { name: 'team-a' }); - await user.click(option); - - await user.click(screen.getByRole('button', { name: 'Next' })); - expect(screen.getByText(/DNS-1123 label/i)).toBeInTheDocument(); - }); - - it('navigates to step 1 with valid name and namespace', async () => { - renderWizard(); - const user = userEvent.setup(); - - await waitFor(() => { - expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1); - }); - - await user.type(getNameInput(), 'my-tool'); - - const nsSelect = screen.getAllByRole('combobox')[0]; - await user.click(nsSelect); - const option = await screen.findByRole('option', { name: 'team-a' }); - await user.click(option); - - await user.click(screen.getByRole('button', { name: 'Next' })); - - expect(screen.getByText('Container image')).toBeInTheDocument(); - }); - - it('shows Back button on step 1', async () => { - renderWizard(); - const user = userEvent.setup(); - - await waitFor(() => { - expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1); - }); - - await user.type(getNameInput(), 'my-tool'); - const nsSelect = screen.getAllByRole('combobox')[0]; - await user.click(nsSelect); - await user.click(await screen.findByRole('option', { name: 'team-a' })); - - await user.click(screen.getByRole('button', { name: 'Next' })); - expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument(); - }); -}); - -describe('CreateToolWizard — submission', () => { - it('submits with valid data and calls API', async () => { - const { api, onCreated, onClose } = renderWizard(); - const user = userEvent.setup(); - - await waitFor(() => { - expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1); - }); - - await user.type(getNameInput(), 'my-tool'); - const nsSelect = screen.getAllByRole('combobox')[0]; - await user.click(nsSelect); - await user.click(await screen.findByRole('option', { name: 'team-a' })); - - await user.click(screen.getByRole('button', { name: 'Next' })); - - const imageInput = screen.getByRole('textbox', { - name: /Container image/i, - }); - await user.type(imageInput, 'quay.io/my-tool:latest'); - - await user.click(screen.getByRole('button', { name: 'Next' })); - - const createBtn = screen.getByRole('button', { name: 'Create' }); - await user.click(createBtn); - - await waitFor(() => { - expect(api.createKagentiTool).toHaveBeenCalledTimes(1); - }); - - const body = api.createKagentiTool.mock.calls[0][0]; - expect(body.name).toBe('my-tool'); - expect(body.namespace).toBe('team-a'); - expect(body.containerImage).toBe('quay.io/my-tool:latest'); - expect(body.deploymentMethod).toBe('image'); - - await waitFor(() => { - expect(onCreated).toHaveBeenCalled(); - expect(onClose).toHaveBeenCalled(); - }); - }); - - it('shows error alert on submission failure', async () => { - const createKagentiTool = jest - .fn() - .mockRejectedValue(new Error('API error')); - const { api } = renderWizard({}, { createKagentiTool }); - const user = userEvent.setup(); - - await waitFor(() => { - expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1); - }); - - await user.type(getNameInput(), 'my-tool'); - const nsSelect = screen.getAllByRole('combobox')[0]; - await user.click(nsSelect); - await user.click(await screen.findByRole('option', { name: 'team-a' })); - - await user.click(screen.getByRole('button', { name: 'Next' })); - - const imageInput = screen.getByRole('textbox', { - name: /Container image/i, - }); - await user.type(imageInput, 'quay.io/my-tool:latest'); - - await user.click(screen.getByRole('button', { name: 'Next' })); - await user.click(screen.getByRole('button', { name: 'Create' })); - - await waitFor(() => { - expect(api.createKagentiTool).toHaveBeenCalled(); - }); - - await waitFor(() => { - expect(screen.getByText(/API error/i)).toBeInTheDocument(); - }); - }); - - it('blocks step 1 when container image is empty', async () => { - renderWizard(); - const user = userEvent.setup(); - - await waitFor(() => { - expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1); - }); - - await user.type(getNameInput(), 'my-tool'); - const nsSelect = screen.getAllByRole('combobox')[0]; - await user.click(nsSelect); - await user.click(await screen.findByRole('option', { name: 'team-a' })); - await user.click(screen.getByRole('button', { name: 'Next' })); - - await user.click(screen.getByRole('button', { name: 'Next' })); - expect( - screen.getByText(/Container image is required/i), - ).toBeInTheDocument(); - }); -}); - -describe('CreateToolWizard — success snackbar', () => { - it('shows success snackbar after successful creation', async () => { - renderWizard(); - const user = userEvent.setup(); - - await waitFor(() => { - expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1); - }); - - await user.type(getNameInput(), 'my-tool'); - const nsSelect = screen.getAllByRole('combobox')[0]; - await user.click(nsSelect); - await user.click(await screen.findByRole('option', { name: 'team-a' })); - - await user.click(screen.getByRole('button', { name: 'Next' })); - - const imageInput = screen.getByRole('textbox', { - name: /Container image/i, - }); - await user.type(imageInput, 'quay.io/my-tool:latest'); - - await user.click(screen.getByRole('button', { name: 'Next' })); - await user.click(screen.getByRole('button', { name: 'Create' })); - - await waitFor(() => { - expect( - screen.getByText('Tool created successfully.'), - ).toBeInTheDocument(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateToolWizard.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateToolWizard.tsx deleted file mode 100644 index f8dc48c7830..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/CreateToolWizard.tsx +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useEffect, useMemo } from 'react'; -import BuildIcon from '@mui/icons-material/Build'; -import CloudUploadIcon from '@mui/icons-material/CloudUpload'; -import SettingsIcon from '@mui/icons-material/Settings'; -import TuneIcon from '@mui/icons-material/Tune'; -import type { CreateToolWizardProps } from './toolWizardTypes'; -import { TOOL_STEPS, TOOL_BUILD_STEP } from './toolWizardTypes'; -import { useToolWizardForm } from './useToolWizardForm'; -import { ToolWizardBasicsStep } from './ToolWizardBasicsStep'; -import { ToolWizardDeployStep } from './ToolWizardDeployStep'; -import { ToolWizardRuntimeStep } from './ToolWizardRuntimeStep'; -import { AgentWizardBuildStep } from './AgentWizardBuildStep'; -import { WizardShell } from './WizardShell'; - -export type { CreateToolWizardProps } from './toolWizardTypes'; - -const STEP_ICONS: Record = { - Basics: , - Deployment: , - Runtime: , - [TOOL_BUILD_STEP]: , -}; - -const STEP_DESCRIPTIONS: Record = { - Basics: 'Name your tool, set its protocol, and choose a runtime framework.', - Deployment: - 'Choose how to deploy — from a pre-built container image or built from source code.', - Runtime: - 'Configure the workload type, environment, ports, and security settings.', - [TOOL_BUILD_STEP]: - 'Monitor the build progress and deployment status of your tool.', -}; - -export function CreateToolWizard({ - open, - namespace: namespaceProp, - onClose, - onCreated, - onStepControl, -}: CreateToolWizardProps) { - const form = useToolWizardForm(open, namespaceProp, onClose, onCreated); - - useEffect(() => { - if (open && onStepControl) { - onStepControl(form.setActiveStep); - } - }, [open, onStepControl, form.setActiveStep]); - - const isSourceDeploy = form.deploymentMethod === 'source'; - const isBuildStep = form.activeStep === TOOL_STEPS.length; - const buildActive = - form.buildProgress.phase !== 'idle' && - form.buildProgress.phase !== 'submitting'; - - const allSteps = useMemo((): string[] => { - const base: string[] = [...TOOL_STEPS]; - if (isBuildStep) { - base.push(TOOL_BUILD_STEP); - } - return base; - }, [isBuildStep]); - - return ( - form.setSubmitError(null)} - submitLabel={isSourceDeploy ? 'Start Build' : 'Create'} - submittingLabel={isSourceDeploy ? 'Starting…' : 'Creating…'} - successOpen={form.successOpen} - successMessage="Tool created successfully." - onSuccessClose={() => form.setSuccessOpen(false)} - onClose={onClose} - onCloseBuild={form.handleCloseBuild} - onBack={form.handleBack} - onNext={form.handleNext} - onSubmit={form.handleSubmit} - > - {form.activeStep === 0 && ( - - )} - - {form.activeStep === 1 && ( - - )} - - {form.activeStep === 2 && ( - - )} - - {isBuildStep && ( - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/DevSpacesFrameworkPicker.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/DevSpacesFrameworkPicker.tsx deleted file mode 100644 index 025abdabc7f..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/DevSpacesFrameworkPicker.tsx +++ /dev/null @@ -1,231 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState, useEffect } from 'react'; -import Box from '@mui/material/Box'; -import Card from '@mui/material/Card'; -import CardActionArea from '@mui/material/CardActionArea'; -import Chip from '@mui/material/Chip'; -import CircularProgress from '@mui/material/CircularProgress'; -import Typography from '@mui/material/Typography'; -import { alpha, useTheme } from '@mui/material/styles'; -import { - useApi, - discoveryApiRef, - fetchApiRef, -} from '@backstage/core-plugin-api'; - -export interface FrameworkOption { - readonly id: string; - readonly name: string; - readonly description?: string; - readonly starterRepo?: string; - readonly tags?: string[]; - readonly badge?: string; -} - -const DEFAULT_FRAMEWORKS: readonly FrameworkOption[] = [ - { - id: 'google-adk', - name: 'Google ADK', - tags: ['Python', 'A2A'], - badge: 'Popular', - }, - { - id: 'langgraph', - name: 'LangGraph', - tags: ['Python', 'Agents'], - badge: 'Popular', - }, - { id: 'crewai', name: 'CrewAI', tags: ['Python', 'Multi-Agent'] }, - { id: 'openai-agents', name: 'OpenAI Agents', tags: ['Python', 'OpenAI'] }, - { id: 'custom', name: 'DIY / Custom', tags: ['Any'] }, -]; - -const FRAMEWORK_COLORS: Record = { - 'google-adk': '#0d9488', - langgraph: '#8b5cf6', - crewai: '#f59e0b', - 'openai-agents': '#10b981', - custom: '#6b7280', -}; - -export interface DevSpacesFrameworkPickerProps { - readonly onSelect: (framework: FrameworkOption) => void; -} - -export function DevSpacesFrameworkPicker({ - onSelect, -}: DevSpacesFrameworkPickerProps) { - const theme = useTheme(); - const discoveryApi = useApi(discoveryApiRef); - const fetchApi = useApi(fetchApiRef); - const [frameworks, setFrameworks] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - let cancelled = false; - discoveryApi - .getBaseUrl('augment') - .then(baseUrl => - fetchApi.fetch(`${baseUrl}/devspaces/frameworks`, { - headers: { 'X-Backstage-Request': 'augment' }, - }), - ) - .then(resp => resp.json()) - .then((data: { frameworks?: FrameworkOption[] }) => { - if (!cancelled) { - const list = data.frameworks?.length - ? data.frameworks - : (DEFAULT_FRAMEWORKS as FrameworkOption[]); - setFrameworks(list); - } - }) - .catch(() => { - if (!cancelled) setFrameworks(DEFAULT_FRAMEWORKS as FrameworkOption[]); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [discoveryApi, fetchApi]); - - if (loading) { - return ( - - - - ); - } - - return ( - - {frameworks.map(fw => { - const isDiy = fw.id === 'custom' || fw.id === 'diy'; - const accent = FRAMEWORK_COLORS[fw.id] ?? theme.palette.primary.main; - return ( - - onSelect(fw)} - sx={{ - display: 'flex', - flexDirection: 'row', - alignItems: 'stretch', - p: 0, - height: '100%', - }} - > - - - - - {fw.name} - - {fw.badge && ( - - )} - - {fw.description && ( - - {fw.description} - - )} - {fw.tags && fw.tags.length > 0 && ( - - {fw.tags.map(tag => ( - - ))} - - )} - - - - ); - })} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/DevSpacesLaunchForm.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/DevSpacesLaunchForm.tsx deleted file mode 100644 index d54f1a1c402..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/DevSpacesLaunchForm.tsx +++ /dev/null @@ -1,912 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useCallback, useEffect, useRef, useState } from 'react'; -import Alert from '@mui/material/Alert'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import CircularProgress from '@mui/material/CircularProgress'; -import Collapse from '@mui/material/Collapse'; -import Divider from '@mui/material/Divider'; -import IconButton from '@mui/material/IconButton'; -import Skeleton from '@mui/material/Skeleton'; -import TextField from '@mui/material/TextField'; -import Tooltip from '@mui/material/Tooltip'; -import Typography from '@mui/material/Typography'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import CodeIcon from '@mui/icons-material/Code'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import LaptopMacOutlinedIcon from '@mui/icons-material/LaptopMacOutlined'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import RocketLaunchOutlinedIcon from '@mui/icons-material/RocketLaunchOutlined'; -import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined'; -import TerminalOutlinedIcon from '@mui/icons-material/TerminalOutlined'; -import { alpha, useTheme } from '@mui/material/styles'; -import { useApi } from '@backstage/core-plugin-api'; -import type { - DevSpacesCreateWorkspaceResponse, - DevSpacesWorkspace, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { augmentApiRef } from '../../../api'; -import { getErrorMessage } from '../../../utils'; - -function stripTrailingSlashes(s: string): string { - let end = s.length; - while (end > 0 && s[end - 1] === '/') end--; - return end === s.length ? s : s.slice(0, end); -} - -function normalizeGitRepoUrl(url: string): string { - const trimmed = stripTrailingSlashes(url.trim()); - try { - const parsed = new URL(trimmed); - const host = parsed.hostname.toLowerCase(); - if (host === 'github.com' || host === 'gitlab.com') { - const parts = parsed.pathname.split('/').filter(Boolean); - if (parts.length >= 4 && (parts[2] === 'blob' || parts[2] === 'tree')) { - return `${parsed.protocol}//${parsed.host}/${parts[0]}/${parts[1]}`; - } - } - } catch { - /* not a valid URL, return as-is */ - } - return trimmed; -} - -const PHASE_COLORS: Record< - string, - 'success' | 'warning' | 'error' | 'default' -> = { - Running: 'success', - Starting: 'warning', - Stopping: 'warning', - Stopped: 'default', - Failed: 'error', - Failing: 'error', -}; - -const STATUS_POLL_INTERVAL_MS = 5_000; - -export interface DevSpacesLaunchFormProps { - readonly onBack: () => void; - readonly onDone?: () => void; - readonly initialGitRepo?: string; - readonly resourceKind?: 'agent' | 'tool'; - readonly frameworkName?: string; -} - -type FormStatus = 'idle' | 'creating' | 'polling' | 'success' | 'error'; -type HealthState = 'loading' | 'ok' | 'not-configured' | 'error'; - -function NextStep({ - icon, - step, - title, - description, -}: { - icon: React.ReactElement; - step: string; - title: string; - description: React.ReactNode; -}) { - const theme = useTheme(); - return ( - - - {icon} - - - - {step}. {title} - - - {description} - - - - ); -} - -export function DevSpacesLaunchForm({ - onBack, - onDone, - initialGitRepo, - resourceKind = 'agent', - frameworkName, -}: DevSpacesLaunchFormProps) { - const theme = useTheme(); - const api = useApi(augmentApiRef); - const label = resourceKind === 'tool' ? 'tool' : 'agent'; - // eslint-disable-next-line no-nested-ternary - const Label = frameworkName - ? frameworkName - : resourceKind === 'tool' - ? 'Tool' - : 'Agent'; - - // ── Health check ──────────────────────────────────────────────────── - const [health, setHealth] = useState('loading'); - const [healthMsg, setHealthMsg] = useState(''); - - useEffect(() => { - let cancelled = false; - api - .checkDevSpacesHealth() - .then(resp => { - if (cancelled) return; - if (!resp.configured) { - setHealth('not-configured'); - setHealthMsg(resp.message); - } else if (resp.ok) { - setHealth('ok'); - } else { - setHealth('error'); - setHealthMsg(resp.message); - } - }) - .catch(err => { - if (cancelled) return; - setHealth('error'); - setHealthMsg(getErrorMessage(err)); - }); - return () => { - cancelled = true; - }; - }, [api]); - - // ── Form state ────────────────────────────────────────────────────── - const [namespace] = useState('auto'); - const [gitRepo, setGitRepo] = useState(initialGitRepo ?? ''); - const [memoryLimit, setMemoryLimit] = useState('8Gi'); - const [cpuLimit, setCpuLimit] = useState('2000m'); - const [advancedOpen, setAdvancedOpen] = useState(false); - - const [status, setStatus] = useState('idle'); - const [result, setResult] = useState( - null, - ); - const [errorMessage, setErrorMessage] = useState(''); - const [urlCorrected, setUrlCorrected] = useState(false); - - // ── Existing workspaces ───────────────────────────────────────────── - const [workspaces, setWorkspaces] = useState([]); - const [wsLoading, setWsLoading] = useState(false); - - const loadWorkspaces = useCallback( - async (ns: string) => { - setWsLoading(true); - try { - const resp = await api.listDevSpacesWorkspaces(ns); - setWorkspaces(resp.workspaces); - } catch { - setWorkspaces([]); - } finally { - setWsLoading(false); - } - }, - [api], - ); - - useEffect(() => { - if (namespace && health === 'ok') { - loadWorkspaces(namespace); - } else { - setWorkspaces([]); - } - }, [namespace, health, loadWorkspaces]); - - // ── Status polling after creation ─────────────────────────────────── - const pollRef = useRef | null>(null); - - const startPolling = useCallback( - (ws: DevSpacesCreateWorkspaceResponse) => { - setStatus('polling'); - pollRef.current = setInterval(async () => { - try { - const updated = await api.getDevSpacesWorkspace( - ws.namespace, - ws.name, - ); - setResult(prev => - prev ? { ...prev, phase: updated.phase, url: updated.url } : prev, - ); - if (updated.phase === 'Running' || updated.phase === 'Failed') { - if (pollRef.current) clearInterval(pollRef.current); - pollRef.current = null; - setStatus('success'); - } - } catch { - // keep polling on transient errors - } - }, STATUS_POLL_INTERVAL_MS); - }, - [api], - ); - - useEffect(() => { - return () => { - if (pollRef.current) clearInterval(pollRef.current); - }; - }, []); - - // ── Create handler ────────────────────────────────────────────────── - const isValid = !!namespace && gitRepo.trim().length > 0; - - const handleCreate = useCallback(async () => { - if (!isValid || !namespace) return; - setStatus('creating'); - setErrorMessage(''); - setResult(null); - setUrlCorrected(false); - - const normalized = normalizeGitRepoUrl(gitRepo); - if (normalized !== gitRepo.trim()) { - setGitRepo(normalized); - setUrlCorrected(true); - } - - try { - const response = await api.createDevSpacesWorkspace({ - namespace, - git_repo: normalized, - memory_limit: memoryLimit.trim() || '8Gi', - cpu_limit: cpuLimit.trim() || '2000m', - }); - setResult(response); - - if (response.phase !== 'Running') { - startPolling(response); - } else { - setStatus('success'); - } - } catch (err) { - let msg = getErrorMessage(err); - if (err && typeof err === 'object' && 'body' in err) { - const body = (err as { body?: { message?: string } }).body; - if (body?.message) msg = body.message; - } - if ( - msg.toLowerCase().includes('not configured') || - msg.toLowerCase().includes('authentication is not configured') - ) { - msg = `${msg} Go to the Administration panel → Dev Spaces section to configure the OpenShift API URL and authentication token.`; - } - setErrorMessage(msg); - setStatus('error'); - } - }, [api, namespace, gitRepo, memoryLimit, cpuLimit, isValid, startPolling]); - - // ── Workspace actions ─────────────────────────────────────────────── - const handleStop = useCallback( - async (ws: DevSpacesWorkspace) => { - try { - await api.stopDevSpacesWorkspace(ws.namespace, ws.name); - if (namespace) loadWorkspaces(namespace); - } catch { - /* best effort */ - } - }, - [api, namespace, loadWorkspaces], - ); - - const handleDelete = useCallback( - async (ws: DevSpacesWorkspace) => { - try { - await api.deleteDevSpacesWorkspace(ws.namespace, ws.name); - if (namespace) loadWorkspaces(namespace); - } catch { - /* best effort */ - } - }, - [api, namespace, loadWorkspaces], - ); - - // ── Render: health loading ────────────────────────────────────────── - if (health === 'loading') { - return ( - - - - - - - - {Label} DevSpace - - - - - ); - } - - // ── Render: not configured ────────────────────────────────────────── - if (health === 'not-configured') { - return ( - - - - - - - - {Label} DevSpace - - - - - Dev Spaces is not configured - - - {healthMsg || 'The OpenShift API URL has not been set.'} Go to{' '} - Administration → Dev Spaces to configure the - OpenShift API URL and authentication token, then return here. - - - - - ); - } - - // ── Render: health error (configured but unreachable) ─────────────── - if (health === 'error') { - return ( - - - - - - - - {Label} DevSpace - - - - - Cannot reach Kubernetes API - - - {healthMsg} Check the API URL and authentication token in{' '} - Administration → Dev Spaces. - - - - - ); - } - - // ── Render: main form ─────────────────────────────────────────────── - return ( - - - - - - - - {Label} DevSpace - - - - - Launch a ready-to-code cloud IDE with your {label}'s repository, - tools, and runtime pre-configured. - - - {/* ── Success / polling state ─────────────────────────────────── */} - {(status === 'success' || status === 'polling') && result && ( - - - ) : ( - - ) - } - sx={{ mb: 2.5 }} - > - - {status === 'polling' - ? 'Workspace starting...' - : 'Workspace created'} - - - {result.name} in {result.namespace} —{' '} - - - {result.created_at && ( - - Created {new Date(result.created_at).toLocaleString()} - - )} - - - {status === 'success' && ( - <> - - - - Next Steps - - - - - } - step="1" - title={`Open your ${Label} DevSpace`} - description={ - result.url ? ( - - - Your cloud IDE is ready. Open it to start writing - your {label} code. - - - - ) : ( - `Your cloud IDE is ready. Open it to start writing your ${label} code.` - ) - } - /> - } - step="2" - title="Build and push from the IDE" - description={`Use the integrated terminal in DevSpaces to build your ${label} container image and push it to your registry.`} - /> - } - step="3" - title={`Deploy your ${label}`} - description={ - <> - Once your image is ready, return here and use{' '} - BYO {Label} to deploy it to the - platform. - - } - /> - - - - - - - - Building and pushing your {label}'s container image is - done through the IDE terminal in DevSpaces, not from this - UI. - - - - - - - - - - - )} - - )} - - {/* ── Error state ─────────────────────────────────────────────── */} - {status === 'error' && errorMessage && ( - { - setStatus('idle'); - setErrorMessage(''); - }} - sx={{ textTransform: 'none', fontWeight: 600 }} - > - Dismiss - - } - > - {errorMessage} - - )} - - {/* ── Form ────────────────────────────────────────────────────── */} - {status !== 'success' && status !== 'polling' && ( - - {urlCorrected && ( - setUrlCorrected(false)} - sx={{ mb: 0 }} - > - The URL was auto-corrected to a cloneable repository URL. - - )} - - setGitRepo(ev.target.value)} - required - fullWidth - size="small" - helperText="Git repository clone URL. Links to specific files or branches will be auto-corrected." - disabled={status === 'creating'} - /> - - - Namespace will be auto-derived from your identity - - - - - - - setMemoryLimit(ev.target.value)} - size="small" - helperText="e.g. 8Gi, 4Gi" - disabled={status === 'creating'} - /> - setCpuLimit(ev.target.value)} - size="small" - helperText="e.g. 2000m, 4000m" - disabled={status === 'creating'} - /> - - - - - - - - - - {/* ── Existing workspaces ─────────────────────────────────── */} - {namespace && ( - - - - - Existing Workspaces - - loadWorkspaces(namespace)} - aria-label="Refresh workspaces" - > - - - - - {wsLoading && ( - - - - - )} - - {!wsLoading && workspaces.length === 0 && ( - - No workspaces in this namespace. - - )} - - {!wsLoading && - workspaces.map(ws => ( - - - - {ws.name} - - {ws.git_repo && ( - - {ws.git_repo} - - )} - - - {ws.url && ( - - - - - - )} - {ws.phase === 'Running' && ( - - handleStop(ws)} - aria-label="Stop workspace" - > - - - - )} - - handleDelete(ws)} - aria-label="Delete workspace" - sx={{ color: theme.palette.error.main }} - > - - - - - ))} - - )} - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/IntentDialogParts.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/IntentDialogParts.tsx deleted file mode 100644 index 69c5d0f96c8..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/IntentDialogParts.tsx +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Box from '@mui/material/Box'; -import ButtonBase from '@mui/material/ButtonBase'; -import IconButton from '@mui/material/IconButton'; -import Typography from '@mui/material/Typography'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { alpha, useTheme } from '@mui/material/styles'; -import { - glassSurface, - borderRadius, - transitions, - typeScale, - animations, - staggerDelay, - reducedMotion, -} from '../../../theme/tokens'; - -export interface IntentCard { - id: string; - tourId?: string; - icon: React.ReactElement; - title: string; - subtitle: string; - description: string; -} - -export function CardGrid({ - cards, - onCardClick, - columns, -}: { - cards: IntentCard[]; - onCardClick: (id: string) => void; - columns: number; -}) { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - - return ( - - {cards.map((card, idx) => ( - onCardClick(card.id)} - sx={{ - ...glassSurface(theme, 6, isDark ? 0.5 : 0.75), - ...animations.fadeSlideIn, - animationDelay: staggerDelay(idx, 60), - animationFillMode: 'both', - '@media (prefers-reduced-motion: reduce)': reducedMotion, - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - textAlign: 'left', - p: 3, - borderRadius: borderRadius.lg, - transition: transitions.normal, - '&:hover': { - borderColor: alpha(theme.palette.primary.main, 0.4), - boxShadow: `0 0 0 1px ${alpha(theme.palette.primary.main, 0.2)}, 0 8px 24px ${alpha(theme.palette.primary.main, isDark ? 0.15 : 0.08)}`, - transform: 'translateY(-3px) scale(1.01)', - }, - '&:focus-visible': { - outline: `2px solid ${theme.palette.primary.main}`, - outlineOffset: -2, - }, - }} - > - - {card.icon} - - - {card.title} - - - {card.subtitle} - - - {card.description} - - - ))} - - ); -} - -export function DialogHeader({ - titleId, - title, - subtitle, - onBack, -}: { - titleId: string; - title: string; - subtitle: string; - onBack?: () => void; -}) { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - - return ( - - {onBack && ( - - - - )} - - - {title} - - - {subtitle} - - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAdminPanel.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAdminPanel.test.tsx deleted file mode 100644 index 05e2de2c8de..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAdminPanel.test.tsx +++ /dev/null @@ -1,271 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import type { ApiRef } from '@backstage/core-plugin-api'; -import { useApi } from '@backstage/core-plugin-api'; -import { augmentApiRef } from '../../../api'; -import { KagentiAdminPanel } from './KagentiAdminPanel'; -import { makeMigratableAgent } from './kagentiTestFixtures'; - -jest.mock('@backstage/core-plugin-api', () => ({ - ...jest.requireActual('@backstage/core-plugin-api'), - useApi: jest.fn(), -})); - -const mockedUseApi = jest.mocked(useApi); - -const theme = createTheme(); - -const strategy = { - name: 'buildah', - description: 'Build with buildah', -}; - -function createAdminMockApi(overrides = {}) { - return { - fetchJson: jest.fn().mockResolvedValue({}), - getAdminConfig: jest - .fn() - .mockResolvedValue({ entry: null, source: 'default' }), - setAdminConfig: jest.fn().mockResolvedValue({}), - deleteAdminConfig: jest.fn().mockResolvedValue({}), - getKagentiDashboards: jest.fn().mockResolvedValue({ - keycloakConsole: 'https://keycloak.example/admin', - }), - listKagentiNamespaces: jest.fn().mockResolvedValue({ - namespaces: ['team-a', 'team-b'], - }), - listKagentiMigratableAgents: jest.fn().mockResolvedValue({ - agents: [ - makeMigratableAgent({ name: 'legacy-one', namespace: 'team-a' }), - ], - }), - listKagentiBuildStrategies: jest.fn().mockResolvedValue({ - strategies: [strategy], - }), - migrateKagentiAgent: jest.fn().mockResolvedValue({}), - migrateAllKagentiAgents: jest.fn().mockResolvedValue({}), - ...overrides, - }; -} - -function setupUseApiMock(api: ReturnType) { - mockedUseApi.mockImplementation((ref: ApiRef) => { - if (ref === augmentApiRef) return api; - throw new Error(`Unexpected API ref in test: ${String(ref)}`); - }); -} - -function renderAdmin(apiOverrides = {}) { - const api = createAdminMockApi(apiOverrides); - setupUseApiMock(api); - return { - ...render( - - - , - ), - api, - }; -} - -describe('KagentiAdminPanel', () => { - beforeEach(() => { - mockedUseApi.mockReset(); - }); - - it('shows centered progress while dashboard config loads', () => { - const api = createAdminMockApi({ - getKagentiDashboards: jest.fn(() => new Promise(() => {})), - }); - setupUseApiMock(api); - render( - - - , - ); - expect( - document.querySelector('.MuiCircularProgress-root'), - ).toBeInTheDocument(); - }); - - it('renders main administration sections after load', async () => { - renderAdmin(); - await waitFor(() => { - expect( - screen.getByRole('heading', { name: /^Administration$/i }), - ).toBeInTheDocument(); - }); - expect(screen.getByText('Identity Management')).toBeInTheDocument(); - expect(screen.getByText('Namespace Management')).toBeInTheDocument(); - expect(screen.getByText('Agent Migration')).toBeInTheDocument(); - expect(screen.getByText('Build Strategies')).toBeInTheDocument(); - }); - - it('reloads namespaces when namespace section refresh is clicked', async () => { - const user = userEvent.setup(); - const listKagentiNamespaces = jest.fn().mockResolvedValue({ - namespaces: ['unique-ns-refresh-test'], - }); - renderAdmin({ listKagentiNamespaces }); - await waitFor(() => { - expect(screen.getByText('Namespace Management')).toBeInTheDocument(); - }); - await waitFor(() => { - expect(screen.getByText('unique-ns-refresh-test')).toBeInTheDocument(); - }); - const initialCalls = listKagentiNamespaces.mock.calls.length; - - const refreshBtn = screen.getByRole('button', { - name: /Refresh namespaces/i, - }); - await user.click(refreshBtn); - - await waitFor(() => { - expect(listKagentiNamespaces.mock.calls.length).toBeGreaterThan( - initialCalls, - ); - }); - }); - - it('shows migration table and Migrate action for migratable agents', async () => { - renderAdmin(); - await waitFor(() => - expect(screen.getByText('legacy-one')).toBeInTheDocument(), - ); - expect( - screen.getByRole('columnheader', { name: /^Agent$/i }), - ).toBeInTheDocument(); - expect( - screen.getAllByRole('button', { name: /^Migrate$/i }).length, - ).toBeGreaterThan(0); - }); - - it('calls migrateKagentiAgent when Migrate is clicked', async () => { - const user = userEvent.setup(); - const { api } = renderAdmin(); - await waitFor(() => - expect(screen.getByText('legacy-one')).toBeInTheDocument(), - ); - - await user.click(screen.getAllByRole('button', { name: /^Migrate$/i })[0]); - - await waitFor(() => { - expect(api.migrateKagentiAgent).toHaveBeenCalledWith( - 'team-a', - 'legacy-one', - false, - ); - }); - }); - - it('shows Migrate All when migratable agents exist', async () => { - renderAdmin(); - await waitFor(() => - expect( - screen.getByRole('button', { name: /Migrate All/i }), - ).toBeInTheDocument(), - ); - }); - - it('calls migrateAllKagentiAgents when Migrate All is clicked', async () => { - const user = userEvent.setup(); - const { api } = renderAdmin(); - await waitFor(() => - expect( - screen.getByRole('button', { name: /Migrate All/i }), - ).toBeInTheDocument(), - ); - - await user.click(screen.getByRole('button', { name: /Migrate All/i })); - - await waitFor(() => { - expect(api.migrateAllKagentiAgents).toHaveBeenCalledWith({ - dryRun: false, - }); - }); - }); - - it('displays build strategy name and description', async () => { - renderAdmin(); - await waitFor(() => - expect(screen.getByText('buildah')).toBeInTheDocument(), - ); - expect(screen.getByText('Build with buildah')).toBeInTheDocument(); - }); - - it('falls back gracefully when dashboard config fails to load', async () => { - renderAdmin({ - getKagentiDashboards: jest.fn().mockRejectedValue(new Error('nope')), - }); - await waitFor(() => { - expect( - screen.getByRole('heading', { name: /^Administration$/i }), - ).toBeInTheDocument(); - }); - expect( - screen.queryByText('Failed to load dashboard configuration.'), - ).not.toBeInTheDocument(); - }); - - it('shows API error when namespace list fails', async () => { - renderAdmin({ - listKagentiNamespaces: jest.fn().mockRejectedValue(new Error('ns boom')), - }); - await waitFor(() => { - expect(screen.getByText('ns boom')).toBeInTheDocument(); - }); - }); - - it('shows API error when migratable agents list fails', async () => { - renderAdmin({ - listKagentiMigratableAgents: jest - .fn() - .mockRejectedValue(new Error('mig fail')), - }); - await waitFor(() => { - expect(screen.getByText('mig fail')).toBeInTheDocument(); - }); - }); - - it('falls back gracefully when build strategies list fails', async () => { - renderAdmin({ - listKagentiBuildStrategies: jest - .fn() - .mockRejectedValue(new Error('bs fail')), - }); - await waitFor(() => { - expect( - screen.getByRole('heading', { name: /^Administration$/i }), - ).toBeInTheDocument(); - }); - expect(screen.queryByText('bs fail')).not.toBeInTheDocument(); - }); - - it('dismisses error alert when closed', async () => { - const user = userEvent.setup(); - renderAdmin({ - listKagentiNamespaces: jest.fn().mockRejectedValue(new Error('bad ns')), - }); - await waitFor(() => expect(screen.getByText('bad ns')).toBeInTheDocument()); - - await user.click(screen.getByRole('button', { name: /close/i })); - expect(screen.queryByText('bad ns')).not.toBeInTheDocument(); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAdminPanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAdminPanel.tsx deleted file mode 100644 index 8e02acf0b28..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAdminPanel.tsx +++ /dev/null @@ -1,1430 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - useCallback, - useEffect, - useMemo, - useState, - type ReactNode, -} from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Alert from '@mui/material/Alert'; -import Chip from '@mui/material/Chip'; -import CircularProgress from '@mui/material/CircularProgress'; -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import DialogContent from '@mui/material/DialogContent'; -import DialogActions from '@mui/material/DialogActions'; -import IconButton from '@mui/material/IconButton'; -import MenuItem from '@mui/material/MenuItem'; -import Snackbar from '@mui/material/Snackbar'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; -import TextField from '@mui/material/TextField'; -import Tooltip from '@mui/material/Tooltip'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import { useTheme, alpha } from '@mui/material/styles'; -import type { - KagentiBuildStrategy, - KagentiMigratableAgent, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { augmentApiRef } from '../../../api'; -import * as kagentiEndpoints from '../../../api/kagentiEndpoints'; -import type { KagentiApiDeps } from '../../../api/kagentiEndpoints'; -import { getErrorMessage } from '../../../utils'; -import { useAdminConfig } from '../../../hooks'; -import { - CONTENT_MAX_WIDTH, - PAGE_TITLE_SX, - PAGE_SUBTITLE_SX, - sectionCardSx, -} from '../shared/commandCenterStyles'; - -export interface KagentiAdminPanelProps { - namespace?: string; -} - -export function KagentiAdminPanel({ - namespace: _namespace, -}: KagentiAdminPanelProps) { - const theme = useTheme(); - const api = useApi(augmentApiRef); - - const [dashboardConfig, setDashboardConfig] = useState<{ - keycloakConsole?: string; - } | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const [namespaces, setNamespaces] = useState([]); - const [nsLoading, setNsLoading] = useState(false); - - const [migratableAgents, setMigratableAgents] = useState< - KagentiMigratableAgent[] - >([]); - const [migLoading, setMigLoading] = useState(false); - const [migrating, setMigrating] = useState(null); - - const [strategies, setStrategies] = useState([]); - const [bsLoading, setBsLoading] = useState(false); - - const kagentiDeps: KagentiApiDeps = useMemo( - () => ({ - fetchJson: ( - api as unknown as { fetchJson: KagentiApiDeps['fetchJson'] } - ).fetchJson.bind(api), - }), - [api], - ); - - // LLM models/teams/keys state - const [llmModels, setLlmModels] = useState | null>( - null, - ); - const [llmTeams, setLlmTeams] = useState>>([]); - const [llmKeys, setLlmKeys] = useState>>([]); - const [llmLoading, setLlmLoading] = useState(false); - const [createTeamOpen, setCreateTeamOpen] = useState(false); - const [createTeamNs, setCreateTeamNs] = useState(''); - const [createTeamName, setCreateTeamName] = useState(''); - const [createKeyOpen, setCreateKeyOpen] = useState(false); - const [createKeyNs, setCreateKeyNs] = useState(''); - const [createKeyAgent, setCreateKeyAgent] = useState(''); - const [actionBusy, setActionBusy] = useState(null); - - // Integrations state - const [integrations, setIntegrations] = useState< - Array> - >([]); - const [intLoading, setIntLoading] = useState(false); - const [createIntOpen, setCreateIntOpen] = useState(false); - const [intFormNs, setIntFormNs] = useState(''); - const [intFormName, setIntFormName] = useState(''); - const [intFormType, setIntFormType] = useState(''); - const [intFormConfig, setIntFormConfig] = useState('{}'); - - // Sandbox trigger state - const [triggerNs, setTriggerNs] = useState(''); - const [triggerAgent, setTriggerAgent] = useState(''); - const [triggerMessage, setTriggerMessage] = useState(''); - const [triggerType, setTriggerType] = useState<'cron' | 'webhook' | 'alert'>( - 'webhook', - ); - const [triggerResult, setTriggerResult] = useState | null>(null); - - const devSpacesConfig = useAdminConfig('devSpacesApiUrl'); - const devSpacesTokenConfig = useAdminConfig('devSpacesToken'); - const [devSpacesUrl, setDevSpacesUrl] = useState(''); - const [devSpacesToken, setDevSpacesToken] = useState(''); - const [devSpacesInitialized, setDevSpacesInitialized] = useState(false); - const [devSpacesToast, setDevSpacesToast] = useState<{ - message: string; - severity: 'success' | 'error'; - } | null>(null); - - useEffect(() => { - if ( - !devSpacesInitialized && - !devSpacesConfig.loading && - !devSpacesTokenConfig.loading - ) { - setDevSpacesUrl( - (devSpacesConfig.entry?.configValue as string | undefined) ?? '', - ); - setDevSpacesToken( - (devSpacesTokenConfig.entry?.configValue as string | undefined) ?? '', - ); - setDevSpacesInitialized(true); - } - }, [ - devSpacesInitialized, - devSpacesConfig.loading, - devSpacesConfig.entry, - devSpacesTokenConfig.loading, - devSpacesTokenConfig.entry, - ]); - - const handleSaveDevSpaces = useCallback(async () => { - const trimmedUrl = devSpacesUrl.trim(); - if (trimmedUrl && !/^https?:\/\/.+/.test(trimmedUrl)) { - setDevSpacesToast({ - message: 'URL must start with http:// or https://', - severity: 'error', - }); - return; - } - try { - await devSpacesConfig.save(trimmedUrl || ''); - await devSpacesTokenConfig.save(devSpacesToken.trim() || ''); - setDevSpacesToast({ - message: 'Dev Spaces configuration saved', - severity: 'success', - }); - } catch { - setDevSpacesToast({ - message: - devSpacesConfig.error || - devSpacesTokenConfig.error || - 'Failed to save', - severity: 'error', - }); - } - }, [devSpacesUrl, devSpacesToken, devSpacesConfig, devSpacesTokenConfig]); - - const handleResetDevSpaces = useCallback(async () => { - try { - await devSpacesConfig.reset(); - await devSpacesTokenConfig.reset(); - setDevSpacesUrl(''); - setDevSpacesToken(''); - setDevSpacesToast({ - message: 'Dev Spaces configuration reset to defaults', - severity: 'success', - }); - } catch { - setDevSpacesToast({ - message: - devSpacesConfig.error || - devSpacesTokenConfig.error || - 'Failed to reset', - severity: 'error', - }); - } - }, [devSpacesConfig, devSpacesTokenConfig]); - - const [devSpacesTestResult, setDevSpacesTestResult] = useState<{ - testing: boolean; - ok?: boolean; - message?: string; - responseTimeMs?: number; - }>({ testing: false }); - - const handleTestDevSpaces = useCallback(async () => { - setDevSpacesTestResult({ testing: true }); - try { - const result = await api.checkDevSpacesHealth(); - setDevSpacesTestResult({ - testing: false, - ok: result.ok, - message: result.message, - responseTimeMs: result.responseTimeMs, - }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - setDevSpacesTestResult({ - testing: false, - ok: false, - message: msg, - }); - } - }, [api]); - - useEffect(() => { - setLoading(true); - api - .getKagentiDashboards() - .then(d => setDashboardConfig(d)) - .catch(() => { - // Dashboard config endpoint may not exist on all Kagenti deployments; - // silently fall back to empty config instead of alarming the user. - setDashboardConfig({}); - }) - .finally(() => setLoading(false)); - }, [api]); - - const loadNamespaces = useCallback(() => { - setNsLoading(true); - api - .listKagentiNamespaces(true) - .then(r => setNamespaces(r.namespaces ?? [])) - .catch(e => setError(getErrorMessage(e))) - .finally(() => setNsLoading(false)); - }, [api]); - - const loadMigratable = useCallback(() => { - setMigLoading(true); - api - .listKagentiMigratableAgents() - .then(r => setMigratableAgents(r.agents ?? [])) - .catch(e => setError(getErrorMessage(e))) - .finally(() => setMigLoading(false)); - }, [api]); - - const loadStrategies = useCallback(() => { - setBsLoading(true); - api - .listKagentiBuildStrategies() - .then(r => setStrategies(r.strategies ?? [])) - .catch(() => { - // Build strategies endpoint may not be available; not critical. - setStrategies([]); - }) - .finally(() => setBsLoading(false)); - }, [api]); - - useEffect(() => { - loadNamespaces(); - loadMigratable(); - loadStrategies(); - }, [loadNamespaces, loadMigratable, loadStrategies]); - - const handleMigrate = async (ns: string, name: string) => { - setMigrating(`${ns}/${name}`); - setError(null); - try { - await api.migrateKagentiAgent(ns, name, false); - loadMigratable(); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setMigrating(null); - } - }; - - const handleMigrateAll = async () => { - setMigrating('__all__'); - setError(null); - try { - await api.migrateAllKagentiAgents({ dryRun: false }); - loadMigratable(); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setMigrating(null); - } - }; - - // ---- LLM helpers ---- - const loadLlmData = useCallback(async () => { - setLlmLoading(true); - try { - const [models, teams, keys] = await Promise.allSettled([ - kagentiEndpoints.listLlmModels(kagentiDeps), - kagentiEndpoints.listLlmTeams(kagentiDeps), - kagentiEndpoints.listLlmKeys(kagentiDeps), - ]); - setLlmModels(models.status === 'fulfilled' ? models.value : null); - setLlmTeams( - teams.status === 'fulfilled' - ? ((teams.value as { teams?: Array> }) - .teams ?? []) - : [], - ); - setLlmKeys( - keys.status === 'fulfilled' - ? ((keys.value as { keys?: Array> }).keys ?? - []) - : [], - ); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setLlmLoading(false); - } - }, [kagentiDeps]); - - const handleCreateTeam = async () => { - if (!createTeamNs) return; - setActionBusy('create-team'); - try { - await kagentiEndpoints.createLlmTeam(kagentiDeps, { - namespace: createTeamNs, - name: createTeamName || undefined, - } as import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiCreateTeamRequest); - setCreateTeamOpen(false); - setCreateTeamNs(''); - setCreateTeamName(''); - loadLlmData(); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - const handleCreateKey = async () => { - if (!createKeyNs || !createKeyAgent) return; - setActionBusy('create-key'); - try { - await kagentiEndpoints.createLlmKey(kagentiDeps, { - namespace: createKeyNs, - agentName: createKeyAgent, - } as import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiCreateKeyRequest); - setCreateKeyOpen(false); - setCreateKeyNs(''); - setCreateKeyAgent(''); - loadLlmData(); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - const handleDeleteKey = async (ns: string, agent: string) => { - setActionBusy(`del-key:${ns}/${agent}`); - try { - await kagentiEndpoints.deleteLlmKey(kagentiDeps, ns, agent); - loadLlmData(); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - // ---- Integrations helpers ---- - const loadIntegrations = useCallback(async () => { - setIntLoading(true); - try { - const res = await kagentiEndpoints.listIntegrations(kagentiDeps); - setIntegrations( - (res.integrations ?? []) as Array>, - ); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setIntLoading(false); - } - }, [kagentiDeps]); - - const handleCreateIntegration = async () => { - if (!intFormNs || !intFormName || !intFormType) return; - setActionBusy('create-int'); - try { - let parsed: Record = {}; - try { - parsed = JSON.parse(intFormConfig); - } catch { - /* use empty */ - } - await kagentiEndpoints.createIntegration(kagentiDeps, { - namespace: intFormNs, - name: intFormName, - type: intFormType, - config: parsed, - } as import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiCreateIntegrationRequest); - setCreateIntOpen(false); - setIntFormNs(''); - setIntFormName(''); - setIntFormType(''); - setIntFormConfig('{}'); - loadIntegrations(); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - const handleDeleteIntegration = async (ns: string, name: string) => { - setActionBusy(`del-int:${ns}/${name}`); - try { - await kagentiEndpoints.deleteIntegration(kagentiDeps, ns, name); - loadIntegrations(); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - const handleTestIntegration = async (ns: string, name: string) => { - setActionBusy(`test-int:${ns}/${name}`); - try { - await kagentiEndpoints.testIntegration(kagentiDeps, ns, name); - setError(null); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - // ---- Trigger helper ---- - const handleTrigger = async () => { - if (!triggerNs || !triggerMessage) return; - setActionBusy('trigger'); - try { - const result = await kagentiEndpoints.createTrigger(kagentiDeps, { - type: triggerType, - namespace: triggerNs, - agentName: triggerAgent || undefined, - message: triggerMessage, - }); - setTriggerResult(result); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - useEffect(() => { - loadLlmData(); - loadIntegrations(); - }, [loadLlmData, loadIntegrations]); - - function loadingOrContent( - isLoading: boolean, - isEmpty: boolean, - emptyText: string, - content: ReactNode, - ): ReactNode { - if (isLoading) { - return ( - - - - ); - } - if (isEmpty) { - return ( - - {emptyText} - - ); - } - return content; - } - - const sectionShell = ( - title: string, - subtitle: string | undefined, - actions: ReactNode | undefined, - children: ReactNode, - ) => ( - - - - - {title} - - {subtitle && ( - - {subtitle} - - )} - - {actions} - - {children} - - ); - - if (loading) { - return ( - - - - ); - } - - return ( - - - - Administration - - - Identity management, namespace oversight, agent migration, and build - configuration. - - - - {error && ( - setError(null)}> - {error} - - )} - - {sectionShell( - 'Identity Management', - 'Manage users, roles, and authentication policies via the Keycloak console.', - undefined, - - - {!dashboardConfig?.keycloakConsole && ( - - Keycloak console URL not configured. - - )} - , - )} - - {sectionShell( - 'Namespace Management', - `${namespaces.length} enabled namespace${namespaces.length !== 1 ? 's' : ''}`, - - - - - , - loadingOrContent( - nsLoading, - namespaces.length === 0, - 'No enabled namespaces found.', - - - - - Namespace - Status - - - - {namespaces.map(ns => ( - - - - {ns} - - - - - - - ))} - -
    -
    , - ), - )} - - {sectionShell( - 'Agent Migration', - 'Migrate agents from Agent CRD to Deployment workload type.', - - {migratableAgents.length > 0 && ( - - )} - - - - - - , - loadingOrContent( - migLoading, - migratableAgents.length === 0, - 'No agents require migration.', - - - - - Agent - Namespace - Status - Has Deployment - Action - - - - {migratableAgents.map(ma => { - const key = `${ma.namespace}/${ma.name}`; - return ( - - - - {ma.name} - - - {ma.namespace} - - - - - {ma.has_deployment ? ( - - ) : ( - - )} - - - - - - ); - })} - -
    -
    , - ), - )} - - {sectionShell( - 'Build Strategies', - 'Available ClusterBuildStrategies for source-to-image agent builds.', - - - - - , - loadingOrContent( - bsLoading, - strategies.length === 0, - 'No build strategies available.', - - - - - Strategy Name - Description - - - - {strategies.map(bs => ( - - - - {bs.name} - - - - - {bs.description || '—'} - - - - ))} - -
    -
    , - ), - )} - - {sectionShell( - 'Dev Spaces', - 'Configure the OpenShift API endpoint used to provision cloud IDE workspaces for agent development via the Kubernetes DevWorkspace API.', - undefined, - - setDevSpacesUrl(e.target.value)} - size="small" - fullWidth - helperText={ - devSpacesConfig.source === 'database' - ? 'Configured via admin settings' - : 'Not configured — the Dev Spaces integration will be unavailable until a URL is set.' - } - disabled={devSpacesConfig.saving} - /> - setDevSpacesToken(e.target.value)} - size="small" - fullWidth - type="password" - helperText={ - devSpacesToken.trim() - ? 'An OpenShift bearer token will be used to authenticate with the Kubernetes API when creating workspaces.' - : 'Optional — if not set, the platform Keycloak token is used. Set this if the cluster does not use Keycloak as an identity provider.' - } - disabled={devSpacesTokenConfig.saving} - /> - - - - - - {devSpacesTestResult.ok !== undefined && - !devSpacesTestResult.testing && ( - setDevSpacesTestResult({ testing: false })} - sx={{ mt: 1 }} - > - {devSpacesTestResult.message} - {devSpacesTestResult.responseTimeMs !== undefined && ( - - Response time: {devSpacesTestResult.responseTimeMs}ms - - )} - - )} - , - )} - - {sectionShell( - 'LLM Models', - 'Available LLM models from the Kagenti platform.', - - - - - , - // eslint-disable-next-line no-nested-ternary - llmLoading ? ( - - - - ) : llmModels ? ( - - {JSON.stringify(llmModels, null, 2)} - - ) : ( - - No model data available. - - ), - )} - - {sectionShell( - 'LLM Teams', - `${llmTeams.length} team${llmTeams.length !== 1 ? 's' : ''}`, - - - - - - - - , - loadingOrContent( - llmLoading, - llmTeams.length === 0, - 'No LLM teams configured.', - - - - - Namespace - Name - Details - - - - {llmTeams.map((t, i) => ( - - - - {String(t.namespace ?? '—')} - - - {String(t.name ?? '—')} - - - {JSON.stringify(t).substring(0, 100)} - - - - ))} - -
    -
    , - ), - )} - - {sectionShell( - 'LLM API Keys', - `${llmKeys.length} key${llmKeys.length !== 1 ? 's' : ''}`, - - - - - - - - , - loadingOrContent( - llmLoading, - llmKeys.length === 0, - 'No API keys configured.', - - - - - Namespace - Agent - Actions - - - - {llmKeys.map((k, i) => { - const ns = String(k.namespace ?? '—'); - const agent = String(k.agentName ?? k.agent_name ?? '—'); - return ( - - - - {ns} - - - {agent} - - - handleDeleteKey(ns, agent)} - disabled={actionBusy === `del-key:${ns}/${agent}`} - > - - - - - - ); - })} - -
    -
    , - ), - )} - - {sectionShell( - 'Integrations', - `${integrations.length} integration${integrations.length !== 1 ? 's' : ''}`, - - - - - - - - , - // eslint-disable-next-line no-nested-ternary - intLoading ? ( - - - - ) : integrations.length === 0 ? ( - - No integrations configured. - - ) : ( - - - - - Namespace - Name - Type - Actions - - - - {integrations.map((int, i) => { - const ns = String(int.namespace ?? '—'); - const name = String(int.name ?? '—'); - return ( - - - - {ns} - - - {name} - - - - - - - handleDeleteIntegration(ns, name)} - disabled={actionBusy === `del-int:${ns}/${name}`} - > - - - - - - ); - })} - -
    -
    - ), - )} - - {sectionShell( - 'Sandbox Trigger', - 'Manually trigger a sandbox session for an agent.', - undefined, - - - setTriggerNs(e.target.value)} - sx={{ flex: 1 }} - /> - setTriggerAgent(e.target.value)} - sx={{ flex: 1 }} - /> - - setTriggerType(e.target.value as 'cron' | 'webhook' | 'alert') - } - sx={{ minWidth: 120 }} - > - webhook - cron - alert - - - setTriggerMessage(e.target.value)} - /> - - {triggerResult && ( - - {JSON.stringify(triggerResult, null, 2)} - - )} - , - )} - - {/* Create Team Dialog */} - setCreateTeamOpen(false)} - fullWidth - maxWidth="xs" - > - Create LLM Team - - setCreateTeamNs(e.target.value)} - sx={{ mt: 1, mb: 2 }} - /> - setCreateTeamName(e.target.value)} - /> - - - - - - - - {/* Create Key Dialog */} - setCreateKeyOpen(false)} - fullWidth - maxWidth="xs" - > - Create LLM API Key - - setCreateKeyNs(e.target.value)} - sx={{ mt: 1, mb: 2 }} - /> - setCreateKeyAgent(e.target.value)} - /> - - - - - - - - {/* Create Integration Dialog */} - setCreateIntOpen(false)} - fullWidth - maxWidth="sm" - > - Create Integration - - - setIntFormNs(e.target.value)} - sx={{ flex: 1 }} - /> - setIntFormName(e.target.value)} - sx={{ flex: 1 }} - /> - - setIntFormType(e.target.value)} - sx={{ mb: 2 }} - /> - setIntFormConfig(e.target.value)} - /> - - - - - - - - setDevSpacesToast(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - {devSpacesToast ? ( - setDevSpacesToast(null)} - variant="filled" - > - {devSpacesToast.message} - - ) : undefined} - -
    - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentDetailView.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentDetailView.test.tsx deleted file mode 100644 index 10491c50f47..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentDetailView.test.tsx +++ /dev/null @@ -1,378 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import type { ApiRef } from '@backstage/core-plugin-api'; -import { useApi } from '@backstage/core-plugin-api'; -import { augmentApiRef } from '../../../api'; -import { KagentiAgentDetailView } from './KagentiAgentDetailView'; -import { - makeKagentiAgentCard, - makeKagentiAgentSummary, -} from './kagentiTestFixtures'; - -jest.mock('@backstage/core-plugin-api', () => ({ - ...jest.requireActual('@backstage/core-plugin-api'), - useApi: jest.fn(), -})); - -const mockedUseApi = jest.mocked(useApi); - -const theme = createTheme(); - -function createDetailMockApi(overrides = {}) { - const agentCard = makeKagentiAgentCard({ - name: 'Card Display Name', - streaming: true, - }); - return { - getKagentiAgent: jest.fn().mockResolvedValue({ - metadata: { uid: 'uid-1' }, - spec: {}, - status: {}, - agentCard, - }), - getKagentiBuildInfo: jest.fn().mockResolvedValue({ - name: 'x', - namespace: 'y', - buildRegistered: false, - outputImage: '', - strategy: '', - gitUrl: '', - gitRevision: '', - contextDir: '', - hasBuildRun: false, - }), - getKagentiAgentRouteStatus: jest.fn().mockResolvedValue({}), - triggerKagentiBuild: jest.fn().mockResolvedValue({}), - listAgents: jest.fn().mockResolvedValue([]), - ...overrides, - }; -} - -function setupUseApiMock(api: ReturnType) { - mockedUseApi.mockImplementation((ref: ApiRef) => { - if (ref === augmentApiRef) return api; - throw new Error(`Unexpected API ref in test: ${String(ref)}`); - }); -} - -function renderDetail(props = {}, apiOverrides = {}) { - const onBack = jest.fn(); - const onChatWithAgent = jest.fn(); - const agent = makeKagentiAgentSummary({ name: 'my-agent', status: 'ready' }); - const api = createDetailMockApi(apiOverrides); - setupUseApiMock(api); - const view = render( - - - , - ); - return { ...view, onBack, onChatWithAgent, api, agent }; -} - -describe('KagentiAgentDetailView', () => { - beforeEach(() => { - mockedUseApi.mockReset(); - }); - - it('shows loading skeletons in Details tab while agent detail loads', async () => { - renderDetail( - {}, - { - getKagentiAgent: jest.fn(() => new Promise(() => {})), - }, - ); - expect( - document.querySelectorAll('.MuiSkeleton-root').length, - ).toBeGreaterThan(0); - }); - - it('shows error alert when getKagentiAgent fails', async () => { - renderDetail( - {}, - { - getKagentiAgent: jest.fn().mockRejectedValue(new Error('load failed')), - }, - ); - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('load failed'); - }); - }); - - it('renders header with agent name from card when present', async () => { - renderDetail(); - await waitFor(() => { - expect( - screen.getByRole('heading', { name: /Card Display Name/i }), - ).toBeInTheDocument(); - }); - expect(screen.getByText('ready').closest('.MuiChip-root')).toHaveClass( - 'MuiChip-colorSuccess', - ); - }); - - it('renders Agent Card section when agent card data is available', async () => { - renderDetail(); - await waitFor(() => { - expect(screen.getByText('Agent Card')).toBeInTheDocument(); - }); - expect( - screen.getAllByText('Card Display Name').length, - ).toBeGreaterThanOrEqual(1); - }); - - it('falls back to summary name when agent card is absent', async () => { - renderDetail( - {}, - { - getKagentiAgent: jest.fn().mockResolvedValue({ - metadata: {}, - spec: {}, - status: {}, - }), - }, - ); - await waitFor(() => { - expect( - screen.getByRole('heading', { name: /my-agent/i }), - ).toBeInTheDocument(); - }); - }); - - it('switches between Details, Status, and Resource tabs', async () => { - const user = userEvent.setup(); - renderDetail(); - await waitFor(() => - expect(screen.getByText('Agent Information')).toBeInTheDocument(), - ); - - await user.click(screen.getByRole('tab', { name: /^Status$/i })); - await waitFor(() => - expect(screen.getByText('Shipwright Build Status')).toBeInTheDocument(), - ); - - await user.click(screen.getByRole('tab', { name: /^Resource$/i })); - await waitFor(() => { - expect(screen.getByText(/"metadata"/)).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('tab', { name: /^Details$/i })); - await waitFor(() => - expect(screen.getByText('Agent Information')).toBeInTheDocument(), - ); - }); - - it('calls onBack when back button is clicked', async () => { - const user = userEvent.setup(); - const { onBack } = renderDetail(); - await waitFor(() => - expect( - screen.getByRole('heading', { name: /Card Display Name/i }), - ).toBeInTheDocument(), - ); - - await user.click(screen.getByRole('button', { name: /Agents/i })); - expect(onBack).toHaveBeenCalledTimes(1); - }); - - it('triggers build via triggerKagentiBuild when Rebuild is clicked', async () => { - const user = userEvent.setup(); - const { api } = renderDetail(); - await waitFor(() => - expect( - screen.getByRole('button', { name: /^Rebuild$/ }), - ).toBeInTheDocument(), - ); - - await user.click(screen.getByRole('button', { name: /^Rebuild$/ })); - - await waitFor(() => { - expect(api.triggerKagentiBuild).toHaveBeenCalledWith( - 'team-a', - 'my-agent', - ); - }); - }); - - it('shows Building... while triggerKagentiBuild is in flight', async () => { - const user = userEvent.setup(); - let resolveBuild: () => void = () => {}; - const buildPromise = new Promise(r => { - resolveBuild = r; - }); - renderDetail( - {}, - { - triggerKagentiBuild: jest.fn().mockReturnValue(buildPromise), - }, - ); - await waitFor(() => - expect( - screen.getByRole('button', { name: /^Rebuild$/ }), - ).toBeInTheDocument(), - ); - - await user.click(screen.getByRole('button', { name: /^Rebuild$/ })); - await waitFor(() => - expect( - screen.getByRole('button', { name: /^Building\.\.\.$/ }), - ).toBeDisabled(), - ); - resolveBuild(); - }); - - it('calls onChatWithAgent with namespace/name when Chat is clicked', async () => { - const user = userEvent.setup(); - const { onChatWithAgent } = renderDetail(); - await waitFor(() => - expect( - screen.getByRole('button', { name: /^Chat$/ }), - ).toBeInTheDocument(), - ); - - await user.click(screen.getByRole('button', { name: /^Chat$/ })); - expect(onChatWithAgent).toHaveBeenCalledWith('team-a/my-agent'); - }); - - it('does not render Chat when onChatWithAgent is omitted', async () => { - const api = createDetailMockApi({ - getKagentiAgent: jest.fn().mockResolvedValue({ - metadata: {}, - spec: {}, - status: {}, - }), - }); - setupUseApiMock(api); - const agent = makeKagentiAgentSummary({ name: 'solo' }); - render( - - - , - ); - await waitFor(() => - expect( - screen.getByRole('heading', { name: /solo/i }), - ).toBeInTheDocument(), - ); - expect( - screen.queryByRole('button', { name: /^Chat$/ }), - ).not.toBeInTheDocument(); - }); - - it('shows error when build fails and dismisses alert', async () => { - const user = userEvent.setup(); - renderDetail( - {}, - { - triggerKagentiBuild: jest - .fn() - .mockRejectedValue(new Error('build broke')), - }, - ); - await waitFor(() => - expect( - screen.getByRole('button', { name: /^Rebuild$/ }), - ).toBeInTheDocument(), - ); - - await user.click(screen.getByRole('button', { name: /^Rebuild$/ })); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('build broke'); - }); - - await user.click(screen.getByRole('button', { name: /close/i })); - expect(screen.queryByText('build broke')).not.toBeInTheDocument(); - }); - - it('renders protocol and framework chips from agent labels', async () => { - const agent = makeKagentiAgentSummary({ - name: 'labeled', - labels: { protocol: 'http', framework: 'Python' }, - }); - const api = createDetailMockApi(); - setupUseApiMock(api); - render( - - - , - ); - await waitFor(() => expect(screen.getByText('HTTP')).toBeInTheDocument()); - expect(screen.getByText('Python')).toBeInTheDocument(); - }); - - it('uses error chip color for failed status in header', async () => { - const agent = makeKagentiAgentSummary({ name: 'bad', status: 'failed' }); - const api = createDetailMockApi(); - setupUseApiMock(api); - render( - - - , - ); - await waitFor(() => expect(screen.getByText('failed')).toBeInTheDocument()); - expect(screen.getByText('failed').closest('.MuiChip-root')).toHaveClass( - 'MuiChip-colorError', - ); - }); - - it('refetches build info after successful build trigger', async () => { - const user = userEvent.setup(); - const getKagentiBuildInfo = jest.fn().mockResolvedValue({ - name: 'my-agent', - namespace: 'team-a', - buildRegistered: true, - outputImage: 'img:v1', - strategy: 's', - gitUrl: '', - gitRevision: '', - contextDir: '', - hasBuildRun: true, - }); - const { api } = renderDetail({}, { getKagentiBuildInfo }); - await waitFor(() => - expect( - screen.getByRole('button', { name: /^Rebuild$/ }), - ).toBeInTheDocument(), - ); - const initialCalls = getKagentiBuildInfo.mock.calls.length; - - await user.click(screen.getByRole('button', { name: /^Rebuild$/ })); - - await waitFor(() => { - expect(getKagentiBuildInfo.mock.calls.length).toBeGreaterThan( - initialCalls, - ); - }); - expect(api.triggerKagentiBuild).toHaveBeenCalled(); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentDetailView.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentDetailView.tsx deleted file mode 100644 index eecf66d2896..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentDetailView.tsx +++ /dev/null @@ -1,332 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState, useCallback, useEffect, useMemo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import Snackbar from '@mui/material/Snackbar'; -import { useTheme } from '@mui/material/styles'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import PlayArrowIcon from '@mui/icons-material/PlayArrow'; -import ChatIcon from '@mui/icons-material/Chat'; -import PublishIcon from '@mui/icons-material/Publish'; -import CloudOffIcon from '@mui/icons-material/CloudOff'; -import { getLifecycleTransition } from './lifecycleTransitions'; -import { useApi } from '@backstage/core-plugin-api'; -import type { KagentiAgentSummary } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { normalizeLifecycleStage } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { augmentApiRef } from '../../../api'; -import { statusChipColor as statusColor } from './kagentiDisplayUtils'; -import { AgentDetailsTab } from './AgentDetailsTab'; -import { AgentStatusTab } from './AgentStatusTab'; -import { AgentResourceTab } from './AgentResourceTab'; -import { AgentCardTab } from './AgentCardTab'; -import { - CONTENT_MAX_WIDTH, - PAGE_TITLE_SX, -} from '../shared/commandCenterStyles'; -import { useKagentiAgentDetail } from './useKagentiAgentDetail'; - -export interface KagentiAgentDetailViewProps { - agent: KagentiAgentSummary; - onBack: () => void; - onChatWithAgent?: (agentId: string) => void; -} - -export function KagentiAgentDetailView({ - agent, - onBack, - onChatWithAgent, -}: KagentiAgentDetailViewProps) { - const theme = useTheme(); - const api = useApi(augmentApiRef); - const [tab, setTab] = useState(0); - - const { - agentCard, - agentDetail, - buildInfo, - routeStatus, - loading, - error, - setError, - buildTriggering, - copied, - hasBuild, - loadBuildInfo, - handleTriggerBuild, - handleCopy, - } = useKagentiAgentDetail(api, agent); - - const agentId = `${agent.namespace}/${agent.name}`; - const displayName = agentCard?.name || agent.name; - - const [lifecycleStage, setLifecycleStage] = useState(null); - const [publishLoading, setPublishLoading] = useState(false); - const [publishToast, setPublishToast] = useState(null); - - useEffect(() => { - let cancelled = false; - api - .listAgents() - .then(agents => { - if (cancelled) return; - const match = agents.find(a => a.id === agentId); - setLifecycleStage(normalizeLifecycleStage(match?.lifecycleStage)); - }) - .catch(() => setLifecycleStage(null)); - return () => { - cancelled = true; - }; - }, [api, agentId]); - - const nextTransition = useMemo(() => { - const t = getLifecycleTransition(lifecycleStage); - return { - ...t, - icon: t.iconType === 'promote' ? : , - }; - }, [lifecycleStage]); - - const handleLifecycleAction = useCallback(async () => { - setPublishLoading(true); - try { - if (nextTransition.action === 'demote') { - const result = await api.demoteAgent(agentId, nextTransition.target); - setLifecycleStage(result.lifecycleStage); - setPublishToast(`Agent moved to ${result.lifecycleStage}`); - } else { - const result = await api.promoteAgent(agentId, nextTransition.target); - setLifecycleStage(result.lifecycleStage); - setPublishToast( - `Agent moved to ${result.lifecycleStage} (v${result.version})`, - ); - } - } catch (err) { - setPublishToast( - `Failed: ${err instanceof Error ? err.message : 'Unknown'}`, - ); - } finally { - setPublishLoading(false); - } - }, [api, agentId, nextTransition]); - - return ( - - - - - - - - {displayName} - - - - - {agent.labels?.protocol && ( - - )} - {agent.labels?.framework && ( - - )} - {agent.workloadType && ( - - )} - - - - - {hasBuild && ( - - )} - {onChatWithAgent && ( - - )} - - - - {error && ( - setError(null)}> - {error} - - )} - - - setTab(v)} - sx={{ - minHeight: 40, - '& .MuiTabs-flexContainer': { - display: 'flex', - flexDirection: 'row', - gap: 0, - }, - '& .MuiTab-root': { - display: 'inline-flex', - minHeight: 40, - minWidth: 'auto', - px: 2, - textTransform: 'none', - fontSize: '0.875rem', - }, - }} - > - - - - - - - - {tab === 0 && ( - - )} - - {tab === 1 && } - - {tab === 2 && ( - void loadBuildInfo()} - onTriggerBuild={() => void handleTriggerBuild()} - /> - )} - - {tab === 3 && ( - - )} - - setPublishToast(null)} - message={publishToast} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentsPanel.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentsPanel.test.tsx deleted file mode 100644 index cf5064f7cf5..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentsPanel.test.tsx +++ /dev/null @@ -1,433 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import type { ApiRef } from '@backstage/core-plugin-api'; -import { - useApi, - configApiRef, - fetchApiRef, - discoveryApiRef, -} from '@backstage/core-plugin-api'; -import { augmentApiRef } from '../../../api'; -import { KagentiAgentsPanel } from './KagentiAgentsPanel'; -import { makeKagentiAgentSummary } from './kagentiTestFixtures'; - -jest.mock('@backstage/core-plugin-api', () => ({ - ...jest.requireActual('@backstage/core-plugin-api'), - useApi: jest.fn(), -})); - -const mockedUseApi = jest.mocked(useApi); - -const mockConfigApi = { - getString: jest.fn().mockReturnValue('http://localhost'), - getOptionalString: jest.fn().mockReturnValue(undefined), - getConfig: jest.fn().mockReturnValue({ getString: jest.fn() }), - getOptionalConfig: jest.fn().mockReturnValue(undefined), - getConfigArray: jest.fn().mockReturnValue([]), - getOptionalConfigArray: jest.fn().mockReturnValue(undefined), - getNumber: jest.fn().mockReturnValue(0), - getOptionalNumber: jest.fn().mockReturnValue(undefined), - getBoolean: jest.fn().mockReturnValue(false), - getOptionalBoolean: jest.fn().mockReturnValue(undefined), - getStringArray: jest.fn().mockReturnValue([]), - getOptionalStringArray: jest.fn().mockReturnValue(undefined), - keys: jest.fn().mockReturnValue([]), - has: jest.fn().mockReturnValue(false), -}; - -const mockFetchApi = { - fetch: jest.fn().mockResolvedValue(new Response()), -}; - -const theme = createTheme(); - -function createPanelMockApi(overrides = {}) { - return { - listKagentiAgents: jest.fn().mockResolvedValue({ agents: [] }), - deleteKagentiAgent: jest.fn().mockResolvedValue(undefined), - listKagentiBuildStrategies: jest.fn().mockResolvedValue({ strategies: [] }), - listKagentiNamespaces: jest.fn().mockResolvedValue({ namespaces: [] }), - createKagentiAgent: jest.fn().mockResolvedValue({ success: true }), - getKagentiAgent: jest.fn().mockResolvedValue({ - metadata: {}, - spec: {}, - status: {}, - }), - getKagentiBuildInfo: jest.fn().mockResolvedValue({ - name: 'x', - namespace: 'y', - buildRegistered: false, - outputImage: '', - strategy: '', - gitUrl: '', - gitRevision: '', - contextDir: '', - hasBuildRun: false, - }), - getKagentiAgentRouteStatus: jest.fn().mockResolvedValue({}), - triggerKagentiBuild: jest.fn().mockResolvedValue({}), - fetchJson: jest.fn().mockResolvedValue({}), - listAgents: jest.fn().mockResolvedValue([]), - ...overrides, - }; -} - -function setupUseApiMock(api: ReturnType) { - mockedUseApi.mockImplementation((ref: ApiRef) => { - if (ref === augmentApiRef) return api; - if (ref === configApiRef) return mockConfigApi; - if (ref === fetchApiRef) return mockFetchApi; - if (ref === discoveryApiRef) - return { getBaseUrl: jest.fn().mockResolvedValue('http://localhost') }; - throw new Error(`Unexpected API ref in test: ${String(ref)}`); - }); -} - -function renderPanel(props = {}, apiOverrides = {}) { - const api = createPanelMockApi(apiOverrides); - setupUseApiMock(api); - const view = render( - - - , - ); - return { ...view, api }; -} - -describe('KagentiAgentsPanel', () => { - beforeEach(() => { - mockedUseApi.mockReset(); - }); - - it('shows loading skeleton while agents load', () => { - const api = createPanelMockApi({ - listKagentiAgents: jest.fn(() => new Promise(() => {})), - }); - setupUseApiMock(api); - render( - - - , - ); - expect(document.querySelector('.MuiSkeleton-root')).toBeInTheDocument(); - }); - - it('shows empty state when listKagentiAgents fails', async () => { - renderPanel( - {}, - { - listKagentiAgents: jest - .fn() - .mockRejectedValue(new Error('network down')), - }, - ); - await waitFor(() => { - expect(screen.getByText('No agents found')).toBeInTheDocument(); - }); - }); - - it('shows empty state with create-first-agent messaging', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('No agents found')).toBeInTheDocument(); - }); - expect( - screen.getByText(/Create your first agent to get started/i), - ).toBeInTheDocument(); - }); - - it('empty state New Agent button opens create wizard', async () => { - const user = userEvent.setup(); - renderPanel(); - await waitFor(() => - expect(screen.getByText('No agents found')).toBeInTheDocument(), - ); - - const createButtons = screen.getAllByRole('button', { - name: /New Agent/i, - }); - expect(createButtons.length).toBeGreaterThanOrEqual(1); - await user.click(createButtons[createButtons.length - 1]); - - expect( - screen.getByRole('dialog', { name: /New Agent/i }), - ).toBeInTheDocument(); - }); - - it('renders agent rows when data loads', async () => { - const agents = [ - makeKagentiAgentSummary({ name: 'one', description: 'desc one' }), - makeKagentiAgentSummary({ - name: 'two', - namespace: 'team-b', - description: 'desc two', - }), - ]; - renderPanel( - {}, - { - listKagentiAgents: jest.fn().mockResolvedValue({ agents }), - }, - ); - await waitFor(() => { - expect(screen.getByText('one')).toBeInTheDocument(); - }); - expect(screen.getByText('two')).toBeInTheDocument(); - expect(screen.getByText('desc one')).toBeInTheDocument(); - }); - - it('filters agents by search on name', async () => { - const agents = [ - makeKagentiAgentSummary({ name: 'apple-bot' }), - makeKagentiAgentSummary({ name: 'banana-bot' }), - ]; - renderPanel( - {}, - { - listKagentiAgents: jest.fn().mockResolvedValue({ agents }), - }, - ); - await waitFor(() => - expect(screen.getByText('apple-bot')).toBeInTheDocument(), - ); - - const search = screen.getByPlaceholderText(/Search agents/i); - fireEvent.change(search, { target: { value: 'banana' } }); - - expect(screen.queryByText('apple-bot')).not.toBeInTheDocument(); - expect(screen.getByText('banana-bot')).toBeInTheDocument(); - }); - - it('filters agents by search on description', async () => { - const agents = [ - makeKagentiAgentSummary({ name: 'a1', description: 'handles invoices' }), - makeKagentiAgentSummary({ name: 'a2', description: 'chat helper' }), - ]; - renderPanel( - {}, - { - listKagentiAgents: jest.fn().mockResolvedValue({ agents }), - }, - ); - await waitFor(() => expect(screen.getByText('a1')).toBeInTheDocument()); - - fireEvent.change(screen.getByPlaceholderText(/Search agents/i), { - target: { value: 'invoice' }, - }); - - expect(screen.getByText('a1')).toBeInTheDocument(); - expect(screen.queryByText('a2')).not.toBeInTheDocument(); - }); - - it('opens delete confirmation dialog and calls deleteKagentiAgent on confirm', async () => { - const user = userEvent.setup(); - const agent = makeKagentiAgentSummary({ - name: 'to-delete', - namespace: 'ns-x', - }); - const { api } = renderPanel( - {}, - { - listKagentiAgents: jest.fn().mockResolvedValue({ agents: [agent] }), - }, - ); - - await waitFor(() => - expect(screen.getByText('to-delete')).toBeInTheDocument(), - ); - - await user.click(screen.getByRole('button', { name: /^Delete$/i })); - - expect( - screen.getByRole('heading', { name: /Delete agent/i }), - ).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: /^Confirm$/i })); - - await waitFor(() => { - expect(api.deleteKagentiAgent).toHaveBeenCalledWith('ns-x', 'to-delete'); - }); - }); - - it('opens create wizard when header New Agent is clicked', async () => { - const user = userEvent.setup(); - renderPanel( - {}, - { - listKagentiAgents: jest - .fn() - .mockResolvedValue({ agents: [makeKagentiAgentSummary()] }), - }, - ); - await waitFor(() => - expect(screen.getByText('alpha-agent')).toBeInTheDocument(), - ); - - const createButtons = screen.getAllByRole('button', { - name: /New Agent/i, - }); - await user.click(createButtons[0]); - - expect( - screen.getByRole('dialog', { name: /New Agent/i }), - ).toBeInTheDocument(); - }); - - it('navigates to detail view when a row is clicked', async () => { - const user = userEvent.setup(); - const agent = makeKagentiAgentSummary({ name: 'detail-me' }); - renderPanel( - {}, - { - listKagentiAgents: jest.fn().mockResolvedValue({ agents: [agent] }), - }, - ); - - await waitFor(() => - expect(screen.getByText('detail-me')).toBeInTheDocument(), - ); - await user.click(screen.getByText('detail-me')); - - await waitFor(() => { - expect( - screen.getByRole('button', { name: /Agents/i }), - ).toBeInTheDocument(); - }); - expect( - screen.getByRole('heading', { name: /detail-me/i }), - ).toBeInTheDocument(); - }); - - it('refresh button calls listKagentiAgents again', async () => { - const listKagentiAgents = jest - .fn() - .mockResolvedValue({ agents: [makeKagentiAgentSummary()] }); - renderPanel({}, { listKagentiAgents }); - - await waitFor(() => - expect(screen.getByText('alpha-agent')).toBeInTheDocument(), - ); - - const refreshBtn = screen.getByRole('button', { name: /Refresh/i }); - expect(refreshBtn).not.toBeDisabled(); - fireEvent.click(refreshBtn); - - await waitFor(() => expect(listKagentiAgents).toHaveBeenCalledTimes(2)); - }); - - it('renders status chip for ready agents', async () => { - renderPanel( - {}, - { - listKagentiAgents: jest.fn().mockResolvedValue({ - agents: [makeKagentiAgentSummary({ status: 'ready' })], - }), - }, - ); - await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument()); - expect(screen.getByText('ready').closest('.MuiChip-root')).toBeTruthy(); - }); - - it('renders status chip for failed agents', async () => { - renderPanel( - {}, - { - listKagentiAgents: jest.fn().mockResolvedValue({ - agents: [makeKagentiAgentSummary({ status: 'failed' })], - }), - }, - ); - await waitFor(() => expect(screen.getByText('failed')).toBeInTheDocument()); - expect(screen.getByText('failed').closest('.MuiChip-root')).toBeTruthy(); - }); - - it('renders status chip for building agents', async () => { - renderPanel( - {}, - { - listKagentiAgents: jest.fn().mockResolvedValue({ - agents: [makeKagentiAgentSummary({ status: 'building' })], - }), - }, - ); - await waitFor(() => - expect(screen.getByText('building')).toBeInTheDocument(), - ); - expect(screen.getByText('building').closest('.MuiChip-root')).toBeTruthy(); - }); - - it('renders status chip for degraded agents', async () => { - renderPanel( - {}, - { - listKagentiAgents: jest.fn().mockResolvedValue({ - agents: [makeKagentiAgentSummary({ status: 'degraded' })], - }), - }, - ); - await waitFor(() => - expect(screen.getByText('degraded')).toBeInTheDocument(), - ); - expect(screen.getByText('degraded').closest('.MuiChip-root')).toBeTruthy(); - }); - - it('invokes onChatWithAgent when Chat icon is clicked on agent card', async () => { - const user = userEvent.setup(); - const onChatWithAgent = jest.fn(); - const agent = makeKagentiAgentSummary({ name: 'chatty' }); - renderPanel( - { onChatWithAgent }, - { - listKagentiAgents: jest.fn().mockResolvedValue({ agents: [agent] }), - }, - ); - - await waitFor(() => expect(screen.getByText('chatty')).toBeInTheDocument()); - - const chatBtn = screen.getByRole('button', { name: /^Chat$/i }); - await user.click(chatBtn); - - expect(onChatWithAgent).toHaveBeenCalledWith('team-a/chatty'); - }); - - it('shows error alert when delete fails', async () => { - const user = userEvent.setup(); - const agent = makeKagentiAgentSummary({ name: 'fail-del' }); - renderPanel( - {}, - { - listKagentiAgents: jest.fn().mockResolvedValue({ agents: [agent] }), - deleteKagentiAgent: jest - .fn() - .mockRejectedValue(new Error('delete boom')), - }, - ); - await waitFor(() => - expect(screen.getByText('fail-del')).toBeInTheDocument(), - ); - - await user.click(screen.getByRole('button', { name: /^Delete$/i })); - await user.click(screen.getByRole('button', { name: /^Confirm$/i })); - - await waitFor(() => - expect(screen.getByText('delete boom')).toBeInTheDocument(), - ); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentsPanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentsPanel.tsx deleted file mode 100644 index 30b35a94596..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiAgentsPanel.tsx +++ /dev/null @@ -1,1052 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useApi, configApiRef, fetchApiRef } from '@backstage/core-plugin-api'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; -import TablePagination from '@mui/material/TablePagination'; -import TableSortLabel from '@mui/material/TableSortLabel'; -import TextField from '@mui/material/TextField'; -import InputAdornment from '@mui/material/InputAdornment'; -import SearchIcon from '@mui/icons-material/Search'; -import Chip from '@mui/material/Chip'; -import Alert from '@mui/material/Alert'; -import Snackbar from '@mui/material/Snackbar'; -import CircularProgress from '@mui/material/CircularProgress'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined'; -import { useTheme, alpha } from '@mui/material/styles'; -import type { - KagentiAgentSummary, - ChatAgent, - WorkflowDefinition, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import HubOutlinedIcon from '@mui/icons-material/HubOutlined'; -import AddIcon from '@mui/icons-material/Add'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import ViewModuleIcon from '@mui/icons-material/ViewModule'; -import ViewListIcon from '@mui/icons-material/ViewList'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; -import { AgentCatalogCard } from './AgentCatalogCard'; -import { CardGridSkeleton } from '../shared/CardGridSkeleton'; -import { augmentApiRef } from '../../../api'; -import { getErrorMessage } from '../../../utils'; -import { AgentsPanel } from '../AgentsPanel'; -import { WorkflowEditor } from '../../WorkflowBuilder/WorkflowEditor'; -import { WorkflowDashboard } from '../../WorkflowBuilder/WorkflowDashboard'; -import { WorkflowErrorBoundary } from '../../WorkflowBuilder/WorkflowErrorBoundary'; -import { ConfirmDialog } from '../shared/ConfirmDialog'; -import { AgentCreateIntentDialog } from './AgentCreateIntentDialog'; -import { CreateAgentWizard } from './CreateAgentWizard'; -import { AgentLifecycleDetail } from './AgentLifecycleDetail'; -import { OrchAgentDetailView } from './OrchAgentDetailView'; -import { statusChipColor, formatDateTime } from './kagentiDisplayUtils'; -import type { DeploymentMethod } from './agentWizardTypes'; -import { - CONTENT_MAX_WIDTH, - PAGE_TITLE_SX, - PAGE_SUBTITLE_SX, - TABLE_HEADER_CELL_SX, - tableContainerSx, - emptyStateSx, -} from '../shared/commandCenterStyles'; - -interface AgentRow { - id: string; - name: string; - namespace?: string; - description: string; - status: string; - labels: { protocol?: string | string[]; framework?: string }; - workloadType?: string; - createdAt?: string; - source: 'kagenti' | 'orchestration'; - agentRole?: string; - kagentiAgent?: KagentiAgentSummary; -} - -type SortField = 'name' | 'status' | 'workloadType' | 'createdAt'; -type SortDir = 'asc' | 'desc'; - -function compareRows(a: AgentRow, b: AgentRow, field: SortField): number { - if (field === 'createdAt') { - const tA = a.createdAt ? new Date(a.createdAt).getTime() : 0; - const tB = b.createdAt ? new Date(b.createdAt).getTime() : 0; - return tA - tB; - } - const valA = a[field] ?? ''; - const valB = b[field] ?? ''; - return String(valA).localeCompare(String(valB)); -} - -function kagentiToRow(a: KagentiAgentSummary): AgentRow { - return { - id: `${a.namespace}/${a.name}`, - name: a.name, - namespace: a.namespace, - description: a.description, - status: a.status, - labels: a.labels, - workloadType: a.workloadType, - createdAt: a.createdAt, - source: 'kagenti', - kagentiAgent: a, - }; -} - -function orchAgentToRow(a: ChatAgent): AgentRow { - return { - id: a.id, - name: a.name, - description: a.description ?? '', - status: a.status, - labels: { framework: a.framework }, - workloadType: 'config', - source: 'orchestration', - agentRole: a.agentRole, - }; -} - -const TH_STYLE = TABLE_HEADER_CELL_SX; - -export interface AgentPanelTourControl { - openIntent: () => void; - closeIntent: () => void; - selectIntent: (cardId: string) => void; - closeWizard: () => void; - setWizardStep: (step: number) => void; - setDeployMethod: (method: string) => void; -} - -export interface KagentiAgentsPanelProps { - namespace?: string; - onChatWithAgent?: (agentId: string) => void; - autoOpenIntent?: boolean; - onIntentOpened?: () => void; - onFullScreenChange?: (fullScreen: boolean) => void; - initialAgentName?: string; - onFocusConsumed?: () => void; - tourControlRef?: React.MutableRefObject; - onAgentCreated?: () => void; -} - -export function KagentiAgentsPanel({ - namespace, - onChatWithAgent, - autoOpenIntent, - onIntentOpened, - onFullScreenChange, - initialAgentName, - onFocusConsumed, - tourControlRef, - onAgentCreated, -}: KagentiAgentsPanelProps) { - const theme = useTheme(); - const api = useApi(augmentApiRef); - const configApi = useApi(configApiRef); - const { fetch: authFetch } = useApi(fetchApiRef); - const [rows, setRows] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [intentOpen, setIntentOpen] = useState(false); - const [createOpen, setCreateOpen] = useState(false); - const [initialDeployMethod, setInitialDeployMethod] = useState< - DeploymentMethod | undefined - >(undefined); - const wizardStepRef = useRef<((step: number) => void) | null>(null); - const deployMethodRef = useRef<((method: string) => void) | null>(null); - const [deleteTarget, setDeleteTarget] = useState( - null, - ); - - const [selectedAgent, setSelectedAgent] = - useState(null); - const [selectedOrchAgent, setSelectedOrchAgent] = useState( - null, - ); - const [showOrchestration, setShowOrchestration] = useState(false); - const [orchFocusKey, setOrchFocusKey] = useState(); - const [autoCreateAgent, setAutoCreateAgent] = useState(false); - const [showDashboard, setShowDashboard] = useState(false); - const [showWorkflowCanvas, setShowWorkflowCanvas] = useState(false); - const [activeWorkflow, setActiveWorkflow] = - useState(null); - const [successToast, setSuccessToast] = useState(null); - const [search, setSearch] = useState(''); - const [catalogView, setCatalogView] = useState<'grid' | 'table'>('grid'); - const [sortField, setSortField] = useState('name'); - const [sortDir, setSortDir] = useState('asc'); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(10); - - const loadAgents = useCallback(() => { - setLoading(true); - setError(null); - const timeout = new Promise((_, reject) => - setTimeout( - () => - reject( - new Error( - 'Request timed out — the Kagenti backend may be unreachable', - ), - ), - 15_000, - ), - ); - Promise.all([ - Promise.race([ - api.listKagentiAgents(namespace || undefined), - timeout, - ]).catch(() => ({ agents: [] as KagentiAgentSummary[] })), - Promise.race([api.listAgents(), timeout]).catch(() => [] as ChatAgent[]), - ]) - .then(([kagentiRes, allAgents]) => { - const kagentiRows = (kagentiRes.agents ?? []).map(kagentiToRow); - const orchRows = allAgents - .filter(a => a.source === 'orchestration') - .map(orchAgentToRow); - setRows([...kagentiRows, ...orchRows]); - }) - .catch(e => setError(getErrorMessage(e))) - .finally(() => setLoading(false)); - }, [api, namespace]); - - useEffect(() => { - loadAgents(); - }, [loadAgents]); - - useEffect(() => { - if (autoOpenIntent) { - setIntentOpen(true); - onIntentOpened?.(); - } - }, [autoOpenIntent, onIntentOpened]); - - useEffect(() => { - if (tourControlRef) { - tourControlRef.current = { - openIntent: () => setIntentOpen(true), - closeIntent: () => setIntentOpen(false), - selectIntent: (cardId: string) => { - if (cardId === 'deploy') { - setIntentOpen(false); - setCreateOpen(true); - } else if (cardId === 'develop') { - const card = document.querySelector('[data-tour="intent-develop"]'); - if (card instanceof HTMLElement) { - card.click(); - } - } else if (cardId === 'configure') { - const card = document.querySelector( - '[data-tour="intent-configure"]', - ); - if (card instanceof HTMLElement) { - card.click(); - } - } - }, - closeWizard: () => { - setCreateOpen(false); - setInitialDeployMethod(undefined); - }, - setWizardStep: (step: number) => { - wizardStepRef.current?.(step); - }, - setDeployMethod: (method: string) => { - deployMethodRef.current?.(method); - }, - }; - } - return () => { - if (tourControlRef) { - tourControlRef.current = null; - } - }; - }, [tourControlRef]); - - useEffect(() => { - if (initialAgentName && !loading && rows.length > 0) { - const match = rows.find( - r => r.name === initialAgentName && r.kagentiAgent, - ); - if (match?.kagentiAgent) { - setSelectedAgent(match.kagentiAgent); - } - onFocusConsumed?.(); - } - }, [initialAgentName, loading, rows, onFocusConsumed]); - - const handleDelete = async () => { - if (!deleteTarget) return; - setError(null); - try { - await api.deleteKagentiAgent(deleteTarget.namespace, deleteTarget.name); - const agentId = `${deleteTarget.namespace}/${deleteTarget.name}`; - await api.deleteAgentConfig(agentId).catch(() => {}); - setDeleteTarget(null); - loadAgents(); - } catch (e) { - setError(getErrorMessage(e)); - } - }; - - const handleSort = (field: SortField) => { - if (sortField === field) { - setSortDir(d => (d === 'asc' ? 'desc' : 'asc')); - } else { - setSortField(field); - setSortDir('asc'); - } - }; - - const visibleRows = useMemo(() => { - let list = rows; - if (search.trim()) { - const q = search.trim().toLowerCase(); - list = list.filter( - r => - r.name.toLowerCase().includes(q) || - r.description.toLowerCase().includes(q), - ); - } - const sorted = [...list].sort((a, b) => compareRows(a, b, sortField)); - return sortDir === 'desc' ? sorted.reverse() : sorted; - }, [rows, search, sortField, sortDir]); - - const paginatedRows = useMemo( - () => - visibleRows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage), - [visibleRows, page, rowsPerPage], - ); - - useEffect(() => { - setPage(0); - }, [search]); - - if (selectedAgent) { - return ( - { - setSelectedAgent(null); - loadAgents(); - }} - onChatWithAgent={onChatWithAgent} - /> - ); - } - - if (selectedOrchAgent) { - return ( - { - setSelectedOrchAgent(null); - loadAgents(); - }} - onEditConfig={agentKey => { - setSelectedOrchAgent(null); - setOrchFocusKey(agentKey); - setShowOrchestration(true); - }} - onChatWithAgent={onChatWithAgent} - /> - ); - } - - if (showDashboard && !showWorkflowCanvas) { - return ( - - - { - setShowDashboard(false); - onFullScreenChange?.(false); - loadAgents(); - }} - aria-label="Back to agents" - > - - - - - { - setActiveWorkflow(wf); - setShowWorkflowCanvas(true); - }} - onCreateWorkflow={async wf => { - setActiveWorkflow(wf); - setShowWorkflowCanvas(true); - try { - const backendUrl = configApi.getString('backend.baseUrl'); - await authFetch(`${backendUrl}/api/augment/workflows`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(wf), - }); - } catch (_e) { - /* best-effort persist */ - } - }} - /> - - - ); - } - - if (showWorkflowCanvas && activeWorkflow) { - return ( - - - - { - setActiveWorkflow(updated); - try { - const backendUrl = configApi.getString('backend.baseUrl'); - const resp = await authFetch( - `${backendUrl}/api/augment/workflows/${updated.id}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updated), - }, - ); - if (!resp.ok) throw new Error(`Save failed: ${resp.status}`); - setSuccessToast('Workflow saved'); - } catch (e) { - setError(getErrorMessage(e)); - } - }} - onPublish={async () => { - if (!activeWorkflow) return; - try { - const backendUrl = configApi.getString('backend.baseUrl'); - const resp = await authFetch( - `${backendUrl}/api/augment/workflows/${activeWorkflow.id}/publish`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - changelog: 'Published from Agent Builder', - }), - }, - ); - if (!resp.ok) { - const errText = await resp.text().catch(() => ''); - throw new Error( - `Publish failed: ${resp.status} ${errText}`, - ); - } - const published = await resp.json(); - setActiveWorkflow(published); - setSuccessToast(`Workflow published (v${published.version})`); - } catch (e) { - setError(getErrorMessage(e)); - } - }} - onPreview={() => { - setSuccessToast('Preview coming soon'); - }} - onDelete={async () => { - if (activeWorkflow) { - try { - const backendUrl = configApi.getString('backend.baseUrl'); - const resp = await authFetch( - `${backendUrl}/api/augment/workflows/${activeWorkflow.id}`, - { - method: 'DELETE', - }, - ); - if (!resp.ok) - throw new Error(`Delete failed: ${resp.status}`); - setSuccessToast('Workflow deleted'); - } catch (e) { - setError(getErrorMessage(e)); - } - } - setShowWorkflowCanvas(false); - setActiveWorkflow(null); - if (!showDashboard) onFullScreenChange?.(false); - loadAgents(); - }} - onBack={() => { - setShowWorkflowCanvas(false); - setActiveWorkflow(null); - if (!showDashboard) onFullScreenChange?.(false); - loadAgents(); - }} - /> - - - - ); - } - - if (showOrchestration) { - const title = orchFocusKey ? 'Edit Agent' : 'Create Agent'; - return ( - - - { - setShowOrchestration(false); - setOrchFocusKey(undefined); - setAutoCreateAgent(false); - loadAgents(); - onFullScreenChange?.(false); - }} - aria-label="Back to agents" - > - - - - {title} - - - { - setShowOrchestration(false); - setOrchFocusKey(undefined); - setAutoCreateAgent(false); - loadAgents(); - onFullScreenChange?.(false); - setSuccessToast('Agent configuration saved.'); - }} - /> - - ); - } - - const sortableHead = (field: SortField, label: string, align?: 'right') => ( - - handleSort(field)} - > - {label} - - - ); - - return ( - - - - - Agents - - - Deploy, manage, and monitor your AI agents. - - - - - - - - - {!loading && rows.length > 0 && ( - - setSearch(e.target.value)} - sx={{ maxWidth: 400, flex: 1 }} - fullWidth - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - val && setCatalogView(val)} - size="small" - aria-label="View mode" - > - - - - - - - - - )} - - {error && ( - setError(null)}> - {error} - - )} - - {loading && catalogView === 'grid' && ( - - )} - {loading && catalogView === 'table' && ( - - - - )} - {!loading && rows.length === 0 && ( - - - - No agents found - - - Create your first agent to get started. Agents can be deployed as - Kubernetes workloads or designed in the visual Agent Builder. - - - - )} - {/* Card Grid View */} - {!loading && rows.length > 0 && catalogView === 'grid' && ( - - {paginatedRows.map((row, idx) => ( - { - if (row.source === 'orchestration') { - const orchAgent = rows.find(r => r.id === row.id); - if (orchAgent) { - setSelectedOrchAgent({ - id: orchAgent.id, - name: orchAgent.name, - description: orchAgent.description || undefined, - status: orchAgent.status, - providerType: 'orchestration', - source: 'orchestration', - agentRole: orchAgent.agentRole as ChatAgent['agentRole'], - }); - } - } else if (row.kagentiAgent) { - setSelectedAgent(row.kagentiAgent); - } - }} - onChat={ - onChatWithAgent ? () => onChatWithAgent(row.id) : undefined - } - onDelete={ - row.source === 'kagenti' && row.kagentiAgent - ? () => { - setDeleteTarget(row.kagentiAgent!); - } - : undefined - } - /> - ))} - - )} - - {/* Table View */} - {!loading && rows.length > 0 && catalogView === 'table' && ( - <> - - - - - {sortableHead('name', 'Name')} - Description - {sortableHead('status', 'Status')} - Labels - Source - {sortableHead('createdAt', 'Created')} - - Actions - - - - - {paginatedRows.map(row => ( - { - if (row.source === 'orchestration') { - const orchAgent = rows.find(r => r.id === row.id); - if (orchAgent) { - const chatAgent: ChatAgent = { - id: orchAgent.id, - name: orchAgent.name, - description: orchAgent.description || undefined, - status: orchAgent.status, - providerType: 'orchestration', - source: 'orchestration', - agentRole: - orchAgent.agentRole as ChatAgent['agentRole'], - }; - setSelectedOrchAgent(chatAgent); - } - } else if (row.kagentiAgent) { - setSelectedAgent(row.kagentiAgent); - } - }} - > - - - {row.name} - - {row.namespace && ( - - {row.namespace} - - )} - - - - {row.description || '—'} - - - - - - - - {row.agentRole && ( - - )} - {row.labels?.protocol && ( - - )} - {row.labels?.framework && ( - - )} - - - - - - - - {formatDateTime(row.createdAt)} - - - e.stopPropagation()}> - {onChatWithAgent && ( - - onChatWithAgent(row.id)} - > - - - - )} - {row.source === 'kagenti' && ( - - { - if (row.kagentiAgent) - setDeleteTarget(row.kagentiAgent); - }} - > - - - - )} - - - ))} - -
    -
    - {visibleRows.length > rowsPerPage && ( - setPage(p)} - rowsPerPage={rowsPerPage} - onRowsPerPageChange={e => { - setRowsPerPage(parseInt(e.target.value, 10)); - setPage(0); - }} - rowsPerPageOptions={[5, 10, 25]} - sx={{ borderTop: 1, borderColor: 'divider' }} - /> - )} - - )} - - setIntentOpen(false)} - onSelectDeploy={method => { - setIntentOpen(false); - setInitialDeployMethod(method); - setCreateOpen(true); - }} - onSelectConfigure={() => { - setIntentOpen(false); - setShowDashboard(true); - onFullScreenChange?.(true); - }} - /> - - { - setCreateOpen(false); - setInitialDeployMethod(undefined); - }} - onCreated={() => { - loadAgents(); - onAgentCreated?.(); - }} - onStepControl={setter => { - wizardStepRef.current = setter; - }} - onDeployMethodControl={setter => { - deployMethodRef.current = setter; - }} - /> - - setDeleteTarget(null)} - /> - - setSuccessToast(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - {successToast ? ( - setSuccessToast(null)} - severity="success" - variant="filled" - sx={{ width: '100%' }} - > - {successToast} - - ) : undefined} - -
    - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiBuildPipelinePanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiBuildPipelinePanel.tsx deleted file mode 100644 index 655317a0b98..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiBuildPipelinePanel.tsx +++ /dev/null @@ -1,450 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; -import TablePagination from '@mui/material/TablePagination'; -import IconButton from '@mui/material/IconButton'; -import Chip from '@mui/material/Chip'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import Alert from '@mui/material/Alert'; -import Tooltip from '@mui/material/Tooltip'; -import CircularProgress from '@mui/material/CircularProgress'; -import BuildIcon from '@mui/icons-material/Build'; -import PlayArrowIcon from '@mui/icons-material/PlayArrow'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import { useTheme, alpha } from '@mui/material/styles'; -import type { - KagentiBuildListItem, - KagentiBuildInfo, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { augmentApiRef } from '../../../api'; -import { getErrorMessage } from '../../../utils'; -import { - CONTENT_MAX_WIDTH, - TABLE_HEADER_CELL_SX, - tableContainerSx, -} from '../shared/commandCenterStyles'; -import { PanelIntroBanner } from '../shared/PanelIntroBanner'; - -export interface KagentiBuildPipelinePanelProps { - namespace?: string; -} - -const MAX_BUILD_INFO_FETCH = 20; - -function timeAgo(dateStr?: string): string { - if (!dateStr) return '--'; - const diff = Date.now() - new Date(dateStr).getTime(); - if (diff < 0) return 'just now'; - const mins = Math.floor(diff / 60_000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; -} - -function phaseColor( - phase: string | undefined, - palette: { - success: { main: string }; - warning: { main: string }; - error: { main: string }; - info: { main: string }; - text: { secondary: string }; - }, -): string { - const p = (phase ?? '').toLowerCase(); - if (p === 'succeeded') return palette.success.main; - if (p === 'running' || p === 'pending') return palette.info.main; - if (p === 'failed') return palette.error.main; - return palette.text.secondary; -} - -function isTool(b: KagentiBuildListItem): boolean { - return (b.resourceType ?? '').toLowerCase() === 'tool'; -} - -export function KagentiBuildPipelinePanel({ - namespace, -}: KagentiBuildPipelinePanelProps) { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const api = useApi(augmentApiRef); - const [builds, setBuilds] = useState([]); - const [buildInfoMap, setBuildInfoMap] = useState< - Map - >(new Map()); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [triggerKey, setTriggerKey] = useState(null); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(10); - - const paginatedBuilds = useMemo( - () => builds.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage), - [builds, page, rowsPerPage], - ); - - const buildRowKey = useCallback( - (b: KagentiBuildListItem) => `${b.namespace}/${b.name}`, - [], - ); - - const fetchBuildInfos = useCallback( - async (items: KagentiBuildListItem[]) => { - const slice = items.slice(0, MAX_BUILD_INFO_FETCH); - const results = await Promise.allSettled( - slice.map(b => - isTool(b) - ? api.getToolBuildInfo(b.namespace, b.name) - : api.getKagentiBuildInfo(b.namespace, b.name), - ), - ); - const map = new Map(); - results.forEach((r, i) => { - if (r.status === 'fulfilled') { - map.set(`${slice[i].namespace}/${slice[i].name}`, r.value); - } - }); - return map; - }, - [api], - ); - - const loadAll = useCallback(async () => { - setLoading(true); - setError(null); - try { - const res = await api.listKagentiShipwrightBuilds({ - namespace: namespace || undefined, - allNamespaces: !namespace, - }); - const items = res.builds ?? []; - setBuilds(items); - const infos = await fetchBuildInfos(items); - setBuildInfoMap(infos); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setLoading(false); - } - }, [api, namespace, fetchBuildInfos]); - - useEffect(() => { - loadAll(); - }, [loadAll]); - - const handleTrigger = async (b: KagentiBuildListItem) => { - const key = buildRowKey(b); - setTriggerKey(key); - setError(null); - try { - if (isTool(b)) { - await api.triggerToolBuild(b.namespace, b.name); - } else { - await api.triggerKagentiBuild(b.namespace, b.name); - } - await loadAll(); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setTriggerKey(null); - } - }; - - const thStyle = TABLE_HEADER_CELL_SX; - - function renderTableContent() { - if (builds.length === 0) { - return ( - - - - - - No builds found - - - Builds are created when agents or tools are imported via Source - from Git. Import an agent to see build activity here. - - - - - ); - } - return paginatedBuilds.map(b => { - const key = buildRowKey(b); - const info = buildInfoMap.get(key); - const typeLabel = isTool(b) ? 'Tool' : 'Agent'; - const typeColor = isTool(b) - ? theme.palette.info.main - : theme.palette.primary.main; - - const regColor = b.registered - ? theme.palette.success.main - : theme.palette.warning.main; - - const runPhase = info?.buildRunPhase; - const runColor = phaseColor(runPhase, theme.palette); - let runLabel = '...'; - if (info) { - runLabel = info.hasBuildRun ? runPhase || 'Unknown' : 'No runs'; - } - - const failMsg = info?.buildRunFailureMessage; - - return ( - - - - {b.name} - - - - - - - - {b.namespace} - - - - - {b.strategy ?? '--'} - - - - - - {b.gitUrl ?? '--'} - - - - - - - - - - - - - - {timeAgo(b.creationTimestamp)} - - - - - - handleTrigger(b)} - disabled={triggerKey === key} - > - {triggerKey === key ? ( - - ) : ( - - )} - - - - - - ); - }); - } - - return ( - - - - - Build Pipelines show Shipwright builds created when agents or tools - are imported via Source from Git. You can also manually trigger - rebuilds here. - - - - - Build Pipelines - - - View and trigger container image builds for your agents and - tools. - - - - - - {error && ( - setError(null)} - > - {error} - - )} - - {loading ? ( - - - - ) : ( - <> - - - - - Name - Type - Workspace - Strategy - Git URL - Status - Created - - Actions - - - - {renderTableContent()} -
    -
    - {builds.length > rowsPerPage && ( - setPage(p)} - rowsPerPage={rowsPerPage} - onRowsPerPageChange={e => { - setRowsPerPage(parseInt(e.target.value, 10)); - setPage(0); - }} - rowsPerPageOptions={[5, 10, 25]} - sx={{ borderTop: 1, borderColor: 'divider' }} - /> - )} - - )} -
    -
    -
    - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiDashboardLinks.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiDashboardLinks.tsx deleted file mode 100644 index 76581bbc68f..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiDashboardLinks.tsx +++ /dev/null @@ -1,340 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useEffect, useState } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import CardActionArea from '@mui/material/CardActionArea'; -import Alert from '@mui/material/Alert'; -import Skeleton from '@mui/material/Skeleton'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import TimelineIcon from '@mui/icons-material/Timeline'; -import HubIcon from '@mui/icons-material/Hub'; -import SearchIcon from '@mui/icons-material/Search'; -import SyncAltIcon from '@mui/icons-material/SyncAlt'; - -import { useTheme, alpha } from '@mui/material/styles'; -import type { KagentiDashboardConfig } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { augmentApiRef } from '../../../api'; -import { - CONTENT_MAX_WIDTH, - HOVER_TRANSITION, - PAGE_TITLE_SX, - PAGE_SUBTITLE_SX, - SECTION_LABEL_SX, -} from '../shared/commandCenterStyles'; - -function appendNamespaceQueryParam(href: string, namespace?: string): string { - const trimmed = namespace?.trim(); - if (!trimmed) return href; - const sep = href.includes('?') ? '&' : '?'; - return `${href}${sep}namespace=${encodeURIComponent(trimmed)}`; -} - -export interface KagentiDashboardLinksProps { - namespace?: string; -} - -interface LinkDef { - key: keyof KagentiDashboardConfig; - label: string; - description: string; - icon: React.ElementType; - category: 'Observability' | 'Development'; -} - -const LINK_DEFS: LinkDef[] = [ - { - key: 'traces', - label: 'Traces', - description: 'Distributed tracing for agent requests and A2A calls', - icon: TimelineIcon, - category: 'Observability', - }, - { - key: 'network', - label: 'Network', - description: 'Network topology and service mesh visualization', - icon: HubIcon, - category: 'Observability', - }, - { - key: 'mcpInspector', - label: 'MCP Inspector', - description: 'Inspect and debug MCP tool connections', - icon: SearchIcon, - category: 'Development', - }, - { - key: 'mcpProxy', - label: 'MCP Proxy', - description: 'Manage MCP proxy routing and endpoints', - icon: SyncAltIcon, - category: 'Development', - }, -]; - -const CATEGORY_ORDER: Array = [ - 'Observability', - 'Development', -]; - -export function KagentiDashboardLinks({ - namespace, -}: KagentiDashboardLinksProps) { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const api = useApi(augmentApiRef); - const [cfg, setCfg] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - setLoading(true); - api - .getKagentiDashboards() - .then(c => { - if (!cancelled) setCfg(c); - }) - .catch((e: Error) => { - if (!cancelled) setError(e.message); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [api]); - - if (loading) { - return ( - - - - Dashboards - - - {[1, 2, 3].map(i => ( - - ))} - - - - ); - } - - if (error) { - return ( - - - {error} - - - ); - } - - const entries = LINK_DEFS.flatMap(d => { - const href = cfg?.[d.key]; - if (typeof href !== 'string' || !href) return []; - return [{ ...d, href: appendNamespaceQueryParam(href, namespace) }]; - }); - - if (entries.length === 0) { - return ( - - - - Dashboards - - - - No dashboard URLs configured - - - Configure dashboard URLs in the backend settings - - - - - ); - } - - const grouped = CATEGORY_ORDER.map(cat => ({ - category: cat, - items: entries.filter(e => e.category === cat), - })).filter(g => g.items.length > 0); - - return ( - - - - Observability - - - Access dashboards to monitor health, performance, traces, and network - traffic of your deployed agents and tools. - - - - - - {grouped.map(group => ( - - - {group.category} - - - {group.items.map( - ({ key, label, description, href, icon: Icon }) => ( - - - - - - - - - - {label} - - - - - {description} - - - - - - ), - )} - - - ))} - - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiHomeDashboard.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiHomeDashboard.tsx deleted file mode 100644 index acc829a2bdc..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiHomeDashboard.tsx +++ /dev/null @@ -1,918 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useEffect, useState, useCallback, useMemo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Card from '@mui/material/Card'; -import CardActionArea from '@mui/material/CardActionArea'; -import Chip from '@mui/material/Chip'; -import Skeleton from '@mui/material/Skeleton'; -import Alert from '@mui/material/Alert'; -import Collapse from '@mui/material/Collapse'; -import IconButton from '@mui/material/IconButton'; -import Tab from '@mui/material/Tab'; -import Tabs from '@mui/material/Tabs'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; -import { useTheme, alpha } from '@mui/material/styles'; -import HubOutlinedIcon from '@mui/icons-material/HubOutlined'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import WorkspacesOutlinedIcon from '@mui/icons-material/WorkspacesOutlined'; -import NoteAddOutlinedIcon from '@mui/icons-material/NoteAddOutlined'; -import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined'; -import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; -import CloseIcon from '@mui/icons-material/Close'; -import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; -import WarningAmberIcon from '@mui/icons-material/WarningAmber'; -import BuildCircleOutlinedIcon from '@mui/icons-material/BuildCircleOutlined'; -import { useApi } from '@backstage/core-plugin-api'; -import { augmentApiRef } from '../../../api'; -import type { AdminPanel } from '../../../hooks'; -import type { - KagentiAgentSummary, - KagentiToolSummary, - KagentiBuildListItem, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { - CONTENT_MAX_WIDTH, - HOVER_TRANSITION, - PAGE_TITLE_SX, - PAGE_SUBTITLE_SX, - SECTION_LABEL_SX, - TABLE_HEADER_CELL_SX, - subtleBorder, - tableContainerSx, -} from '../shared/commandCenterStyles'; -import { - typeScale, - iconSize, - glassSurface, - borderRadius, - transitions, -} from '../../../theme/tokens'; -import { InfoTip } from '../shared/InfoTip'; -import ExploreOutlinedIcon from '@mui/icons-material/ExploreOutlined'; - -export interface KagentiHomeDashboardProps { - namespace?: string; - onNavigate: (panel: AdminPanel, focusName?: string) => void; - onCreateAgent?: () => void; - onHelpTours?: () => void; -} - -interface DashboardData { - agents: KagentiAgentSummary[]; - tools: KagentiToolSummary[]; - builds: KagentiBuildListItem[]; - namespaceCount: number; -} - -const GETTING_STARTED_KEY = 'augment:kagenti-getting-started-dismissed'; - -function timeAgo(dateStr?: string): string { - if (!dateStr) return '--'; - const diff = Date.now() - new Date(dateStr).getTime(); - if (diff < 0) return 'just now'; - const mins = Math.floor(diff / 60_000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; -} - -function statusColor( - status: string, - palette: { - success: { main: string }; - warning: { main: string }; - error: { main: string }; - text: { secondary: string }; - }, -): string { - const s = status?.toLowerCase() ?? ''; - if (s === 'ready' || s === 'succeeded' || s === 'true') - return palette.success.main; - if (s === 'running' || s === 'pending' || s === 'building') - return palette.warning.main; - if (s === 'failed' || s === 'error' || s === 'crashloopbackoff') - return palette.error.main; - return palette.text.secondary; -} - -type HealthRow = { - name: string; - namespace: string; - kind: 'Agent' | 'Tool'; - status: string; - framework?: string; - createdAt?: string; -}; - -export function KagentiHomeDashboard({ - namespace, - onNavigate, - onCreateAgent, - onHelpTours, -}: KagentiHomeDashboardProps) { - const theme = useTheme(); - const api = useApi(augmentApiRef); - const isDark = theme.palette.mode === 'dark'; - - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [buildError, setBuildError] = useState(null); - const [activeTab, setActiveTab] = useState(0); - const [showGettingStarted, setShowGettingStarted] = useState(() => { - try { - return !localStorage.getItem(GETTING_STARTED_KEY); - } catch { - return true; - } - }); - - const loadData = useCallback(async () => { - setLoading(true); - setError(null); - setBuildError(null); - try { - const [agentsRes, toolsRes, nsRes, buildsRes] = await Promise.all([ - api.listKagentiAgents(namespace || undefined), - api.listKagentiTools(namespace || undefined), - api.listKagentiNamespaces(), - api - .listKagentiShipwrightBuilds(namespace ? { namespace } : undefined) - .catch((e: unknown) => { - setBuildError( - e instanceof Error ? e.message : 'Failed to load builds', - ); - return { builds: [] as KagentiBuildListItem[] }; - }), - ]); - setData({ - agents: agentsRes.agents ?? [], - tools: toolsRes.tools ?? [], - builds: buildsRes.builds ?? [], - namespaceCount: nsRes.namespaces?.length ?? 0, - }); - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to load dashboard'); - } finally { - setLoading(false); - } - }, [api, namespace]); - - useEffect(() => { - loadData(); - }, [loadData]); - - const dismissGettingStarted = () => { - setShowGettingStarted(false); - try { - localStorage.setItem(GETTING_STARTED_KEY, '1'); - } catch { - /* noop */ - } - }; - - const totalAgents = data?.agents.length ?? 0; - const readyAgents = - data?.agents.filter(a => a.status?.toLowerCase() === 'ready').length ?? 0; - const totalTools = data?.tools.length ?? 0; - const readyTools = - data?.tools.filter(t => t.status?.toLowerCase() === 'ready').length ?? 0; - const allAgentsHealthy = totalAgents > 0 && readyAgents === totalAgents; - const allToolsHealthy = totalTools > 0 && readyTools === totalTools; - - const healthRows = useMemo(() => { - const agents: HealthRow[] = (data?.agents ?? []).map(a => ({ - name: a.name, - namespace: a.namespace, - kind: 'Agent', - status: a.status, - framework: a.labels?.framework, - createdAt: a.createdAt, - })); - const tools: HealthRow[] = (data?.tools ?? []).map(t => ({ - name: t.name, - namespace: t.namespace, - kind: 'Tool', - status: t.status, - createdAt: t.createdAt, - })); - const all = [...agents, ...tools]; - all.sort((a, b) => { - const aReady = a.status?.toLowerCase() === 'ready' ? 1 : 0; - const bReady = b.status?.toLowerCase() === 'ready' ? 1 : 0; - if (aReady !== bReady) return aReady - bReady; - const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0; - const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0; - return bTime - aTime; - }); - return all; - }, [data]); - - const recentBuilds = useMemo(() => { - const sorted = [...(data?.builds ?? [])].sort((a, b) => { - const aTime = a.creationTimestamp - ? new Date(a.creationTimestamp).getTime() - : 0; - const bTime = b.creationTimestamp - ? new Date(b.creationTimestamp).getTime() - : 0; - return bTime - aTime; - }); - return sorted.slice(0, 5); - }, [data]); - - const statCards = [ - { - label: 'Total Agents', - value: totalAgents, - icon: , - accent: theme.palette.primary.main, - tip: 'Total number of agents deployed in the selected namespace.', - }, - { - label: 'Ready Agents', - value: `${readyAgents} / ${totalAgents}`, - icon: allAgentsHealthy ? : , - accent: allAgentsHealthy - ? theme.palette.success.main - : theme.palette.warning.main, - tip: "Agents with status 'Ready' — running and accepting requests.", - }, - { - label: 'Ready Tools', - value: `${readyTools} / ${totalTools}`, - icon: allToolsHealthy ? : , - accent: allToolsHealthy - ? theme.palette.success.main - : theme.palette.warning.main, - tip: 'MCP tool servers running and available for agent use.', - }, - { - label: 'Namespaces', - value: data?.namespaceCount ?? 0, - icon: , - accent: theme.palette.info.main, - tip: 'Kubernetes namespaces with deployed agents or tools. Use the sidebar picker to filter.', - }, - ]; - - const createActions = [ - { - label: 'Guided Experience', - description: 'Step-by-step interactive walkthroughs', - icon: , - accent: theme.palette.text.secondary, - panel: 'kagenti-home' as AdminPanel, - action: onHelpTours, - }, - { - label: 'New Agent', - description: 'Import or build a new AI agent', - icon: , - accent: theme.palette.primary.main, - panel: 'kagenti-agents' as AdminPanel, - action: onCreateAgent, - }, - { - label: 'New Tool', - description: 'Register an MCP tool server', - icon: , - accent: theme.palette.info.main, - panel: 'kagenti-tools' as AdminPanel, - }, - ]; - - const gettingStartedSteps = [ - { - step: 1, - title: 'Import an agent', - description: 'Start by importing an agent from the examples repository.', - done: totalAgents > 0, - onClick: () => onCreateAgent?.(), - }, - { - step: 2, - title: 'Chat with it', - description: 'Then chat with it to see it in action.', - done: totalAgents > 0, - onClick: () => onNavigate('kagenti-agents' as AdminPanel), - }, - { - step: 3, - title: 'Monitor & observe', - description: - 'Use the observability dashboards to monitor traces and network traffic.', - done: readyAgents > 0, - onClick: () => onNavigate('kagenti-dashboards' as AdminPanel), - }, - ]; - - const stepsCompleted = gettingStartedSteps.filter(s => s.done).length; - - const thStyle = TABLE_HEADER_CELL_SX; - - function renderLoadingSkeleton() { - return ( - - {[0, 1, 2].map(i => ( - - ))} - - ); - } - - function renderHealthContent() { - if (loading) return renderLoadingSkeleton(); - if (healthRows.length === 0) { - return ( - - - No agents or tools deployed yet. Create one to get started. - - - ); - } - return ( - - - - - Name - Type - Status - Namespace - Framework - Created - - - - {healthRows.map(row => { - return ( - - onNavigate( - (row.kind === 'Agent' - ? 'kagenti-agents' - : 'kagenti-tools') as AdminPanel, - row.name, - ) - } - > - - - {row.name} - - - - - - - - - - - {row.namespace} - - - - - {row.framework || '--'} - - - - - {timeAgo(row.createdAt)} - - - - ); - })} - -
    -
    - ); - } - - function renderBuildsContent() { - if (loading) return renderLoadingSkeleton(); - if (buildError) { - return ( - - - - Failed to load builds - - - {buildError} - - - ); - } - if (recentBuilds.length === 0) { - return ( - - - No builds yet. Trigger a build from an agent or tool to see activity - here. - - - ); - } - return ( - - - - - Build - Namespace - Status - Strategy - Started - - - - {recentBuilds.map(build => { - const regColor = build.registered - ? theme.palette.success.main - : theme.palette.warning.main; - return ( - onNavigate('kagenti-builds' as AdminPanel)} - > - - - - - {build.name} - - - - - - {build.namespace} - - - - - - - - {build.strategy || '--'} - - - - - {timeAgo(build.creationTimestamp)} - - - - ); - })} - -
    -
    - ); - } - - return ( - - {/* Page Title */} - - - Overview - - - Manage, deploy, and observe your AI agents and tools. - - - - {error && ( - setError(null)}> - {error} - - )} - - {/* Stats Row */} - - {statCards.map(card => ( - - - - {card.label} - {card.tip && } - - {card.icon} - - {loading ? ( - - ) : ( - - {card.value} - - )} - - ))} - - - {/* Getting Started */} - - - - - - - Getting Started - - {stepsCompleted} of {gettingStartedSteps.length} completed - - - - - - - - - {gettingStartedSteps.map(step => ( - { - if (e.key === 'Enter' || e.key === ' ') step.onClick?.(); - }} - sx={{ - display: 'flex', - gap: 1.5, - alignItems: 'flex-start', - cursor: 'pointer', - borderRadius: 1.5, - p: 1.5, - mx: -1.5, - transition: 'background-color 0.15s ease', - '&:hover': { - bgcolor: alpha( - theme.palette.text.primary, - isDark ? 0.06 : 0.03, - ), - }, - }} - > - - {step.done ? ( - - ) : ( - step.step - )} - - - - {step.title} - - - {step.description} - - - - - ))} - - - - - {/* Quick Actions */} - - - Quick Actions - - - {createActions.map(action => ( - - - action.action ? action.action() : onNavigate(action.panel) - } - sx={{ - p: 1.5, - display: 'flex', - alignItems: 'center', - gap: 1.5, - }} - > - - {action.icon} - - - - {action.label} - - - {action.description} - - - - - - ))} - - - - {/* Agent & Tool Health + Recent Builds (tabbed) */} - - setActiveTab(v)} - sx={{ - minHeight: 40, - borderBottom: 1, - borderColor: 'divider', - px: 2, - '& .MuiTab-root': { - minHeight: 40, - textTransform: 'none', - fontWeight: 600, - fontSize: typeScale.bodySmall.fontSize, - mr: 2, - }, - }} - > - - - - {activeTab === 0 && renderHealthContent()} - {activeTab === 1 && renderBuildsContent()} - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiPanelUtils.test.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiPanelUtils.test.ts deleted file mode 100644 index 5dba83b7f14..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiPanelUtils.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Tests for pure utility logic used inside KagentiPanels components. - * These functions are defined inline in the panel files but are - * tested here by reimplementing the exact logic to catch regressions. - */ - -describe('KagentiToolsPanel utility logic', () => { - function statusChipColor( - status: string | undefined, - ): 'success' | 'info' | 'error' | 'warning' | 'default' { - if (!status) return 'default'; - const s = status.toLowerCase(); - if (s === 'ready' || s === 'running' || s === 'active') return 'success'; - if (s === 'building' || s === 'pending') return 'info'; - if (s === 'error' || s === 'failed') return 'error'; - if (s === 'warning' || s === 'degraded') return 'warning'; - return 'default'; - } - - function formatDateTime(iso?: string): string { - if (!iso) return '—'; - const d = new Date(iso); - return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); - } - - describe('statusChipColor', () => { - it('returns success for running/ready/active', () => { - expect(statusChipColor('Running')).toBe('success'); - expect(statusChipColor('ready')).toBe('success'); - expect(statusChipColor('ACTIVE')).toBe('success'); - }); - - it('returns info for building/pending', () => { - expect(statusChipColor('Building')).toBe('info'); - expect(statusChipColor('pending')).toBe('info'); - }); - - it('returns error for error/failed', () => { - expect(statusChipColor('Error')).toBe('error'); - expect(statusChipColor('failed')).toBe('error'); - }); - - it('returns warning for warning/degraded', () => { - expect(statusChipColor('warning')).toBe('warning'); - expect(statusChipColor('Degraded')).toBe('warning'); - }); - - it('returns default for undefined', () => { - expect(statusChipColor(undefined)).toBe('default'); - }); - - it('returns default for unknown status', () => { - expect(statusChipColor('unknown')).toBe('default'); - }); - }); - - describe('formatDateTime', () => { - it('returns dash for undefined', () => { - expect(formatDateTime()).toBe('—'); - }); - - it('returns formatted date for valid ISO string', () => { - const result = formatDateTime('2024-01-15T10:30:00Z'); - expect(result).not.toBe('—'); - expect(result).toContain('2024'); - }); - - it('returns raw string for invalid date', () => { - expect(formatDateTime('not-a-date')).toBe('not-a-date'); - }); - }); -}); - -describe('KagentiToolDetailDrawer utility logic', () => { - function toolSummaryStatusChipColor( - status: string | undefined, - ): 'success' | 'warning' | 'default' { - if (!status) return 'default'; - const s = status.toLowerCase(); - if (['running', 'ready', 'active'].includes(s)) return 'success'; - if (['pending', 'building'].includes(s)) return 'warning'; - return 'default'; - } - - describe('toolSummaryStatusChipColor', () => { - it('returns success for running/ready/active', () => { - expect(toolSummaryStatusChipColor('Running')).toBe('success'); - expect(toolSummaryStatusChipColor('ready')).toBe('success'); - }); - - it('returns warning for pending/building', () => { - expect(toolSummaryStatusChipColor('pending')).toBe('warning'); - expect(toolSummaryStatusChipColor('Building')).toBe('warning'); - }); - - it('returns default for undefined', () => { - expect(toolSummaryStatusChipColor(undefined)).toBe('default'); - }); - - it('returns default for unknown status', () => { - expect(toolSummaryStatusChipColor('error')).toBe('default'); - }); - }); -}); - -describe('CreateToolWizard service port logic', () => { - function buildServicePorts( - rows: Array<{ - name: string; - port: string; - targetPort: string; - protocol: string; - }>, - ) { - return rows.filter(r => r.name && r.port && !Number.isNaN(Number(r.port))); - } - - it('filters out rows with empty name', () => { - const result = buildServicePorts([ - { name: '', port: '8080', targetPort: '8080', protocol: 'TCP' }, - ]); - expect(result).toHaveLength(0); - }); - - it('filters out rows with empty port', () => { - const result = buildServicePorts([ - { name: 'http', port: '', targetPort: '8080', protocol: 'TCP' }, - ]); - expect(result).toHaveLength(0); - }); - - it('filters out rows with non-numeric port', () => { - const result = buildServicePorts([ - { name: 'http', port: 'abc', targetPort: '8080', protocol: 'TCP' }, - ]); - expect(result).toHaveLength(0); - }); - - it('keeps valid rows', () => { - const result = buildServicePorts([ - { name: 'http', port: '8080', targetPort: '8080', protocol: 'TCP' }, - { name: 'grpc', port: '50051', targetPort: '50051', protocol: 'TCP' }, - ]); - expect(result).toHaveLength(2); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiPropertyRow.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiPropertyRow.tsx deleted file mode 100644 index e2931a436e3..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiPropertyRow.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ReactNode } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; - -export function PropertyRow({ - label, - value, -}: { - label: string; - value: ReactNode; -}) { - return ( - - - {label} - - - {value ?? '—'} - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiSandboxPanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiSandboxPanel.tsx deleted file mode 100644 index 1554418576e..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiSandboxPanel.tsx +++ /dev/null @@ -1,1839 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - useCallback, - useEffect, - useMemo, - useState, - type ReactNode, -} from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; -import TablePagination from '@mui/material/TablePagination'; -import IconButton from '@mui/material/IconButton'; -import MenuItem from '@mui/material/MenuItem'; -import Select from '@mui/material/Select'; -import FormControl from '@mui/material/FormControl'; -import InputLabel from '@mui/material/InputLabel'; -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import DialogContent from '@mui/material/DialogContent'; -import DialogActions from '@mui/material/DialogActions'; -import Chip from '@mui/material/Chip'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import Tooltip from '@mui/material/Tooltip'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import Switch from '@mui/material/Switch'; -import LinearProgress from '@mui/material/LinearProgress'; -import CheckIcon from '@mui/icons-material/Check'; -import CloseIcon from '@mui/icons-material/Close'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import EditIcon from '@mui/icons-material/Edit'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import FolderIcon from '@mui/icons-material/Folder'; -import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; -import CleaningServicesIcon from '@mui/icons-material/CleaningServices'; -import TimelineIcon from '@mui/icons-material/Timeline'; -import MemoryIcon from '@mui/icons-material/Memory'; -import { useTheme, alpha } from '@mui/material/styles'; -import type { - KagentiSandboxAgentInfo, - KagentiSandboxSession, - KagentiSessionTokenUsage, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { augmentApiRef } from '../../../api'; -import * as kagentiEndpoints from '../../../api/kagentiEndpoints'; -import type { KagentiApiDeps } from '../../../api/kagentiEndpoints'; -import { getErrorMessage } from '../../../utils'; -import { ConfirmDialog } from '../shared/ConfirmDialog'; -import { sectionCardSx, tableContainerSx } from '../shared/commandCenterStyles'; -import { SELECT_MENU_PROPS } from '../shared/selectMenuProps'; -import { PanelIntroBanner } from '../shared/PanelIntroBanner'; - -export interface KagentiSandboxPanelProps { - namespace: string; -} - -const ROWS = 10; - -export function KagentiSandboxPanel({ namespace }: KagentiSandboxPanelProps) { - const theme = useTheme(); - const api = useApi(augmentApiRef); - const kagentiDeps: KagentiApiDeps = useMemo( - () => ({ - fetchJson: ( - api as unknown as { fetchJson: KagentiApiDeps['fetchJson'] } - ).fetchJson.bind(api), - }), - [api], - ); - - const [flagsOk, setFlagsOk] = useState(null); - const [sessions, setSessions] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(0); - const [searchInput, setSearchInput] = useState(''); - const [search, setSearch] = useState(''); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [selected, setSelected] = useState(null); - const [renameOpen, setRenameOpen] = useState(false); - const [renameTitle, setRenameTitle] = useState(''); - const [actionBusy, setActionBusy] = useState(null); - const [killTarget, setKillTarget] = useState( - null, - ); - const [tokenUsage, setTokenUsage] = useState( - null, - ); - const [tokenLoading, setTokenLoading] = useState(false); - const [sandboxAgents, setSandboxAgents] = useState( - [], - ); - const [podStatus, setPodStatus] = useState>({}); - const [podsLoading, setPodsLoading] = useState(false); - - // Session detail + chain + history - const [detailOpen, setDetailOpen] = useState(false); - const [sessionDetail, setSessionDetail] = useState | null>(null); - const [sessionChain, setSessionChain] = useState | null>(null); - const [sessionHistory, setSessionHistory] = useState | null>(null); - const [detailLoading, setDetailLoading] = useState(false); - const [detailTab, setDetailTab] = useState(0); - - // Session lifecycle - const [cleanupOpen, setCleanupOpen] = useState(false); - const [cleanupTtl, setCleanupTtl] = useState('60'); - const [deleteTarget, setDeleteTarget] = - useState(null); - - // Pod metrics + events - const [podMetrics, setPodMetrics] = useState< - Record> - >({}); - const [podEvents, setPodEvents] = useState< - Record> - >({}); - - // File browser - const [fileAgent, setFileAgent] = useState(''); - const [filePath, setFilePath] = useState('/'); - const [fileEntries, setFileEntries] = useState | null>(null); - const [fileContent, setFileContent] = useState | null>(null); - const [fileLoading, setFileLoading] = useState(false); - - // Sidecars - const [sidecarContextId, setSidecarContextId] = useState(''); - const [sidecars, setSidecars] = useState>>([]); - const [sidecarsLoading, setSidecarsLoading] = useState(false); - - // Token tree - const [tokenTree, setTokenTree] = useState | null>( - null, - ); - const [tokenTreeLoading, setTokenTreeLoading] = useState(false); - - // Events timeline - const [eventsContextId, setEventsContextId] = useState(''); - const [events, setEvents] = useState | null>(null); - const [tasks, setTasks] = useState | null>(null); - const [eventsLoading, setEventsLoading] = useState(false); - - // Sandbox agent lifecycle - const [createAgentOpen, setCreateAgentOpen] = useState(false); - const [createAgentName, setCreateAgentName] = useState(''); - const [createAgentImage, setCreateAgentImage] = useState(''); - const [agentConfig, setAgentConfig] = useState | null>(null); - const [agentConfigTarget, setAgentConfigTarget] = useState(''); - - // Top-level tab - const [mainTab, setMainTab] = useState(0); - - useEffect(() => { - let cancelled = false; - api - .getKagentiFeatureFlags() - .then(f => { - if (!cancelled) setFlagsOk(f.sandbox); - }) - .catch(() => { - if (!cancelled) setFlagsOk(false); - }); - return () => { - cancelled = true; - }; - }, [api]); - - const loadSessions = useCallback(() => { - if (!namespace) return; - setLoading(true); - setError(null); - kagentiEndpoints - .listSandboxSessions(kagentiDeps, namespace, { - limit: ROWS, - offset: page * ROWS, - search: search || undefined, - }) - .then(res => { - setSessions(res.sessions ?? []); - setTotal( - typeof res.total === 'number' - ? res.total - : (res.sessions ?? []).length, - ); - }) - .catch(e => setError(getErrorMessage(e))) - .finally(() => setLoading(false)); - }, [namespace, page, search, kagentiDeps]); - - useEffect(() => { - if (flagsOk) loadSessions(); - }, [flagsOk, loadSessions]); - - const loadTokenUsage = useCallback( - async (s: KagentiSandboxSession) => { - setTokenLoading(true); - setError(null); - try { - const u = await kagentiEndpoints.getSessionTokenUsage( - kagentiDeps, - namespace, - s.contextId, - ); - setTokenUsage(u); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setTokenLoading(false); - } - }, - [namespace, kagentiDeps], - ); - - const loadPods = useCallback(() => { - if (!namespace) return; - setPodsLoading(true); - setError(null); - kagentiEndpoints - .listSandboxAgents(kagentiDeps, namespace) - .then(res => { - setSandboxAgents(res.agents ?? []); - setPodStatus({}); - }) - .catch(e => setError(getErrorMessage(e))) - .finally(() => setPodsLoading(false)); - }, [namespace, kagentiDeps]); - - useEffect(() => { - if (flagsOk && namespace) loadPods(); - }, [flagsOk, namespace, loadPods]); - - useEffect(() => { - if (selected) loadTokenUsage(selected); - else setTokenUsage(null); - }, [selected, loadTokenUsage]); - - const refreshPodForAgent = async (name: string) => { - setActionBusy(`pod:${name}`); - try { - const st = await kagentiEndpoints.getSandboxAgentPodStatus( - kagentiDeps, - namespace, - name, - ); - setPodStatus(prev => ({ ...prev, [name]: st })); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - const openSessionDetail = useCallback( - async (s: KagentiSandboxSession) => { - setSelected(s); - setDetailOpen(true); - setDetailLoading(true); - setDetailTab(0); - try { - const [detail, chain, history] = await Promise.allSettled([ - kagentiEndpoints.getSandboxSession( - kagentiDeps, - namespace, - s.contextId, - ), - kagentiEndpoints.getSandboxSessionChain( - kagentiDeps, - namespace, - s.contextId, - ), - kagentiEndpoints.getSandboxSessionHistory( - kagentiDeps, - namespace, - s.contextId, - ), - ]); - setSessionDetail( - detail.status === 'fulfilled' - ? (detail.value as unknown as Record) - : null, - ); - setSessionChain(chain.status === 'fulfilled' ? chain.value : null); - setSessionHistory( - history.status === 'fulfilled' ? history.value : null, - ); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setDetailLoading(false); - } - }, - [namespace, kagentiDeps], - ); - - const handleDeleteSession = async (s: KagentiSandboxSession) => { - setActionBusy(`del:${s.contextId}`); - setError(null); - try { - await kagentiEndpoints.deleteSandboxSession( - kagentiDeps, - namespace, - s.contextId, - ); - loadSessions(); - if (selected?.contextId === s.contextId) setSelected(null); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - setDeleteTarget(null); - } - }; - - const handleCleanup = async () => { - setActionBusy('cleanup'); - setError(null); - try { - await kagentiEndpoints.cleanupSandboxSessions( - kagentiDeps, - namespace, - Number(cleanupTtl) || 60, - ); - loadSessions(); - setCleanupOpen(false); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - const loadPodMetrics = async (name: string) => { - setActionBusy(`metrics:${name}`); - try { - const m = await kagentiEndpoints.getSandboxAgentMetrics( - kagentiDeps, - namespace, - name, - ); - setPodMetrics(prev => ({ ...prev, [name]: m })); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - const loadPodEvents = async (name: string) => { - setActionBusy(`events:${name}`); - try { - const ev = await kagentiEndpoints.getSandboxAgentEvents( - kagentiDeps, - namespace, - name, - ); - setPodEvents(prev => ({ ...prev, [name]: ev })); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - const loadDirectory = async () => { - if (!fileAgent) return; - setFileLoading(true); - setFileContent(null); - try { - const entries = await kagentiEndpoints.listSandboxDirectory( - kagentiDeps, - namespace, - fileAgent, - filePath, - ); - setFileEntries(entries); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setFileLoading(false); - } - }; - - const loadFileContent = async (path: string) => { - if (!fileAgent) return; - setFileLoading(true); - try { - const content = await kagentiEndpoints.getSandboxFileContent( - kagentiDeps, - namespace, - fileAgent, - path, - ); - setFileContent(content); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setFileLoading(false); - } - }; - - const loadSidecars = async () => { - if (!sidecarContextId) return; - setSidecarsLoading(true); - try { - const res = await kagentiEndpoints.listSidecars( - kagentiDeps, - namespace, - sidecarContextId, - ); - setSidecars( - (res.sidecars ?? []) as unknown as Array>, - ); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setSidecarsLoading(false); - } - }; - - const toggleSidecar = async (sidecarType: string, enabled: boolean) => { - setActionBusy(`sidecar:${sidecarType}`); - try { - if (enabled) { - await kagentiEndpoints.enableSidecar( - kagentiDeps, - namespace, - sidecarContextId, - sidecarType, - ); - } else { - await kagentiEndpoints.disableSidecar( - kagentiDeps, - namespace, - sidecarContextId, - sidecarType, - ); - } - await loadSidecars(); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - const resetSidecarHandler = async (sidecarType: string) => { - setActionBusy(`sidecar-reset:${sidecarType}`); - try { - await kagentiEndpoints.resetSidecar( - kagentiDeps, - namespace, - sidecarContextId, - sidecarType, - ); - await loadSidecars(); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - const loadTokenTree = async (contextId: string) => { - setTokenTreeLoading(true); - try { - const tree = await kagentiEndpoints.getSessionTreeUsage( - kagentiDeps, - namespace, - contextId, - ); - setTokenTree(tree); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setTokenTreeLoading(false); - } - }; - - const loadEvents = async () => { - if (!eventsContextId) return; - setEventsLoading(true); - try { - const [ev, tk] = await Promise.allSettled([ - kagentiEndpoints.getSandboxEvents( - kagentiDeps, - namespace, - eventsContextId, - { limit: 50 }, - ), - kagentiEndpoints.getSandboxTasksPaginated( - kagentiDeps, - namespace, - eventsContextId, - { limit: 50 }, - ), - ]); - setEvents(ev.status === 'fulfilled' ? ev.value : null); - setTasks(tk.status === 'fulfilled' ? tk.value : null); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setEventsLoading(false); - } - }; - - const runSessionAction = async (key: string, fn: () => Promise) => { - setActionBusy(key); - setError(null); - try { - await fn(); - loadSessions(); - if (selected) loadTokenUsage(selected); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - const openRename = (s: KagentiSandboxSession) => { - setSelected(s); - setRenameTitle(s.title ?? ''); - setRenameOpen(true); - }; - - const submitRename = async () => { - if (!selected) return; - setActionBusy('rename'); - setError(null); - try { - await kagentiEndpoints.renameSandboxSession( - kagentiDeps, - namespace, - selected.contextId, - renameTitle, - ); - loadSessions(); - setRenameOpen(false); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - if (flagsOk === null) { - return ( - - - - ); - } - - if (!flagsOk) { - return ( - - Sandbox admin is disabled for this deployment (feature flag). - - ); - } - - if (!namespace) { - return ( - Select a namespace to list sessions. - ); - } - - let tokenUsagePanel: ReactNode; - if (!selected) { - tokenUsagePanel = ( - - Select a session to view token usage. - - ); - } else if (tokenLoading) { - tokenUsagePanel = ; - } else if (tokenUsage) { - tokenUsagePanel = ( - - - Total tokens: {tokenUsage.totalTokens ?? '—'} · Cost:{' '} - {tokenUsage.totalCost ?? '—'} - - {(tokenUsage.models ?? []).map(m => ( - - {m.model}: {m.tokens} tokens - {m.cost !== undefined && m.cost !== null ? ` ($${m.cost})` : ''} - - ))} - - ); - } else { - tokenUsagePanel = ( - - No usage data. - - ); - } - - const jsonBlock = (data: unknown) => ( - - {JSON.stringify(data, null, 2)} - - ); - - const sectionCard = ( - title: string, - actions: ReactNode | undefined, - children: ReactNode, - ) => ( - - - - {title} - - {actions} - - {children} - - ); - - // ---- Sessions tab content ---- - const sessionsTab = ( - <> - - setSearchInput(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') { - setPage(0); - setSearch(searchInput); - } - }} - sx={{ minWidth: 200 }} - /> - - - - - - {loading ? ( - - - - ) : ( - <> - - - - - Title - Context - Agent - Status - Visibility - Actions - - - - {sessions.length === 0 && ( - - - - No sessions found - - - - )} - {sessions.map(s => ( - setSelected(s)} - > - {s.title ?? '—'} - - - {s.contextId} - - - {s.agentName ?? '—'} - - - - {s.visibility ?? '—'} - e.stopPropagation()}> - - openSessionDetail(s)} - > - - - - - openRename(s)} - > - - - - - - - runSessionAction(`ap:${s.contextId}`, () => - kagentiEndpoints.approveSandboxSession( - kagentiDeps, - namespace, - s.contextId, - ), - ) - } - > - - - - - - - - runSessionAction(`dn:${s.contextId}`, () => - kagentiEndpoints.denySandboxSession( - kagentiDeps, - namespace, - s.contextId, - ), - ) - } - > - - - - - - setDeleteTarget(s)} - > - - - - - setKillTarget(s)} - > - - - - - Visibility - - - - - ))} - -
    -
    - setPage(p)} - rowsPerPage={ROWS} - rowsPerPageOptions={[ROWS]} - onRowsPerPageChange={() => {}} - /> - - )} - - - - Token usage - {selected && ( - - - - - )} - - {tokenUsagePanel} - {tokenTree && ( - - - Token Usage Tree - - {jsonBlock(tokenTree)} - - - )} - - - ); - - // ---- Pod Observability tab ---- - const podsTab = ( - <> - - Sandbox Agents - - - {/* eslint-disable-next-line no-nested-ternary */} - {podsLoading ? ( - - - - ) : sandboxAgents.length === 0 ? ( - - No sandbox agents. - - ) : ( - - - - - Name - Sessions - Pod Status - Metrics - Events - - - - {sandboxAgents.map(a => ( - - - - {a.name} - - - {a.sessionCount ?? '—'} - - - {Object.prototype.hasOwnProperty.call(podStatus, a.name) && - (() => { - const ps = podStatus[a.name] as Record< - string, - unknown - > | null; - if (!ps) - return ( - - No data - - ); - const phase = String(ps.phase ?? ps.status ?? '—'); - const restarts = ps.restartCount ?? ps.restarts; - const colorMap: Record = { - Running: 'success', - Pending: 'warning', - Failed: 'error', - }; - return ( - - - {restarts !== undefined && ( - - )} - - ); - })()} - - - - {podMetrics[a.name] && jsonBlock(podMetrics[a.name])} - - - - {podEvents[a.name] && jsonBlock(podEvents[a.name])} - - - ))} - -
    -
    - )} - - ); - - // ---- File Browser tab ---- - const fileTab = ( - <> - - - Agent - - - setFilePath(e.target.value)} - sx={{ minWidth: 200 }} - /> - - - {fileLoading && } - {fileEntries && ( - - {(() => { - const entries = - (fileEntries as Record).entries ?? - (fileEntries as Record).files ?? - []; - if (!Array.isArray(entries) || entries.length === 0) - return ( - - Empty directory - - ); - return entries.map( - (entry: Record, idx: number) => { - const name = String( - entry.name ?? entry.path ?? entry.filename ?? '', - ); - const isDir = - entry.type === 'directory' || entry.isDirectory === true; - return ( - { - const newPath = filePath.endsWith('/') - ? `${filePath}${name}` - : `${filePath}/${name}`; - if (isDir) { - setFilePath(newPath); - setFileContent(null); - loadDirectory(); - } else loadFileContent(newPath); - }} - > - {isDir ? ( - - ) : ( - - )} - {name} - {entry.size !== undefined && ( - - {String(entry.size)} - - )} - - ); - }, - ); - })()} - - )} - {fileContent && ( - - - - File content - - - - {jsonBlock(fileContent)} - - )} - - ); - - // ---- Sidecars tab ---- - const sidecarsTab = ( - <> - - setSidecarContextId(e.target.value)} - sx={{ minWidth: 240 }} - placeholder="Enter a session context ID" - /> - - - {sidecarsLoading && } - {sidecars.length > 0 && ( - - - - - Type - Status - Enabled - Actions - - - - {sidecars.map((sc, idx) => { - const scType = String(sc.type ?? sc.name ?? `sidecar-${idx}`); - const isEnabled = - sc.enabled === true || sc.status === 'enabled'; - return ( - - - - {scType} - - - - - - - - toggleSidecar(scType, checked) - } - /> - - - - - - ); - })} - -
    -
    - )} - {!sidecarsLoading && sidecars.length === 0 && sidecarContextId && ( - - No sidecars loaded. Click "Load sidecars" to fetch. - - )} - - ); - - // ---- Sandbox Agent Lifecycle tab ---- - const handleCreateSandbox = async () => { - if (!createAgentName) return; - setActionBusy('create-sandbox'); - setError(null); - try { - await kagentiEndpoints.createSandbox(kagentiDeps, namespace, { - name: createAgentName, - image: createAgentImage || undefined, - } as import('@red-hat-developer-hub/backstage-plugin-augment-common').KagentiSandboxCreateRequest); - loadPods(); - setCreateAgentOpen(false); - setCreateAgentName(''); - setCreateAgentImage(''); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - const handleDeleteSandbox = async (name: string) => { - setActionBusy(`del-sandbox:${name}`); - setError(null); - try { - await kagentiEndpoints.deleteSandbox(kagentiDeps, namespace, name); - loadPods(); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - const loadAgentConfig = async (name: string) => { - setActionBusy(`config:${name}`); - try { - const cfg = await kagentiEndpoints.getSandboxConfig( - kagentiDeps, - namespace, - name, - ); - setAgentConfig(cfg); - setAgentConfigTarget(name); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setActionBusy(null); - } - }; - - const agentLifecycleTab = ( - <> - - Sandbox Agent Lifecycle - - - - - - {/* eslint-disable-next-line no-nested-ternary */} - {podsLoading ? ( - - - - ) : sandboxAgents.length === 0 ? ( - - No sandbox agents found. - - ) : ( - - - - - Name - Sessions - Actions - - - - {sandboxAgents.map(a => ( - - - - {a.name} - - - {a.sessionCount ?? '—'} - - - - - - ))} - -
    -
    - )} - {agentConfig && agentConfigTarget && ( - - - - Config for {agentConfigTarget} - - - - {jsonBlock(agentConfig)} - - )} - setCreateAgentOpen(false)} - fullWidth - maxWidth="xs" - > - Create Sandbox Agent - - setCreateAgentName(e.target.value)} - sx={{ mt: 1, mb: 2 }} - /> - setCreateAgentImage(e.target.value)} - placeholder="e.g. quay.io/..." - /> - - - - - - - - ); - - // ---- Events timeline tab ---- - const eventsTab = ( - <> - - setEventsContextId(e.target.value)} - sx={{ minWidth: 240 }} - placeholder="Enter a session context ID" - /> - - - {eventsLoading && } - {events && sectionCard('Events', undefined, jsonBlock(events))} - {tasks && sectionCard('Tasks', undefined, jsonBlock(tasks))} - - ); - - return ( - - - - The Sandbox provides isolated environments to test agents before - promoting them to production. Create sessions, inspect conversations, - and validate behavior. - - - Sandbox Management - - - {error && ( - setError(null)}> - {error} - - )} - - setMainTab(v)} - sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }} - variant="scrollable" - scrollButtons="auto" - > - - - - - - - - - {mainTab === 0 && sessionsTab} - {mainTab === 1 && agentLifecycleTab} - {mainTab === 2 && podsTab} - {mainTab === 3 && fileTab} - {mainTab === 4 && sidecarsTab} - {mainTab === 5 && eventsTab} - - {/* Session detail dialog */} - setDetailOpen(false)} - fullWidth - maxWidth="md" - > - - Session Detail — {selected?.contextId ?? ''} - - - {detailLoading ? ( - - - - ) : ( - <> - setDetailTab(v)} - sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }} - > - - - - - {detailTab === 0 && - (sessionDetail ? ( - jsonBlock(sessionDetail) - ) : ( - - No detail data - - ))} - {detailTab === 1 && - (sessionChain ? ( - jsonBlock(sessionChain) - ) : ( - - No chain data - - ))} - {detailTab === 2 && - (sessionHistory ? ( - jsonBlock(sessionHistory) - ) : ( - - No history data - - ))} - - )} - - - - - - - {/* Rename dialog */} - setRenameOpen(false)} - fullWidth - maxWidth="xs" - > - Rename session - - setRenameTitle(e.target.value)} - sx={{ mt: 1 }} - /> - - - - - - - - {/* Bulk cleanup dialog */} - setCleanupOpen(false)} - fullWidth - maxWidth="xs" - > - Bulk Session Cleanup - - - Remove sessions older than the specified TTL (minutes). This - action cannot be undone. - - setCleanupTtl(e.target.value)} - /> - - - - - - - - {/* Delete session confirm */} - { - if (deleteTarget) await handleDeleteSession(deleteTarget); - }} - onCancel={() => setDeleteTarget(null)} - /> - - {/* Kill session confirm */} - { - const t = killTarget; - if (!t) return; - await runSessionAction(`kill:${t.contextId}`, () => - kagentiEndpoints.killSandboxSession( - kagentiDeps, - namespace, - t.contextId, - ), - ); - setKillTarget(null); - if (selected?.contextId === t.contextId) setSelected(null); - }} - onCancel={() => setKillTarget(null)} - /> - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolDetailDrawer.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolDetailDrawer.test.tsx deleted file mode 100644 index cd7d006d105..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolDetailDrawer.test.tsx +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render, screen, waitFor } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -// eslint-disable-next-line @backstage/no-undeclared-imports -import { TestApiProvider } from '@backstage/test-utils'; -import { augmentApiRef, type AugmentApi } from '../../../api'; -import { KagentiToolDetailDrawer } from './KagentiToolDetailDrawer'; -import type { KagentiToolSummary } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -const theme = createTheme(); - -const MOCK_TOOL: KagentiToolSummary = { - name: 'weather-tool', - namespace: 'team-a', - description: 'Fetches weather data', - status: 'Running', - labels: { protocol: ['mcp'], framework: 'Python' }, - workloadType: 'deployment', - createdAt: '2025-01-15T10:00:00Z', -}; - -function createMockApi(overrides: Partial> = {}) { - return { - getKagentiTool: jest.fn().mockResolvedValue({ - metadata: {}, - spec: { - protocol: 'mcp', - framework: 'Python', - template: { - spec: { - containers: [ - { - image: 'quay.io/weather:v1', - env: [ - { name: 'API_KEY', value: 'test-key' }, - { - name: 'DB_PASS', - valueFrom: { - secretKeyRef: { name: 'db-secret', key: 'pass' }, - }, - }, - ], - ports: [ - { - name: 'http', - containerPort: 8080, - protocol: 'TCP', - }, - ], - }, - ], - }, - }, - }, - status: {}, - }), - getToolRouteStatus: jest.fn().mockResolvedValue({ - ready: true, - status: 'active', - }), - getToolBuildInfo: jest.fn().mockRejectedValue(new Error('not found')), - connectKagentiTool: jest.fn().mockResolvedValue({ tools: [] }), - invokeKagentiTool: jest.fn().mockResolvedValue({ result: 'ok' }), - triggerToolBuild: jest.fn().mockResolvedValue({}), - finalizeToolBuild: jest.fn().mockResolvedValue({}), - ...overrides, - }; -} - -function renderDrawer( - tool: KagentiToolSummary | null = MOCK_TOOL, - open = true, - apiOverrides: Partial> = {}, -) { - const onClose = jest.fn(); - const api = createMockApi(apiOverrides); - const result = render( - - - - - , - ); - return { ...result, onClose, api }; -} - -describe('KagentiToolDetailDrawer — rendering', () => { - it('renders tool name and namespace', async () => { - renderDrawer(); - expect(screen.getByText('weather-tool')).toBeInTheDocument(); - expect(screen.getByText('team-a')).toBeInTheDocument(); - }); - - it('renders status chip', () => { - renderDrawer(); - expect(screen.getByText('Running')).toBeInTheDocument(); - }); - - it('renders protocol and framework badges', () => { - renderDrawer(); - expect(screen.getByText('MCP')).toBeInTheDocument(); - expect(screen.getByText('Python')).toBeInTheDocument(); - }); - - it('renders nothing when tool is null', () => { - const { container } = renderDrawer(null); - expect(container.querySelector('.MuiDrawer-root')).toBeNull(); - }); - - it('renders nothing when closed', () => { - renderDrawer(MOCK_TOOL, false); - expect(screen.queryByText('weather-tool')).not.toBeInTheDocument(); - }); -}); - -describe('KagentiToolDetailDrawer — detail loading', () => { - it('loads tool detail from API', async () => { - const { api } = renderDrawer(); - await waitFor(() => { - expect(api.getKagentiTool).toHaveBeenCalledWith('team-a', 'weather-tool'); - }); - }); - - it('loads route status from API', async () => { - const { api } = renderDrawer(); - await waitFor(() => { - expect(api.getToolRouteStatus).toHaveBeenCalledWith( - 'team-a', - 'weather-tool', - ); - }); - }); -}); - -describe('KagentiToolDetailDrawer — spec display', () => { - it('shows spec fields after loading', async () => { - renderDrawer(); - await waitFor(() => { - expect(screen.getByText('quay.io/weather:v1')).toBeInTheDocument(); - }); - }); - - it('shows env vars as expanded list', async () => { - renderDrawer(); - await waitFor(() => { - expect(screen.getByText(/API_KEY/)).toBeInTheDocument(); - expect(screen.getByText(/test-key/)).toBeInTheDocument(); - expect(screen.getByText(/DB_PASS/)).toBeInTheDocument(); - }); - }); - - it('shows service ports as expanded list', async () => { - renderDrawer(); - await waitFor(() => { - expect(screen.getByText(/http.*8080/)).toBeInTheDocument(); - }); - }); -}); - -describe('KagentiToolDetailDrawer — overview', () => { - it('shows description', () => { - renderDrawer(); - expect(screen.getByText('Fetches weather data')).toBeInTheDocument(); - }); - - it('shows workload type', () => { - renderDrawer(); - expect(screen.getByText('deployment')).toBeInTheDocument(); - }); -}); - -describe('KagentiToolDetailDrawer — route status', () => { - it('shows route status chips', async () => { - renderDrawer(); - await waitFor(() => { - expect(screen.getByText(/Route ready/)).toBeInTheDocument(); - }); - }); -}); - -describe('KagentiToolDetailDrawer — error handling', () => { - it('shows error when detail load fails', async () => { - renderDrawer(MOCK_TOOL, true, { - getKagentiTool: jest.fn().mockRejectedValue(new Error('Load failed')), - }); - await waitFor(() => { - expect(screen.getByText(/Load failed/i)).toBeInTheDocument(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolDetailDrawer.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolDetailDrawer.tsx deleted file mode 100644 index 92b6cfaad26..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolDetailDrawer.tsx +++ /dev/null @@ -1,317 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useEffect, useState } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import Box from '@mui/material/Box'; -import Drawer from '@mui/material/Drawer'; -import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; -import IconButton from '@mui/material/IconButton'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import Alert from '@mui/material/Alert'; -import CloseIcon from '@mui/icons-material/Close'; -import { useTheme, alpha } from '@mui/material/styles'; -import type { - KagentiMcpToolSchema, - KagentiToolSummary, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { augmentApiRef } from '../../../api'; -import { getErrorMessage } from '../../../utils'; -import { PropertyRow } from './KagentiPropertyRow'; -import { ToolBuildSection } from './ToolBuildSection'; -import { ToolMcpSection } from './ToolMcpSection'; -import { ToolRouteSection } from './ToolRouteSection'; -import { ToolSpecSection } from './ToolSpecSection'; -import { - formatDateTime, - toolSummaryStatusChipColor, -} from './kagentiDisplayUtils'; -import { useKagentiToolDetail } from './useKagentiToolDetail'; -import { useToolInvoke } from './useToolInvoke'; - -export interface KagentiToolDetailDrawerProps { - open: boolean; - tool: KagentiToolSummary | null; - onClose: () => void; -} - -export function KagentiToolDetailDrawer({ - open, - tool, - onClose, -}: KagentiToolDetailDrawerProps) { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const api = useApi(augmentApiRef); - - const detailState = useKagentiToolDetail(open, tool, api); - const invoke = useToolInvoke(api); - const { resetInvoke } = invoke; - - const [mcpTools, setMcpTools] = useState([]); - const [mcpDiscovering, setMcpDiscovering] = useState(false); - const [mcpError, setMcpError] = useState(null); - - useEffect(() => { - if (!open || !tool) { - setMcpTools([]); - setMcpDiscovering(false); - setMcpError(null); - resetInvoke(); - } - }, [open, tool, resetInvoke]); - - const handleDiscoverMcp = async () => { - if (!tool) return; - setMcpDiscovering(true); - setMcpError(null); - try { - const res = await api.connectKagentiTool(tool.namespace, tool.name); - setMcpTools(res.tools ?? []); - } catch (e) { - setMcpError(getErrorMessage(e)); - } finally { - setMcpDiscovering(false); - } - }; - - if (!tool) { - return null; - } - - const spec = detailState.detail?.spec as Record | undefined; - - const overviewWorkload = - detailState.detail?.workloadType ?? tool.workloadType ?? '—'; - const overviewDescription = tool.description || '—'; - const HIDDEN_LABEL_KEYS = new Set(['type', 'protocol', 'framework']); - const labelEntries = Object.entries(tool.labels ?? {}).filter( - ([k, v]) => v && !HIDDEN_LABEL_KEYS.has(k), - ); - - const sectionCardSx = { - mb: 2, - border: 1, - borderColor: 'divider', - borderRadius: 1, - bgcolor: 'background.paper', - }; - - return ( - - - - - - - {tool.name} - - - {tool.namespace} - - - - {tool.labels?.protocol && ( - - )} - {tool.labels?.framework && ( - - )} - - - - - - - - Overview - - - - - - Labels - - {labelEntries.length === 0 ? ( - - — - - ) : ( - - {labelEntries.map(([k, v]) => ( - - ))} - - )} - - - - - - - - - Route status - - - - - - - - - Spec - - - - - - {detailState.hasBuild !== false && ( - - - - Build pipeline - - {detailState.buildActionError && ( - detailState.setBuildActionError(null)} - > - {detailState.buildActionError} - - )} - - - - )} - - - - - MCP tools - - setMcpError(null)} - invoke={invoke} - /> - - - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolsPanel.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolsPanel.test.tsx deleted file mode 100644 index 8b052e206f8..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolsPanel.test.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -// eslint-disable-next-line @backstage/no-undeclared-imports -import { TestApiProvider } from '@backstage/test-utils'; -import { augmentApiRef, type AugmentApi } from '../../../api'; -import { KagentiToolsPanel } from './KagentiToolsPanel'; -import type { KagentiToolSummary } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -const theme = createTheme(); - -const MOCK_TOOLS: KagentiToolSummary[] = [ - { - name: 'weather-tool', - namespace: 'team-a', - description: 'Fetches weather data', - status: 'Running', - labels: { protocol: ['mcp'], framework: 'Python' }, - workloadType: 'deployment', - createdAt: '2025-01-15T10:00:00Z', - }, - { - name: 'db-tool', - namespace: 'team-b', - description: 'Database queries', - status: 'Pending', - labels: { protocol: ['http'] }, - workloadType: 'statefulset', - createdAt: '2025-01-10T08:00:00Z', - }, -]; - -function createMockApi(overrides: Partial> = {}) { - return { - listKagentiTools: jest.fn().mockResolvedValue({ tools: MOCK_TOOLS }), - deleteKagentiTool: jest.fn().mockResolvedValue(undefined), - connectKagentiTool: jest.fn().mockResolvedValue({ tools: [] }), - invokeKagentiTool: jest.fn().mockResolvedValue({ result: 'ok' }), - listKagentiBuildStrategies: jest.fn().mockResolvedValue({ strategies: [] }), - listKagentiNamespaces: jest.fn().mockResolvedValue({ namespaces: [] }), - createKagentiTool: jest.fn().mockResolvedValue({ - success: true, - name: 'new', - namespace: 'ns', - message: 'ok', - }), - getKagentiTool: jest - .fn() - .mockResolvedValue({ metadata: {}, spec: {}, status: {} }), - getToolRouteStatus: jest.fn().mockResolvedValue({}), - getToolBuildInfo: jest.fn().mockRejectedValue(new Error('not found')), - ...overrides, - }; -} - -function renderPanel(apiOverrides: Partial> = {}) { - const api = createMockApi(apiOverrides); - const result = render( - - - - - , - ); - return { ...result, api }; -} - -describe('KagentiToolsPanel — rendering', () => { - it('renders heading and tool list', async () => { - renderPanel(); - expect(screen.getByText('Tools')).toBeInTheDocument(); - await waitFor(() => { - expect(screen.getByText('weather-tool')).toBeInTheDocument(); - }); - }); - - it('shows tool table with all tools', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('weather-tool')).toBeInTheDocument(); - expect(screen.getByText('db-tool')).toBeInTheDocument(); - }); - }); - - it('displays description column', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Fetches weather data')).toBeInTheDocument(); - expect(screen.getByText('Database queries')).toBeInTheDocument(); - }); - }); - - it('displays protocol and framework chips', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('MCP')).toBeInTheDocument(); - expect(screen.getByText('Python')).toBeInTheDocument(); - expect(screen.getByText('HTTP')).toBeInTheDocument(); - }); - }); - - it('displays status chips', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Running')).toBeInTheDocument(); - expect(screen.getByText('Pending')).toBeInTheDocument(); - }); - }); -}); - -describe('KagentiToolsPanel — empty state', () => { - it('shows empty state when no tools', async () => { - renderPanel({ - listKagentiTools: jest.fn().mockResolvedValue({ tools: [] }), - }); - await waitFor(() => { - expect(screen.getByText('No tools found')).toBeInTheDocument(); - expect( - screen.getByText(/Create your first MCP tool/i), - ).toBeInTheDocument(); - }); - }); -}); - -describe('KagentiToolsPanel — search', () => { - it('filters by name', async () => { - renderPanel(); - const user = userEvent.setup(); - - await waitFor(() => { - expect(screen.getByText('weather-tool')).toBeInTheDocument(); - }); - - const searchInput = screen.getByPlaceholderText(/Search tools/i); - await user.type(searchInput, 'weather'); - - expect(screen.getByText('weather-tool')).toBeInTheDocument(); - expect(screen.queryByText('db-tool')).not.toBeInTheDocument(); - }); - - it('filters by description', async () => { - renderPanel(); - const user = userEvent.setup(); - - await waitFor(() => { - expect(screen.getByText('weather-tool')).toBeInTheDocument(); - }); - - const searchInput = screen.getByPlaceholderText(/Search tools/i); - await user.type(searchInput, 'Database'); - - expect(screen.getByText('db-tool')).toBeInTheDocument(); - expect(screen.queryByText('weather-tool')).not.toBeInTheDocument(); - }); -}); - -describe('KagentiToolsPanel — error handling', () => { - it('shows error alert on load failure', async () => { - renderPanel({ - listKagentiTools: jest.fn().mockRejectedValue(new Error('Network error')), - }); - await waitFor(() => { - expect(screen.getByText(/Network error/i)).toBeInTheDocument(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolsPanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolsPanel.tsx deleted file mode 100644 index 480f4c53b90..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/KagentiToolsPanel.tsx +++ /dev/null @@ -1,494 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; -import TablePagination from '@mui/material/TablePagination'; -import TableSortLabel from '@mui/material/TableSortLabel'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import TextField from '@mui/material/TextField'; -import Chip from '@mui/material/Chip'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import LinkIcon from '@mui/icons-material/Link'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import SearchIcon from '@mui/icons-material/Search'; -import BuildIcon from '@mui/icons-material/Build'; -import InputAdornment from '@mui/material/InputAdornment'; -import { useTheme } from '@mui/material/styles'; -import type { KagentiToolSummary } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { augmentApiRef } from '../../../api'; -import { getErrorMessage } from '../../../utils'; -import { ConfirmDialog } from '../shared/ConfirmDialog'; -import { KagentiToolDetailDrawer } from './KagentiToolDetailDrawer'; -import { CreateToolWizard } from './CreateToolWizard'; -import { ToolCreateIntentDialog } from './ToolCreateIntentDialog'; -import { - statusChipColor as toolStatusColor, - formatDateTime, -} from './kagentiDisplayUtils'; -import { ToolConnectDialog } from './ToolConnectDialog'; -import { ToolInvokeDialog } from './ToolInvokeDialog'; -import { useKagentiToolsList, type SortField } from './useKagentiToolsList'; -import { useToolInvoke } from './useToolInvoke'; -import { - CONTENT_MAX_WIDTH, - PAGE_TITLE_SX, - PAGE_SUBTITLE_SX, - TABLE_HEADER_CELL_SX, - tableContainerSx, - emptyStateSx, -} from '../shared/commandCenterStyles'; - -export interface ToolPanelTourControl { - openIntent: () => void; - closeIntent: () => void; - selectDeploy: () => void; - closeWizard: () => void; - setWizardStep: (step: number) => void; -} - -export interface KagentiToolsPanelProps { - namespace?: string; - initialToolName?: string; - onFocusConsumed?: () => void; - tourControlRef?: React.MutableRefObject; -} - -export function KagentiToolsPanel({ - namespace, - initialToolName, - onFocusConsumed, - tourControlRef, -}: KagentiToolsPanelProps) { - const theme = useTheme(); - const api = useApi(augmentApiRef); - const { - tools, - loading, - error: listError, - setError: setListError, - loadTools, - search, - setSearch, - sortField, - sortDir, - handleSort, - sortedTools, - } = useKagentiToolsList(api, namespace); - - const toolInvoke = useToolInvoke(api); - - const [intentOpen, setIntentOpen] = useState(false); - const [createOpen, setCreateOpen] = useState(false); - const wizardStepRef = useRef<((step: number) => void) | null>(null); - const [deleteTarget, setDeleteTarget] = useState( - null, - ); - const [actionError, setActionError] = useState(null); - - const [connectOpen, setConnectOpen] = useState(false); - const [connectTool, setConnectTool] = useState( - null, - ); - - const [detailTool, setDetailTool] = useState(null); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(10); - - const paginatedTools = useMemo( - () => - sortedTools.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage), - [sortedTools, page, rowsPerPage], - ); - - useEffect(() => { - setPage(0); - }, [search]); - - useEffect(() => { - if (initialToolName && !loading && tools.length > 0) { - const match = tools.find(t => t.name === initialToolName); - if (match) { - setDetailTool(match); - } - onFocusConsumed?.(); - } - }, [initialToolName, loading, tools, onFocusConsumed]); - - useEffect(() => { - if (tourControlRef) { - tourControlRef.current = { - openIntent: () => setIntentOpen(true), - closeIntent: () => setIntentOpen(false), - selectDeploy: () => { - setIntentOpen(false); - setCreateOpen(true); - }, - closeWizard: () => setCreateOpen(false), - setWizardStep: (step: number) => { - wizardStepRef.current?.(step); - }, - }; - } - return () => { - if (tourControlRef) { - tourControlRef.current = null; - } - }; - }, [tourControlRef]); - - const displayError = listError || actionError; - - const toolKey = (t: KagentiToolSummary) => `${t.namespace}/${t.name}`; - - const handleDelete = async () => { - if (!deleteTarget) return; - try { - await api.deleteKagentiTool(deleteTarget.namespace, deleteTarget.name); - setDeleteTarget(null); - setActionError(null); - loadTools(); - } catch (e) { - setActionError(getErrorMessage(e)); - } - }; - - const openConnect = (t: KagentiToolSummary) => { - setConnectTool(t); - setConnectOpen(true); - }; - - const thStyle = TABLE_HEADER_CELL_SX; - - const sortableHead = (field: SortField, label: string, align?: 'right') => ( - - handleSort(field)} - > - {label} - - - ); - - return ( - - - - - Tools - - - Register and manage MCP tool servers for your agents. - - - - - {/* New Tool button removed -- tools are created via self-service - from the My Agents tab, not the admin Command Center */} - - - - {!loading && tools.length > 0 && ( - setSearch(e.target.value)} - sx={{ mb: 2, maxWidth: 400 }} - fullWidth - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - )} - - {displayError && ( - { - setListError(null); - setActionError(null); - }} - > - {displayError} - - )} - - {loading && ( - - - - )} - {!loading && tools.length === 0 && ( - - - - No tools found - - - Create your first MCP tool to give your agents additional - capabilities. - - {/* Create tool button removed -- tools are created via My Agents */} - - )} - {!loading && tools.length > 0 && ( - <> - - - - - {sortableHead('name', 'Name')} - Description - {sortableHead('status', 'Status')} - Labels - {sortableHead('workloadType', 'Workload')} - {sortableHead('createdAt', 'Created')} - - Actions - - - - - {paginatedTools.map(t => ( - setDetailTool(t)} - > - - - {t.name} - - - {t.namespace} - - - - - {t.description || '—'} - - - - - - - - {t.labels?.protocol && ( - - )} - {t.labels?.framework && ( - - )} - - - {t.workloadType ?? '—'} - {formatDateTime(t.createdAt)} - - - { - e.stopPropagation(); - openConnect(t); - }} - > - - - - - { - e.stopPropagation(); - toolInvoke.openInvoke(t); - }} - > - - - - - { - e.stopPropagation(); - setDeleteTarget(t); - }} - > - - - - - - ))} - -
    -
    - {sortedTools.length > rowsPerPage && ( - setPage(p)} - rowsPerPage={rowsPerPage} - onRowsPerPageChange={e => { - setRowsPerPage(parseInt(e.target.value, 10)); - setPage(0); - }} - rowsPerPageOptions={[5, 10, 25]} - sx={{ borderTop: 1, borderColor: 'divider' }} - /> - )} - - )} - - setIntentOpen(false)} - onSelectDeploy={() => { - setIntentOpen(false); - setCreateOpen(true); - }} - /> - - setCreateOpen(false)} - onCreated={loadTools} - onStepControl={setter => { - wizardStepRef.current = setter; - }} - /> - - setConnectOpen(false)} - onInvoke={(toolName, inputSchema) => { - if (connectTool) { - toolInvoke.openInvokePrefilled(connectTool, toolName, inputSchema); - } - setConnectOpen(false); - }} - api={api} - /> - - - - setDeleteTarget(null)} - /> - - setDetailTool(null)} - /> -
    - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/LifecycleActionButtons.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/LifecycleActionButtons.tsx deleted file mode 100644 index 145b14d42f4..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/LifecycleActionButtons.tsx +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useCallback } from 'react'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import CircularProgress from '@mui/material/CircularProgress'; -import Tooltip from '@mui/material/Tooltip'; -import PublishIcon from '@mui/icons-material/Publish'; -import CloudOffIcon from '@mui/icons-material/CloudOff'; -import { borderRadius } from '../../../theme/tokens'; - -const actionBtnSx = { - textTransform: 'none' as const, - fontWeight: 600, - fontSize: '0.8rem', - borderRadius: borderRadius.sm, -}; - -export interface LifecycleActionButtonsProps { - readonly lifecycleStage: string; - readonly isAdmin: boolean; - readonly loading: boolean; - readonly onSubmitForReview: () => void; - readonly onWithdraw: () => void; - readonly onReactivate: () => void; -} - -export interface UseLifecycleActionOptions { - api: { - promoteAgent( - agentId: string, - target?: string, - ): Promise<{ lifecycleStage: string; version?: number }>; - demoteAgent( - agentId: string, - target?: string, - ): Promise<{ lifecycleStage: string }>; - }; - agentId: string; - action: 'promote' | 'demote'; - target: string; - setLoading: (v: boolean) => void; - setToast: (msg: string) => void; - onStageChange: (stage: string) => void; -} - -export function useLifecycleAction({ - api, - agentId, - action, - target, - setLoading, - setToast, - onStageChange, -}: UseLifecycleActionOptions) { - const handleAction = useCallback(async () => { - setLoading(true); - try { - if (action === 'demote') { - const result = await api.demoteAgent(agentId, target); - onStageChange(result.lifecycleStage); - setToast(`Agent moved to ${result.lifecycleStage}`); - } else { - const result = await api.promoteAgent(agentId, target); - onStageChange(result.lifecycleStage); - setToast( - `Agent moved to ${result.lifecycleStage} (v${result.version})`, - ); - } - } catch (err) { - setToast(`Failed: ${err instanceof Error ? err.message : 'Unknown'}`); - } finally { - setLoading(false); - } - }, [api, agentId, action, target, setLoading, setToast, onStageChange]); - - return { handleAction }; -} - -export interface UseWithdrawOptions { - api: { withdrawAgent(agentId: string): Promise }; - agentId: string; - onSuccess: () => void; - setToast: (msg: string) => void; - setLoading: (v: boolean) => void; -} - -export function useWithdrawHandler({ - api, - agentId, - onSuccess, - setToast, - setLoading, -}: UseWithdrawOptions) { - const handleWithdraw = useCallback(async () => { - setLoading(true); - try { - await api.withdrawAgent(agentId); - onSuccess(); - setToast('Agent withdrawn to draft'); - } catch (err) { - setToast(`Failed: ${err instanceof Error ? err.message : 'Unknown'}`); - } finally { - setLoading(false); - } - }, [api, agentId, onSuccess, setToast, setLoading]); - - return { handleWithdraw }; -} - -export function LifecycleActionButtons({ - lifecycleStage, - isAdmin, - loading, - onSubmitForReview, - onWithdraw, - onReactivate, -}: LifecycleActionButtonsProps) { - return ( - <> - {lifecycleStage === 'draft' && ( - - - - )} - {lifecycleStage === 'pending' && ( - - - - )} - {lifecycleStage === 'published' && ( - - )} - {lifecycleStage === 'archived' && isAdmin && ( - - - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/LifecycleDetailShell.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/LifecycleDetailShell.tsx deleted file mode 100644 index 158c1989ffc..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/LifecycleDetailShell.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ReactNode } from 'react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Stepper from '@mui/material/Stepper'; -import Step from '@mui/material/Step'; -import StepLabel from '@mui/material/StepLabel'; -import { useTheme, alpha } from '@mui/material/styles'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { glassSurface, borderRadius, typeScale } from '../../../theme/tokens'; - -const LIFECYCLE_STAGES = ['Draft', 'Pending', 'Published', 'Archived']; - -interface LifecycleDetailShellProps { - readonly onBack: () => void; - readonly backLabel?: string; - readonly currentStep: number; - readonly children: ReactNode; -} - -export function LifecycleDetailShell({ - onBack, - backLabel = 'Agents', - currentStep, - children, -}: LifecycleDetailShellProps) { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const glass = glassSurface(theme, 6); - - return ( - <> - - - - {children} - - - - {LIFECYCLE_STAGES.map(label => ( - - {label} - - ))} - - - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/McpToolCatalog.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/McpToolCatalog.test.tsx deleted file mode 100644 index bd26bd7eec4..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/McpToolCatalog.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render, screen } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import type { KagentiMcpToolSchema } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { McpToolCatalog } from './McpToolCatalog'; - -const theme = createTheme(); - -function renderCatalog( - tools: KagentiMcpToolSchema[], - onInvoke?: (name: string) => void, -) { - return render( - - - , - ); -} - -describe('McpToolCatalog — empty', () => { - it('shows empty state when no tools', () => { - renderCatalog([]); - expect(screen.getByText(/No MCP tools discovered/i)).toBeInTheDocument(); - }); -}); - -describe('McpToolCatalog — rendering tools', () => { - const tools: KagentiMcpToolSchema[] = [ - { - name: 'get_weather', - description: 'Fetches weather data for a location', - input_schema: { - type: 'object', - properties: { - city: { type: 'string', description: 'City name' }, - unit: { type: 'string', description: 'Temperature unit' }, - }, - required: ['city'], - }, - }, - { - name: 'simple_tool', - }, - ]; - - it('renders tool names', () => { - renderCatalog(tools); - expect(screen.getByText('get_weather')).toBeInTheDocument(); - expect(screen.getByText('simple_tool')).toBeInTheDocument(); - }); - - it('shows Invoke buttons when onInvoke provided', () => { - renderCatalog(tools, jest.fn()); - const invokeButtons = screen.getAllByRole('button', { name: /invoke/i }); - expect(invokeButtons.length).toBeGreaterThanOrEqual(2); - }); - - it('passes onInvoke callback for invoke buttons', () => { - const onInvoke = jest.fn(); - renderCatalog(tools, onInvoke); - const invokeButtons = screen.getAllByRole('button', { name: /invoke/i }); - expect(invokeButtons.length).toBeGreaterThanOrEqual(2); - }); - - it('does not show Invoke button when onInvoke omitted', () => { - renderCatalog(tools); - expect( - screen.queryByRole('button', { name: /invoke/i }), - ).not.toBeInTheDocument(); - }); -}); - -describe('McpToolCatalog — schema without properties', () => { - it('shows "No input parameters" for schema with no properties', () => { - const tools: KagentiMcpToolSchema[] = [ - { name: 'noop', input_schema: { type: 'object' } }, - ]; - renderCatalog(tools); - expect(screen.getByText('noop')).toBeInTheDocument(); - }); - - it('shows "No input schema" for tool without schema', () => { - const tools: KagentiMcpToolSchema[] = [{ name: 'bare_tool' }]; - renderCatalog(tools); - expect(screen.getByText('bare_tool')).toBeInTheDocument(); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/McpToolCatalog.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/McpToolCatalog.tsx deleted file mode 100644 index e17f03ac22e..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/McpToolCatalog.tsx +++ /dev/null @@ -1,284 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Accordion from '@mui/material/Accordion'; -import AccordionDetails from '@mui/material/AccordionDetails'; -import AccordionSummary from '@mui/material/AccordionSummary'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; -import Typography from '@mui/material/Typography'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; -import { alpha, useTheme } from '@mui/material/styles'; -import type { KagentiMcpToolSchema } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -export interface McpToolCatalogProps { - tools: KagentiMcpToolSchema[]; - onInvoke?: (toolName: string, inputSchema?: Record) => void; -} - -function isPlainObject(v: unknown): v is Record { - return typeof v === 'object' && v !== null && !Array.isArray(v); -} - -function normalizeRequired(inputSchema: Record): string[] { - const req = inputSchema.required; - if (!Array.isArray(req)) return []; - return req.filter((x): x is string => typeof x === 'string'); -} - -function formatJsonSchemaType(prop: Record): string { - const t = prop.type; - if (typeof t === 'string') return t; - if (Array.isArray(t) && t.every(x => typeof x === 'string')) { - return t.join(' | '); - } - if (prop.enum !== undefined) return 'enum'; - if (prop.const !== undefined) return 'const'; - if (prop.properties !== undefined || prop.items !== undefined) - return 'object'; - return 'any'; -} - -function propertyDescription(prop: Record): string { - const d = prop.description; - return typeof d === 'string' ? d : '—'; -} - -const MONO_FALLBACK = - 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace'; - -export function McpToolCatalog({ tools, onInvoke }: McpToolCatalogProps) { - const theme = useTheme(); - const mono = - (theme.typography as { fontFamilyMono?: string }).fontFamilyMono ?? - MONO_FALLBACK; - - if (!tools.length) { - return ( - - - No MCP tools discovered. The server returned an empty tool list. - - - ); - } - - return ( - - {tools.map((tool, idx) => { - const schema = tool.input_schema; - const properties = - schema && isPlainObject(schema.properties) ? schema.properties : null; - const requiredSet = new Set(schema ? normalizeRequired(schema) : []); - const propertyEntries = properties ? Object.entries(properties) : []; - - return ( - - } - sx={{ - minHeight: 48, - px: 1.5, - bgcolor: alpha(theme.palette.primary.main, 0.04), - '&.Mui-expanded': { minHeight: 48 }, - '& .MuiAccordionSummary-content': { - alignItems: 'center', - gap: 1, - my: 1, - }, - }} - > - - {tool.name} - - {onInvoke ? ( - - ) : null} - - - {tool.description ? ( - - {tool.description} - - ) : null} - - {schema && propertyEntries.length > 0 ? ( - - - - Name - Type - Required - - Description - - - - - {propertyEntries.map(([propName, raw]) => { - const prop = isPlainObject(raw) ? raw : {}; - const isReq = requiredSet.has(propName); - return ( - - - {propName} - - - {formatJsonSchemaType(prop)} - - - {isReq ? ( - - ) : ( - - )} - - - {propertyDescription(prop)} - - - ); - })} - -
    - ) : null} - - {schema && !propertyEntries.length ? ( - - No input parameters defined for this tool. - - ) : null} - - {!schema ? ( - - No input schema provided. - - ) : null} -
    -
    - ); - })} -
    - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/NamespacePicker.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/NamespacePicker.tsx deleted file mode 100644 index 44484b86dd6..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/NamespacePicker.tsx +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useEffect, useState } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import Box from '@mui/material/Box'; -import FormControl from '@mui/material/FormControl'; -import InputLabel from '@mui/material/InputLabel'; -import MenuItem from '@mui/material/MenuItem'; -import Select from '@mui/material/Select'; -import Skeleton from '@mui/material/Skeleton'; -import Alert from '@mui/material/Alert'; -import { useTheme, alpha } from '@mui/material/styles'; -import { augmentApiRef } from '../../../api'; -import { SELECT_MENU_PROPS } from '../shared/selectMenuProps'; - -export interface NamespacePickerProps { - value: string | undefined; - onChange: (ns: string) => void; - label?: string; - size?: 'small' | 'medium'; - fullWidth?: boolean; - enabledOnly?: boolean; - /** "minimal" renders without a floating InputLabel (suitable for tight spaces like sidebars) */ - variant?: 'standard' | 'minimal'; - /** When true, hides the "All namespaces" option — the user must pick one. */ - required?: boolean; -} - -export function NamespacePicker({ - value, - onChange, - label = 'Namespace', - size = 'small', - fullWidth = true, - enabledOnly = true, - variant = 'standard', - required = false, -}: NamespacePickerProps) { - const theme = useTheme(); - const api = useApi(augmentApiRef); - const [namespaces, setNamespaces] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - setLoading(true); - setError(null); - api - .listKagentiNamespaces(enabledOnly) - .then(res => { - if (!cancelled) { - setNamespaces(res.namespaces ?? []); - } - }) - .catch((e: Error) => { - if (!cancelled) setError(e.message); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [api, enabledOnly]); - - if (loading) { - return ( - - - - ); - } - - if (error) { - return {error}; - } - - if (variant === 'minimal') { - return ( - - ); - } - - return ( - - {label} - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/OrchAgentDetailView.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/OrchAgentDetailView.tsx deleted file mode 100644 index ba3bdbcbc39..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/OrchAgentDetailView.tsx +++ /dev/null @@ -1,478 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState, useCallback, useEffect, useMemo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import Snackbar from '@mui/material/Snackbar'; -import Divider from '@mui/material/Divider'; -import { useTheme, alpha } from '@mui/material/styles'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import EditIcon from '@mui/icons-material/Edit'; -import PublishIcon from '@mui/icons-material/Publish'; -import CloudOffIcon from '@mui/icons-material/CloudOff'; -import DeleteIcon from '@mui/icons-material/Delete'; -import { getLifecycleTransition } from './lifecycleTransitions'; -import ChatIcon from '@mui/icons-material/Chat'; -import { useApi } from '@backstage/core-plugin-api'; -import type { ChatAgent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { normalizeLifecycleStage } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { augmentApiRef } from '../../../api'; -import { useEffectiveConfig } from '../../../hooks/useEffectiveConfig'; -import { agentFromConfig } from '../AgentsPanel/agentValidation'; -import { ConfirmDialog } from '../shared/ConfirmDialog'; -import { - CONTENT_MAX_WIDTH, - PAGE_TITLE_SX, -} from '../shared/commandCenterStyles'; - -export interface OrchAgentDetailViewProps { - agent: ChatAgent; - onBack: () => void; - onEditConfig: (agentKey: string) => void; - onChatWithAgent?: (agentId: string) => void; -} - -function roleChipColor(role?: string) { - switch (role) { - case 'router': - return 'primary' as const; - case 'specialist': - return 'secondary' as const; - default: - return 'default' as const; - } -} - -function lifecycleColor(stage?: string) { - switch (stage) { - case 'published': - case 'deployed': - return 'success' as const; - case 'pending': - case 'registered': - return 'warning' as const; - case 'archived': - return 'error' as const; - default: - return 'default' as const; - } -} - -export function OrchAgentDetailView({ - agent, - onBack, - onEditConfig, - onChatWithAgent, -}: OrchAgentDetailViewProps) { - const theme = useTheme(); - const api = useApi(augmentApiRef); - - const { config: effectiveConfig } = useEffectiveConfig(); - - const [lifecycleStage, setLifecycleStage] = useState( - normalizeLifecycleStage(agent.lifecycleStage), - ); - const [publishLoading, setPublishLoading] = useState(false); - const [toast, setToast] = useState<{ - message: string; - severity: 'success' | 'error' | 'warning'; - } | null>(null); - const [error, setError] = useState(null); - const [deleteOpen, setDeleteOpen] = useState(false); - - useEffect(() => { - let cancelled = false; - api - .listAgents() - .then(agents => { - if (cancelled) return; - const match = agents.find(a => a.id === agent.id); - setLifecycleStage(normalizeLifecycleStage(match?.lifecycleStage)); - }) - .catch(() => { - if (!cancelled) { - setToast({ - message: - 'Could not refresh lifecycle status — showing last known state', - severity: 'warning', - }); - } - }); - return () => { - cancelled = true; - }; - }, [api, agent.id]); - - const nextTransition = useMemo(() => { - const t = getLifecycleTransition(lifecycleStage); - return { - ...t, - icon: t.iconType === 'promote' ? : , - }; - }, [lifecycleStage]); - - const agentConfig = useMemo(() => { - if (!effectiveConfig) return null; - const agents = effectiveConfig.agents as - | Record> - | undefined; - if (!agents || !agents[agent.id]) return null; - return agentFromConfig(agents[agent.id]); - }, [effectiveConfig, agent.id]); - - const availableMcpServers = useMemo(() => { - if (!effectiveConfig) return []; - const servers = - (effectiveConfig.mcpServers as Array<{ id: string; name: string }>) || []; - return servers.map(s => ({ id: s.id, name: s.name || s.id })); - }, [effectiveConfig]); - - const handleLifecycleAction = useCallback(async () => { - setPublishLoading(true); - try { - if (nextTransition.action === 'demote') { - const result = await api.demoteAgent(agent.id, nextTransition.target); - setLifecycleStage(result.lifecycleStage); - setToast({ - message: `Agent moved to ${result.lifecycleStage}`, - severity: 'success', - }); - } else { - const result = await api.promoteAgent(agent.id, nextTransition.target); - setLifecycleStage(result.lifecycleStage); - setToast({ - message: `Agent moved to ${result.lifecycleStage} (v${result.version})`, - severity: 'success', - }); - } - } catch (err) { - setToast({ - message: `Failed: ${err instanceof Error ? err.message : 'Unknown error'}`, - severity: 'error', - }); - } finally { - setPublishLoading(false); - } - }, [api, agent.id, nextTransition]); - - const handleDelete = useCallback(async () => { - setError(null); - try { - await api.deleteAgentConfig(agent.id); - setToast({ message: 'Agent deleted', severity: 'success' }); - onBack(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to delete agent'); - } finally { - setDeleteOpen(false); - } - }, [api, agent.id, onBack]); - - const roleLabel = agent.agentRole - ? agent.agentRole.charAt(0).toUpperCase() + agent.agentRole.slice(1) - : 'Standalone'; - - return ( - - - - {/* Header */} - - - - - {agent.name} - - - - - {agent.description && ( - - {agent.description} - - )} - - - - - - {/* Actions */} - - - - {onChatWithAgent && ( - - )} - - - - - {error && ( - setError(null)}> - {error} - - )} - - {/* Agent Configuration Details */} - {agentConfig && ( - - - Configuration - - - - - - - - - Instructions - - - {agentConfig.instructions || '(none)'} - - - - - - Capabilities - - - {agentConfig.enableRAG && ( - - )} - {agentConfig.enableWebSearch && ( - - )} - {agentConfig.enableCodeInterpreter && ( - - )} - {agentConfig.mcpServers.length > 0 && - agentConfig.mcpServers.map(id => ( - s.id === id)?.name || id} - size="small" - color="info" - /> - ))} - {!agentConfig.enableRAG && - !agentConfig.enableWebSearch && - !agentConfig.enableCodeInterpreter && - agentConfig.mcpServers.length === 0 && ( - - No capabilities configured - - )} - - - {(agentConfig.handoffs.length > 0 || - agentConfig.asTools.length > 0) && ( - <> - - - Connections - - {agentConfig.handoffs.length > 0 && ( - - )} - {agentConfig.asTools.length > 0 && ( - - )} - - )} - - )} - - {!agentConfig && !error && ( - - Agent configuration not found. It may have been deleted or the config - is still loading. - - )} - - setDeleteOpen(false)} - /> - - setToast(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - {toast ? ( - setToast(null)} - severity={toast.severity} - variant="filled" - sx={{ width: '100%' }} - > - {toast.message} - - ) : undefined} - - - ); -} - -function DetailRow({ label, value }: { label: string; value: string }) { - return ( - - - {label} - - - {value} - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/SchemaInvokeForm.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/SchemaInvokeForm.test.tsx deleted file mode 100644 index 06ee6e4e256..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/SchemaInvokeForm.test.tsx +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { SchemaInvokeForm } from './SchemaInvokeForm'; - -const theme = createTheme(); - -function renderForm( - props: Partial> = {}, -) { - const defaultProps: React.ComponentProps = { - schema: { - type: 'object', - properties: { - city: { type: 'string', description: 'City name' }, - count: { type: 'integer', description: 'Number of results' }, - verbose: { type: 'boolean', description: 'Verbose output' }, - }, - required: ['city'], - }, - onSubmit: jest.fn(), - submitting: false, - result: null, - error: null, - ...props, - }; - return { - ...render( - - - , - ), - props: defaultProps, - }; -} - -describe('SchemaInvokeForm', () => { - it('renders text fields for string properties', () => { - renderForm(); - expect(screen.getByLabelText(/city/i)).toBeInTheDocument(); - }); - - it('renders number fields for integer properties', () => { - renderForm(); - const countField = screen.getByLabelText(/count/i); - expect(countField).toBeInTheDocument(); - expect(countField).toHaveAttribute('type', 'number'); - }); - - it('renders switch for boolean properties', () => { - renderForm(); - expect(screen.getByRole('checkbox')).toBeInTheDocument(); - expect(screen.getByText('verbose')).toBeInTheDocument(); - }); - - it('marks required fields', () => { - renderForm(); - const cityField = screen.getByLabelText(/city/i); - expect(cityField).toBeRequired(); - }); - - it('does not mark optional fields as required', () => { - renderForm(); - const countField = screen.getByLabelText(/count/i); - expect(countField).not.toBeRequired(); - }); - - it('renders select for enum properties', () => { - const schema = { - type: 'object', - properties: { - unit: { type: 'string', enum: ['celsius', 'fahrenheit'] }, - }, - }; - renderForm({ schema }); - expect(screen.getByText('celsius')).toBeInTheDocument(); - }); - - it('renders helper text as descriptions', () => { - renderForm(); - expect(screen.getByText('City name')).toBeInTheDocument(); - expect(screen.getByText('Number of results')).toBeInTheDocument(); - }); - - it('shows fallback JSON field for no-property schemas', () => { - const schema = { type: 'object' }; - renderForm({ schema }); - expect(screen.getByLabelText(/arguments.*json/i)).toBeInTheDocument(); - }); - - it('renders Invoke button', () => { - renderForm(); - expect(screen.getByRole('button', { name: /invoke/i })).toBeInTheDocument(); - }); - - it('disables Invoke button when submitting', () => { - renderForm({ submitting: true }); - const buttons = screen.getAllByRole('button'); - const invokeBtn = buttons.find( - b => - b.getAttribute('disabled') !== null && !b.textContent?.match(/cancel/i), - ); - expect(invokeBtn).toBeTruthy(); - expect(invokeBtn).toBeDisabled(); - }); - - it('renders result when provided', () => { - renderForm({ result: '{"temp": 72}' }); - expect(screen.getByDisplayValue('{"temp": 72}')).toBeInTheDocument(); - }); - - it('renders error alert when provided', () => { - renderForm({ error: 'Something went wrong' }); - expect(screen.getByText('Something went wrong')).toBeInTheDocument(); - }); - - it('calls onSubmit with assembled values', async () => { - const onSubmit = jest.fn(); - renderForm({ onSubmit }); - const user = userEvent.setup(); - - await user.type(screen.getByLabelText(/city/i), 'London'); - await user.click(screen.getByRole('button', { name: /invoke/i })); - - expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ city: 'London' }), - ); - }); - - it('provides a JSON toggle link', () => { - renderForm(); - expect(screen.getByText(/switch to json/i)).toBeInTheDocument(); - }); - - it('switches to JSON mode when toggle is clicked', async () => { - renderForm(); - const user = userEvent.setup(); - await user.click(screen.getByText(/switch to json/i)); - expect(screen.getByLabelText(/arguments.*json/i)).toBeInTheDocument(); - expect(screen.getByText(/switch to form/i)).toBeInTheDocument(); - }); - - it('renders cancel button when onCancel is provided', () => { - renderForm({ onCancel: jest.fn() }); - expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); - }); - - it('does not render cancel button when onCancel is not provided', () => { - renderForm(); - expect( - screen.queryByRole('button', { name: /cancel/i }), - ).not.toBeInTheDocument(); - }); - - it('renders array field with comma-separated hint', () => { - const schema = { - type: 'object', - properties: { - tags: { type: 'array', description: 'Tag list' }, - }, - }; - renderForm({ schema }); - expect(screen.getByLabelText(/tags/i)).toBeInTheDocument(); - }); - - it('renders JSON fallback for object/complex properties', () => { - const schema = { - type: 'object', - properties: { - config: { type: 'object', description: 'Config block' }, - }, - }; - renderForm({ schema }); - expect(screen.getByLabelText(/config.*json/i)).toBeInTheDocument(); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/SchemaInvokeForm.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/SchemaInvokeForm.tsx deleted file mode 100644 index 4b4736dcf93..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/SchemaInvokeForm.tsx +++ /dev/null @@ -1,524 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useCallback, useEffect, useMemo, useState } from 'react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Switch from '@mui/material/Switch'; -import Select from '@mui/material/Select'; -import MenuItem from '@mui/material/MenuItem'; -import FormControl from '@mui/material/FormControl'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import InputLabel from '@mui/material/InputLabel'; -import Typography from '@mui/material/Typography'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import Link from '@mui/material/Link'; -import { alpha, useTheme } from '@mui/material/styles'; - -export interface SchemaInvokeFormProps { - schema: Record; - onSubmit: (args: Record) => void; - submitting: boolean; - result: string | null; - error: string | null; - onErrorClear?: () => void; - onCancel?: () => void; -} - -interface PropertyDef { - name: string; - type: string; - description?: string; - required: boolean; - enumValues?: string[]; - defaultValue?: unknown; -} - -function isPlainObject(v: unknown): v is Record { - return typeof v === 'object' && v !== null && !Array.isArray(v); -} - -function extractProperties(schema: Record): PropertyDef[] { - const props = schema.properties; - if (!isPlainObject(props)) return []; - - const requiredArr = Array.isArray(schema.required) - ? (schema.required as string[]) - : []; - const requiredSet = new Set(requiredArr); - - return Object.entries(props).map(([name, raw]) => { - const def = isPlainObject(raw) ? raw : {}; - const type = typeof def.type === 'string' ? def.type : 'unknown'; - const enumValues = Array.isArray(def.enum) - ? def.enum.filter((v): v is string => typeof v === 'string') - : undefined; - - return { - name, - type: enumValues?.length ? 'enum' : type, - description: - typeof def.description === 'string' ? def.description : undefined, - required: requiredSet.has(name), - enumValues, - defaultValue: def.default, - }; - }); -} - -function getInitialValue(prop: PropertyDef): unknown { - if (prop.defaultValue !== undefined) return prop.defaultValue; - switch (prop.type) { - case 'boolean': - return false; - case 'number': - case 'integer': - return ''; - case 'enum': - return prop.enumValues?.[0] ?? ''; - case 'array': - return ''; - default: - return ''; - } -} - -function hasUnsupportedFeatures(schema: Record): boolean { - const keys = Object.keys(schema); - return ['$ref', 'oneOf', 'anyOf', 'allOf'].some(k => keys.includes(k)); -} - -export function SchemaInvokeForm({ - schema, - onSubmit, - submitting, - result, - error, - onErrorClear, - onCancel, -}: SchemaInvokeFormProps) { - const theme = useTheme(); - const properties = useMemo(() => extractProperties(schema), [schema]); - const unsupported = useMemo(() => hasUnsupportedFeatures(schema), [schema]); - - const [values, setValues] = useState>(() => { - const init: Record = {}; - for (const p of properties) { - init[p.name] = getInitialValue(p); - } - return init; - }); - const [jsonMode, setJsonMode] = useState(false); - const [rawJson, setRawJson] = useState('{}'); - const [jsonParseError, setJsonParseError] = useState(null); - const [fieldErrors, setFieldErrors] = useState>({}); - - useEffect(() => { - const init: Record = {}; - for (const p of properties) { - init[p.name] = getInitialValue(p); - } - setValues(init); - setJsonMode(unsupported); - setRawJson('{}'); - setJsonParseError(null); - setFieldErrors({}); - }, [schema, properties, unsupported]); - - const setValue = useCallback((name: string, value: unknown) => { - setValues(prev => ({ ...prev, [name]: value })); - setFieldErrors(prev => { - if (!prev[name]) return prev; - const next = { ...prev }; - delete next[name]; - return next; - }); - }, []); - - const validateRequired = useCallback((): boolean => { - const errs: Record = {}; - for (const prop of properties) { - if (!prop.required) continue; - const val = values[prop.name]; - if (val === '' || val === undefined || val === null) { - if (prop.type !== 'boolean') { - errs[prop.name] = 'This field is required'; - } - } - } - setFieldErrors(errs); - return Object.keys(errs).length === 0; - }, [properties, values]); - - const handleSubmit = useCallback(() => { - if (jsonMode) { - try { - const parsed = JSON.parse(rawJson) as Record; - setJsonParseError(null); - onSubmit(parsed); - } catch (e) { - setJsonParseError(e instanceof Error ? e.message : 'Invalid JSON'); - return; - } - } else { - if (!validateRequired()) return; - const assembled: Record = {}; - for (const prop of properties) { - const val = values[prop.name]; - if (val === '' || val === undefined || val === null) { - if (prop.required) { - assembled[prop.name] = prop.type === 'boolean' ? false : ''; - } - continue; - } - switch (prop.type) { - case 'number': - case 'integer': { - const n = Number(val); - if (!Number.isNaN(n)) assembled[prop.name] = n; - break; - } - case 'boolean': - assembled[prop.name] = Boolean(val); - break; - case 'array': - if (typeof val === 'string') { - assembled[prop.name] = val - .split(',') - .map(s => s.trim()) - .filter(Boolean); - } else { - assembled[prop.name] = val; - } - break; - default: - assembled[prop.name] = val; - break; - } - } - onSubmit(assembled); - } - }, [jsonMode, rawJson, properties, values, onSubmit, validateRequired]); - - if (properties.length === 0) { - return ( - - - This tool has no input parameters. - - setRawJson(e.target.value)} - fullWidth - multiline - minRows={3} - /> - {error && ( - - {error} - - )} - {result && ( - - )} - - - {onCancel && ( - - )} - - - ); - } - - return ( - - {unsupported && ( - - This schema uses features not fully supported by the form ($ref, - oneOf, anyOf, or allOf). Using JSON mode instead. - - )} - - { - if (!jsonMode) { - const assembled: Record = {}; - for (const prop of properties) { - const val = values[prop.name]; - if (val !== '' && val !== undefined && val !== null) { - assembled[prop.name] = val; - } - } - setRawJson(JSON.stringify(assembled, null, 2)); - } - setJsonParseError(null); - setJsonMode(prev => !prev); - }} - sx={{ cursor: 'pointer' }} - > - {jsonMode ? 'Switch to form' : 'Switch to JSON'} - - - - {jsonMode ? ( - <> - { - setRawJson(e.target.value); - setJsonParseError(null); - }} - fullWidth - multiline - minRows={4} - error={!!jsonParseError} - helperText={jsonParseError} - /> - - ) : ( - properties.map(prop => { - switch (prop.type) { - case 'boolean': - return ( - setValue(prop.name, e.target.checked)} - size="small" - /> - } - label={ - - - {prop.name} - {prop.required && ( - - {' '} - * - - )} - - {prop.description && ( - - {prop.description} - - )} - - } - /> - ); - - case 'enum': - return ( - - {prop.name} - - {prop.description && ( - - {prop.description} - - )} - - ); - - case 'number': - case 'integer': - return ( - setValue(prop.name, e.target.value)} - fullWidth - required={prop.required} - error={!!fieldErrors[prop.name]} - helperText={fieldErrors[prop.name] || prop.description} - /> - ); - - case 'array': - return ( - setValue(prop.name, e.target.value)} - fullWidth - required={prop.required} - error={!!fieldErrors[prop.name]} - helperText={ - fieldErrors[prop.name] || - prop.description || - 'Comma-separated values' - } - placeholder="value1, value2, value3" - /> - ); - - case 'unknown': - case 'object': - return ( - setValue(prop.name, e.target.value)} - fullWidth - multiline - minRows={2} - required={prop.required} - helperText={prop.description ?? 'Enter valid JSON'} - /> - ); - - default: - return ( - setValue(prop.name, e.target.value)} - fullWidth - required={prop.required} - error={!!fieldErrors[prop.name]} - helperText={fieldErrors[prop.name] || prop.description} - /> - ); - } - }) - )} - - {error && ( - - {error} - - )} - {result && ( - - )} - - - - {onCancel && ( - - )} - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolBuildSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolBuildSection.tsx deleted file mode 100644 index c9bb58fe92d..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolBuildSection.tsx +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ReactNode } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; -import Button from '@mui/material/Button'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import Divider from '@mui/material/Divider'; -import type { KagentiBuildInfo } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { PropertyRow } from './KagentiPropertyRow'; -import { buildRunPhaseChipColor, formatDateTime } from './kagentiDisplayUtils'; - -export interface ToolBuildSectionProps { - buildLoading: boolean; - buildFetchFailed: boolean; - buildInfo: KagentiBuildInfo | null; - triggeringBuild: boolean; - finalizingBuild: boolean; - onTriggerBuild: () => void; - onFinalizeBuild: () => void; -} - -export function ToolBuildSection({ - buildLoading, - buildFetchFailed, - buildInfo, - triggeringBuild, - finalizingBuild, - onTriggerBuild, - onFinalizeBuild, -}: ToolBuildSectionProps): ReactNode { - if (buildLoading) { - return ( - - - - ); - } - if (!buildInfo) { - return ( - - {buildFetchFailed - ? 'Could not load build information. The service may be temporarily unavailable.' - : 'No build pipeline configured'} - - ); - } - - const phaseLower = buildInfo.buildRunPhase?.toLowerCase(); - const buildIsRunning = - phaseLower === 'running' || - phaseLower === 'pending' || - phaseLower === 'workqueue'; - const canTriggerBuild = - buildInfo.buildRegistered && !buildIsRunning && !triggeringBuild; - const showFinalize = buildInfo.hasBuildRun && phaseLower === 'succeeded'; - - return ( - <> - - - - - - - - Git - - - - - {buildInfo.hasBuildRun && ( - <> - - - Build run - - - - - Phase - - {buildInfo.buildRunPhase ? ( - - ) : ( - — - )} - - - - {buildInfo.buildRunFailureMessage ? ( - - {buildInfo.buildRunFailureMessage} - - ) : null} - - )} - - - {showFinalize && ( - - )} - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolConnectDialog.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolConnectDialog.tsx deleted file mode 100644 index 57c2cfababa..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolConnectDialog.tsx +++ /dev/null @@ -1,246 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useEffect, useState } from 'react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import Dialog from '@mui/material/Dialog'; -import DialogActions from '@mui/material/DialogActions'; -import DialogContent from '@mui/material/DialogContent'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import Divider from '@mui/material/Divider'; -import Typography from '@mui/material/Typography'; -import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import { alpha, useTheme } from '@mui/material/styles'; -import type { - KagentiMcpToolSchema, - KagentiToolSummary, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { AugmentApi } from '../../../api'; -import { getErrorMessage } from '../../../utils'; -import { McpToolCatalog } from './McpToolCatalog'; -import { toolSummaryStatusChipColor } from './kagentiDisplayUtils'; - -export interface ToolConnectDialogProps { - open: boolean; - tool: KagentiToolSummary | null; - onClose: () => void; - onInvoke: (toolName: string, inputSchema?: Record) => void; - api: AugmentApi; -} - -export function ToolConnectDialog({ - open, - tool, - onClose, - onInvoke, - api, -}: ToolConnectDialogProps) { - const theme = useTheme(); - const [connecting, setConnecting] = useState(false); - const [connectedSchemas, setConnectedSchemas] = useState< - KagentiMcpToolSchema[] | null - >(null); - const [discoverError, setDiscoverError] = useState(null); - - const runDiscover = async () => { - if (!tool) return; - setConnecting(true); - setDiscoverError(null); - try { - const res = await api.connectKagentiTool(tool.namespace, tool.name); - setConnectedSchemas(res.tools ?? []); - } catch (e) { - setDiscoverError(getErrorMessage(e)); - } finally { - setConnecting(false); - } - }; - - useEffect(() => { - if (open && tool) { - setConnectedSchemas(null); - setDiscoverError(null); - runDiscover(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, tool?.namespace, tool?.name]); - - const toolCount = connectedSchemas?.length ?? 0; - - return ( - !connecting && onClose()} - maxWidth="md" - fullWidth - PaperProps={{ - sx: { borderRadius: 2, overflow: 'hidden' }, - }} - > - - - - - MCP Tool Discovery - - {tool && ( - - )} - - {tool && ( - - Connecting to{' '} - - {tool.name} - {' '} - in{' '} - - {tool.namespace} - {' '} - to list the MCP tools it exposes. - - )} - - - - {connecting && !connectedSchemas && ( - - - - Connecting to the MCP server... - - - )} - - {discoverError && ( - setDiscoverError(null)} - > - {discoverError} - - {tool?.name?.endsWith('-mcp') - ? `The platform appends "-mcp" to the service name internally. Because this tool is named "${tool.name}", it may be looking for a service called "${tool.name}-mcp" which does not exist. Try recreating the tool without the "-mcp" suffix.` - : 'The tool may still be starting up, or its MCP endpoint may not be reachable. Verify the tool is in Ready status and try again.'} - - - )} - - {!connecting && discoverError && ( - - )} - - {!connecting && connectedSchemas !== null && !discoverError && ( - <> - - - {toolCount > 0 - ? `${toolCount} tool${toolCount > 1 ? 's' : ''} discovered` - : 'No tools discovered'} - - - - - - )} - - {connectedSchemas !== null && ( - - )} - - - - - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolCreateIntentDialog.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolCreateIntentDialog.tsx deleted file mode 100644 index 91be31075b1..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolCreateIntentDialog.tsx +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useCallback, useId, useState } from 'react'; -import Dialog from '@mui/material/Dialog'; -import DialogActions from '@mui/material/DialogActions'; -import DialogContent from '@mui/material/DialogContent'; -import Button from '@mui/material/Button'; -import CodeIcon from '@mui/icons-material/Code'; -import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; -import LaptopMacOutlinedIcon from '@mui/icons-material/LaptopMacOutlined'; -import RocketLaunchOutlinedIcon from '@mui/icons-material/RocketLaunchOutlined'; -import { useTheme } from '@mui/material/styles'; -import { AgentTemplateBrowser } from './AgentTemplateBrowser'; -import { DevSpacesLaunchForm } from './DevSpacesLaunchForm'; -import { CardGrid, DialogHeader } from './IntentDialogParts'; -import type { IntentCard } from './IntentDialogParts'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface ToolCreateIntentDialogProps { - open: boolean; - onClose: () => void; - onSelectDeploy: () => void; -} - -type View = 'intent' | 'develop-sub' | 'templates' | 'devspaces'; - -// --------------------------------------------------------------------------- -// Card definitions -// --------------------------------------------------------------------------- - -const INTENT_CARDS: IntentCard[] = [ - { - id: 'develop', - tourId: 'tool-intent-develop', - icon: , - title: 'Develop', - subtitle: 'Start building your MCP tool', - description: - 'Scaffold from a template or launch a cloud workspace to develop your tool with full tooling.', - }, - { - id: 'deploy', - tourId: 'tool-intent-deploy', - icon: , - title: 'Deploy', - subtitle: 'Deploy your tool to the platform', - description: - 'Deploy a tool from a container image or a Git repository. The platform handles building, containerizing, and running it.', - }, -]; - -const DEVELOP_SUB_CARDS: IntentCard[] = [ - { - id: 'templates', - icon: , - title: 'From Template', - subtitle: 'Use a software template', - description: - 'Scaffold a new MCP tool project from a pre-configured software template with best-practice structure and dependencies.', - }, - { - id: 'devspaces', - icon: , - title: 'Tool DevSpace', - subtitle: 'Cloud IDE workspace', - description: - 'Launch a cloud IDE to develop your MCP tool with full tooling and runtime support.', - }, -]; - -// --------------------------------------------------------------------------- -// Main Component -// --------------------------------------------------------------------------- - -export function ToolCreateIntentDialog({ - open, - onClose, - onSelectDeploy, -}: ToolCreateIntentDialogProps) { - const theme = useTheme(); - const titleId = useId(); - const [view, setView] = useState('intent'); - const [devSpacesGitRepo, setDevSpacesGitRepo] = useState( - undefined, - ); - - const handleClose = useCallback(() => { - setView('intent'); - setDevSpacesGitRepo(undefined); - onClose(); - }, [onClose]); - - const handleIntentCardClick = useCallback( - (cardId: string) => { - if (cardId === 'develop') { - setView('develop-sub'); - } else if (cardId === 'deploy') { - setView('intent'); - onSelectDeploy(); - } - }, - [onSelectDeploy], - ); - - const handleDevelopSubClick = useCallback((cardId: string) => { - if (cardId === 'templates') { - setView('templates'); - } else if (cardId === 'devspaces') { - setView('devspaces'); - } - }, []); - - const handleOpenInDevSpace = useCallback((gitRepoUrl: string) => { - setDevSpacesGitRepo(gitRepoUrl); - setView('devspaces'); - }, []); - - return ( - - } - > - {view === 'intent' && ( - <> - - - - - - - - - )} - - {view === 'develop-sub' && ( - <> - setView('intent')} - /> - - - - - - - - )} - - {view === 'templates' && ( - - setView('develop-sub')} - onOpenInDevSpace={handleOpenInDevSpace} - specType="tool" - title="Tool Templates" - description="Choose a software template to scaffold a new MCP tool project. Templates are discovered from the catalog automatically." - emptyTitle="No tool templates found" - devSpaceLabel="Open in Tool DevSpace" - /> - - )} - - {view === 'devspaces' && ( - - { - setDevSpacesGitRepo(undefined); - setView('develop-sub'); - }} - initialGitRepo={devSpacesGitRepo} - resourceKind="tool" - /> - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolInvokeDialog.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolInvokeDialog.tsx deleted file mode 100644 index 5ed30335649..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolInvokeDialog.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Button from '@mui/material/Button'; -import Dialog from '@mui/material/Dialog'; -import DialogActions from '@mui/material/DialogActions'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; -import TextField from '@mui/material/TextField'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import type { KagentiToolSummary } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { SchemaInvokeForm } from './SchemaInvokeForm'; -import type { UseToolInvokeResult } from './useToolInvoke'; - -export type ToolInvokeDialogProps = { - invoke: UseToolInvokeResult; - /** When omitted, the hook’s `invokeTarget` (set by `openInvoke` / `openInvokePrefilled`) is used. */ - tool?: KagentiToolSummary | null; -}; - -export function ToolInvokeDialog({ - invoke, - tool = null, -}: ToolInvokeDialogProps) { - const { - invokeOpen, - invokeTarget, - invokeToolName, - setInvokeToolName, - invokeArgsJson, - setInvokeArgsJson, - invoking, - invokeResult, - invokeError, - invokeSchema, - resetInvoke, - clearInvokeError, - handleInvoke, - invokeWithArgs, - } = invoke; - - const resource = tool ?? invokeTarget; - - return ( - !invoking && resetInvoke()} - maxWidth="sm" - fullWidth - > - - Invoke {resource ? `${resource.namespace}/${resource.name}` : ''} - {invokeToolName ? ` \u2014 ${invokeToolName}` : ''} - - - {invokeError && !invokeSchema && ( - - {invokeError} - - )} - {!invokeToolName && ( - setInvokeToolName(e.target.value)} - fullWidth - required - /> - )} - {invokeSchema ? ( - { - if (!resource || !invokeToolName.trim()) return; - invokeWithArgs( - resource.namespace, - resource.name, - invokeToolName.trim(), - args, - ); - }} - submitting={invoking} - result={invokeResult} - error={invokeError} - onErrorClear={clearInvokeError} - onCancel={resetInvoke} - /> - ) : ( - <> - {invokeToolName && ( - setInvokeToolName(e.target.value)} - fullWidth - required - /> - )} - setInvokeArgsJson(e.target.value)} - fullWidth - multiline - minRows={4} - /> - {invokeResult && ( - - )} - - )} - - {!invokeSchema && ( - - - - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolMcpSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolMcpSection.tsx deleted file mode 100644 index 0715c2cff3c..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolMcpSection.tsx +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Alert from '@mui/material/Alert'; -import Divider from '@mui/material/Divider'; -import TextField from '@mui/material/TextField'; -import CircularProgress from '@mui/material/CircularProgress'; -import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import { alpha, useTheme } from '@mui/material/styles'; -import type { - KagentiMcpToolSchema, - KagentiToolSummary, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { McpToolCatalog } from './McpToolCatalog'; -import { SchemaInvokeForm } from './SchemaInvokeForm'; -import type { UseToolInvokeResult } from './useToolInvoke'; - -export interface ToolMcpSectionProps { - tool: KagentiToolSummary; - mcpTools: KagentiMcpToolSchema[]; - mcpDiscovering: boolean; - mcpError: string | null; - onDiscover: () => void; - onDismissMcpError: () => void; - invoke: UseToolInvokeResult; -} - -export function ToolMcpSection({ - tool, - mcpTools, - mcpDiscovering, - mcpError, - onDiscover, - onDismissMcpError, - invoke, -}: ToolMcpSectionProps) { - const theme = useTheme(); - const { - invokeOpen, - invokeToolName, - setInvokeToolName, - invokeArgsJson, - setInvokeArgsJson, - invokeResult, - invokeError, - invoking, - invokeSchema, - handleStartInvoke, - handleInvoke, - invokeWithArgs, - setInvokeError, - setInvokeResult, - setInvokeOpen, - } = invoke; - - const closeInvokePanel = () => { - setInvokeOpen(false); - setInvokeResult(null); - setInvokeError(null); - }; - - const hasTools = mcpTools.length > 0; - - return ( - <> - - - - - {hasTools - ? `${mcpTools.length} tool${mcpTools.length > 1 ? 's' : ''} discovered` - : 'Connect to discover available tools'} - - - - - - {mcpError && ( - - {mcpError} - - {tool.name.endsWith('-mcp') - ? `The platform appends "-mcp" to the service name internally. Because this tool is named "${tool.name}", it may be looking for a service called "${tool.name}-mcp" which does not exist. Try recreating the tool without the "-mcp" suffix.` - : 'The tool may still be starting up, or its MCP endpoint may not be reachable. Verify the tool is in Ready status and try again.'} - - - )} - - {hasTools && } - - - - {invokeOpen && ( - - - Invoke: {invokeToolName} - - {invokeSchema ? ( - - invokeWithArgs(tool.namespace, tool.name, invokeToolName, args) - } - submitting={invoking} - result={invokeResult} - error={invokeError} - onErrorClear={() => setInvokeError(null)} - onCancel={closeInvokePanel} - /> - ) : ( - <> - setInvokeToolName(e.target.value)} - fullWidth - sx={{ mb: 1.5 }} - /> - setInvokeArgsJson(e.target.value)} - fullWidth - multiline - minRows={3} - sx={{ mb: 1.5 }} - /> - {invokeError && ( - setInvokeError(null)} - > - {invokeError} - - )} - {invokeResult && ( - - )} - - - - - - )} - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolRouteSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolRouteSection.tsx deleted file mode 100644 index 94213e47bde..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolRouteSection.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ReactNode } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import Divider from '@mui/material/Divider'; -import { PropertyRow } from './KagentiPropertyRow'; -import type { KagentiRouteStatus } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { - formatValue, - routeReadyChipColor, - routeStatusStringChipColor, -} from './kagentiDisplayUtils'; - -export interface ToolRouteSectionProps { - routeLoading: boolean; - routeError: string | null; - routeStatus: KagentiRouteStatus | null; -} - -export function ToolRouteSection({ - routeLoading, - routeError, - routeStatus, -}: ToolRouteSectionProps): ReactNode { - if (routeLoading) { - return ( - - - - ); - } - if (routeError) { - return {routeError}; - } - if (!routeStatus || Object.keys(routeStatus).length === 0) { - return ( - - No route status data. - - ); - } - - const hasRoute = - 'hasRoute' in routeStatus - ? (routeStatus as Record).hasRoute - : undefined; - - if (hasRoute === false && Object.keys(routeStatus).length === 1) { - return ( - - No external route configured. The tool is accessible only within the - cluster via its internal service DNS name. - - ); - } - - const routeReady = 'ready' in routeStatus ? routeStatus.ready : undefined; - const routeStatusField = - 'status' in routeStatus ? routeStatus.status : undefined; - - const displayEntries = Object.entries(routeStatus).filter( - ([key]) => key !== 'hasRoute', - ); - - return ( - <> - - {routeReady !== undefined && ( - - )} - {routeStatusField !== undefined && routeStatusField !== null && ( - - )} - - {displayEntries.length > 0 && ( - <> - - {displayEntries.map(([key, val]) => ( - - ))} - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolSpecSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolSpecSection.tsx deleted file mode 100644 index 565873bc6b8..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolSpecSection.tsx +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ReactNode } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import { PropertyRow } from './KagentiPropertyRow'; -import { formatValue } from './kagentiDisplayUtils'; - -export interface ToolSpecSectionProps { - detailLoading: boolean; - detailError: string | null; - spec: Record | undefined; - protocol?: string | null; - framework?: string | null; -} - -function dig(obj: unknown, ...path: string[]): unknown { - let cur: unknown = obj; - for (const key of path) { - if (cur === null || cur === undefined || typeof cur !== 'object') - return undefined; - cur = (cur as Record)[key]; - } - return cur; -} - -function extractContainerImage( - spec: Record, -): string | undefined { - const image = dig(spec, 'template', 'spec', 'containers', '0', 'image') as - | string - | undefined; - if (image) return image; - - const containers = dig(spec, 'template', 'spec', 'containers') as - | unknown[] - | undefined; - if (Array.isArray(containers) && containers.length > 0) { - return (containers[0] as Record)?.image as - | string - | undefined; - } - return undefined; -} - -function extractEnvVars( - spec: Record, -): Array> { - const containers = dig(spec, 'template', 'spec', 'containers') as - | unknown[] - | undefined; - if (!Array.isArray(containers) || containers.length === 0) return []; - const env = (containers[0] as Record)?.env; - return Array.isArray(env) - ? env.map(e => - typeof e === 'object' && e !== null - ? (e as Record) - : {}, - ) - : []; -} - -function extractPorts( - spec: Record, -): Array> { - const containers = dig(spec, 'template', 'spec', 'containers') as - | unknown[] - | undefined; - if (!Array.isArray(containers) || containers.length === 0) return []; - const ports = (containers[0] as Record)?.ports; - return Array.isArray(ports) - ? ports.map(p => - typeof p === 'object' && p !== null - ? (p as Record) - : {}, - ) - : []; -} - -export function ToolSpecSection({ - detailLoading, - detailError, - spec, - protocol, - framework, -}: ToolSpecSectionProps): ReactNode { - if (detailLoading) { - return ( - - - - ); - } - if (detailError) { - return {detailError}; - } - if (!spec || Object.keys(spec).length === 0) { - return ( - - No spec available. - - ); - } - - const image = extractContainerImage(spec); - const envVars = extractEnvVars(spec); - const ports = extractPorts(spec); - - const summaryFields: Array<{ label: string; value: unknown }> = [ - { label: 'Protocol', value: protocol }, - { label: 'Framework', value: framework }, - { label: 'Image', value: image }, - ].filter(f => f.value !== undefined && f.value !== null && f.value !== ''); - - return ( - <> - {summaryFields.length > 0 ? ( - summaryFields.map(f => ( - - )) - ) : ( - - No deployment-level spec fields set. - - )} - - - Environment variables ({envVars.length}) - - {envVars.length > 0 ? ( - - {envVars.map((ev, i) => ( - - {String(ev.name ?? '')} - {ev.value ? ` = ${String(ev.value)}` : ''} - {ev.value_from || ev.valueFrom - ? ` (ref: ${formatValue(ev.value_from ?? ev.valueFrom)})` - : ''} - - ))} - - ) : ( - - — - - )} - - - - Container ports ({ports.length}) - - {ports.length > 0 ? ( - - {ports.map((p, i) => ( - - {p.name ? `${String(p.name)}: ` : ''} - {String(p.container_port ?? p.containerPort ?? '')} - {p.protocol ? ` (${String(p.protocol)})` : ''} - - ))} - - ) : ( - - — - - )} - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolWizardBasicsStep.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolWizardBasicsStep.tsx deleted file mode 100644 index 400c7ab7523..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolWizardBasicsStep.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { FC } from 'react'; -import Alert from '@mui/material/Alert'; -import FormControl from '@mui/material/FormControl'; -import FormHelperText from '@mui/material/FormHelperText'; -import InputLabel from '@mui/material/InputLabel'; -import MenuItem from '@mui/material/MenuItem'; -import Select from '@mui/material/Select'; -import Stack from '@mui/material/Stack'; -import TextField from '@mui/material/TextField'; -import Typography from '@mui/material/Typography'; -import { SELECT_MENU_PROPS } from '../shared/selectMenuProps'; - -interface ToolWizardBasicsStepProps { - name: string; - setName: (v: string) => void; - namespace: string; - setNamespace: (v: string) => void; - description: string; - setDescription: (v: string) => void; - protocol: string; - setProtocol: (v: string) => void; - framework: string; - setFramework: (v: string) => void; - nameError: string | undefined; - nameWarning: string | undefined; - availableNamespaces: string[]; -} - -export const ToolWizardBasicsStep: FC = ({ - name, - setName, - namespace, - setNamespace, - description, - setDescription, - protocol, - setProtocol, - framework, - setFramework, - nameError, - nameWarning, - availableNamespaces, -}) => ( - - - Define the identity of your MCP tool. The name and namespace determine - where it runs on the cluster. - - setName(e.target.value)} - fullWidth - required - size="small" - error={!!nameError} - helperText={ - nameError ?? 'Lowercase alphanumeric and hyphens (max 63 chars).' - } - placeholder="my-weather-tool" - /> - {nameWarning && !nameError && ( - - {nameWarning} - - )} - {availableNamespaces.length > 0 ? ( - - Namespace - - - ) : ( - setNamespace(e.target.value)} - fullWidth - required - size="small" - helperText="Target Kubernetes namespace for the tool." - /> - )} - setDescription(e.target.value)} - fullWidth - multiline - minRows={2} - size="small" - helperText="Brief description of what this tool does." - placeholder="e.g. Weather lookup via Open-Meteo API" - /> - - Protocol - - - Transport protocol for the tool endpoint. Streamable HTTP is recommended - for most MCP tools. - - - - Framework - - - Runtime language. Sets a label on the deployment — does not affect the - container itself. - - - -); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolWizardDeployStep.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolWizardDeployStep.tsx deleted file mode 100644 index 3c6700ae315..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolWizardDeployStep.tsx +++ /dev/null @@ -1,346 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { type FC, useState } from 'react'; -import type { KagentiBuildStrategy } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import AddIcon from '@mui/icons-material/Add'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import Alert from '@mui/material/Alert'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Collapse from '@mui/material/Collapse'; -import FormControl from '@mui/material/FormControl'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import FormLabel from '@mui/material/FormLabel'; -import IconButton from '@mui/material/IconButton'; -import InputLabel from '@mui/material/InputLabel'; -import Link from '@mui/material/Link'; -import MenuItem from '@mui/material/MenuItem'; -import Radio from '@mui/material/Radio'; -import RadioGroup from '@mui/material/RadioGroup'; -import Select from '@mui/material/Select'; -import Stack from '@mui/material/Stack'; -import TextField from '@mui/material/TextField'; -import Typography from '@mui/material/Typography'; -import type { BuildArgRow, DeploymentMethod } from './toolWizardTypes'; - -interface ToolWizardDeployStepProps { - deploymentMethod: DeploymentMethod; - setDeploymentMethod: (v: DeploymentMethod) => void; - containerImage: string; - setContainerImage: (v: string) => void; - imagePullSecret: string; - setImagePullSecret: (v: string) => void; - gitUrl: string; - setGitUrl: (v: string) => void; - gitRevision: string; - setGitRevision: (v: string) => void; - contextDir: string; - setContextDir: (v: string) => void; - registryUrl: string; - setRegistryUrl: (v: string) => void; - registrySecret: string; - setRegistrySecret: (v: string) => void; - imageTag: string; - setImageTag: (v: string) => void; - buildStrategy: string; - setBuildStrategy: (v: string) => void; - buildStrategies: KagentiBuildStrategy[]; - buildStrategyError: string | null; - dockerfile: string; - setDockerfile: (v: string) => void; - buildArgRows: BuildArgRow[]; - addBuildArgRow: () => void; - updateBuildArgRow: (id: number, value: string) => void; - removeBuildArgRow: (id: number) => void; - buildTimeout: string; - setBuildTimeout: (v: string) => void; -} - -export const ToolWizardDeployStep: FC = ({ - deploymentMethod, - setDeploymentMethod, - containerImage, - setContainerImage, - imagePullSecret, - setImagePullSecret, - gitUrl, - setGitUrl, - gitRevision, - setGitRevision, - contextDir, - setContextDir, - registryUrl, - setRegistryUrl, - registrySecret, - setRegistrySecret, - imageTag, - setImageTag, - buildStrategy, - setBuildStrategy, - buildStrategies, - buildStrategyError, - dockerfile, - setDockerfile, - buildArgRows, - addBuildArgRow, - updateBuildArgRow, - removeBuildArgRow, - buildTimeout, - setBuildTimeout, -}) => { - const [showAdvanced, setShowAdvanced] = useState(false); - - return ( - - - Choose how to deploy your tool — from a pre-built container image or by - building from source code. - - - Deployment method - setDeploymentMethod(v as DeploymentMethod)} - > - } - label={ - - - Container Image - - - Deploy from a pre-built image. Fastest option. - - - } - /> - } - label={ - - - Source from Git - - - Build the image from source via Shipwright, then deploy. Takes - a few minutes. - - - } - /> - - - - {deploymentMethod === 'image' ? ( - - setContainerImage(e.target.value)} - fullWidth - required - size="small" - placeholder="ghcr.io/your-org/tool-name:latest" - helperText="Full image reference including tag." - /> - setImagePullSecret(e.target.value)} - fullWidth - size="small" - helperText="Name of the Kubernetes Secret for private registries." - /> - - ) : ( - - setGitUrl(e.target.value)} - fullWidth - required - size="small" - placeholder="https://github.com/your-org/repo.git" - /> - - - setShowAdvanced(prev => !prev)} - sx={{ mb: showAdvanced ? 1 : 0 }} - > - {showAdvanced - ? 'Hide advanced build options' - : 'Show advanced build options'} - - - - setGitRevision(e.target.value)} - fullWidth - size="small" - placeholder="main" - helperText="Branch, tag, or commit SHA. Defaults to main." - /> - setContextDir(e.target.value)} - fullWidth - size="small" - placeholder="e.g. tools/my_tool" - helperText="Subdirectory containing the Dockerfile. Leave empty for repo root." - /> - setRegistryUrl(e.target.value)} - fullWidth - size="small" - placeholder="e.g. image-registry.openshift-image-registry.svc:5000/my-ns" - helperText="Container registry path including namespace (e.g. registry/namespace)." - /> - setRegistrySecret(e.target.value)} - fullWidth - size="small" - helperText="Kubernetes Secret name for authenticating with the target container registry." - /> - setImageTag(e.target.value)} - fullWidth - size="small" - placeholder="v0.0.1" - helperText="Tag for the built image. Defaults to v0.0.1." - /> - - {buildStrategyError && ( - {buildStrategyError} - )} - {buildStrategies.length > 0 ? ( - - Build strategy - - - ) : ( - setBuildStrategy(e.target.value)} - fullWidth - size="small" - placeholder="e.g. buildpacks-v3" - /> - )} - setDockerfile(e.target.value)} - fullWidth - size="small" - placeholder="Dockerfile" - helperText="Path to the Dockerfile relative to the context directory." - /> - - - Build arguments - - - {buildArgRows.map(row => ( - - - updateBuildArgRow(row.id, e.target.value) - } - size="small" - fullWidth - placeholder="ARG_NAME=value" - /> - removeBuildArgRow(row.id)} - size="small" - > - - - - ))} - - - - setBuildTimeout(e.target.value)} - fullWidth - size="small" - placeholder="15m" - helperText="Maximum build duration. Defaults to 15m." - /> - - - - - )} - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolWizardRuntimeStep.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolWizardRuntimeStep.tsx deleted file mode 100644 index 0d485147aa2..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/ToolWizardRuntimeStep.tsx +++ /dev/null @@ -1,401 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { FC } from 'react'; -import AddIcon from '@mui/icons-material/Add'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import FormControl from '@mui/material/FormControl'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import FormLabel from '@mui/material/FormLabel'; -import IconButton from '@mui/material/IconButton'; -import InputLabel from '@mui/material/InputLabel'; -import MenuItem from '@mui/material/MenuItem'; -import Radio from '@mui/material/Radio'; -import RadioGroup from '@mui/material/RadioGroup'; -import Select, { type SelectChangeEvent } from '@mui/material/Select'; -import Stack from '@mui/material/Stack'; -import Switch from '@mui/material/Switch'; -import TextField from '@mui/material/TextField'; -import Tooltip from '@mui/material/Tooltip'; -import Typography from '@mui/material/Typography'; -import type { - EnvRow, - EnvSource, - PortProtocol, - ServicePortRow, - WorkloadType, -} from './toolWizardTypes'; - -interface ToolWizardRuntimeStepProps { - workloadType: WorkloadType; - setWorkloadType: (v: WorkloadType) => void; - persistentStorageEnabled: boolean; - setPersistentStorageEnabled: (v: boolean) => void; - persistentStorageSize: string; - setPersistentStorageSize: (v: string) => void; - envRows: EnvRow[]; - addEnvRow: () => void; - updateEnvRow: (id: number, patch: Partial) => void; - removeEnvRow: (id: number) => void; - duplicateEnvNames: Set; - portRows: ServicePortRow[]; - addPortRow: () => void; - updatePortRow: (id: number, patch: Partial) => void; - removePortRow: (id: number) => void; - handlePortProtocol: (id: number, e: SelectChangeEvent) => void; - portErrors: Map; - createHttpRoute: boolean; - setCreateHttpRoute: (v: boolean) => void; - authBridgeEnabled: boolean; - setAuthBridgeEnabled: (v: boolean) => void; - spireEnabled: boolean; - setSpireEnabled: (v: boolean) => void; -} - -export const ToolWizardRuntimeStep: FC = ({ - workloadType, - setWorkloadType, - persistentStorageEnabled, - setPersistentStorageEnabled, - persistentStorageSize, - setPersistentStorageSize, - envRows, - addEnvRow, - updateEnvRow, - removeEnvRow, - duplicateEnvNames, - portRows, - addPortRow, - updatePortRow, - removePortRow, - handlePortProtocol, - portErrors, - createHttpRoute, - setCreateHttpRoute, - authBridgeEnabled, - setAuthBridgeEnabled, - spireEnabled, - setSpireEnabled, -}) => ( - - - Configure the runtime environment. The platform auto-configures port 8000 - and essential variables — only customize if your tool has specific - requirements. - - - Workload type - setWorkloadType(v as WorkloadType)} - > - } - label={ - - - Deployment - - - Standard for most tools. Stateless, easily scalable. - - - } - /> - } - label={ - - - StatefulSet - - - Use when the tool needs persistent disk storage across restarts. - - - } - /> - - - - {workloadType === 'statefulset' && ( - - setPersistentStorageEnabled(e.target.checked)} - size="small" - /> - } - label="Persistent storage" - /> - {persistentStorageEnabled && ( - setPersistentStorageSize(e.target.value)} - size="small" - sx={{ mt: 1, width: 200 }} - placeholder="1Gi" - helperText="e.g. 1Gi, 5Gi, 10Gi" - /> - )} - - )} - - - - Environment variables - - - The platform automatically sets PORT=8000 and HOST=0.0.0.0. Add - variables here only if your tool requires additional configuration. - - - {envRows.map(row => { - const isDupe = duplicateEnvNames.has(row.name.trim().toLowerCase()); - return ( - - - updateEnvRow(row.id, { name: e.target.value })} - size="small" - sx={{ flex: 1 }} - error={isDupe} - helperText={isDupe ? 'Duplicate' : undefined} - /> - - Source - - - removeEnvRow(row.id)} - size="small" - sx={{ mt: 0.5 }} - > - - - - {row.source === 'direct' ? ( - - updateEnvRow(row.id, { value: e.target.value }) - } - size="small" - fullWidth - /> - ) : ( - - - updateEnvRow(row.id, { refName: e.target.value }) - } - size="small" - sx={{ flex: 1 }} - /> - - updateEnvRow(row.id, { refKey: e.target.value }) - } - size="small" - sx={{ flex: 1 }} - /> - - )} - - ); - })} - - - - - - - Service ports - - - The platform auto-configures port 8000 (TCP) for MCP tools. Only add - ports if your tool listens on a different port or exposes multiple - endpoints. - - - {portRows.map(row => ( - - updatePortRow(row.id, { name: e.target.value })} - size="small" - sx={{ minWidth: 120, flex: '1 1 100px' }} - /> - updatePortRow(row.id, { port: e.target.value })} - size="small" - type="number" - inputProps={{ min: 1, max: 65535 }} - sx={{ width: 100 }} - error={portErrors.has(row.id)} - helperText={portErrors.get(row.id)} - /> - - updatePortRow(row.id, { targetPort: e.target.value }) - } - size="small" - type="number" - inputProps={{ min: 1, max: 65535 }} - sx={{ width: 120 }} - /> - - Protocol - - labelId={`tool-proto-${row.id}`} - label="Protocol" - value={row.protocol} - onChange={e => handlePortProtocol(row.id, e)} - > - TCP - UDP - - - removePortRow(row.id)} - size="small" - sx={{ mt: 0.5 }} - > - - - - ))} - - - - - - - setCreateHttpRoute(e.target.checked)} - size="small" - /> - } - label="Create HTTP route" - /> - - - setAuthBridgeEnabled(e.target.checked)} - size="small" - /> - } - label="Auth bridge enabled" - /> - - - setSpireEnabled(e.target.checked)} - size="small" - /> - } - label="SPIRE enabled" - /> - - - -); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/WizardShell.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/WizardShell.tsx deleted file mode 100644 index 89e424d6d69..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/WizardShell.tsx +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { type ReactElement, type ReactNode, useCallback, useId } from 'react'; -import Alert from '@mui/material/Alert'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import CircularProgress from '@mui/material/CircularProgress'; -import Dialog from '@mui/material/Dialog'; -import DialogActions from '@mui/material/DialogActions'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; -import Snackbar from '@mui/material/Snackbar'; -import Step from '@mui/material/Step'; -import StepLabel from '@mui/material/StepLabel'; -import Stepper from '@mui/material/Stepper'; -import Typography from '@mui/material/Typography'; - -export interface WizardShellProps { - open: boolean; - title: string; - subtitle?: string; - steps: string[]; - activeStep: number; - formStepCount: number; - stepIcons: Record; - stepDescriptions: Record; - isBuildStep: boolean; - buildActive: boolean; - submitting: boolean; - submitError: string | null; - onSubmitErrorClose: () => void; - submitLabel: string; - submittingLabel: string; - successOpen: boolean; - successMessage: string; - onSuccessClose: () => void; - onClose: () => void; - onCloseBuild: () => void; - onBack: () => void; - onNext: () => void; - onSubmit: () => void; - children: ReactNode; -} - -export function WizardShell({ - open, - title, - subtitle, - steps, - activeStep, - formStepCount, - stepIcons, - stepDescriptions, - isBuildStep, - buildActive, - submitting, - submitError, - onSubmitErrorClose, - submitLabel, - submittingLabel, - successOpen, - successMessage, - onSuccessClose, - onClose, - onCloseBuild, - onBack, - onNext, - onSubmit, - children, -}: WizardShellProps) { - const titleId = useId(); - - const handleDialogClose = useCallback( - (_: object, reason: 'backdropClick' | 'escapeKeyDown') => { - if (submitting) return; - if (reason === 'backdropClick' || reason === 'escapeKeyDown') { - if (buildActive) { - onCloseBuild(); - } else { - onClose(); - } - } - }, - [onClose, submitting, buildActive, onCloseBuild], - ); - - return ( - <> - - - {title} - {subtitle && ( - - {subtitle} - - )} - - - - {steps.map(label => ( - - - {label} - - - ))} - - - {!isBuildStep && stepDescriptions[steps[activeStep]] && ( - - {stepDescriptions[steps[activeStep]]} - - )} - - {submitError && !isBuildStep && ( - - {submitError} - - )} - - {children} - - - {!isBuildStep && ( - - - - {activeStep > 0 && ( - - )} - {activeStep < formStepCount - 1 ? ( - - ) : ( - - )} - - - )} - - - - - {successMessage} - - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/WorkflowAgentDetail.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/WorkflowAgentDetail.tsx deleted file mode 100644 index 5dfb6d0a46d..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/WorkflowAgentDetail.tsx +++ /dev/null @@ -1,791 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState, useCallback, useEffect, useMemo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import Snackbar from '@mui/material/Snackbar'; -import Tooltip from '@mui/material/Tooltip'; -import Skeleton from '@mui/material/Skeleton'; -import { useTheme } from '@mui/material/styles'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import ChatIcon from '@mui/icons-material/Chat'; -import PublishIcon from '@mui/icons-material/Publish'; -import CloudOffIcon from '@mui/icons-material/CloudOff'; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import EditIcon from '@mui/icons-material/Edit'; -import { - getLifecycleTransition, - getLifecycleStep, -} from './lifecycleTransitions'; -import { - LifecycleActionButtons, - useWithdrawHandler, -} from './LifecycleActionButtons'; -import { LifecycleDetailShell } from './LifecycleDetailShell'; -import { - useApi, - discoveryApiRef, - fetchApiRef, -} from '@backstage/core-plugin-api'; -import type { - ChatAgent, - AgentLifecycleStage, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { normalizeLifecycleStage } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { augmentApiRef } from '../../../api'; -import { - glassSurface, - borderRadius, - typeScale, - animations, - reducedMotion, -} from '../../../theme/tokens'; -import { CONTENT_MAX_WIDTH } from '../shared/commandCenterStyles'; -import { InlineAgentChat } from '../shared/InlineAgentChat'; - -type DetailTab = 'overview' | 'test'; - -export interface WorkflowAgentDetailProps { - agentId: string; - onBack: () => void; - onChatWithAgent?: (agentId: string) => void; - onEditInBuilder?: (agentId: string) => void; - isAdmin?: boolean; -} - -/** - * Detail view for workflow-builder agents (created via the no-code UI - * with the Responses API). Mirrors the structure and styling of - * AgentLifecycleDetail but without Kagenti-specific tabs (Build, - * Design, Agent Card) since those are for Kagenti-managed agents. - */ -function getAgentTypeLabel(framework?: string): string { - if (framework === 'docsclaw') return 'Skill Agent (DocsClaw)'; - if (framework === 'workflow-builder') - return 'Workflow Agent (No-Code Builder)'; - return 'Responses API Agent'; -} - -function getAgentChipLabel(framework?: string): string { - if (framework === 'docsclaw') return 'Skill Agent'; - if (framework === 'workflow-builder') return 'Workflow Agent'; - return 'Responses API'; -} - -export function WorkflowAgentDetail({ - agentId, - onBack, - onChatWithAgent, - onEditInBuilder, - isAdmin = false, -}: WorkflowAgentDetailProps) { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const api = useApi(augmentApiRef); - const [activeTab, setActiveTab] = useState('overview'); - - const [agent, setAgent] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [publishLoading, setPublishLoading] = useState(false); - const [publishToast, setPublishToast] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState(false); - const [deleteLoading, setDeleteLoading] = useState(false); - const [liveInfo, setLiveInfo] = useState<{ - health?: { status?: string; error?: string }; - models?: unknown; - skills?: { skills?: Array<{ id?: string; name?: string }> }; - systemPrompt?: string; - } | null>(null); - - const discoveryApi = useApi(discoveryApiRef); - const fetchApi = useApi(fetchApiRef); - - useEffect(() => { - let cancelled = false; - setLoading(true); - api - .listAgents() - .then(agents => { - if (cancelled) return; - const match = agents.find(a => a.id === agentId); - if (match) { - setAgent(match); - } else { - setError(`Agent "${agentId}" not found`); - } - }) - .catch(err => { - if (!cancelled) - setError(err instanceof Error ? err.message : 'Failed to load agent'); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [api, agentId]); - - useEffect(() => { - if (!agent?.chatEndpoint) return undefined; - let cancelled = false; - discoveryApi - .getBaseUrl('augment') - .then(baseUrl => - fetchApi.fetch( - `${baseUrl}/skills/agents/${encodeURIComponent(agentId)}/info`, - { headers: { 'X-Backstage-Request': 'augment' } }, - ), - ) - .then(r => r.json()) - .then(data => { - if (!cancelled) setLiveInfo(data); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, [agent?.chatEndpoint, agentId, discoveryApi, fetchApi]); - - const lifecycleStage = normalizeLifecycleStage(agent?.lifecycleStage); - - const nextTransition = useMemo(() => { - const t = getLifecycleTransition(lifecycleStage); - return { - ...t, - icon: t.iconType === 'promote' ? : , - }; - }, [lifecycleStage]); - - const handleLifecycleAction = useCallback(async () => { - setPublishLoading(true); - try { - if (nextTransition.action === 'demote') { - const result = await api.demoteAgent(agentId, nextTransition.target); - setAgent(prev => - prev - ? { - ...prev, - lifecycleStage: result.lifecycleStage as AgentLifecycleStage, - } - : prev, - ); - setPublishToast(`Agent moved to ${result.lifecycleStage}`); - } else { - const result = await api.promoteAgent(agentId, nextTransition.target); - setAgent(prev => - prev - ? { - ...prev, - lifecycleStage: result.lifecycleStage as AgentLifecycleStage, - } - : prev, - ); - setPublishToast( - `Agent moved to ${result.lifecycleStage} (v${result.version})`, - ); - } - } catch (err) { - setPublishToast( - `Failed: ${err instanceof Error ? err.message : 'Unknown'}`, - ); - } finally { - setPublishLoading(false); - } - }, [api, agentId, nextTransition]); - - const { handleWithdraw } = useWithdrawHandler({ - api, - agentId, - onSuccess: () => - setAgent(prev => - prev - ? { - ...prev, - lifecycleStage: - 'draft' as import('@red-hat-developer-hub/backstage-plugin-augment-common').AgentLifecycleStage, - } - : prev, - ), - setToast: setPublishToast, - setLoading: setPublishLoading, - }); - - const handleDelete = useCallback(async () => { - setDeleteLoading(true); - try { - await api.deleteAgentConfig(agentId); - setPublishToast('Agent deleted successfully'); - onBack(); - } catch (err) { - setPublishToast( - `Delete failed: ${err instanceof Error ? err.message : 'Unknown'}`, - ); - } finally { - setDeleteLoading(false); - setDeleteConfirm(false); - } - }, [api, agentId, onBack]); - - const currentStep = getLifecycleStep(lifecycleStage); - const glass = glassSurface(theme, 6); - - if (loading) { - return ( - - - - - - - - - ); - } - - if (error || !agent) { - return ( - - - - {error || 'Agent not found'} - - - ); - } - - return ( - - - - - - - {agent.name} - - {agent.status && ( - - )} - - - - - {agent.framework && ( - - )} - - - - {/* Action buttons */} - - - {lifecycleStage === 'draft' && - onEditInBuilder && - agent?.framework === 'workflow-builder' && ( - - - - )} - {lifecycleStage === 'draft' && ( - - - - )} - {onChatWithAgent && ( - - )} - - - {/* Delete confirmation */} - {deleteConfirm && ( - - - - - } - > - This will permanently delete this agent. This action cannot be - undone. - - )} - - - - {/* Tabs */} - - setActiveTab(v)} - sx={{ - minHeight: 40, - '& .MuiTab-root': { - minHeight: 40, - minWidth: 'auto', - px: 2.5, - textTransform: 'none', - fontSize: typeScale.body.fontSize, - fontWeight: 500, - }, - }} - > - - - - - - {/* Tab Content */} - {activeTab === 'overview' && ( - - - - - Agent Information - - - - - ID - - - {agent.id} - - - - Name - - {agent.name} - - {agent.description && ( - <> - - Description - - - {agent.description} - - - )} - - - Type - - - {getAgentTypeLabel(agent.framework)} - - - - Lifecycle Stage - - - {lifecycleStage} - - - {agent.promotedAt && ( - <> - - Last Promoted - - - {new Date(agent.promotedAt).toLocaleString()} - - - )} - - {agent.promotedBy && ( - <> - - Promoted By - - {agent.promotedBy} - - )} - - - Default Agent - - - {agent.isDefault ? 'Yes' : 'No'} - - - {agent.chatEndpoint && ( - <> - - Chat Endpoint - - - {agent.chatEndpoint} - - - )} - - {liveInfo?.health && ( - <> - - Pod Health - - - {liveInfo.health.status === 'healthy' ? ( - - ) : ( - - )} - - - )} - - {liveInfo?.skills?.skills && liveInfo.skills.skills.length > 0 && ( - <> - - Loaded Skills - - - {liveInfo.skills.skills.map((s, i) => ( - - ))} - - - )} - - {liveInfo?.systemPrompt && ( - <> - - Agent Instructions (system-prompt.txt) - - - {liveInfo.systemPrompt} - - - )} - - {agent.agentRole && ( - <> - - Role - - - {agent.agentRole} - - - )} - - - )} - - {activeTab === 'test' && ( - - )} - - setPublishToast(null)} - message={publishToast} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> -
    - ); -} - -// InlineAgentChat is now shared -- imported from ../shared/InlineAgentChat diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardTypes.test.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardTypes.test.ts deleted file mode 100644 index 011b524acaa..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardTypes.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { isValidDns1123 } from './agentWizardTypes'; - -describe('isValidDns1123', () => { - it.each(['my-agent', 'a', 'a1b2', '0starts-with-digit', 'a'.repeat(63)])( - 'accepts valid name %j', - name => { - expect(isValidDns1123(name)).toBe(true); - }, - ); - - it.each([ - ['empty string', ''], - ['uppercase', 'My-Agent'], - ['leading hyphen', '-start'], - ['trailing hyphen', 'end-'], - ['too long (64 chars)', 'a'.repeat(64)], - ['contains dot', 'has.dot'], - ['contains underscore', 'has_underscore'], - ['contains space', 'has space'], - ['only hyphens', '---'], - ['single hyphen', '-'], - ])('rejects invalid name: %s (%j)', (_label, name) => { - expect(isValidDns1123(name)).toBe(false); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardTypes.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardTypes.ts deleted file mode 100644 index c87ca11a3bc..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardTypes.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export const FORM_STEPS = ['Basics', 'Deployment', 'Runtime'] as const; -export const BUILD_STEP = 'Build & Deploy' as const; - -export type DeploymentMethod = 'image' | 'source'; -export type WorkloadType = 'deployment' | 'statefulset' | 'job'; -export type PortProtocol = 'TCP' | 'UDP'; -export type EnvSource = 'direct' | 'secret' | 'configMap'; - -export type BuildPhase = - | 'idle' - | 'submitting' - | 'building' - | 'finalizing' - | 'complete' - | 'failed'; - -export interface BuildProgress { - phase: BuildPhase; - buildRunName?: string; - buildRunPhase?: string; - startTime?: string; - elapsedMs: number; - message?: string; - failureMessage?: string; - outputImage?: string; - strategy?: string; - gitUrl?: string; - contextDir?: string; - pollErrorCount: number; - deployFailedAfterBuild?: boolean; -} - -const DNS_1123_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; - -export function isValidDns1123(value: string): boolean { - return DNS_1123_RE.test(value); -} - -export interface EnvRow { - id: number; - name: string; - value: string; - source: EnvSource; - refName: string; - refKey: string; -} - -export interface BuildArgRow { - id: number; - value: string; -} - -export interface ServicePortRow { - id: number; - name: string; - port: string; - targetPort: string; - protocol: PortProtocol; -} - -export interface FormState { - name: string; - namespace: string; - protocol: string; - framework: string; - deploymentMethod: DeploymentMethod; - containerImage: string; - imagePullSecret: string; - gitUrl: string; - gitBranch: string; - gitPath: string; - registryUrl: string; - registrySecret: string; - imageTag: string; - buildStrategy: string; - startCommand: string; - dockerfile: string; - buildArgRows: BuildArgRow[]; - buildTimeout: string; - workloadType: WorkloadType; - envRows: EnvRow[]; - portRows: ServicePortRow[]; - createHttpRoute: boolean; - authBridgeEnabled: boolean; - spireEnabled: boolean; -} - -export interface CreateAgentWizardProps { - open: boolean; - namespace?: string; - initialDeploymentMethod?: DeploymentMethod; - onClose: () => void; - onCreated: () => void; - onStepControl?: (setter: (step: number) => void) => void; - onDeployMethodControl?: (setter: (method: string) => void) => void; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardUtils.test.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardUtils.test.ts deleted file mode 100644 index ae7563c42ec..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardUtils.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { MutableRefObject } from 'react'; -import type { EnvRow, FormState, ServicePortRow } from './agentWizardTypes'; -import { - nextRowId, - buildEnvVars, - parsePositivePort, - buildServicePorts, - buildRequest, - getDuplicateEnvNames, -} from './agentWizardUtils'; - -function makeEnvRow(overrides: Partial & { id: number }): EnvRow { - return { - name: '', - value: '', - source: 'direct', - refName: '', - refKey: '', - ...overrides, - }; -} - -function makePortRow( - overrides: Partial & { id: number }, -): ServicePortRow { - return { - name: '', - port: '', - targetPort: '', - protocol: 'TCP', - ...overrides, - }; -} - -function baseFormState(overrides: Partial = {}): FormState { - return { - name: 'my-agent', - namespace: 'team-a', - protocol: 'a2a', - framework: '', - deploymentMethod: 'image', - containerImage: 'quay.io/org/agent:v1', - imagePullSecret: '', - gitUrl: '', - gitBranch: 'main', - gitPath: '', - registryUrl: '', - registrySecret: '', - imageTag: '', - buildStrategy: '', - startCommand: '', - dockerfile: 'Dockerfile', - buildArgRows: [], - buildTimeout: '15m', - workloadType: 'deployment', - envRows: [], - portRows: [], - createHttpRoute: false, - authBridgeEnabled: true, - spireEnabled: false, - ...overrides, - }; -} - -// ----------------------------------------------------------------------------- -// nextRowId -// ----------------------------------------------------------------------------- - -describe('nextRowId', () => { - it('increments the ref and returns the new value', () => { - const ref: MutableRefObject = { current: 0 }; - expect(nextRowId(ref)).toBe(1); - expect(ref.current).toBe(1); - }); - - it('produces incrementing IDs on sequential calls', () => { - const ref: MutableRefObject = { current: 5 }; - expect(nextRowId(ref)).toBe(6); - expect(nextRowId(ref)).toBe(7); - expect(nextRowId(ref)).toBe(8); - }); -}); - -// ----------------------------------------------------------------------------- -// buildEnvVars -// ----------------------------------------------------------------------------- - -describe('buildEnvVars', () => { - it('returns undefined for empty rows', () => { - expect(buildEnvVars([])).toBeUndefined(); - }); - - it('filters out rows with empty name', () => { - const rows = [makeEnvRow({ id: 1, name: '', value: 'val' })]; - expect(buildEnvVars(rows)).toBeUndefined(); - }); - - it('maps direct-value rows', () => { - const rows = [makeEnvRow({ id: 1, name: ' FOO ', value: ' bar ' })]; - expect(buildEnvVars(rows)).toEqual([{ name: 'FOO', value: 'bar' }]); - }); - - it('maps secret ref rows', () => { - const rows = [ - makeEnvRow({ - id: 1, - name: 'DB_PASS', - source: 'secret', - refName: 'my-secret', - refKey: 'password', - }), - ]; - expect(buildEnvVars(rows)).toEqual([ - { - name: 'DB_PASS', - valueFrom: { secretKeyRef: { name: 'my-secret', key: 'password' } }, - }, - ]); - }); - - it('maps configMap ref rows', () => { - const rows = [ - makeEnvRow({ - id: 1, - name: 'CONFIG', - source: 'configMap', - refName: 'my-cm', - refKey: 'setting', - }), - ]; - expect(buildEnvVars(rows)).toEqual([ - { - name: 'CONFIG', - valueFrom: { configMapKeyRef: { name: 'my-cm', key: 'setting' } }, - }, - ]); - }); - - it('falls through to direct value when ref fields are empty', () => { - const rows = [ - makeEnvRow({ - id: 1, - name: 'KEY', - source: 'secret', - refName: '', - refKey: '', - value: 'fallback', - }), - ]; - expect(buildEnvVars(rows)).toEqual([{ name: 'KEY', value: 'fallback' }]); - }); - - it('trims whitespace on all fields', () => { - const rows = [ - makeEnvRow({ - id: 1, - name: ' X ', - source: 'secret', - refName: ' sec ', - refKey: ' k ', - }), - ]; - expect(buildEnvVars(rows)).toEqual([ - { - name: 'X', - valueFrom: { secretKeyRef: { name: 'sec', key: 'k' } }, - }, - ]); - }); -}); - -// ----------------------------------------------------------------------------- -// parsePositivePort -// ----------------------------------------------------------------------------- - -describe('parsePositivePort', () => { - it.each([ - ['8080', 8080], - ['1', 1], - ['65535', 65535], - ['8080.9', 8080], - ])('parses %j as %d', (input, expected) => { - expect(parsePositivePort(input)).toBe(expected); - }); - - it.each([ - ['empty string', ''], - ['non-numeric', 'abc'], - ['zero', '0'], - ['negative', '-1'], - ['too large', '65536'], - ['NaN', 'NaN'], - ['Infinity', 'Infinity'], - ])('returns undefined for %s', (_label, input) => { - expect(parsePositivePort(input)).toBeUndefined(); - }); -}); - -// ----------------------------------------------------------------------------- -// buildServicePorts -// ----------------------------------------------------------------------------- - -describe('buildServicePorts', () => { - it('returns undefined for empty rows', () => { - expect(buildServicePorts([])).toBeUndefined(); - }); - - it('skips rows with invalid ports', () => { - const rows = [makePortRow({ id: 1, port: 'abc' })]; - expect(buildServicePorts(rows)).toBeUndefined(); - }); - - it('maps valid rows to KagentiServicePort objects', () => { - const rows = [ - makePortRow({ - id: 1, - name: 'http', - port: '8080', - targetPort: '8080', - protocol: 'TCP', - }), - ]; - expect(buildServicePorts(rows)).toEqual([ - { port: 8080, protocol: 'TCP', name: 'http', targetPort: 8080 }, - ]); - }); - - it('omits name when empty', () => { - const rows = [makePortRow({ id: 1, port: '3000' })]; - const result = buildServicePorts(rows)!; - expect(result[0]).not.toHaveProperty('name'); - }); - - it('omits targetPort when invalid', () => { - const rows = [makePortRow({ id: 1, port: '8080', targetPort: '' })]; - const result = buildServicePorts(rows)!; - expect(result[0]).not.toHaveProperty('targetPort'); - }); - - it('handles multiple rows, filtering invalid ones', () => { - const rows = [ - makePortRow({ id: 1, name: 'http', port: '8080' }), - makePortRow({ id: 2, port: 'bad' }), - makePortRow({ id: 3, name: 'grpc', port: '50051', protocol: 'UDP' }), - ]; - const result = buildServicePorts(rows)!; - expect(result).toHaveLength(2); - expect(result[0].port).toBe(8080); - expect(result[1].port).toBe(50051); - expect(result[1].protocol).toBe('UDP'); - }); -}); - -// ----------------------------------------------------------------------------- -// buildRequest -// ----------------------------------------------------------------------------- - -describe('buildRequest', () => { - it('always includes common fields', () => { - const result = buildRequest(baseFormState()); - expect(result).toMatchObject({ - name: 'my-agent', - namespace: 'team-a', - deploymentMethod: 'image', - workloadType: 'deployment', - createHttpRoute: false, - authBridgeEnabled: true, - spireEnabled: false, - }); - }); - - it('includes protocol and framework when non-empty', () => { - const result = buildRequest( - baseFormState({ protocol: 'mcp', framework: 'CrewAI' }), - ); - expect(result.protocol).toBe('mcp'); - expect(result.framework).toBe('CrewAI'); - }); - - it('omits protocol and framework when empty', () => { - const result = buildRequest(baseFormState({ protocol: '', framework: '' })); - expect(result).not.toHaveProperty('protocol'); - expect(result).not.toHaveProperty('framework'); - }); - - describe('image deployment', () => { - it('sets containerImage and imagePullSecret', () => { - const result = buildRequest( - baseFormState({ - containerImage: 'quay.io/img:v1', - imagePullSecret: 'my-secret', - }), - ); - expect(result.containerImage).toBe('quay.io/img:v1'); - expect(result.imagePullSecret).toBe('my-secret'); - expect(result).not.toHaveProperty('gitUrl'); - }); - - it('omits imagePullSecret when empty', () => { - const result = buildRequest(baseFormState()); - expect(result).not.toHaveProperty('imagePullSecret'); - }); - }); - - describe('source deployment', () => { - const sourceBase = baseFormState({ - deploymentMethod: 'source', - containerImage: '', - gitUrl: 'https://github.com/org/repo', - gitBranch: 'develop', - gitPath: 'src', - registryUrl: 'quay.io/org', - registrySecret: 'reg-secret', - imageTag: 'v2', - startCommand: 'python main.py', - }); - - it('sets source fields and omits containerImage', () => { - const result = buildRequest(sourceBase); - expect(result.gitUrl).toBe('https://github.com/org/repo'); - expect(result.gitBranch).toBe('develop'); - expect(result.gitPath).toBe('src'); - expect(result.registryUrl).toBe('quay.io/org'); - expect(result.registrySecret).toBe('reg-secret'); - expect(result.imageTag).toBe('v2'); - expect(result.startCommand).toBe('python main.py'); - expect(result).not.toHaveProperty('containerImage'); - }); - - it('builds shipwrightConfig with non-default values', () => { - const result = buildRequest({ - ...sourceBase, - buildStrategy: 'kaniko', - dockerfile: 'docker/Dockerfile.prod', - buildArgRows: [ - { id: 1, value: 'ARG1=val1' }, - { id: 2, value: '' }, - ], - buildTimeout: '30m', - }); - expect(result.shipwrightConfig).toEqual({ - buildStrategy: 'kaniko', - dockerfile: 'docker/Dockerfile.prod', - buildArgs: ['ARG1=val1'], - buildTimeout: '30m', - }); - }); - - it('normalizes gitPath by stripping leading dot-slash', () => { - const result = buildRequest({ - ...sourceBase, - gitPath: './a2a/a2a_currency_converter', - }); - expect(result.gitPath).toBe('a2a/a2a_currency_converter'); - }); - - it('omits gitPath when it is only a dot', () => { - const result = buildRequest({ ...sourceBase, gitPath: '.' }); - expect(result).not.toHaveProperty('gitPath'); - }); - - it('omits gitPath when it is dots only', () => { - const result = buildRequest({ ...sourceBase, gitPath: '..' }); - expect(result).not.toHaveProperty('gitPath'); - }); - - it('omits gitPath when empty', () => { - const result = buildRequest({ ...sourceBase, gitPath: '' }); - expect(result).not.toHaveProperty('gitPath'); - }); - - it('keeps gitPath as-is when it is a clean subdirectory', () => { - const result = buildRequest({ - ...sourceBase, - gitPath: 'agents/weather', - }); - expect(result.gitPath).toBe('agents/weather'); - }); - - it('omits shipwrightConfig when all values are defaults', () => { - const result = buildRequest({ - ...sourceBase, - buildStrategy: '', - dockerfile: 'Dockerfile', - buildArgRows: [], - buildTimeout: '15m', - }); - expect(result).not.toHaveProperty('shipwrightConfig'); - }); - - it('omits dockerfile from shipwrightConfig when it equals "Dockerfile"', () => { - const result = buildRequest({ - ...sourceBase, - buildStrategy: 'buildah', - dockerfile: 'Dockerfile', - }); - expect(result.shipwrightConfig).toEqual({ buildStrategy: 'buildah' }); - }); - }); - - it('attaches envVars when present', () => { - const result = buildRequest( - baseFormState({ - envRows: [makeEnvRow({ id: 1, name: 'FOO', value: 'bar' })], - }), - ); - expect(result.envVars).toEqual([{ name: 'FOO', value: 'bar' }]); - }); - - it('attaches servicePorts when present', () => { - const result = buildRequest( - baseFormState({ - portRows: [makePortRow({ id: 1, name: 'http', port: '8080' })], - }), - ); - expect(result.servicePorts).toEqual([ - { port: 8080, protocol: 'TCP', name: 'http' }, - ]); - }); - - it('omits envVars and servicePorts when empty', () => { - const result = buildRequest(baseFormState()); - expect(result).not.toHaveProperty('envVars'); - expect(result).not.toHaveProperty('servicePorts'); - }); - - it('trims whitespace from name and namespace', () => { - const result = buildRequest( - baseFormState({ name: ' padded ', namespace: ' ns ' }), - ); - expect(result.name).toBe('padded'); - expect(result.namespace).toBe('ns'); - }); -}); - -// ----------------------------------------------------------------------------- -// getDuplicateEnvNames -// ----------------------------------------------------------------------------- - -describe('getDuplicateEnvNames', () => { - it('returns empty set when no duplicates', () => { - const rows = [ - makeEnvRow({ id: 1, name: 'FOO' }), - makeEnvRow({ id: 2, name: 'BAR' }), - ]; - expect(getDuplicateEnvNames(rows).size).toBe(0); - }); - - it('detects case-insensitive duplicates', () => { - const rows = [ - makeEnvRow({ id: 1, name: 'FOO' }), - makeEnvRow({ id: 2, name: 'foo' }), - ]; - const dupes = getDuplicateEnvNames(rows); - expect(dupes.has('foo')).toBe(true); - expect(dupes.size).toBe(1); - }); - - it('ignores rows with empty names', () => { - const rows = [ - makeEnvRow({ id: 1, name: '' }), - makeEnvRow({ id: 2, name: '' }), - ]; - expect(getDuplicateEnvNames(rows).size).toBe(0); - }); - - it('trims names before comparing', () => { - const rows = [ - makeEnvRow({ id: 1, name: ' KEY ' }), - makeEnvRow({ id: 2, name: 'key' }), - ]; - expect(getDuplicateEnvNames(rows).has('key')).toBe(true); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardUtils.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardUtils.ts deleted file mode 100644 index b0aa738e78e..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/agentWizardUtils.ts +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - KagentiCreateAgentRequest, - KagentiEnvVar, - KagentiServicePort, - KagentiShipwrightConfig, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { EnvRow, FormState, ServicePortRow } from './agentWizardTypes'; -import type { MutableRefObject } from 'react'; - -export function nextRowId(ref: MutableRefObject): number { - ref.current += 1; - return ref.current; -} - -export function buildEnvVars(rows: EnvRow[]): KagentiEnvVar[] | undefined { - const list = rows - .filter(r => r.name.trim()) - .map(r => { - const ev: KagentiEnvVar = { name: r.name.trim() }; - if (r.source === 'secret' && r.refName.trim() && r.refKey.trim()) { - ev.valueFrom = { - secretKeyRef: { name: r.refName.trim(), key: r.refKey.trim() }, - }; - } else if ( - r.source === 'configMap' && - r.refName.trim() && - r.refKey.trim() - ) { - ev.valueFrom = { - configMapKeyRef: { name: r.refName.trim(), key: r.refKey.trim() }, - }; - } else if (r.value.trim()) { - ev.value = r.value.trim(); - } - return ev; - }); - return list.length ? list : undefined; -} - -export function parsePositivePort(s: string): number | undefined { - const n = Number(s); - if (!Number.isFinite(n)) return undefined; - const p = Math.floor(n); - if (p < 1 || p > 65535) return undefined; - return p; -} - -export function buildServicePorts( - rows: ServicePortRow[], -): KagentiServicePort[] | undefined { - const list = rows - .map(r => { - const port = parsePositivePort(r.port); - if (port === undefined) return null; - const sp: KagentiServicePort = { port, protocol: r.protocol }; - if (r.name.trim()) sp.name = r.name.trim(); - const tp = parsePositivePort(r.targetPort); - if (tp !== undefined) sp.targetPort = tp; - return sp; - }) - .filter((x): x is KagentiServicePort => x !== null); - return list.length ? list : undefined; -} - -export function buildRequest(s: FormState): KagentiCreateAgentRequest { - const body: KagentiCreateAgentRequest = { - name: s.name.trim(), - namespace: s.namespace.trim(), - deploymentMethod: s.deploymentMethod, - workloadType: s.workloadType, - createHttpRoute: s.createHttpRoute, - authBridgeEnabled: s.authBridgeEnabled, - spireEnabled: s.spireEnabled, - }; - - if (s.protocol.trim()) body.protocol = s.protocol.trim(); - if (s.framework.trim()) body.framework = s.framework.trim(); - - if (s.deploymentMethod === 'image') { - body.containerImage = s.containerImage.trim(); - if (s.imagePullSecret.trim()) - body.imagePullSecret = s.imagePullSecret.trim(); - } else { - body.gitUrl = s.gitUrl.trim(); - if (s.gitBranch.trim()) body.gitBranch = s.gitBranch.trim(); - const normalizedPath = s.gitPath - .trim() - .replace(/^\.\/+/, '') - .replace(/^\.+$/, ''); - if (normalizedPath) body.gitPath = normalizedPath; - if (s.registryUrl.trim()) body.registryUrl = s.registryUrl.trim(); - if (s.registrySecret.trim()) body.registrySecret = s.registrySecret.trim(); - if (s.imageTag.trim()) body.imageTag = s.imageTag.trim(); - if (s.startCommand.trim()) body.startCommand = s.startCommand.trim(); - - const swConfig: KagentiShipwrightConfig = {}; - if (s.buildStrategy.trim()) swConfig.buildStrategy = s.buildStrategy.trim(); - if (s.dockerfile.trim() && s.dockerfile.trim() !== 'Dockerfile') { - swConfig.dockerfile = s.dockerfile.trim(); - } - const args = s.buildArgRows.map(r => r.value.trim()).filter(Boolean); - if (args.length) swConfig.buildArgs = args; - if (s.buildTimeout.trim() && s.buildTimeout.trim() !== '15m') { - swConfig.buildTimeout = s.buildTimeout.trim(); - } - if (Object.keys(swConfig).length) body.shipwrightConfig = swConfig; - } - - const envVars = buildEnvVars(s.envRows); - if (envVars) body.envVars = envVars; - const servicePorts = buildServicePorts(s.portRows); - if (servicePorts) body.servicePorts = servicePorts; - - return body; -} - -export function getDuplicateEnvNames(rows: EnvRow[]): Set { - const seen = new Map(); - for (const r of rows) { - const n = r.name.trim().toLowerCase(); - if (n) seen.set(n, (seen.get(n) ?? 0) + 1); - } - const dupes = new Set(); - for (const [k, v] of seen) { - if (v > 1) dupes.add(k); - } - return dupes; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/index.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/index.ts deleted file mode 100644 index 2aab9b2ffaa..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { NamespacePicker, type NamespacePickerProps } from './NamespacePicker'; -export { - KagentiAgentsPanel, - type KagentiAgentsPanelProps, -} from './KagentiAgentsPanel'; -export { - KagentiToolsPanel, - type KagentiToolsPanelProps, -} from './KagentiToolsPanel'; -export { - KagentiAdminPanel, - type KagentiAdminPanelProps, -} from './KagentiAdminPanel'; -export { - KagentiDashboardLinks, - type KagentiDashboardLinksProps, -} from './KagentiDashboardLinks'; -export { - KagentiHomeDashboard, - type KagentiHomeDashboardProps, -} from './KagentiHomeDashboard'; -export { - KagentiAgentDetailView, - type KagentiAgentDetailViewProps, -} from './KagentiAgentDetailView'; -export { - CreateAgentWizard, - type CreateAgentWizardProps, -} from './CreateAgentWizard'; -export { - KagentiBuildPipelinePanel, - type KagentiBuildPipelinePanelProps, -} from './KagentiBuildPipelinePanel'; -export { - KagentiSandboxPanel, - type KagentiSandboxPanelProps, -} from './KagentiSandboxPanel'; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/kagentiDisplayUtils.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/kagentiDisplayUtils.ts deleted file mode 100644 index 51365ab4a67..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/kagentiDisplayUtils.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export function formatValue(v: unknown): string { - if (v === null || v === undefined) return '—'; - if (typeof v === 'object') { - try { - return JSON.stringify(v); - } catch { - return String(v); - } - } - return String(v); -} - -export function formatDateTime(iso?: string): string { - if (!iso) return '—'; - const d = new Date(iso); - return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); -} - -export function readSpecField( - spec: Record, - ...keys: string[] -): unknown { - for (const k of keys) { - if (spec[k] !== undefined && spec[k] !== null) return spec[k]; - } - return undefined; -} - -export function statusChipColor( - status: string | undefined, -): 'success' | 'error' | 'warning' | 'info' | 'default' { - if (!status) return 'default'; - const s = status.toLowerCase(); - if (s === 'ready' || s === 'running' || s === 'active' || s === 'true') - return 'success'; - if (s === 'error' || s === 'failed') return 'error'; - if (s === 'building' || s === 'pending') return 'info'; - if (s === 'warning' || s === 'degraded') return 'warning'; - return 'default'; -} - -export function buildRunPhaseChipColor( - phase?: string, -): 'success' | 'error' | 'info' | 'default' { - const p = phase?.toLowerCase(); - if (p === 'succeeded') return 'success'; - if (p === 'failed' || p === 'cancelled' || p === 'canceled') return 'error'; - if (p === 'running' || p === 'pending' || p === 'workqueue') return 'info'; - return 'default'; -} - -export function toolSummaryStatusChipColor( - status: string | undefined, -): 'success' | 'warning' | 'default' { - if (!status) return 'default'; - const s = status.toLowerCase(); - if (['running', 'ready', 'active'].includes(s)) return 'success'; - if (['pending', 'building'].includes(s)) return 'warning'; - return 'default'; -} - -export function routeReadyChipColor( - ready: unknown, -): 'success' | 'warning' | 'default' { - if (ready === true) return 'success'; - if (ready === false) return 'warning'; - return 'default'; -} - -export function routeStatusStringChipColor( - statusField: unknown, -): 'success' | 'error' | 'default' { - const s = String(statusField).toLowerCase(); - if (s === 'ready' || s === 'active') return 'success'; - if (s === 'failed') return 'error'; - return 'default'; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/kagentiTestFixtures.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/kagentiTestFixtures.ts deleted file mode 100644 index ab261c255a6..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/kagentiTestFixtures.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - KagentiAgentCard, - KagentiAgentSummary, - KagentiMigratableAgent, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -export function makeKagentiAgentSummary( - overrides: Partial = {}, -): KagentiAgentSummary { - return { - name: 'alpha-agent', - namespace: 'team-a', - description: 'First agent description', - status: 'ready', - labels: {}, - ...overrides, - }; -} - -export function makeKagentiAgentCard( - overrides: Partial = {}, -): KagentiAgentCard { - return { - name: 'Card Name', - version: '1.0.0', - url: 'https://agent.example/ws', - streaming: true, - skills: [], - ...overrides, - }; -} - -export function makeMigratableAgent( - overrides: Partial = {}, -): KagentiMigratableAgent { - return { - name: 'legacy-agent', - namespace: 'team-a', - status: 'Ready', - has_deployment: false, - labels: {}, - ...overrides, - }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/lifecycleTransitions.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/lifecycleTransitions.ts deleted file mode 100644 index a9c40df5480..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/lifecycleTransitions.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { AgentLifecycleStage } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -export interface LifecycleTransition { - target: AgentLifecycleStage; - label: string; - action: 'promote' | 'demote'; - variant: 'outlined' | 'contained'; - color: 'inherit' | 'primary' | 'success'; - iconType: 'promote' | 'demote'; -} - -const LIFECYCLE_TRANSITION_MAP: Record = { - draft: { - target: 'pending', - label: 'Submit for Review', - action: 'promote', - variant: 'contained', - color: 'primary', - iconType: 'promote', - }, - pending: { - target: 'published', - label: 'Approve and Publish', - action: 'promote', - variant: 'contained', - color: 'success', - iconType: 'promote', - }, - published: { - target: 'pending', - label: 'Request Unpublish', - action: 'demote', - variant: 'outlined', - color: 'inherit', - iconType: 'demote', - }, - archived: { - target: 'draft', - label: 'Restore', - action: 'demote', - variant: 'outlined', - color: 'inherit', - iconType: 'demote', - }, -}; - -export function getLifecycleTransition( - stage: string | null | undefined, -): LifecycleTransition { - return ( - LIFECYCLE_TRANSITION_MAP[stage ?? 'draft'] ?? LIFECYCLE_TRANSITION_MAP.draft - ); -} - -export function getLifecycleStep(lifecycleStage?: string | null): number { - if (lifecycleStage === 'archived') return 3; - if (lifecycleStage === 'published' || lifecycleStage === 'deployed') return 2; - if (lifecycleStage === 'pending' || lifecycleStage === 'registered') return 1; - return 0; -} - -export const LIFECYCLE_STEP_LABELS = [ - 'Draft', - 'Pending', - 'Published', - 'Archived', -] as const; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardTypes.test.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardTypes.test.ts deleted file mode 100644 index d8a38c83936..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardTypes.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { isValidDns1123, TOOL_STEPS } from './toolWizardTypes'; - -describe('TOOL_STEPS', () => { - it('has three steps', () => { - expect(TOOL_STEPS).toEqual(['Basics', 'Deployment', 'Runtime']); - }); -}); - -describe('isValidDns1123', () => { - it.each([ - ['my-tool', true], - ['a', true], - ['tool-123', true], - ['a-b-c', true], - ['a'.repeat(63), true], - ])('accepts %s', (input, expected) => { - expect(isValidDns1123(input)).toBe(expected); - }); - - it.each([ - ['UPPERCASE', false], - ['-leading', false], - ['trailing-', false], - ['has_underscore', false], - ['has.dot', false], - ['a'.repeat(64), false], - ['', false], - ])('rejects %s', (input, expected) => { - expect(isValidDns1123(input)).toBe(expected); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardTypes.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardTypes.ts deleted file mode 100644 index a44d83a8324..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardTypes.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export const TOOL_STEPS = ['Basics', 'Deployment', 'Runtime'] as const; -export const TOOL_BUILD_STEP = 'Build & Deploy' as const; - -export type DeploymentMethod = 'image' | 'source'; -export type WorkloadType = 'deployment' | 'statefulset'; -export type PortProtocol = 'TCP' | 'UDP'; -export type EnvSource = 'direct' | 'secret' | 'configMap'; - -const DNS_1123_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; - -export function isValidDns1123(value: string): boolean { - return DNS_1123_RE.test(value); -} - -export interface EnvRow { - id: number; - name: string; - value: string; - source: EnvSource; - refName: string; - refKey: string; -} - -export interface BuildArgRow { - id: number; - value: string; -} - -export interface ServicePortRow { - id: number; - name: string; - port: string; - targetPort: string; - protocol: PortProtocol; -} - -export interface ToolFormState { - name: string; - namespace: string; - description: string; - protocol: string; - framework: string; - deploymentMethod: DeploymentMethod; - containerImage: string; - imagePullSecret: string; - gitUrl: string; - gitRevision: string; - contextDir: string; - registryUrl: string; - registrySecret: string; - imageTag: string; - buildStrategy: string; - dockerfile: string; - buildArgRows: BuildArgRow[]; - buildTimeout: string; - workloadType: WorkloadType; - persistentStorageEnabled: boolean; - persistentStorageSize: string; - envRows: EnvRow[]; - portRows: ServicePortRow[]; - createHttpRoute: boolean; - authBridgeEnabled: boolean; - spireEnabled: boolean; -} - -export interface CreateToolWizardProps { - open: boolean; - namespace?: string; - onClose: () => void; - onCreated: () => void; - onStepControl?: (setter: (step: number) => void) => void; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardUtils.test.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardUtils.test.ts deleted file mode 100644 index b7c15b43960..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardUtils.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - buildEnvVars, - buildServicePorts, - buildToolRequest, - getDuplicateEnvNames, - parsePositivePort, -} from './toolWizardUtils'; -import type { EnvRow, ToolFormState } from './toolWizardTypes'; - -function baseState(): ToolFormState { - return { - name: 'my-tool', - namespace: 'team-a', - description: 'A test tool', - protocol: 'streamable_http', - framework: 'Python', - deploymentMethod: 'image', - containerImage: 'quay.io/my-tool:v1', - imagePullSecret: '', - gitUrl: '', - gitRevision: 'main', - contextDir: '', - registryUrl: '', - registrySecret: '', - imageTag: 'v0.0.1', - buildStrategy: '', - dockerfile: 'Dockerfile', - buildArgRows: [], - buildTimeout: '15m', - workloadType: 'deployment', - persistentStorageEnabled: false, - persistentStorageSize: '1Gi', - envRows: [], - portRows: [], - createHttpRoute: false, - authBridgeEnabled: false, - spireEnabled: false, - }; -} - -describe('parsePositivePort', () => { - it('returns valid port', () => expect(parsePositivePort('8080')).toBe(8080)); - it('rejects 0', () => expect(parsePositivePort('0')).toBeUndefined()); - it('rejects 65536', () => expect(parsePositivePort('65536')).toBeUndefined()); - it('rejects NaN', () => expect(parsePositivePort('abc')).toBeUndefined()); - it('accepts 1', () => expect(parsePositivePort('1')).toBe(1)); - it('accepts 65535', () => expect(parsePositivePort('65535')).toBe(65535)); -}); - -describe('buildEnvVars', () => { - it('returns undefined for empty rows', () => { - expect(buildEnvVars([])).toBeUndefined(); - }); - - it('builds direct value env vars', () => { - const rows: EnvRow[] = [ - { - id: 1, - name: 'FOO', - value: 'bar', - source: 'direct', - refName: '', - refKey: '', - }, - ]; - expect(buildEnvVars(rows)).toEqual([{ name: 'FOO', value: 'bar' }]); - }); - - it('builds secret env var', () => { - const rows: EnvRow[] = [ - { - id: 1, - name: 'DB_PASS', - value: '', - source: 'secret', - refName: 'my-secret', - refKey: 'password', - }, - ]; - const result = buildEnvVars(rows); - expect(result).toEqual([ - { - name: 'DB_PASS', - valueFrom: { secretKeyRef: { name: 'my-secret', key: 'password' } }, - }, - ]); - }); - - it('builds configMap env var', () => { - const rows: EnvRow[] = [ - { - id: 1, - name: 'APP_CFG', - value: '', - source: 'configMap', - refName: 'my-cm', - refKey: 'key1', - }, - ]; - const result = buildEnvVars(rows); - expect(result).toEqual([ - { - name: 'APP_CFG', - valueFrom: { configMapKeyRef: { name: 'my-cm', key: 'key1' } }, - }, - ]); - }); - - it('skips rows with empty names', () => { - const rows: EnvRow[] = [ - { - id: 1, - name: '', - value: 'val', - source: 'direct', - refName: '', - refKey: '', - }, - ]; - expect(buildEnvVars(rows)).toBeUndefined(); - }); -}); - -describe('buildServicePorts', () => { - it('returns undefined for empty rows', () => { - expect(buildServicePorts([])).toBeUndefined(); - }); - - it('builds valid port', () => { - const result = buildServicePorts([ - { - id: 1, - name: 'http', - port: '8080', - targetPort: '8080', - protocol: 'TCP', - }, - ]); - expect(result).toEqual([ - { name: 'http', port: 8080, targetPort: 8080, protocol: 'TCP' }, - ]); - }); - - it('skips invalid ports', () => { - const result = buildServicePorts([ - { id: 1, name: '', port: 'abc', targetPort: '', protocol: 'TCP' }, - ]); - expect(result).toBeUndefined(); - }); -}); - -describe('buildToolRequest', () => { - it('builds minimal image deployment request', () => { - const result = buildToolRequest(baseState()); - expect(result.name).toBe('my-tool'); - expect(result.namespace).toBe('team-a'); - expect(result.description).toBe('A test tool'); - expect(result.containerImage).toBe('quay.io/my-tool:v1'); - expect(result.deploymentMethod).toBe('image'); - expect(result.protocol).toBe('streamable_http'); - expect(result.framework).toBe('Python'); - }); - - it('builds source deployment with shipwright config', () => { - const state = { - ...baseState(), - deploymentMethod: 'source' as const, - gitUrl: 'https://github.com/org/repo', - buildStrategy: 'buildpacks-v3', - buildTimeout: '30m', - }; - const result = buildToolRequest(state); - expect(result.gitUrl).toBe('https://github.com/org/repo'); - expect(result.shipwrightConfig).toEqual({ - buildStrategy: 'buildpacks-v3', - buildTimeout: '30m', - }); - }); - - it('includes buildArgs in shipwright config', () => { - const state = { - ...baseState(), - deploymentMethod: 'source' as const, - gitUrl: 'https://github.com/org/repo', - buildArgRows: [ - { id: 1, value: 'ARG1=val1' }, - { id: 2, value: 'ARG2=val2' }, - { id: 3, value: ' ' }, - ], - }; - const result = buildToolRequest(state); - expect(result.shipwrightConfig).toEqual({ - buildArgs: ['ARG1=val1', 'ARG2=val2'], - }); - }); - - it('includes persistent storage for statefulset', () => { - const state = { - ...baseState(), - workloadType: 'statefulset' as const, - persistentStorageEnabled: true, - persistentStorageSize: '5Gi', - }; - const result = buildToolRequest(state); - expect(result.persistentStorage).toEqual({ enabled: true, size: '5Gi' }); - }); - - it('omits persistent storage for deployment', () => { - const state = { - ...baseState(), - workloadType: 'deployment' as const, - persistentStorageEnabled: true, - }; - const result = buildToolRequest(state); - expect(result.persistentStorage).toBeUndefined(); - }); - - it('includes env vars', () => { - const state = { - ...baseState(), - envRows: [ - { - id: 1, - name: 'FOO', - value: 'bar', - source: 'direct' as const, - refName: '', - refKey: '', - }, - ], - }; - const result = buildToolRequest(state); - expect(result.envVars).toEqual([{ name: 'FOO', value: 'bar' }]); - }); - - it('includes service ports', () => { - const state = { - ...baseState(), - portRows: [ - { - id: 1, - name: 'http', - port: '8080', - targetPort: '8080', - protocol: 'TCP' as const, - }, - ], - }; - const result = buildToolRequest(state); - expect(result.servicePorts).toEqual([ - { name: 'http', port: 8080, targetPort: 8080, protocol: 'TCP' }, - ]); - }); -}); - -describe('getDuplicateEnvNames', () => { - it('returns empty for no duplicates', () => { - const rows: EnvRow[] = [ - { - id: 1, - name: 'FOO', - value: '', - source: 'direct', - refName: '', - refKey: '', - }, - { - id: 2, - name: 'BAR', - value: '', - source: 'direct', - refName: '', - refKey: '', - }, - ]; - expect(getDuplicateEnvNames(rows).size).toBe(0); - }); - - it('detects duplicates case-insensitively', () => { - const rows: EnvRow[] = [ - { - id: 1, - name: 'FOO', - value: '', - source: 'direct', - refName: '', - refKey: '', - }, - { - id: 2, - name: 'foo', - value: '', - source: 'direct', - refName: '', - refKey: '', - }, - ]; - expect(getDuplicateEnvNames(rows).has('foo')).toBe(true); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardUtils.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardUtils.ts deleted file mode 100644 index f63a727dbea..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/toolWizardUtils.ts +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - KagentiCreateToolRequest, - KagentiEnvVar, - KagentiFinalizeToolBuildRequest, - KagentiServicePort, - KagentiShipwrightConfig, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { EnvRow, ToolFormState, ServicePortRow } from './toolWizardTypes'; -import type { MutableRefObject } from 'react'; - -export function nextRowId(ref: MutableRefObject): number { - ref.current += 1; - return ref.current; -} - -export function buildEnvVars(rows: EnvRow[]): KagentiEnvVar[] | undefined { - const list = rows - .filter(r => r.name.trim()) - .map(r => { - const ev: KagentiEnvVar = { name: r.name.trim() }; - if (r.source === 'secret' && r.refName.trim() && r.refKey.trim()) { - ev.valueFrom = { - secretKeyRef: { name: r.refName.trim(), key: r.refKey.trim() }, - }; - } else if ( - r.source === 'configMap' && - r.refName.trim() && - r.refKey.trim() - ) { - ev.valueFrom = { - configMapKeyRef: { name: r.refName.trim(), key: r.refKey.trim() }, - }; - } else if (r.value.trim()) { - ev.value = r.value.trim(); - } - return ev; - }) - .filter(ev => ev.value !== undefined || ev.valueFrom !== undefined); - return list.length ? list : undefined; -} - -export function parsePositivePort(s: string): number | undefined { - const n = Number(s); - if (!Number.isFinite(n)) return undefined; - const p = Math.floor(n); - if (p < 1 || p > 65535) return undefined; - return p; -} - -export function buildServicePorts( - rows: ServicePortRow[], -): KagentiServicePort[] | undefined { - const list = rows - .map(r => { - const port = parsePositivePort(r.port); - if (port === undefined) return null; - const sp: KagentiServicePort = { port, protocol: r.protocol }; - if (r.name.trim()) sp.name = r.name.trim(); - const tp = parsePositivePort(r.targetPort); - if (tp !== undefined) sp.targetPort = tp; - return sp; - }) - .filter((x): x is KagentiServicePort => x !== null); - return list.length ? list : undefined; -} - -export function buildToolRequest(s: ToolFormState): KagentiCreateToolRequest { - const body: KagentiCreateToolRequest = { - name: s.name.trim(), - namespace: s.namespace.trim(), - deploymentMethod: s.deploymentMethod, - workloadType: s.workloadType, - createHttpRoute: s.createHttpRoute, - authBridgeEnabled: s.authBridgeEnabled, - spireEnabled: s.spireEnabled, - }; - - if (s.description.trim()) body.description = s.description.trim(); - if (s.protocol.trim()) body.protocol = s.protocol.trim(); - if (s.framework.trim()) body.framework = s.framework.trim(); - - if (s.deploymentMethod === 'image') { - body.containerImage = s.containerImage.trim(); - if (s.imagePullSecret.trim()) - body.imagePullSecret = s.imagePullSecret.trim(); - } else { - body.gitUrl = s.gitUrl.trim(); - if (s.gitRevision.trim()) body.gitRevision = s.gitRevision.trim(); - const normalizedDir = s.contextDir - .trim() - .replace(/^\.\/+/, '') - .replace(/^\.+$/, ''); - if (normalizedDir) body.contextDir = normalizedDir; - if (s.registryUrl.trim()) body.registryUrl = s.registryUrl.trim(); - if (s.registrySecret.trim()) body.registrySecret = s.registrySecret.trim(); - if (s.imageTag.trim()) body.imageTag = s.imageTag.trim(); - - const swConfig: KagentiShipwrightConfig = {}; - if (s.buildStrategy.trim()) swConfig.buildStrategy = s.buildStrategy.trim(); - if (s.dockerfile.trim() && s.dockerfile.trim() !== 'Dockerfile') { - swConfig.dockerfile = s.dockerfile.trim(); - } - const args = s.buildArgRows.map(r => r.value.trim()).filter(Boolean); - if (args.length) swConfig.buildArgs = args; - if (s.buildTimeout.trim() && s.buildTimeout.trim() !== '15m') { - swConfig.buildTimeout = s.buildTimeout.trim(); - } - if (Object.keys(swConfig).length) body.shipwrightConfig = swConfig; - } - - if (s.workloadType === 'statefulset' && s.persistentStorageEnabled) { - body.persistentStorage = { - enabled: true, - size: s.persistentStorageSize.trim() || '1Gi', - }; - } - - const envVars = buildEnvVars(s.envRows); - if (envVars) body.envVars = envVars; - const servicePorts = buildServicePorts(s.portRows); - if (servicePorts) body.servicePorts = servicePorts; - - return body; -} - -export function buildFinalizeBody( - s: ToolFormState, -): KagentiFinalizeToolBuildRequest { - const body: KagentiFinalizeToolBuildRequest = {}; - if (s.protocol.trim()) body.protocol = s.protocol.trim(); - if (s.framework.trim()) body.framework = s.framework.trim(); - body.workloadType = s.workloadType; - body.createHttpRoute = s.createHttpRoute; - body.authBridgeEnabled = s.authBridgeEnabled; - if (s.workloadType === 'statefulset' && s.persistentStorageEnabled) { - body.persistentStorage = { - enabled: true, - size: s.persistentStorageSize.trim() || '1Gi', - }; - } - const envVars = buildEnvVars(s.envRows); - if (envVars) body.envVars = envVars; - const servicePorts = buildServicePorts(s.portRows); - if (servicePorts) body.servicePorts = servicePorts; - if (s.imagePullSecret.trim()) body.imagePullSecret = s.imagePullSecret.trim(); - return body; -} - -export function getDuplicateEnvNames(rows: EnvRow[]): Set { - const seen = new Map(); - for (const r of rows) { - const n = r.name.trim().toLowerCase(); - if (n) seen.set(n, (seen.get(n) ?? 0) + 1); - } - const dupes = new Set(); - for (const [k, v] of seen) { - if (v > 1) dupes.add(k); - } - return dupes; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useAgentTemplates.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useAgentTemplates.ts deleted file mode 100644 index 1dcca1b0692..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useAgentTemplates.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useEffect, useState, useMemo } from 'react'; -import { useApiHolder } from '@backstage/core-plugin-api'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import type { Entity } from '@backstage/catalog-model'; -import { getErrorMessage } from '../../../utils'; - -export interface UseAgentTemplatesOptions { - tag?: string; - specType?: string; -} - -export interface UseAgentTemplatesReturn { - templates: Entity[]; - loading: boolean; - error: string | null; - reload: () => void; -} - -export function useAgentTemplates( - options?: UseAgentTemplatesOptions, -): UseAgentTemplatesReturn { - const apiHolder = useApiHolder(); - const catalogApi = useMemo(() => { - try { - return apiHolder.get(catalogApiRef); - } catch { - return undefined; - } - }, [apiHolder]); - - const [templates, setTemplates] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [tick, setTick] = useState(0); - - const tag = options?.tag; - const specType = options?.specType; - - useEffect(() => { - if (!catalogApi) { - setLoading(false); - setError( - 'Catalog API is not available. Templates require a full Backstage deployment with the catalog plugin.', - ); - return undefined; - } - - let cancelled = false; - setLoading(true); - setError(null); - - const baseFilter: Record = { kind: 'Template' }; - if (tag) { - baseFilter['metadata.tags'] = tag; - } - - async function fetchTemplates(): Promise { - if (specType) { - const typed = await catalogApi!.getEntities({ - filter: { ...baseFilter, 'spec.type': specType }, - }); - if (typed.items.length > 0) return typed.items; - } - const all = await catalogApi!.getEntities({ filter: baseFilter }); - return all.items; - } - - fetchTemplates() - .then(items => { - if (!cancelled) setTemplates(items); - }) - .catch(err => { - if (!cancelled) setError(getErrorMessage(err)); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [catalogApi, tag, specType, tick]); - - const reload = () => setTick(t => t + 1); - - return { templates, loading, error, reload }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useAgentWizardForm.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useAgentWizardForm.test.tsx deleted file mode 100644 index bcbafa335fc..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useAgentWizardForm.test.tsx +++ /dev/null @@ -1,448 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { renderHook, act, waitFor } from '@testing-library/react'; -import { createApiTestWrapper } from '../../../test-utils'; -import { useAgentWizardForm } from './useAgentWizardForm'; - -function createMockApi(overrides: Record = {}) { - return { - listKagentiBuildStrategies: jest.fn().mockResolvedValue({ - strategies: [{ name: 'buildah' }, { name: 'kaniko' }], - }), - listKagentiNamespaces: jest.fn().mockResolvedValue({ - namespaces: ['team-a', 'team-b'], - }), - createKagentiAgent: jest.fn().mockResolvedValue({ - success: true, - name: 'test-agent', - namespace: 'team-a', - message: 'Agent created', - }), - ...overrides, - }; -} - -function renderForm( - open: boolean, - namespaceProp: string | undefined, - api = createMockApi(), -) { - const onClose = jest.fn(); - const onCreated = jest.fn(); - const wrapper = createApiTestWrapper(api); - const hook = renderHook( - ({ o, ns }) => useAgentWizardForm(o, ns, onClose, onCreated), - { wrapper, initialProps: { o: open, ns: namespaceProp } }, - ); - return { ...hook, onClose, onCreated, api }; -} - -// ----------------------------------------------------------------------------- -// Initialization -// ----------------------------------------------------------------------------- - -describe('useAgentWizardForm — initialization', () => { - it('has correct default state', () => { - const { result } = renderForm(false, undefined); - expect(result.current.activeStep).toBe(0); - expect(result.current.submitting).toBe(false); - expect(result.current.submitError).toBeNull(); - expect(result.current.name).toBe(''); - expect(result.current.namespace).toBe(''); - expect(result.current.protocol).toBe('a2a'); - expect(result.current.deploymentMethod).toBe('image'); - expect(result.current.authBridgeEnabled).toBe(true); - expect(result.current.spireEnabled).toBe(false); - expect(result.current.createHttpRoute).toBe(false); - expect(result.current.dockerfile).toBe('Dockerfile'); - expect(result.current.buildTimeout).toBe('15m'); - }); - - it('uses namespaceProp as initial namespace', () => { - const { result } = renderForm(false, 'custom-ns'); - expect(result.current.namespace).toBe('custom-ns'); - }); - - it('fetches build strategies and namespaces on open', async () => { - const api = createMockApi(); - const { result, rerender } = renderForm(false, undefined, api); - - expect(api.listKagentiBuildStrategies).not.toHaveBeenCalled(); - - rerender({ o: true, ns: undefined }); - - await waitFor(() => { - expect(result.current.buildStrategies).toHaveLength(2); - }); - expect(result.current.availableNamespaces).toEqual(['team-a', 'team-b']); - expect(api.listKagentiBuildStrategies).toHaveBeenCalledTimes(1); - expect(api.listKagentiNamespaces).toHaveBeenCalledTimes(1); - }); - - it('sets buildStrategyError on fetch failure', async () => { - const api = createMockApi({ - listKagentiBuildStrategies: jest - .fn() - .mockRejectedValue(new Error('fail')), - }); - const { result, rerender } = renderForm(false, undefined, api); - - rerender({ o: true, ns: undefined }); - - await waitFor(() => { - expect(result.current.buildStrategyError).toBe( - 'Failed to load build strategies.', - ); - }); - expect(result.current.buildStrategies).toEqual([]); - }); -}); - -// ----------------------------------------------------------------------------- -// Validation and navigation -// ----------------------------------------------------------------------------- - -describe('useAgentWizardForm — validation and navigation', () => { - it('blocks step 0 with empty name', async () => { - const { result, rerender } = renderForm(false, undefined); - rerender({ o: true, ns: undefined }); - - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(0); - expect(result.current.submitError).toBe('Name and namespace are required.'); - }); - - it('blocks step 0 with invalid DNS name', async () => { - const { result, rerender } = renderForm(false, 'team-a'); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - act(() => result.current.setName('My Agent')); - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(0); - expect(result.current.submitError).toBe( - 'Agent name must be a valid DNS-1123 label.', - ); - }); - - it('advances to step 1 with valid name and namespace', async () => { - const { result, rerender } = renderForm(false, 'team-a'); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - act(() => result.current.setName('my-agent')); - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(1); - expect(result.current.submitError).toBeNull(); - }); - - it('blocks step 1 (image) with empty containerImage', async () => { - const { result, rerender } = renderForm(false, 'team-a'); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - act(() => result.current.setName('my-agent')); - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(1); - - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(1); - expect(result.current.submitError).toBe('Container image is required.'); - }); - - it('advances to step 2 with valid image', async () => { - const { result, rerender } = renderForm(false, 'team-a'); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - act(() => result.current.setName('my-agent')); - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(1); - - act(() => result.current.setContainerImage('quay.io/img:v1')); - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(2); - }); - - it('handleBack goes back and stays at 0', async () => { - const { result, rerender } = renderForm(false, 'team-a'); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - act(() => result.current.setName('my-agent')); - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(1); - - act(() => result.current.handleBack()); - expect(result.current.activeStep).toBe(0); - - act(() => result.current.handleBack()); - expect(result.current.activeStep).toBe(0); - }); - - it('blocks step 1 (source) with empty gitUrl', async () => { - const { result, rerender } = renderForm(false, 'team-a'); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - act(() => result.current.setName('my-agent')); - act(() => result.current.setDeploymentMethod('source')); - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(1); - - act(() => result.current.handleNext()); - expect(result.current.submitError).toBe( - 'Git URL is required for source deployment.', - ); - }); -}); - -// ----------------------------------------------------------------------------- -// Computed values -// ----------------------------------------------------------------------------- - -describe('useAgentWizardForm — computed values', () => { - it('nameError is undefined for empty and valid names', () => { - const { result } = renderForm(false, undefined); - expect(result.current.nameError).toBeUndefined(); - - act(() => result.current.setName('valid-name')); - expect(result.current.nameError).toBeUndefined(); - }); - - it('nameError returns message for invalid DNS name', () => { - const { result } = renderForm(false, undefined); - act(() => result.current.setName('Invalid')); - expect(result.current.nameError).toContain('Lowercase alphanumeric'); - }); - - it('duplicateEnvNames detects case-insensitive duplicates', () => { - const { result } = renderForm(false, undefined); - act(() => { - result.current.addEnvRow(); - result.current.addEnvRow(); - }); - const [row1, row2] = result.current.envRows; - act(() => { - result.current.updateEnvRow(row1.id, { name: 'FOO' }); - result.current.updateEnvRow(row2.id, { name: 'foo' }); - }); - expect(result.current.duplicateEnvNames.has('foo')).toBe(true); - }); - - it('portErrors flags invalid port values', () => { - const { result } = renderForm(false, undefined); - act(() => result.current.addPortRow()); - const row = result.current.portRows[0]; - act(() => result.current.updatePortRow(row.id, { port: 'abc' })); - expect(result.current.portErrors.has(row.id)).toBe(true); - }); -}); - -// ----------------------------------------------------------------------------- -// Row CRUD -// ----------------------------------------------------------------------------- - -describe('useAgentWizardForm — row CRUD', () => { - it('addEnvRow / updateEnvRow / removeEnvRow', () => { - const { result } = renderForm(false, undefined); - expect(result.current.envRows).toHaveLength(0); - - act(() => result.current.addEnvRow()); - expect(result.current.envRows).toHaveLength(1); - const id = result.current.envRows[0].id; - - act(() => result.current.updateEnvRow(id, { name: 'CHANGED' })); - expect(result.current.envRows[0].name).toBe('CHANGED'); - - act(() => result.current.removeEnvRow(id)); - expect(result.current.envRows).toHaveLength(0); - }); - - it('addBuildArgRow / updateBuildArgRow / removeBuildArgRow', () => { - const { result } = renderForm(false, undefined); - expect(result.current.buildArgRows).toHaveLength(0); - - act(() => result.current.addBuildArgRow()); - expect(result.current.buildArgRows).toHaveLength(1); - const id = result.current.buildArgRows[0].id; - - act(() => result.current.updateBuildArgRow(id, 'ARG=val')); - expect(result.current.buildArgRows[0].value).toBe('ARG=val'); - - act(() => result.current.removeBuildArgRow(id)); - expect(result.current.buildArgRows).toHaveLength(0); - }); - - it('addPortRow / updatePortRow / removePortRow', () => { - const { result } = renderForm(false, undefined); - expect(result.current.portRows).toHaveLength(0); - - act(() => result.current.addPortRow()); - expect(result.current.portRows).toHaveLength(1); - const id = result.current.portRows[0].id; - - act(() => result.current.updatePortRow(id, { name: 'http', port: '8080' })); - expect(result.current.portRows[0].name).toBe('http'); - expect(result.current.portRows[0].port).toBe('8080'); - - act(() => result.current.removePortRow(id)); - expect(result.current.portRows).toHaveLength(0); - }); -}); - -// ----------------------------------------------------------------------------- -// Submit flow -// ----------------------------------------------------------------------------- - -describe('useAgentWizardForm — submit flow', () => { - async function advanceToStep2(result: { - current: ReturnType; - }) { - act(() => { - result.current.setName('my-agent'); - result.current.setNamespace('team-a'); - result.current.handleNext(); - }); - act(() => { - result.current.setContainerImage('quay.io/img:v1'); - result.current.handleNext(); - }); - } - - it('calls createKagentiAgent on successful submit', async () => { - const api = createMockApi(); - const { result, rerender, onClose, onCreated } = renderForm( - false, - 'team-a', - api, - ); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - await advanceToStep2(result); - - await act(async () => { - await result.current.handleSubmit(); - }); - - expect(api.createKagentiAgent).toHaveBeenCalledTimes(1); - expect(api.createKagentiAgent).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'my-agent', - namespace: 'team-a', - deploymentMethod: 'image', - containerImage: 'quay.io/img:v1', - }), - ); - expect(result.current.successOpen).toBe(true); - expect(onCreated).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('sets submitError on API failure', async () => { - const api = createMockApi({ - createKagentiAgent: jest - .fn() - .mockRejectedValue(new Error('Server error')), - }); - const { result, rerender, onClose } = renderForm(false, 'team-a', api); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - await advanceToStep2(result); - - await act(async () => { - await result.current.handleSubmit(); - }); - - expect(result.current.submitError).toBe('Server error'); - expect(result.current.submitting).toBe(false); - expect(onClose).not.toHaveBeenCalled(); - }); - - it('rejects submit when port errors exist', async () => { - const api = createMockApi(); - const { result, rerender } = renderForm(false, 'team-a', api); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - await advanceToStep2(result); - - act(() => { - result.current.addPortRow(); - }); - const portId = result.current.portRows[0].id; - act(() => { - result.current.updatePortRow(portId, { port: 'bad' }); - }); - - await act(async () => { - await result.current.handleSubmit(); - }); - - expect(result.current.submitError).toBe( - 'Fix invalid service port entries before submitting.', - ); - expect(api.createKagentiAgent).not.toHaveBeenCalled(); - }); - - it('re-validates step 0 on submit even from step 2', async () => { - const api = createMockApi(); - const { result, rerender } = renderForm(false, 'team-a', api); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - await advanceToStep2(result); - - act(() => result.current.setName('')); - - await act(async () => { - await result.current.handleSubmit(); - }); - - expect(result.current.activeStep).toBe(0); - expect(result.current.submitError).toContain( - 'Name and namespace are required', - ); - expect(api.createKagentiAgent).not.toHaveBeenCalled(); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useAgentWizardForm.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useAgentWizardForm.ts deleted file mode 100644 index 54cd9e7e845..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useAgentWizardForm.ts +++ /dev/null @@ -1,820 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import type { KagentiBuildStrategy } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { SelectChangeEvent } from '@mui/material/Select'; -import { augmentApiRef } from '../../../api'; -import { getErrorMessage } from '../../../utils'; -import type { - BuildArgRow, - BuildProgress, - DeploymentMethod, - EnvRow, - EnvSource, - FormState, - PortProtocol, - ServicePortRow, - WorkloadType, -} from './agentWizardTypes'; -import { isValidDns1123, FORM_STEPS } from './agentWizardTypes'; -import { - buildRequest, - getDuplicateEnvNames, - nextRowId, - parsePositivePort, -} from './agentWizardUtils'; - -const BUILD_POLL_INTERVAL_MS = 4000; -const BUILD_TIMEOUT_WARN_MS = 10 * 60 * 1000; -const MAX_CONSECUTIVE_POLL_ERRORS = 5; - -const INTERNAL_REGISTRY_BASE = - 'image-registry.openshift-image-registry.svc:5000'; - -function defaultRegistryUrl(ns: string): string { - return ns ? `${INTERNAL_REGISTRY_BASE}/${ns}` : INTERNAL_REGISTRY_BASE; -} - -const INTERNAL_REGISTRY_RE = new RegExp( - `^${INTERNAL_REGISTRY_BASE.replace(/\./g, '\\.')}(\\/[a-z0-9-]*)?$`, -); - -export interface UseAgentWizardFormReturn { - // Wizard chrome - activeStep: number; - setActiveStep: (step: number) => void; - submitting: boolean; - submitError: string | null; - setSubmitError: (v: string | null) => void; - successOpen: boolean; - setSuccessOpen: (v: boolean) => void; - handleNext: () => void; - handleBack: () => void; - handleSubmit: () => Promise; - - // Build progress (source deployments) - buildProgress: BuildProgress; - handleRetryBuild: () => void; - handleCloseBuild: () => void; - - // Step 0 — Basics - name: string; - setName: (v: string) => void; - namespace: string; - setNamespace: (v: string) => void; - protocol: string; - setProtocol: (v: string) => void; - framework: string; - setFramework: (v: string) => void; - availableNamespaces: string[]; - nameError: string | undefined; - - // Step 1 — Deployment - deploymentMethod: DeploymentMethod; - setDeploymentMethod: (v: DeploymentMethod) => void; - containerImage: string; - setContainerImage: (v: string) => void; - imagePullSecret: string; - setImagePullSecret: (v: string) => void; - gitUrl: string; - setGitUrl: (v: string) => void; - gitBranch: string; - setGitBranch: (v: string) => void; - gitPath: string; - setGitPath: (v: string) => void; - registryUrl: string; - setRegistryUrl: (v: string) => void; - registrySecret: string; - setRegistrySecret: (v: string) => void; - imageTag: string; - setImageTag: (v: string) => void; - buildStrategy: string; - setBuildStrategy: (v: string) => void; - buildStrategies: KagentiBuildStrategy[]; - buildStrategyError: string | null; - startCommand: string; - setStartCommand: (v: string) => void; - dockerfile: string; - setDockerfile: (v: string) => void; - buildArgRows: BuildArgRow[]; - addBuildArgRow: () => void; - updateBuildArgRow: (id: number, value: string) => void; - removeBuildArgRow: (id: number) => void; - buildTimeout: string; - setBuildTimeout: (v: string) => void; - - // Step 2 — Runtime - workloadType: WorkloadType; - setWorkloadType: (v: WorkloadType) => void; - envRows: EnvRow[]; - addEnvRow: () => void; - updateEnvRow: (id: number, patch: Partial) => void; - removeEnvRow: (id: number) => void; - portRows: ServicePortRow[]; - addPortRow: () => void; - updatePortRow: (id: number, patch: Partial) => void; - removePortRow: (id: number) => void; - handlePortProtocol: (id: number, e: SelectChangeEvent) => void; - createHttpRoute: boolean; - setCreateHttpRoute: (v: boolean) => void; - authBridgeEnabled: boolean; - setAuthBridgeEnabled: (v: boolean) => void; - spireEnabled: boolean; - setSpireEnabled: (v: boolean) => void; - duplicateEnvNames: Set; - portErrors: Map; -} - -export function useAgentWizardForm( - open: boolean, - namespaceProp: string | undefined, - onClose: () => void, - onCreated: () => void, - initialDeploymentMethod?: DeploymentMethod, -): UseAgentWizardFormReturn { - const api = useApi(augmentApiRef); - const rowIdRef = useRef(0); - const wasOpenRef = useRef(false); - - const [activeStep, setActiveStep] = useState(0); - const [submitting, setSubmitting] = useState(false); - const [submitError, setSubmitError] = useState(null); - const [successOpen, setSuccessOpen] = useState(false); - - const [buildProgress, setBuildProgress] = useState({ - phase: 'idle', - elapsedMs: 0, - pollErrorCount: 0, - }); - const pollRef = useRef | null>(null); - const elapsedRef = useRef | null>(null); - const pollErrorCountRef = useRef(0); - const buildNameRef = useRef(''); - const buildNsRef = useRef(''); - - // Step 0 - const [name, setName] = useState(''); - const [namespace, setNamespace] = useState(namespaceProp ?? ''); - const [protocol, setProtocol] = useState('a2a'); - const [framework, setFramework] = useState('ADK'); - const [availableNamespaces, setAvailableNamespaces] = useState([]); - - // Step 1 - const [deploymentMethod, setDeploymentMethod] = - useState('image'); - const [containerImage, setContainerImage] = useState(''); - const [imagePullSecret, setImagePullSecret] = useState(''); - const [gitUrl, setGitUrl] = useState(''); - const [gitBranch, setGitBranch] = useState('main'); - const [gitPath, setGitPath] = useState(''); - const [registryUrl, setRegistryUrl] = useState( - defaultRegistryUrl(namespaceProp ?? ''), - ); - const [registrySecret, setRegistrySecret] = useState(''); - const [imageTag, setImageTag] = useState(''); - const [buildStrategy, setBuildStrategy] = useState(''); - const [buildStrategies, setBuildStrategies] = useState< - KagentiBuildStrategy[] - >([]); - const [buildStrategyError, setBuildStrategyError] = useState( - null, - ); - const [startCommand, setStartCommand] = useState(''); - const [dockerfile, setDockerfile] = useState('Dockerfile'); - const [buildArgRows, setBuildArgRows] = useState([]); - const [buildTimeout, setBuildTimeout] = useState('15m'); - - // Step 2 - const [workloadType, setWorkloadType] = useState('deployment'); - const [envRows, setEnvRows] = useState([]); - const [portRows, setPortRows] = useState([]); - const [createHttpRoute, setCreateHttpRoute] = useState(false); - const [authBridgeEnabled, setAuthBridgeEnabled] = useState(true); - const [spireEnabled, setSpireEnabled] = useState(false); - - // --------------------------------------------------------------------------- - // Polling helpers - // --------------------------------------------------------------------------- - - const stopPolling = useCallback(() => { - if (pollRef.current) { - clearInterval(pollRef.current); - pollRef.current = null; - } - if (elapsedRef.current) { - clearInterval(elapsedRef.current); - elapsedRef.current = null; - } - }, []); - - useEffect(() => stopPolling, [stopPolling]); - - // --------------------------------------------------------------------------- - - const resetForm = useCallback(() => { - stopPolling(); - rowIdRef.current = 0; - setActiveStep(0); - setSubmitError(null); - setName(''); - setNamespace(namespaceProp ?? ''); - setProtocol('a2a'); - setFramework(''); - setDeploymentMethod(initialDeploymentMethod ?? 'image'); - setContainerImage(''); - setImagePullSecret(''); - setGitUrl(''); - setGitBranch('main'); - setGitPath(''); - setRegistryUrl(defaultRegistryUrl(namespaceProp ?? '')); - setRegistrySecret(''); - setImageTag(''); - setBuildStrategy(''); - setBuildStrategyError(null); - setStartCommand(''); - setDockerfile('Dockerfile'); - setBuildArgRows([]); - setBuildTimeout('15m'); - setWorkloadType('deployment'); - setEnvRows([]); - setPortRows([]); - setCreateHttpRoute(false); - setAuthBridgeEnabled(true); - setSpireEnabled(false); - setBuildProgress({ phase: 'idle', elapsedMs: 0, pollErrorCount: 0 }); - buildNameRef.current = ''; - buildNsRef.current = ''; - pollErrorCountRef.current = 0; - }, [namespaceProp, initialDeploymentMethod, stopPolling]); - - useEffect(() => { - if (open && !wasOpenRef.current) { - resetForm(); - api - .listKagentiBuildStrategies() - .then(r => { - setBuildStrategies(r.strategies ?? []); - setBuildStrategyError(null); - }) - .catch(() => { - setBuildStrategies([]); - setBuildStrategyError('Failed to load build strategies.'); - }); - api - .listKagentiNamespaces() - .then(r => setAvailableNamespaces(r.namespaces ?? [])) - .catch(() => setAvailableNamespaces([])); - } - wasOpenRef.current = open; - }, [open, resetForm, api]); - - useEffect(() => { - setNamespace(n => namespaceProp ?? n); - }, [namespaceProp]); - - useEffect(() => { - setRegistryUrl(prev => - INTERNAL_REGISTRY_RE.test(prev) ? defaultRegistryUrl(namespace) : prev, - ); - }, [namespace]); - - // --------------------------------------------------------------------------- - // Derived / computed - // --------------------------------------------------------------------------- - - const formState = useMemo( - (): FormState => ({ - name, - namespace, - protocol, - framework, - deploymentMethod, - containerImage, - imagePullSecret, - gitUrl, - gitBranch, - gitPath, - registryUrl, - registrySecret, - imageTag, - buildStrategy, - startCommand, - dockerfile, - buildArgRows, - buildTimeout, - workloadType, - envRows, - portRows, - createHttpRoute, - authBridgeEnabled, - spireEnabled, - }), - [ - name, - namespace, - protocol, - framework, - deploymentMethod, - containerImage, - imagePullSecret, - gitUrl, - gitBranch, - gitPath, - registryUrl, - registrySecret, - imageTag, - buildStrategy, - startCommand, - dockerfile, - buildArgRows, - buildTimeout, - workloadType, - envRows, - portRows, - createHttpRoute, - authBridgeEnabled, - spireEnabled, - ], - ); - - const nameError = useMemo((): string | undefined => { - const trimmed = name.trim(); - if (!trimmed) return undefined; - if (!isValidDns1123(trimmed)) { - return 'Lowercase alphanumeric and hyphens only. Must start/end with alphanumeric (max 63 chars).'; - } - return undefined; - }, [name]); - - const duplicateEnvNames = useMemo( - () => getDuplicateEnvNames(envRows), - [envRows], - ); - - const portErrors = useMemo((): Map => { - const errors = new Map(); - for (const row of portRows) { - if (row.port.trim() && parsePositivePort(row.port) === undefined) { - errors.set(row.id, 'Port must be 1\u201365535'); - } - } - return errors; - }, [portRows]); - - // --------------------------------------------------------------------------- - // Validation - // --------------------------------------------------------------------------- - - const validateStep0 = useCallback((): boolean => { - const trimmedName = name.trim(); - if (!trimmedName || !namespace.trim()) return false; - if (!isValidDns1123(trimmedName)) return false; - return true; - }, [name, namespace]); - - const validateStep1 = useCallback((): boolean => { - if (deploymentMethod === 'image') return Boolean(containerImage.trim()); - return Boolean(gitUrl.trim()); - }, [deploymentMethod, containerImage, gitUrl]); - - // --------------------------------------------------------------------------- - // Navigation - // --------------------------------------------------------------------------- - - const handleNext = useCallback(() => { - setSubmitError(null); - if (activeStep === 0 && !validateStep0()) { - const trimmedName = name.trim(); - if (!trimmedName || !namespace.trim()) { - setSubmitError('Name and namespace are required.'); - } else { - setSubmitError('Agent name must be a valid DNS-1123 label.'); - } - return; - } - if (activeStep === 1 && !validateStep1()) { - setSubmitError( - deploymentMethod === 'image' - ? 'Container image is required.' - : 'Git URL is required for source deployment.', - ); - return; - } - setActiveStep(s => Math.min(s + 1, FORM_STEPS.length - 1)); - }, [ - activeStep, - validateStep0, - validateStep1, - deploymentMethod, - name, - namespace, - ]); - - const handleBack = useCallback(() => { - setSubmitError(null); - setActiveStep(s => Math.max(s - 1, 0)); - }, []); - - const startBuildPolling = useCallback( - (agentName: string, agentNamespace: string) => { - buildNameRef.current = agentName; - buildNsRef.current = agentNamespace; - pollErrorCountRef.current = 0; - const startedAt = Date.now(); - - elapsedRef.current = setInterval(() => { - const elapsed = Date.now() - startedAt; - setBuildProgress(prev => { - const next = { ...prev, elapsedMs: elapsed }; - if ( - elapsed >= BUILD_TIMEOUT_WARN_MS && - prev.phase === 'building' && - !prev.message?.includes('taking longer') - ) { - next.message = - 'Build is taking longer than expected. It will continue in the background if you close this dialog.'; - } - return next; - }); - }, 1000); - - pollRef.current = setInterval(async () => { - try { - const info = await api.getKagentiBuildInfo(agentNamespace, agentName); - const elapsed = Date.now() - startedAt; - const phase = info.buildRunPhase?.toLowerCase() ?? ''; - pollErrorCountRef.current = 0; - - if (phase === 'succeeded') { - stopPolling(); - setBuildProgress(prev => ({ - ...prev, - phase: 'finalizing', - buildRunPhase: info.buildRunPhase ?? undefined, - outputImage: info.outputImage, - elapsedMs: elapsed, - message: 'Build complete. Deploying agent…', - pollErrorCount: 0, - })); - - try { - const result = await api.finalizeKagentiAgentBuild( - agentNamespace, - agentName, - ); - setBuildProgress(prev => ({ - ...prev, - phase: 'complete', - elapsedMs: Date.now() - startedAt, - message: result.message, - deployFailedAfterBuild: false, - })); - setSuccessOpen(true); - onCreated(); - } catch (finErr) { - setBuildProgress(prev => ({ - ...prev, - phase: 'failed', - elapsedMs: Date.now() - startedAt, - failureMessage: `Build succeeded but deployment failed: ${getErrorMessage(finErr)}`, - deployFailedAfterBuild: true, - })); - } - } else if (phase === 'failed') { - stopPolling(); - setBuildProgress(prev => ({ - ...prev, - phase: 'failed', - buildRunPhase: info.buildRunPhase ?? undefined, - elapsedMs: elapsed, - failureMessage: info.buildRunFailureMessage ?? 'Build failed.', - deployFailedAfterBuild: false, - })); - } else { - setBuildProgress(prev => ({ - ...prev, - buildRunPhase: info.buildRunPhase ?? undefined, - buildRunName: info.buildRunName ?? undefined, - outputImage: info.outputImage, - strategy: info.strategy, - gitUrl: info.gitUrl, - contextDir: info.contextDir ?? undefined, - startTime: info.buildRunStartTime ?? undefined, - elapsedMs: elapsed, - pollErrorCount: 0, - })); - } - } catch { - pollErrorCountRef.current += 1; - setBuildProgress(prev => ({ - ...prev, - pollErrorCount: pollErrorCountRef.current, - })); - if (pollErrorCountRef.current >= MAX_CONSECUTIVE_POLL_ERRORS) { - stopPolling(); - setBuildProgress(prev => ({ - ...prev, - phase: 'failed', - failureMessage: - 'Lost connection to the build service. The build may still be running — check Build Pipelines for status.', - })); - } - } - }, BUILD_POLL_INTERVAL_MS); - }, - [api, onCreated, stopPolling], - ); - - const handleSubmit = useCallback(async () => { - setSubmitError(null); - if (!validateStep0()) { - setSubmitError( - 'Name and namespace are required, and name must be a valid DNS-1123 label.', - ); - setActiveStep(0); - return; - } - if (!validateStep1()) { - setSubmitError( - deploymentMethod === 'image' - ? 'Container image is required.' - : 'Git URL is required for source deployment.', - ); - setActiveStep(1); - return; - } - if (portErrors.size > 0) { - setSubmitError('Fix invalid service port entries before submitting.'); - return; - } - if (duplicateEnvNames.size > 0) { - setSubmitError( - `Duplicate environment variable names: ${Array.from(duplicateEnvNames).join(', ')}`, - ); - return; - } - const body = buildRequest(formState); - setSubmitting(true); - - try { - const result = await api.createKagentiAgent(body); - - if (deploymentMethod === 'source') { - setSubmitting(false); - setActiveStep(FORM_STEPS.length); - setBuildProgress({ - phase: 'building', - elapsedMs: 0, - message: result.message, - pollErrorCount: 0, - }); - startBuildPolling(body.name, body.namespace); - } else { - setSuccessOpen(true); - onCreated(); - onClose(); - } - } catch (e) { - setSubmitError(getErrorMessage(e)); - } finally { - if (deploymentMethod !== 'source') { - setSubmitting(false); - } - } - }, [ - api, - formState, - onClose, - onCreated, - validateStep0, - validateStep1, - deploymentMethod, - portErrors, - duplicateEnvNames, - startBuildPolling, - ]); - - const handleRetryBuild = useCallback(() => { - if (!buildNameRef.current || !buildNsRef.current) return; - const ns = buildNsRef.current; - const agentName = buildNameRef.current; - const wasDeployFailure = buildProgress.deployFailedAfterBuild; - - if (wasDeployFailure) { - setBuildProgress(prev => ({ - ...prev, - phase: 'finalizing', - failureMessage: undefined, - message: 'Retrying deployment…', - pollErrorCount: 0, - })); - api - .finalizeKagentiAgentBuild(ns, agentName) - .then(result => { - setBuildProgress(prev => ({ - ...prev, - phase: 'complete', - message: result.message, - deployFailedAfterBuild: false, - })); - setSuccessOpen(true); - onCreated(); - }) - .catch(err => { - setBuildProgress(prev => ({ - ...prev, - phase: 'failed', - failureMessage: `Deployment failed: ${getErrorMessage(err)}`, - deployFailedAfterBuild: true, - })); - }); - } else { - setBuildProgress({ - phase: 'building', - elapsedMs: 0, - message: 'Retrying build…', - pollErrorCount: 0, - }); - api - .triggerKagentiBuild(ns, agentName) - .then(() => { - startBuildPolling(agentName, ns); - }) - .catch(err => { - setBuildProgress(prev => ({ - ...prev, - phase: 'failed', - failureMessage: `Retry failed: ${getErrorMessage(err)}`, - })); - }); - } - }, [api, buildProgress.deployFailedAfterBuild, onCreated, startBuildPolling]); - - const handleCloseBuild = useCallback(() => { - stopPolling(); - setBuildProgress({ phase: 'idle', elapsedMs: 0, pollErrorCount: 0 }); - onClose(); - }, [stopPolling, onClose]); - - // --------------------------------------------------------------------------- - // Row CRUD - // --------------------------------------------------------------------------- - - const addEnvRow = useCallback(() => { - setEnvRows(rows => [ - ...rows, - { - id: nextRowId(rowIdRef), - name: '', - value: '', - source: 'direct' as EnvSource, - refName: '', - refKey: '', - }, - ]); - }, []); - const updateEnvRow = useCallback((id: number, patch: Partial) => { - setEnvRows(rows => rows.map(r => (r.id === id ? { ...r, ...patch } : r))); - }, []); - const removeEnvRow = useCallback((id: number) => { - setEnvRows(rows => rows.filter(r => r.id !== id)); - }, []); - - const addBuildArgRow = useCallback(() => { - setBuildArgRows(rows => [...rows, { id: nextRowId(rowIdRef), value: '' }]); - }, []); - const updateBuildArgRow = useCallback((id: number, value: string) => { - setBuildArgRows(rows => rows.map(r => (r.id === id ? { ...r, value } : r))); - }, []); - const removeBuildArgRow = useCallback((id: number) => { - setBuildArgRows(rows => rows.filter(r => r.id !== id)); - }, []); - - const addPortRow = useCallback(() => { - setPortRows(rows => [ - ...rows, - { - id: nextRowId(rowIdRef), - name: '', - port: '', - targetPort: '', - protocol: 'TCP' as PortProtocol, - }, - ]); - }, []); - const updatePortRow = useCallback( - (id: number, patch: Partial) => { - setPortRows(rows => - rows.map(r => (r.id === id ? { ...r, ...patch } : r)), - ); - }, - [], - ); - const removePortRow = useCallback((id: number) => { - setPortRows(rows => rows.filter(r => r.id !== id)); - }, []); - const handlePortProtocol = useCallback( - (id: number, e: SelectChangeEvent) => { - updatePortRow(id, { protocol: e.target.value as PortProtocol }); - }, - [updatePortRow], - ); - - // --------------------------------------------------------------------------- - - return { - activeStep, - setActiveStep, - submitting, - submitError, - setSubmitError, - successOpen, - setSuccessOpen, - handleNext, - handleBack, - handleSubmit, - - buildProgress, - handleRetryBuild, - handleCloseBuild, - - name, - setName, - namespace, - setNamespace, - protocol, - setProtocol, - framework, - setFramework, - availableNamespaces, - nameError, - - deploymentMethod, - setDeploymentMethod, - containerImage, - setContainerImage, - imagePullSecret, - setImagePullSecret, - gitUrl, - setGitUrl, - gitBranch, - setGitBranch, - gitPath, - setGitPath, - registryUrl, - setRegistryUrl, - registrySecret, - setRegistrySecret, - imageTag, - setImageTag, - buildStrategy, - setBuildStrategy, - buildStrategies, - buildStrategyError, - startCommand, - setStartCommand, - dockerfile, - setDockerfile, - buildArgRows, - addBuildArgRow, - updateBuildArgRow, - removeBuildArgRow, - buildTimeout, - setBuildTimeout, - - workloadType, - setWorkloadType, - envRows, - addEnvRow, - updateEnvRow, - removeEnvRow, - portRows, - addPortRow, - updatePortRow, - removePortRow, - handlePortProtocol, - createHttpRoute, - setCreateHttpRoute, - authBridgeEnabled, - setAuthBridgeEnabled, - spireEnabled, - setSpireEnabled, - duplicateEnvNames, - portErrors, - }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useKagentiAgentDetail.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useKagentiAgentDetail.ts deleted file mode 100644 index 9926c64c5a8..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useKagentiAgentDetail.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { - KagentiAgentCard, - KagentiAgentDetail, - KagentiAgentSummary, - KagentiBuildInfo, - KagentiRouteStatus, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { AugmentApi } from '../../../api'; -import { getErrorMessage } from '../../../utils'; - -const STATUS_POLL_INTERVAL_MS = 15000; - -export function useKagentiAgentDetail( - api: AugmentApi, - agent: KagentiAgentSummary, -) { - const [agentCard, setAgentCard] = useState(null); - const [agentDetail, setAgentDetail] = useState< - (KagentiAgentDetail & { agentCard?: KagentiAgentCard }) | null - >(null); - const [buildInfo, setBuildInfo] = useState(null); - const [routeStatus, setRouteStatus] = useState( - null, - ); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [buildTriggering, setBuildTriggering] = useState(false); - const [copied, setCopied] = useState(false); - const [hasBuild, setHasBuild] = useState(null); - const pollRef = useRef>(); - - const loadDetail = useCallback(async () => { - setLoading(true); - setError(null); - try { - const detail = await api.getKagentiAgent(agent.namespace, agent.name); - setAgentDetail(detail); - if (detail.agentCard) setAgentCard(detail.agentCard); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setLoading(false); - } - }, [api, agent.namespace, agent.name]); - - useEffect(() => { - void loadDetail(); - }, [loadDetail]); - - const loadBuildInfo = useCallback(async () => { - try { - const info = await api.getKagentiBuildInfo(agent.namespace, agent.name); - setBuildInfo(info); - setHasBuild(true); - } catch { - setHasBuild(false); - } - }, [api, agent.namespace, agent.name]); - - const loadRouteStatus = useCallback(async () => { - try { - const rs = await api.getKagentiAgentRouteStatus( - agent.namespace, - agent.name, - ); - setRouteStatus(rs); - } catch { - /* optional */ - } - }, [api, agent.namespace, agent.name]); - - useEffect(() => { - void loadBuildInfo(); - void loadRouteStatus(); - }, [loadBuildInfo, loadRouteStatus]); - - useEffect(() => { - pollRef.current = setInterval(() => { - void loadDetail(); - if (hasBuild) void loadBuildInfo(); - }, STATUS_POLL_INTERVAL_MS); - return () => clearInterval(pollRef.current); - }, [loadDetail, loadBuildInfo, hasBuild]); - - const handleTriggerBuild = useCallback(async () => { - setBuildTriggering(true); - try { - await api.triggerKagentiBuild(agent.namespace, agent.name); - await loadBuildInfo(); - } catch (e) { - setError(getErrorMessage(e)); - } finally { - setBuildTriggering(false); - } - }, [api, agent.namespace, agent.name, loadBuildInfo]); - - const handleCopy = useCallback((text: string) => { - void window.navigator.clipboard.writeText(text).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - }, []); - - return { - agentCard, - agentDetail, - buildInfo, - routeStatus, - loading, - error, - setError, - buildTriggering, - copied, - hasBuild, - loadDetail, - loadBuildInfo, - loadRouteStatus, - handleTriggerBuild, - handleCopy, - }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useKagentiToolDetail.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useKagentiToolDetail.ts deleted file mode 100644 index 1f17b88fbc1..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useKagentiToolDetail.ts +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useCallback, useEffect, useState } from 'react'; -import type { - KagentiBuildInfo, - KagentiRouteStatus, - KagentiToolDetail, - KagentiToolSummary, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { AugmentApi } from '../../../api'; -import { getErrorMessage } from '../../../utils'; - -export function useKagentiToolDetail( - open: boolean, - tool: KagentiToolSummary | null, - api: AugmentApi, -) { - const [detail, setDetail] = useState(null); - const [detailLoading, setDetailLoading] = useState(false); - const [detailError, setDetailError] = useState(null); - - const [routeStatus, setRouteStatus] = useState( - null, - ); - const [routeLoading, setRouteLoading] = useState(false); - const [routeError, setRouteError] = useState(null); - - const [buildInfo, setBuildInfo] = useState(null); - const [buildLoading, setBuildLoading] = useState(false); - const [buildFetchFailed, setBuildFetchFailed] = useState(false); - - const [hasBuild, setHasBuild] = useState(null); - const [triggeringBuild, setTriggeringBuild] = useState(false); - const [finalizingBuild, setFinalizingBuild] = useState(false); - const [buildActionError, setBuildActionError] = useState(null); - - const refreshBuildInfo = useCallback(async () => { - if (!tool) return; - setBuildLoading(true); - setBuildFetchFailed(false); - try { - const info = await api.getToolBuildInfo(tool.namespace, tool.name); - setBuildInfo(info); - setHasBuild(true); - } catch (err) { - setBuildInfo(null); - setBuildFetchFailed(true); - const is404 = err instanceof Error && /not found|404/i.test(err.message); - if (is404) setHasBuild(false); - } finally { - setBuildLoading(false); - } - }, [api, tool]); - - useEffect(() => { - if (!open || !tool) { - setDetail(null); - setDetailLoading(false); - setDetailError(null); - return () => {}; - } - let cancelled = false; - setDetailLoading(true); - setDetailError(null); - api - .getKagentiTool(tool.namespace, tool.name) - .then(d => { - if (!cancelled) setDetail(d); - }) - .catch(e => { - if (!cancelled) setDetailError(getErrorMessage(e)); - }) - .finally(() => { - if (!cancelled) setDetailLoading(false); - }); - return () => { - cancelled = true; - }; - }, [open, tool, api]); - - useEffect(() => { - if (!open || !tool) { - setRouteStatus(null); - setRouteLoading(false); - setRouteError(null); - return () => {}; - } - let cancelled = false; - setRouteLoading(true); - setRouteError(null); - api - .getToolRouteStatus(tool.namespace, tool.name) - .then(rs => { - if (!cancelled) setRouteStatus(rs); - }) - .catch(e => { - if (!cancelled) setRouteError(getErrorMessage(e)); - }) - .finally(() => { - if (!cancelled) setRouteLoading(false); - }); - return () => { - cancelled = true; - }; - }, [open, tool, api]); - - useEffect(() => { - if (!open || !tool) { - setBuildInfo(null); - setBuildLoading(false); - setBuildFetchFailed(false); - return () => {}; - } - let cancelled = false; - setBuildLoading(true); - setBuildFetchFailed(false); - api - .getToolBuildInfo(tool.namespace, tool.name) - .then(info => { - if (!cancelled) { - setBuildInfo(info); - setHasBuild(true); - } - }) - .catch((err: unknown) => { - if (!cancelled) { - setBuildInfo(null); - setBuildFetchFailed(true); - const is404 = - err instanceof Error && /not found|404/i.test(err.message); - if (is404) setHasBuild(false); - } - }) - .finally(() => { - if (!cancelled) setBuildLoading(false); - }); - return () => { - cancelled = true; - }; - }, [open, tool, api]); - - useEffect(() => { - if (!open || !tool) { - setBuildActionError(null); - } - }, [open, tool]); - - const handleTriggerBuild = useCallback(async () => { - if (!tool) return; - setTriggeringBuild(true); - setBuildActionError(null); - try { - await api.triggerToolBuild(tool.namespace, tool.name); - await refreshBuildInfo(); - } catch (e) { - setBuildActionError(getErrorMessage(e)); - } finally { - setTriggeringBuild(false); - } - }, [api, tool, refreshBuildInfo]); - - const handleFinalizeBuild = useCallback(async () => { - if (!tool) return; - setFinalizingBuild(true); - setBuildActionError(null); - try { - await api.finalizeToolBuild(tool.namespace, tool.name); - await refreshBuildInfo(); - } catch (e) { - setBuildActionError(getErrorMessage(e)); - } finally { - setFinalizingBuild(false); - } - }, [api, tool, refreshBuildInfo]); - - return { - detail, - detailLoading, - detailError, - routeStatus, - routeLoading, - routeError, - buildInfo, - buildLoading, - buildFetchFailed, - hasBuild, - triggeringBuild, - finalizingBuild, - buildActionError, - setBuildActionError, - refreshBuildInfo, - handleTriggerBuild, - handleFinalizeBuild, - }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useKagentiToolsList.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useKagentiToolsList.ts deleted file mode 100644 index fd9e5f0f592..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useKagentiToolsList.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import type { KagentiToolSummary } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { AugmentApi } from '../../../api'; -import { getErrorMessage } from '../../../utils'; - -export type SortField = - | 'name' - | 'namespace' - | 'status' - | 'workloadType' - | 'createdAt'; -export type SortDir = 'asc' | 'desc'; - -function compareTools( - a: KagentiToolSummary, - b: KagentiToolSummary, - field: SortField, -): number { - const valA = a[field] ?? ''; - const valB = b[field] ?? ''; - if (field === 'createdAt') { - return new Date(valA || 0).getTime() - new Date(valB || 0).getTime(); - } - return String(valA).localeCompare(String(valB)); -} - -export function useKagentiToolsList(api: AugmentApi, namespace?: string) { - const [tools, setTools] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [search, setSearch] = useState(''); - const [sortField, setSortField] = useState('name'); - const [sortDir, setSortDir] = useState('asc'); - - const loadTools = useCallback(() => { - setLoading(true); - setError(null); - api - .listKagentiTools(namespace || undefined) - .then(res => setTools(res.tools ?? [])) - .catch(e => setError(getErrorMessage(e))) - .finally(() => setLoading(false)); - }, [api, namespace]); - - useEffect(() => { - loadTools(); - }, [loadTools]); - - const sortedTools = useMemo(() => { - let list = tools; - if (search.trim()) { - const q = search.trim().toLowerCase(); - list = list.filter( - t => - t.name.toLowerCase().includes(q) || - (t.description && t.description.toLowerCase().includes(q)), - ); - } - const sorted = [...list].sort((a, b) => compareTools(a, b, sortField)); - return sortDir === 'desc' ? sorted.reverse() : sorted; - }, [tools, search, sortField, sortDir]); - - const handleSort = useCallback((field: SortField) => { - setSortField(prevField => { - if (prevField === field) { - setSortDir(d => (d === 'asc' ? 'desc' : 'asc')); - return prevField; - } - setSortDir('asc'); - return field; - }); - }, []); - - return { - tools, - loading, - error, - setError, - loadTools, - search, - setSearch, - sortField, - sortDir, - handleSort, - sortedTools, - }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useToolInvoke.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useToolInvoke.ts deleted file mode 100644 index eafb3680c41..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useToolInvoke.ts +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useCallback, useState } from 'react'; -import type { KagentiToolSummary } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { AugmentApi } from '../../../api'; -import { getErrorMessage } from '../../../utils'; - -export interface UseToolInvokeResult { - invokeOpen: boolean; - setInvokeOpen: (open: boolean) => void; - invokeTarget: KagentiToolSummary | null; - invokeToolName: string; - setInvokeToolName: (v: string) => void; - invokeArgsJson: string; - setInvokeArgsJson: (v: string) => void; - invokeResult: string | null; - setInvokeResult: (v: string | null) => void; - invokeError: string | null; - setInvokeError: (v: string | null) => void; - invoking: boolean; - invokeSchema: Record | undefined; - handleStartInvoke: (name: string, schema?: Record) => void; - openInvoke: (target: KagentiToolSummary) => void; - openInvokePrefilled: ( - target: KagentiToolSummary, - toolName: string, - inputSchema?: Record, - ) => void; - clearInvokeError: () => void; - handleInvoke: (ns: string, name: string) => Promise; - invokeWithArgs: ( - ns: string, - resourceName: string, - mcpToolName: string, - args: Record, - ) => Promise; - resetInvoke: () => void; -} - -export function useToolInvoke(api: AugmentApi): UseToolInvokeResult { - const [invokeOpen, setInvokeOpen] = useState(false); - const [invokeTarget, setInvokeTarget] = useState( - null, - ); - const [invokeToolName, setInvokeToolName] = useState(''); - const [invokeArgsJson, setInvokeArgsJson] = useState('{}'); - const [invokeResult, setInvokeResult] = useState(null); - const [invokeError, setInvokeError] = useState(null); - const [invoking, setInvoking] = useState(false); - const [invokeSchema, setInvokeSchema] = useState< - Record | undefined - >(undefined); - - const resetInvoke = useCallback(() => { - setInvokeOpen(false); - setInvokeTarget(null); - setInvokeToolName(''); - setInvokeArgsJson('{}'); - setInvokeResult(null); - setInvokeError(null); - setInvokeSchema(undefined); - setInvoking(false); - }, []); - - const clearInvokeError = useCallback(() => setInvokeError(null), []); - - const handleStartInvoke = useCallback( - (name: string, schema?: Record) => { - setInvokeToolName(name); - setInvokeArgsJson('{}'); - setInvokeResult(null); - setInvokeError(null); - setInvokeSchema(schema); - setInvokeOpen(true); - }, - [], - ); - - const openInvoke = useCallback((target: KagentiToolSummary) => { - setInvokeTarget(target); - setInvokeToolName(''); - setInvokeArgsJson('{}'); - setInvokeResult(null); - setInvokeError(null); - setInvokeSchema(undefined); - setInvokeOpen(true); - }, []); - - const openInvokePrefilled = useCallback( - ( - target: KagentiToolSummary, - toolName: string, - inputSchema?: Record, - ) => { - setInvokeTarget(target); - setInvokeToolName(toolName); - setInvokeArgsJson('{}'); - setInvokeResult(null); - setInvokeError(null); - setInvokeSchema(inputSchema); - setInvokeOpen(true); - }, - [], - ); - - const handleInvoke = useCallback( - async (ns: string, name: string) => { - if (!invokeToolName.trim()) return; - let args: Record = {}; - try { - args = JSON.parse(invokeArgsJson || '{}') as Record; - } catch { - setInvokeError('Arguments must be valid JSON'); - return; - } - setInvoking(true); - setInvokeError(null); - try { - const res = await api.invokeKagentiTool( - ns, - name, - invokeToolName.trim(), - args, - ); - setInvokeResult(JSON.stringify(res, null, 2)); - } catch (e) { - setInvokeError(getErrorMessage(e)); - } finally { - setInvoking(false); - } - }, - [api, invokeToolName, invokeArgsJson], - ); - - const invokeWithArgs = useCallback( - async ( - ns: string, - resourceName: string, - mcpToolName: string, - args: Record, - ) => { - setInvoking(true); - setInvokeError(null); - try { - const res = await api.invokeKagentiTool( - ns, - resourceName, - mcpToolName.trim(), - args, - ); - setInvokeResult(JSON.stringify(res, null, 2)); - } catch (e) { - setInvokeError(getErrorMessage(e)); - } finally { - setInvoking(false); - } - }, - [api], - ); - - return { - invokeOpen, - setInvokeOpen, - invokeTarget, - invokeToolName, - setInvokeToolName, - invokeArgsJson, - setInvokeArgsJson, - invokeResult, - setInvokeResult, - invokeError, - setInvokeError, - invoking, - invokeSchema, - handleStartInvoke, - openInvoke, - openInvokePrefilled, - clearInvokeError, - handleInvoke, - invokeWithArgs, - resetInvoke, - }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useToolWizardForm.test.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useToolWizardForm.test.ts deleted file mode 100644 index 7a9361c589c..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useToolWizardForm.test.ts +++ /dev/null @@ -1,512 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { renderHook, act, waitFor } from '@testing-library/react'; -import type { ApiRef } from '@backstage/core-plugin-api'; -import { useApi } from '@backstage/core-plugin-api'; -import type { SelectChangeEvent } from '@mui/material/Select'; -import { augmentApiRef } from '../../../api'; -import { useToolWizardForm } from './useToolWizardForm'; -import type { PortProtocol } from './toolWizardTypes'; - -jest.mock('@backstage/core-plugin-api', () => ({ - ...jest.requireActual('@backstage/core-plugin-api'), - useApi: jest.fn(), -})); - -const mockedUseApi = jest.mocked(useApi); - -type ToolWizardHookResult = ReturnType; - -const sampleStrategy = { - name: 'kaniko', - description: 'Kaniko build', -}; - -// KagentiToolSummary-shaped create response -const createToolResponse = { - name: 'my-tool', - namespace: 'team-a', - description: '', - status: 'pending', - labels: {}, -}; - -function createMockApi(overrides = {}) { - return { - listKagentiBuildStrategies: jest.fn().mockResolvedValue({ - strategies: [sampleStrategy], - }), - listKagentiNamespaces: jest.fn().mockResolvedValue({ - namespaces: ['team-a', 'team-b'], - }), - createKagentiTool: jest.fn().mockResolvedValue(createToolResponse), - ...overrides, - }; -} - -function renderForm( - open: boolean, - namespaceProp: string | undefined, - api = createMockApi(), -) { - const onClose = jest.fn(); - const onCreated = jest.fn(); - mockedUseApi.mockImplementation((ref: ApiRef) => { - if (ref === augmentApiRef) { - return api; - } - return jest.requireActual('@backstage/core-plugin-api').useApi(ref); - }); - const hook = renderHook( - ({ o, ns }) => useToolWizardForm(o, ns, onClose, onCreated), - { initialProps: { o: open, ns: namespaceProp } }, - ); - return { ...hook, onClose, onCreated, api }; -} - -describe('useToolWizardForm — initialization', () => { - beforeEach(() => { - mockedUseApi.mockReset(); - }); - - it('has correct default state', () => { - const { result } = renderForm(false, undefined); - expect(result.current.activeStep).toBe(0); - expect(result.current.submitting).toBe(false); - expect(result.current.submitError).toBeNull(); - expect(result.current.name).toBe(''); - expect(result.current.namespace).toBe(''); - expect(result.current.protocol).toBe('streamable_http'); - expect(result.current.framework).toBe('Python'); - expect(result.current.deploymentMethod).toBe('image'); - expect(result.current.authBridgeEnabled).toBe(false); - expect(result.current.spireEnabled).toBe(false); - expect(result.current.createHttpRoute).toBe(false); - expect(result.current.dockerfile).toBe('Dockerfile'); - expect(result.current.buildTimeout).toBe('15m'); - expect(result.current.gitRevision).toBe('main'); - expect(result.current.contextDir).toBe(''); - expect(result.current.imageTag).toBe('v0.0.1'); - }); - - it('uses namespaceProp as initial namespace', () => { - const { result } = renderForm(false, 'custom-ns'); - expect(result.current.namespace).toBe('custom-ns'); - }); - - it('fetches build strategies and namespaces on open', async () => { - const api = createMockApi(); - const { result, rerender } = renderForm(false, undefined, api); - - expect(api.listKagentiBuildStrategies).not.toHaveBeenCalled(); - - rerender({ o: true, ns: undefined }); - - await waitFor(() => { - expect(result.current.buildStrategies).toHaveLength(1); - }); - expect(result.current.availableNamespaces).toEqual(['team-a', 'team-b']); - expect(api.listKagentiBuildStrategies).toHaveBeenCalledTimes(1); - expect(api.listKagentiNamespaces).toHaveBeenCalledTimes(1); - }); - - it('sets buildStrategyError on strategies fetch failure', async () => { - const api = createMockApi({ - listKagentiBuildStrategies: jest - .fn() - .mockRejectedValue(new Error('fail')), - }); - const { result, rerender } = renderForm(false, undefined, api); - - rerender({ o: true, ns: undefined }); - - await waitFor(() => { - expect(result.current.buildStrategyError).toBe( - 'Failed to load build strategies.', - ); - }); - expect(result.current.buildStrategies).toEqual([]); - }); - - it('leaves namespaces empty when namespace fetch fails', async () => { - const api = createMockApi({ - listKagentiNamespaces: jest.fn().mockRejectedValue(new Error('ns')), - }); - const { result, rerender } = renderForm(false, undefined, api); - rerender({ o: true, ns: undefined }); - await waitFor(() => { - expect(api.listKagentiNamespaces).toHaveBeenCalled(); - }); - expect(result.current.availableNamespaces).toEqual([]); - }); -}); - -describe('useToolWizardForm — validation and navigation', () => { - beforeEach(() => { - mockedUseApi.mockReset(); - }); - - it('blocks step 0 with empty name', async () => { - const { result, rerender } = renderForm(false, undefined); - rerender({ o: true, ns: undefined }); - - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(0); - expect(result.current.submitError).toBe('Name and namespace are required.'); - }); - - it('blocks step 0 with invalid DNS name', async () => { - const { result, rerender } = renderForm(false, 'team-a'); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - act(() => result.current.setName('My Tool')); - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(0); - expect(result.current.submitError).toBe( - 'Tool name must be a valid DNS-1123 label.', - ); - }); - - it('advances to step 1 with valid name and namespace', async () => { - const { result, rerender } = renderForm(false, 'team-a'); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - act(() => result.current.setName('my-tool')); - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(1); - expect(result.current.submitError).toBeNull(); - }); - - it('blocks step 1 (image) with empty containerImage', async () => { - const { result, rerender } = renderForm(false, 'team-a'); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - act(() => result.current.setName('my-tool')); - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(1); - - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(1); - expect(result.current.submitError).toBe('Container image is required.'); - }); - - it('advances to step 2 with valid image', async () => { - const { result, rerender } = renderForm(false, 'team-a'); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - act(() => result.current.setName('my-tool')); - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(1); - - act(() => result.current.setContainerImage('quay.io/img:v1')); - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(2); - }); - - it('handleBack goes back and stays at 0', async () => { - const { result, rerender } = renderForm(false, 'team-a'); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - act(() => result.current.setName('my-tool')); - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(1); - - act(() => result.current.handleBack()); - expect(result.current.activeStep).toBe(0); - - act(() => result.current.handleBack()); - expect(result.current.activeStep).toBe(0); - }); - - it('blocks step 1 (source) with empty gitUrl', async () => { - const { result, rerender } = renderForm(false, 'team-a'); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - act(() => result.current.setName('my-tool')); - act(() => result.current.setDeploymentMethod('source')); - act(() => result.current.handleNext()); - expect(result.current.activeStep).toBe(1); - - act(() => result.current.handleNext()); - expect(result.current.submitError).toBe( - 'Git URL is required for source deployment.', - ); - }); -}); - -describe('useToolWizardForm — field updates and helpers', () => { - beforeEach(() => { - mockedUseApi.mockReset(); - }); - - it('updates description, protocol, and framework', () => { - const { result } = renderForm(false, undefined); - act(() => { - result.current.setDescription('A tool'); - result.current.setProtocol('stdio'); - result.current.setFramework('Go'); - }); - expect(result.current.description).toBe('A tool'); - expect(result.current.protocol).toBe('stdio'); - expect(result.current.framework).toBe('Go'); - }); - - it('nameError is undefined for empty and valid names', () => { - const { result } = renderForm(false, undefined); - expect(result.current.nameError).toBeUndefined(); - - act(() => result.current.setName('valid-name')); - expect(result.current.nameError).toBeUndefined(); - }); - - it('nameError returns message for invalid DNS name', () => { - const { result } = renderForm(false, undefined); - act(() => result.current.setName('Invalid')); - expect(result.current.nameError).toContain('Lowercase alphanumeric'); - }); - - it('duplicateEnvNames detects case-insensitive duplicates', () => { - const { result } = renderForm(false, undefined); - act(() => { - result.current.addEnvRow(); - result.current.addEnvRow(); - }); - const [row1, row2] = result.current.envRows; - act(() => { - result.current.updateEnvRow(row1.id, { name: 'FOO' }); - result.current.updateEnvRow(row2.id, { name: 'foo' }); - }); - expect(result.current.duplicateEnvNames.has('foo')).toBe(true); - }); - - it('addEnvRow / updateEnvRow / removeEnvRow', () => { - const { result } = renderForm(false, undefined); - expect(result.current.envRows).toHaveLength(0); - - act(() => result.current.addEnvRow()); - expect(result.current.envRows).toHaveLength(1); - const id = result.current.envRows[0].id; - - act(() => - result.current.updateEnvRow(id, { - name: 'VAR', - value: 'x', - source: 'secret', - refName: 'sec', - refKey: 'k', - }), - ); - expect(result.current.envRows[0].name).toBe('VAR'); - expect(result.current.envRows[0].source).toBe('secret'); - - act(() => result.current.removeEnvRow(id)); - expect(result.current.envRows).toHaveLength(0); - }); - - it('portErrors flags invalid port values', () => { - const { result } = renderForm(false, undefined); - act(() => result.current.addPortRow()); - const row = result.current.portRows[0]; - act(() => result.current.updatePortRow(row.id, { port: 'abc' })); - expect(result.current.portErrors.has(row.id)).toBe(true); - }); - - it('addPortRow / updatePortRow / removePortRow', () => { - const { result } = renderForm(false, undefined); - act(() => result.current.addPortRow()); - const id = result.current.portRows[0].id; - act(() => - result.current.updatePortRow(id, { - name: 'http', - port: '8080', - targetPort: '8080', - }), - ); - expect(result.current.portRows[0].name).toBe('http'); - act(() => result.current.removePortRow(id)); - expect(result.current.portRows).toHaveLength(0); - }); - - it('handlePortProtocol updates protocol', () => { - const { result } = renderForm(false, undefined); - act(() => result.current.addPortRow()); - const id = result.current.portRows[0].id; - const ev = { - target: { value: 'UDP' as PortProtocol, name: 'protocol' }, - } as unknown as SelectChangeEvent; - act(() => result.current.handlePortProtocol(id, ev)); - expect(result.current.portRows[0].protocol).toBe('UDP'); - }); -}); - -describe('useToolWizardForm — submit flow', () => { - beforeEach(() => { - mockedUseApi.mockReset(); - }); - - async function advanceToStep2(result: { current: ToolWizardHookResult }) { - act(() => { - result.current.setName('my-tool'); - result.current.setNamespace('team-a'); - result.current.handleNext(); - }); - act(() => { - result.current.setContainerImage('quay.io/img:v1'); - result.current.handleNext(); - }); - } - - it('calls createKagentiTool on successful submit', async () => { - const api = createMockApi(); - const { result, rerender, onClose, onCreated } = renderForm( - false, - 'team-a', - api, - ); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - await advanceToStep2(result); - - await act(async () => { - await result.current.handleSubmit(); - }); - - expect(api.createKagentiTool).toHaveBeenCalledTimes(1); - expect(api.createKagentiTool).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'my-tool', - namespace: 'team-a', - deploymentMethod: 'image', - containerImage: 'quay.io/img:v1', - }), - ); - expect(result.current.successOpen).toBe(true); - expect(onCreated).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('sets submitError on API failure', async () => { - const api = createMockApi({ - createKagentiTool: jest.fn().mockRejectedValue(new Error('Server error')), - }); - const { result, rerender, onClose } = renderForm(false, 'team-a', api); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - await advanceToStep2(result); - - await act(async () => { - await result.current.handleSubmit(); - }); - - expect(result.current.submitError).toBe('Server error'); - expect(result.current.submitting).toBe(false); - expect(onClose).not.toHaveBeenCalled(); - }); - - it('rejects submit when port errors exist', async () => { - const api = createMockApi(); - const { result, rerender } = renderForm(false, 'team-a', api); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - await advanceToStep2(result); - - act(() => { - result.current.addPortRow(); - }); - const portId = result.current.portRows[0].id; - act(() => { - result.current.updatePortRow(portId, { port: 'bad' }); - }); - - await act(async () => { - await result.current.handleSubmit(); - }); - - expect(result.current.submitError).toBe( - 'Fix invalid service port entries before submitting.', - ); - expect(api.createKagentiTool).not.toHaveBeenCalled(); - }); - - it('re-validates step 0 on submit even from step 2', async () => { - const api = createMockApi(); - const { result, rerender } = renderForm(false, 'team-a', api); - rerender({ o: true, ns: 'team-a' }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - - await advanceToStep2(result); - - act(() => result.current.setName('')); - - await act(async () => { - await result.current.handleSubmit(); - }); - - expect(result.current.activeStep).toBe(0); - expect(result.current.submitError).toContain( - 'Name and namespace are required', - ); - expect(api.createKagentiTool).not.toHaveBeenCalled(); - }); - - it('clears submitError via setSubmitError', async () => { - const { result, rerender } = renderForm(false, 'team-a'); - await act(async () => { - rerender({ o: true, ns: 'team-a' }); - }); - await waitFor(() => - expect(result.current.availableNamespaces).toHaveLength(2), - ); - act(() => result.current.setSubmitError('x')); - expect(result.current.submitError).toBe('x'); - act(() => result.current.setSubmitError(null)); - expect(result.current.submitError).toBeNull(); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useToolWizardForm.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useToolWizardForm.ts deleted file mode 100644 index 1020e41352f..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/useToolWizardForm.ts +++ /dev/null @@ -1,831 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import type { - KagentiBuildStrategy, - KagentiFinalizeToolBuildRequest, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { SelectChangeEvent } from '@mui/material/Select'; -import { augmentApiRef } from '../../../api'; -import { getErrorMessage } from '../../../utils'; -import type { BuildProgress } from './agentWizardTypes'; -import type { - BuildArgRow, - DeploymentMethod, - EnvRow, - EnvSource, - PortProtocol, - ServicePortRow, - ToolFormState, - WorkloadType, -} from './toolWizardTypes'; -import { isValidDns1123, TOOL_STEPS } from './toolWizardTypes'; -import { - buildFinalizeBody, - buildToolRequest, - getDuplicateEnvNames, - nextRowId, - parsePositivePort, -} from './toolWizardUtils'; - -const BUILD_POLL_INTERVAL_MS = 4000; -const BUILD_TIMEOUT_WARN_MS = 10 * 60 * 1000; -const MAX_CONSECUTIVE_POLL_ERRORS = 8; - -const INTERNAL_REGISTRY_BASE = - 'image-registry.openshift-image-registry.svc:5000'; - -function defaultRegistryUrl(ns: string): string { - return ns ? `${INTERNAL_REGISTRY_BASE}/${ns}` : INTERNAL_REGISTRY_BASE; -} - -const INTERNAL_REGISTRY_RE = new RegExp( - `^${INTERNAL_REGISTRY_BASE.replace(/\./g, '\\.')}(\\/[a-z0-9-]*)?$`, -); - -export interface UseToolWizardFormReturn { - activeStep: number; - setActiveStep: (step: number) => void; - submitting: boolean; - submitError: string | null; - setSubmitError: (v: string | null) => void; - successOpen: boolean; - setSuccessOpen: (v: boolean) => void; - handleNext: () => void; - handleBack: () => void; - handleSubmit: () => Promise; - - buildProgress: BuildProgress; - handleRetryBuild: () => void; - handleCloseBuild: () => void; - - name: string; - setName: (v: string) => void; - namespace: string; - setNamespace: (v: string) => void; - description: string; - setDescription: (v: string) => void; - protocol: string; - setProtocol: (v: string) => void; - framework: string; - setFramework: (v: string) => void; - availableNamespaces: string[]; - nameError: string | undefined; - nameWarning: string | undefined; - - deploymentMethod: DeploymentMethod; - setDeploymentMethod: (v: DeploymentMethod) => void; - containerImage: string; - setContainerImage: (v: string) => void; - imagePullSecret: string; - setImagePullSecret: (v: string) => void; - gitUrl: string; - setGitUrl: (v: string) => void; - gitRevision: string; - setGitRevision: (v: string) => void; - contextDir: string; - setContextDir: (v: string) => void; - registryUrl: string; - setRegistryUrl: (v: string) => void; - registrySecret: string; - setRegistrySecret: (v: string) => void; - imageTag: string; - setImageTag: (v: string) => void; - buildStrategy: string; - setBuildStrategy: (v: string) => void; - buildStrategies: KagentiBuildStrategy[]; - buildStrategyError: string | null; - dockerfile: string; - setDockerfile: (v: string) => void; - buildArgRows: BuildArgRow[]; - addBuildArgRow: () => void; - updateBuildArgRow: (id: number, value: string) => void; - removeBuildArgRow: (id: number) => void; - buildTimeout: string; - setBuildTimeout: (v: string) => void; - - workloadType: WorkloadType; - setWorkloadType: (v: WorkloadType) => void; - persistentStorageEnabled: boolean; - setPersistentStorageEnabled: (v: boolean) => void; - persistentStorageSize: string; - setPersistentStorageSize: (v: string) => void; - envRows: EnvRow[]; - addEnvRow: () => void; - updateEnvRow: (id: number, patch: Partial) => void; - removeEnvRow: (id: number) => void; - portRows: ServicePortRow[]; - addPortRow: () => void; - updatePortRow: (id: number, patch: Partial) => void; - removePortRow: (id: number) => void; - handlePortProtocol: (id: number, e: SelectChangeEvent) => void; - createHttpRoute: boolean; - setCreateHttpRoute: (v: boolean) => void; - authBridgeEnabled: boolean; - setAuthBridgeEnabled: (v: boolean) => void; - spireEnabled: boolean; - setSpireEnabled: (v: boolean) => void; - duplicateEnvNames: Set; - portErrors: Map; -} - -export function useToolWizardForm( - open: boolean, - namespaceProp: string | undefined, - onClose: () => void, - onCreated: () => void, -): UseToolWizardFormReturn { - const api = useApi(augmentApiRef); - const rowIdRef = useRef(0); - const wasOpenRef = useRef(false); - - const [activeStep, setActiveStep] = useState(0); - const [submitting, setSubmitting] = useState(false); - const [submitError, setSubmitError] = useState(null); - const [successOpen, setSuccessOpen] = useState(false); - - const [buildProgress, setBuildProgress] = useState({ - phase: 'idle', - elapsedMs: 0, - pollErrorCount: 0, - }); - const pollRef = useRef | null>(null); - const elapsedRef = useRef | null>(null); - const pollErrorCountRef = useRef(0); - const buildNameRef = useRef(''); - const buildNsRef = useRef(''); - const finalizeBodyRef = useRef({}); - - const [name, setName] = useState(''); - const [namespace, setNamespace] = useState(namespaceProp ?? ''); - const [description, setDescription] = useState(''); - const [protocol, setProtocol] = useState('streamable_http'); - const [framework, setFramework] = useState('Python'); - const [availableNamespaces, setAvailableNamespaces] = useState([]); - - const [deploymentMethod, setDeploymentMethod] = - useState('image'); - const [containerImage, setContainerImage] = useState(''); - const [imagePullSecret, setImagePullSecret] = useState(''); - const [gitUrl, setGitUrl] = useState(''); - const [gitRevision, setGitRevision] = useState('main'); - const [contextDir, setContextDir] = useState(''); - const [registryUrl, setRegistryUrl] = useState( - defaultRegistryUrl(namespaceProp ?? ''), - ); - const [registrySecret, setRegistrySecret] = useState(''); - const [imageTag, setImageTag] = useState('v0.0.1'); - const [buildStrategy, setBuildStrategy] = useState(''); - const [buildStrategies, setBuildStrategies] = useState< - KagentiBuildStrategy[] - >([]); - const [buildStrategyError, setBuildStrategyError] = useState( - null, - ); - const [dockerfile, setDockerfile] = useState('Dockerfile'); - const [buildArgRows, setBuildArgRows] = useState([]); - const [buildTimeout, setBuildTimeout] = useState('15m'); - - const [workloadType, setWorkloadType] = useState('deployment'); - const [persistentStorageEnabled, setPersistentStorageEnabled] = - useState(false); - const [persistentStorageSize, setPersistentStorageSize] = useState('1Gi'); - const [envRows, setEnvRows] = useState([]); - const [portRows, setPortRows] = useState([]); - const [createHttpRoute, setCreateHttpRoute] = useState(false); - const [authBridgeEnabled, setAuthBridgeEnabled] = useState(false); - const [spireEnabled, setSpireEnabled] = useState(false); - - const stopPolling = useCallback(() => { - if (pollRef.current) { - clearInterval(pollRef.current); - pollRef.current = null; - } - if (elapsedRef.current) { - clearInterval(elapsedRef.current); - elapsedRef.current = null; - } - }, []); - - useEffect(() => stopPolling, [stopPolling]); - - const resetForm = useCallback(() => { - stopPolling(); - rowIdRef.current = 0; - setActiveStep(0); - setSubmitError(null); - setName(''); - setNamespace(namespaceProp ?? ''); - setDescription(''); - setProtocol('streamable_http'); - setFramework('Python'); - setDeploymentMethod('image'); - setContainerImage(''); - setImagePullSecret(''); - setGitUrl(''); - setGitRevision('main'); - setContextDir(''); - setRegistryUrl(defaultRegistryUrl(namespaceProp ?? '')); - setRegistrySecret(''); - setImageTag('v0.0.1'); - setBuildStrategy(''); - setBuildStrategyError(null); - setDockerfile('Dockerfile'); - setBuildArgRows([]); - setBuildTimeout('15m'); - setWorkloadType('deployment'); - setPersistentStorageEnabled(false); - setPersistentStorageSize('1Gi'); - setEnvRows([]); - setPortRows([]); - setCreateHttpRoute(false); - setAuthBridgeEnabled(false); - setSpireEnabled(false); - setBuildProgress({ phase: 'idle', elapsedMs: 0, pollErrorCount: 0 }); - buildNameRef.current = ''; - buildNsRef.current = ''; - pollErrorCountRef.current = 0; - }, [namespaceProp, stopPolling]); - - useEffect(() => { - if (open && !wasOpenRef.current) { - resetForm(); - api - .listKagentiBuildStrategies() - .then(r => { - setBuildStrategies(r.strategies ?? []); - setBuildStrategyError(null); - }) - .catch(() => { - setBuildStrategies([]); - setBuildStrategyError('Failed to load build strategies.'); - }); - api - .listKagentiNamespaces() - .then(r => setAvailableNamespaces(r.namespaces ?? [])) - .catch(() => setAvailableNamespaces([])); - } - wasOpenRef.current = open; - }, [open, resetForm, api]); - - useEffect(() => { - setNamespace(n => namespaceProp ?? n); - }, [namespaceProp]); - - useEffect(() => { - setRegistryUrl(prev => - INTERNAL_REGISTRY_RE.test(prev) ? defaultRegistryUrl(namespace) : prev, - ); - }, [namespace]); - - const formState = useMemo( - (): ToolFormState => ({ - name, - namespace, - description, - protocol, - framework, - deploymentMethod, - containerImage, - imagePullSecret, - gitUrl, - gitRevision, - contextDir, - registryUrl, - registrySecret, - imageTag, - buildStrategy, - dockerfile, - buildArgRows, - buildTimeout, - workloadType, - persistentStorageEnabled, - persistentStorageSize, - envRows, - portRows, - createHttpRoute, - authBridgeEnabled, - spireEnabled, - }), - [ - name, - namespace, - description, - protocol, - framework, - deploymentMethod, - containerImage, - imagePullSecret, - gitUrl, - gitRevision, - contextDir, - registryUrl, - registrySecret, - imageTag, - buildStrategy, - dockerfile, - buildArgRows, - buildTimeout, - workloadType, - persistentStorageEnabled, - persistentStorageSize, - envRows, - portRows, - createHttpRoute, - authBridgeEnabled, - spireEnabled, - ], - ); - - const nameError = useMemo((): string | undefined => { - const trimmed = name.trim(); - if (!trimmed) return undefined; - if (!isValidDns1123(trimmed)) { - return 'Lowercase alphanumeric and hyphens only. Must start/end with alphanumeric (max 63 chars).'; - } - return undefined; - }, [name]); - - const nameWarning = useMemo((): string | undefined => { - if (name.trim().endsWith('-mcp')) { - return 'Names ending in "-mcp" may cause connection issues. The platform appends "-mcp" to the internal service name automatically.'; - } - return undefined; - }, [name]); - - const duplicateEnvNames = useMemo( - () => getDuplicateEnvNames(envRows), - [envRows], - ); - - const portErrors = useMemo((): Map => { - const errors = new Map(); - for (const row of portRows) { - if (row.port.trim() && parsePositivePort(row.port) === undefined) { - errors.set(row.id, 'Port must be 1\u201365535'); - } - } - return errors; - }, [portRows]); - - const validateStep0 = useCallback((): boolean => { - const trimmedName = name.trim(); - if (!trimmedName || !namespace.trim()) return false; - if (!isValidDns1123(trimmedName)) return false; - return true; - }, [name, namespace]); - - const validateStep1 = useCallback((): boolean => { - if (deploymentMethod === 'image') return Boolean(containerImage.trim()); - return Boolean(gitUrl.trim()); - }, [deploymentMethod, containerImage, gitUrl]); - - const handleNext = useCallback(() => { - setSubmitError(null); - if (activeStep === 0 && !validateStep0()) { - const trimmedName = name.trim(); - if (!trimmedName || !namespace.trim()) { - setSubmitError('Name and namespace are required.'); - } else { - setSubmitError('Tool name must be a valid DNS-1123 label.'); - } - return; - } - if (activeStep === 1 && !validateStep1()) { - setSubmitError( - deploymentMethod === 'image' - ? 'Container image is required.' - : 'Git URL is required for source deployment.', - ); - return; - } - setActiveStep(s => Math.min(s + 1, TOOL_STEPS.length - 1)); - }, [ - activeStep, - validateStep0, - validateStep1, - deploymentMethod, - name, - namespace, - ]); - - const handleBack = useCallback(() => { - setSubmitError(null); - setActiveStep(s => Math.max(s - 1, 0)); - }, []); - - const startBuildPolling = useCallback( - ( - toolName: string, - toolNamespace: string, - finBody?: KagentiFinalizeToolBuildRequest, - ) => { - buildNameRef.current = toolName; - buildNsRef.current = toolNamespace; - if (finBody) finalizeBodyRef.current = finBody; - pollErrorCountRef.current = 0; - const startedAt = Date.now(); - - elapsedRef.current = setInterval(() => { - const elapsed = Date.now() - startedAt; - setBuildProgress(prev => { - const next = { ...prev, elapsedMs: elapsed }; - if ( - elapsed >= BUILD_TIMEOUT_WARN_MS && - prev.phase === 'building' && - !prev.message?.includes('taking longer') - ) { - next.message = - 'Build is taking longer than expected. It will continue in the background if you close this dialog.'; - } - return next; - }); - }, 1000); - - const doPoll = async () => { - try { - const info = await api.getToolBuildInfo(toolNamespace, toolName); - const elapsed = Date.now() - startedAt; - const phase = info.buildRunPhase?.toLowerCase() ?? ''; - pollErrorCountRef.current = 0; - - if (phase === 'succeeded') { - stopPolling(); - setBuildProgress(prev => ({ - ...prev, - phase: 'finalizing', - buildRunPhase: info.buildRunPhase ?? undefined, - outputImage: info.outputImage, - elapsedMs: elapsed, - message: 'Build complete. Deploying tool…', - pollErrorCount: 0, - })); - - try { - const result = await api.finalizeToolBuild( - toolNamespace, - toolName, - finalizeBodyRef.current, - ); - setBuildProgress(prev => ({ - ...prev, - phase: 'complete', - elapsedMs: Date.now() - startedAt, - message: result.message, - deployFailedAfterBuild: false, - })); - setSuccessOpen(true); - onCreated(); - } catch (finErr) { - setBuildProgress(prev => ({ - ...prev, - phase: 'failed', - elapsedMs: Date.now() - startedAt, - failureMessage: `Build succeeded but deployment failed: ${getErrorMessage(finErr)}`, - deployFailedAfterBuild: true, - })); - } - } else if (phase === 'failed') { - stopPolling(); - setBuildProgress(prev => ({ - ...prev, - phase: 'failed', - buildRunPhase: info.buildRunPhase ?? undefined, - elapsedMs: elapsed, - failureMessage: info.buildRunFailureMessage ?? 'Build failed.', - deployFailedAfterBuild: false, - })); - } else { - setBuildProgress(prev => ({ - ...prev, - buildRunPhase: info.buildRunPhase ?? undefined, - buildRunName: info.buildRunName ?? undefined, - outputImage: info.outputImage, - strategy: info.strategy, - gitUrl: info.gitUrl, - startTime: info.buildRunStartTime ?? undefined, - elapsedMs: elapsed, - pollErrorCount: 0, - })); - } - } catch { - pollErrorCountRef.current += 1; - setBuildProgress(prev => ({ - ...prev, - pollErrorCount: pollErrorCountRef.current, - })); - if (pollErrorCountRef.current >= MAX_CONSECUTIVE_POLL_ERRORS) { - stopPolling(); - setBuildProgress(prev => ({ - ...prev, - phase: 'failed', - failureMessage: - 'Lost connection to the build service. The build may still be running — check Build Pipelines for status.', - })); - } - } - }; - - setTimeout(doPoll, 1000); - pollRef.current = setInterval(doPoll, BUILD_POLL_INTERVAL_MS); - }, - [api, onCreated, stopPolling], - ); - - const handleSubmit = useCallback(async () => { - setSubmitError(null); - if (!validateStep0()) { - setSubmitError( - 'Name and namespace are required, and name must be a valid DNS-1123 label.', - ); - setActiveStep(0); - return; - } - if (!validateStep1()) { - setSubmitError( - deploymentMethod === 'image' - ? 'Container image is required.' - : 'Git URL is required for source deployment.', - ); - setActiveStep(1); - return; - } - if (portErrors.size > 0) { - setSubmitError('Fix invalid service port entries before submitting.'); - return; - } - if (duplicateEnvNames.size > 0) { - setSubmitError( - `Duplicate environment variable names: ${Array.from(duplicateEnvNames).join(', ')}`, - ); - return; - } - const body = buildToolRequest(formState); - setSubmitting(true); - - try { - const result = await api.createKagentiTool(body); - - if (deploymentMethod === 'source') { - setSubmitting(false); - setActiveStep(TOOL_STEPS.length); - setBuildProgress({ - phase: 'building', - elapsedMs: 0, - message: result.message, - pollErrorCount: 0, - }); - startBuildPolling( - body.name, - body.namespace, - buildFinalizeBody(formState), - ); - } else { - setSuccessOpen(true); - onCreated(); - onClose(); - } - } catch (err) { - setSubmitError(getErrorMessage(err)); - setSubmitting(false); - } finally { - if (deploymentMethod !== 'source') { - setSubmitting(false); - } - } - }, [ - api, - formState, - onClose, - onCreated, - validateStep0, - validateStep1, - deploymentMethod, - portErrors, - duplicateEnvNames, - startBuildPolling, - ]); - - const handleRetryBuild = useCallback(() => { - if (!buildNameRef.current || !buildNsRef.current) return; - const ns = buildNsRef.current; - const toolName = buildNameRef.current; - const wasDeployFailure = buildProgress.deployFailedAfterBuild; - - if (wasDeployFailure) { - setBuildProgress(prev => ({ - ...prev, - phase: 'finalizing', - failureMessage: undefined, - message: 'Retrying deployment…', - pollErrorCount: 0, - })); - api - .finalizeToolBuild(ns, toolName, finalizeBodyRef.current) - .then(result => { - setBuildProgress(prev => ({ - ...prev, - phase: 'complete', - message: result.message, - deployFailedAfterBuild: false, - })); - setSuccessOpen(true); - onCreated(); - }) - .catch(err => { - setBuildProgress(prev => ({ - ...prev, - phase: 'failed', - failureMessage: `Deployment failed: ${getErrorMessage(err)}`, - deployFailedAfterBuild: true, - })); - }); - } else { - setBuildProgress({ - phase: 'building', - elapsedMs: 0, - message: 'Retrying build…', - pollErrorCount: 0, - }); - api - .triggerToolBuild(ns, toolName) - .then(() => { - startBuildPolling(toolName, ns); - }) - .catch(err => { - setBuildProgress(prev => ({ - ...prev, - phase: 'failed', - failureMessage: `Retry failed: ${getErrorMessage(err)}`, - })); - }); - } - }, [api, buildProgress.deployFailedAfterBuild, onCreated, startBuildPolling]); - - const handleCloseBuild = useCallback(() => { - stopPolling(); - setBuildProgress({ phase: 'idle', elapsedMs: 0, pollErrorCount: 0 }); - onClose(); - }, [stopPolling, onClose]); - - const addEnvRow = useCallback(() => { - setEnvRows(rows => [ - ...rows, - { - id: nextRowId(rowIdRef), - name: '', - value: '', - source: 'direct' as EnvSource, - refName: '', - refKey: '', - }, - ]); - }, []); - const updateEnvRow = useCallback((id: number, patch: Partial) => { - setEnvRows(rows => rows.map(r => (r.id === id ? { ...r, ...patch } : r))); - }, []); - const removeEnvRow = useCallback((id: number) => { - setEnvRows(rows => rows.filter(r => r.id !== id)); - }, []); - - const addPortRow = useCallback(() => { - setPortRows(rows => [ - ...rows, - { - id: nextRowId(rowIdRef), - name: '', - port: '', - targetPort: '', - protocol: 'TCP' as PortProtocol, - }, - ]); - }, []); - const updatePortRow = useCallback( - (id: number, patch: Partial) => { - setPortRows(rows => - rows.map(r => (r.id === id ? { ...r, ...patch } : r)), - ); - }, - [], - ); - const removePortRow = useCallback((id: number) => { - setPortRows(rows => rows.filter(r => r.id !== id)); - }, []); - const handlePortProtocol = useCallback( - (id: number, e: SelectChangeEvent) => { - updatePortRow(id, { protocol: e.target.value as PortProtocol }); - }, - [updatePortRow], - ); - - const addBuildArgRow = useCallback(() => { - setBuildArgRows(rows => [...rows, { id: nextRowId(rowIdRef), value: '' }]); - }, []); - const updateBuildArgRow = useCallback((id: number, value: string) => { - setBuildArgRows(rows => rows.map(r => (r.id === id ? { ...r, value } : r))); - }, []); - const removeBuildArgRow = useCallback((id: number) => { - setBuildArgRows(rows => rows.filter(r => r.id !== id)); - }, []); - - return { - activeStep, - setActiveStep, - submitting, - submitError, - setSubmitError, - successOpen, - setSuccessOpen, - handleNext, - handleBack, - handleSubmit, - - buildProgress, - handleRetryBuild, - handleCloseBuild, - - name, - setName, - namespace, - setNamespace, - description, - setDescription, - protocol, - setProtocol, - framework, - setFramework, - availableNamespaces, - nameError, - nameWarning, - - deploymentMethod, - setDeploymentMethod, - containerImage, - setContainerImage, - imagePullSecret, - setImagePullSecret, - gitUrl, - setGitUrl, - gitRevision, - setGitRevision, - contextDir, - setContextDir, - registryUrl, - setRegistryUrl, - registrySecret, - setRegistrySecret, - imageTag, - setImageTag, - buildStrategy, - setBuildStrategy, - buildStrategies, - buildStrategyError, - dockerfile, - setDockerfile, - buildArgRows, - addBuildArgRow, - updateBuildArgRow, - removeBuildArgRow, - buildTimeout, - setBuildTimeout, - - workloadType, - setWorkloadType, - persistentStorageEnabled, - setPersistentStorageEnabled, - persistentStorageSize, - setPersistentStorageSize, - envRows, - addEnvRow, - updateEnvRow, - removeEnvRow, - portRows, - addPortRow, - updatePortRow, - removePortRow, - handlePortProtocol, - createHttpRoute, - setCreateHttpRoute, - authBridgeEnabled, - setAuthBridgeEnabled, - spireEnabled, - setSpireEnabled, - duplicateEnvNames, - portErrors, - }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KnowledgeBasePanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KnowledgeBasePanel.tsx deleted file mode 100644 index bdbb82a20d2..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KnowledgeBasePanel.tsx +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback } from 'react'; -import Box from '@mui/material/Box'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import Typography from '@mui/material/Typography'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import Chip from '@mui/material/Chip'; -import Snackbar from '@mui/material/Snackbar'; -import { useVectorStores, useVectorStoreConfig } from '../../hooks'; -import { KBManageStores } from './KBManageStores'; -import { KBCreateStore } from './KBCreateStore'; -import { KBRagTest } from './KBRagTest'; - -type VectorSubTab = 'manage' | 'create' | 'rag'; - -const TABS_SX = { - minHeight: 32, - '& .MuiTab-root': { - minHeight: 32, - textTransform: 'none', - fontSize: '0.8125rem', - minWidth: 'auto', - px: 2, - mr: 0.5, - }, -} as const; - -const VectorRagContent = () => { - const vectorStores = useVectorStores(); - const vsConfig = useVectorStoreConfig(); - const hasStores = vectorStores.stores.length > 0; - const [subTab, setSubTab] = useState( - hasStores ? 'manage' : 'create', - ); - const [createNotice, setCreateNotice] = useState(null); - - const handleCreated = useCallback( - async (id: string, name: string) => { - setCreateNotice(`"${name}" created (${id})`); - await vectorStores.refresh(); - vectorStores.setSelectedStoreId(id); - setSubTab('manage'); - }, - [vectorStores], - ); - - if (vsConfig.loading && vectorStores.loading) { - return ( - - - - Loading vector stores... - - - ); - } - - return ( - <> - {vsConfig.error && ( - - {vsConfig.error} - - )} - {vectorStores.error && ( - - {vectorStores.error} - - )} - - - - Configuration saved - - - - setCreateNotice(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - setCreateNotice(null)} - > - {createNotice} - - - - - setSubTab(v as VectorSubTab)} - variant="scrollable" - scrollButtons="auto" - allowScrollButtonsMobile - sx={{ ...TABS_SX, flexGrow: 1 }} - > - - - - - - - - {subTab === 'manage' && ( - - )} - {subTab === 'create' && ( - - )} - {subTab === 'rag' && ( - - )} - - ); -}; - -export const KnowledgeBasePanel = () => { - return ( - - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/AllowedToolsSelector.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/AllowedToolsSelector.tsx deleted file mode 100644 index d8f4a896cd4..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/AllowedToolsSelector.tsx +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { Dispatch, SetStateAction } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Checkbox from '@mui/material/Checkbox'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import Tooltip from '@mui/material/Tooltip'; -import type { McpServer } from './mcpServerTypes'; - -export interface AllowedToolsSelectorProps { - /** List of tools from test connection */ - tools: { name: string; description?: string }[]; - /** Current draft - allowedTools comes from here */ - draft: McpServer; - /** Update the draft */ - setDraft: Dispatch>; -} - -export function AllowedToolsSelector({ - tools, - draft, - setDraft, -}: AllowedToolsSelectorProps) { - return ( - - - - Allowed Tools - - - {draft.allowedTools?.length - ? `${draft.allowedTools.length} of ${tools.length} selected` - : `All ${tools.length} tools enabled`} - - - - Select specific tools to reduce token usage. Leave unchecked to send all - tools. - - - - - - - - {tools.map(tool => { - const checked = draft.allowedTools?.includes(tool.name) ?? false; - return ( - { - setDraft(prev => { - const current = prev.allowedTools ?? []; - const next = checked - ? current.filter(n => n !== tool.name) - : [...current, tool.name]; - if (next.length === 0) { - const { allowedTools: _, ...rest } = prev; - return rest as McpServer; - } - return { ...prev, allowedTools: next }; - }); - }} - /> - } - label={ - - {tool.name} - - } - /> - ); - })} - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/CapabilitiesSelector.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/CapabilitiesSelector.tsx deleted file mode 100644 index fb4db630748..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/CapabilitiesSelector.tsx +++ /dev/null @@ -1,259 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useEffect, useRef } from 'react'; -import Box from '@mui/material/Box'; -import Chip from '@mui/material/Chip'; -import Typography from '@mui/material/Typography'; -import TravelExploreIcon from '@mui/icons-material/TravelExplore'; -import CodeIcon from '@mui/icons-material/Code'; -import StorageIcon from '@mui/icons-material/Storage'; -import ShieldIcon from '@mui/icons-material/Shield'; -import type { AgentCapabilities } from '../../../hooks'; -import type { PromptCapabilities } from '../../../types'; -import { McpToolsSection } from './McpToolsSection'; - -interface Props { - capabilities: AgentCapabilities; - onChange: (selected: PromptCapabilities) => void; - disabled?: boolean; -} - -export function CapabilitiesSelector({ - capabilities, - onChange, - disabled, -}: Props) { - const [webSearch, setWebSearch] = useState(capabilities.enableWebSearch); - const [codeInterpreter, setCodeInterpreter] = useState( - capabilities.enableCodeInterpreter, - ); - const [selectedTools, setSelectedTools] = useState>( - () => new Set(capabilities.mcpTools.map(t => `${t.serverId}::${t.name}`)), - ); - const [ragEnabled, setRagEnabled] = useState(capabilities.ragEnabled); - const [safetyEnabled, setSafetyEnabled] = useState( - capabilities.safetyEnabled, - ); - - const onChangeRef = useRef(onChange); - onChangeRef.current = onChange; - - const prevCapsRef = useRef(capabilities); - useEffect(() => { - const prev = prevCapsRef.current; - const isInitialMount = prev === capabilities; - if (!isInitialMount) { - prevCapsRef.current = capabilities; - setWebSearch(capabilities.enableWebSearch); - setCodeInterpreter(capabilities.enableCodeInterpreter); - setSelectedTools( - new Set(capabilities.mcpTools.map(t => `${t.serverId}::${t.name}`)), - ); - setRagEnabled(capabilities.ragEnabled); - setSafetyEnabled(capabilities.safetyEnabled); - } - - const allToolKeys = new Set( - capabilities.mcpTools.map(t => `${t.serverId}::${t.name}`), - ); - onChangeRef.current({ - enableWebSearch: capabilities.enableWebSearch, - enableCodeInterpreter: capabilities.enableCodeInterpreter, - tools: capabilities.mcpTools - .filter(t => allToolKeys.has(`${t.serverId}::${t.name}`)) - .map(t => ({ - name: t.name, - description: t.description, - serverLabel: t.serverLabel, - })), - ragEnabled: capabilities.ragEnabled, - vectorStoreNames: capabilities.ragEnabled - ? capabilities.vectorStoreNames - : [], - safetyEnabled: capabilities.safetyEnabled, - safetyShields: capabilities.safetyEnabled - ? capabilities.safetyShields - : [], - }); - }, [capabilities]); - - const emitChange = useCallback( - ( - overrides: Partial<{ - ws: boolean; - ci: boolean; - tools: Set; - rag: boolean; - safety: boolean; - }>, - ) => { - const ws = overrides.ws ?? webSearch; - const ci = overrides.ci ?? codeInterpreter; - const tools = overrides.tools ?? selectedTools; - const rag = overrides.rag ?? ragEnabled; - const safety = overrides.safety ?? safetyEnabled; - - const selectedMcpTools = capabilities.mcpTools.filter(t => - tools.has(`${t.serverId}::${t.name}`), - ); - - const result: PromptCapabilities = { - enableWebSearch: ws, - enableCodeInterpreter: ci, - tools: selectedMcpTools.map(t => ({ - name: t.name, - description: t.description, - serverLabel: t.serverLabel, - })), - ragEnabled: rag, - vectorStoreNames: rag ? capabilities.vectorStoreNames : [], - safetyEnabled: safety, - safetyShields: safety ? capabilities.safetyShields : [], - }; - onChange(result); - }, - [ - webSearch, - codeInterpreter, - selectedTools, - ragEnabled, - safetyEnabled, - capabilities, - onChange, - ], - ); - - const toggleTool = (key: string) => { - setSelectedTools(prev => { - const next = new Set(prev); - if (next.has(key)) next.delete(key); - else next.add(key); - emitChange({ tools: next }); - return next; - }); - }; - - const hasAnyCapability = - capabilities.enableWebSearch || - capabilities.enableCodeInterpreter || - capabilities.mcpTools.length > 0 || - capabilities.mcpServers.length > 0 || - capabilities.ragEnabled || - capabilities.safetyEnabled; - - if (!hasAnyCapability) { - return ( - - - No tools or capabilities detected. Configure tools, MCP servers, or - knowledge base in the other tabs to enrich instruction generation. - - - ); - } - - return ( - - - Include in generated instructions - - - {/* Built-in tools */} - - {capabilities.enableWebSearch && ( - } - label="Web Search" - size="small" - variant={webSearch ? 'filled' : 'outlined'} - color={webSearch ? 'primary' : 'default'} - onClick={() => { - const next = !webSearch; - setWebSearch(next); - emitChange({ ws: next }); - }} - disabled={disabled} - /> - )} - {capabilities.enableCodeInterpreter && ( - } - label="Code Interpreter" - size="small" - variant={codeInterpreter ? 'filled' : 'outlined'} - color={codeInterpreter ? 'primary' : 'default'} - onClick={() => { - const next = !codeInterpreter; - setCodeInterpreter(next); - emitChange({ ci: next }); - }} - disabled={disabled} - /> - )} - {capabilities.ragEnabled && ( - } - label={`Vector RAG (${capabilities.vectorStoreNames.length} store${ - capabilities.vectorStoreNames.length !== 1 ? 's' : '' - })`} - size="small" - variant={ragEnabled ? 'filled' : 'outlined'} - color={ragEnabled ? 'primary' : 'default'} - onClick={() => { - const next = !ragEnabled; - setRagEnabled(next); - emitChange({ rag: next }); - }} - disabled={disabled} - /> - )} - {capabilities.safetyEnabled && ( - } - label="Safety Shields" - size="small" - variant={safetyEnabled ? 'filled' : 'outlined'} - color={safetyEnabled ? 'secondary' : 'default'} - onClick={() => { - const next = !safetyEnabled; - setSafetyEnabled(next); - emitChange({ safety: next }); - }} - disabled={disabled} - /> - )} - - - {/* MCP servers & tools section */} - {capabilities.mcpServers.length > 0 && ( - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/GeneratePromptForm.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/GeneratePromptForm.tsx deleted file mode 100644 index 9a6782a668e..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/GeneratePromptForm.tsx +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useRef, useState, useCallback } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Divider from '@mui/material/Divider'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import Autocomplete from '@mui/material/Autocomplete'; -import Chip from '@mui/material/Chip'; -import CircularProgress from '@mui/material/CircularProgress'; -import Alert from '@mui/material/Alert'; -import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import { - useGeneratePrompt, - useModels, - type AgentCapabilities, -} from '../../../hooks'; -import { CapabilitiesSelector } from './CapabilitiesSelector'; -import type { PromptCapabilities } from '../../../types'; - -export interface GeneratePromptFormProps { - /** Agent capabilities for the selector */ - capabilities: AgentCapabilities; - /** Effective model from config (for "active" chip) */ - effectiveModel: string; - /** Callback when prompt is generated - receives the new prompt text */ - onGenerated: (prompt: string) => void; -} - -export const GeneratePromptForm = ({ - capabilities, - effectiveModel, - onGenerated, -}: GeneratePromptFormProps) => { - const { generate, generating, error: genError } = useGeneratePrompt(); - const { - models, - loading: modelsLoading, - refresh: refreshModels, - } = useModels(); - const [description, setDescription] = useState(''); - const [selectedModel, setSelectedModel] = useState(''); - const selectedCapsRef = useRef(undefined); - - const modelOptions = models.map(m => m.id).filter(Boolean) as string[]; - - const handleGenerate = useCallback(async () => { - if (!description.trim()) return; - const model = selectedModel || undefined; - let prompt: string; - try { - prompt = await generate( - description.trim(), - model, - selectedCapsRef.current, - ); - } catch { - return; - } - onGenerated(prompt); - }, [description, generate, selectedModel, onGenerated]); - - return ( - - - Describe what you want your agent to do, then select which capabilities - to emphasize. The LLM will generate a production-quality system prompt - that references your agent's actual tools by name. - - - setDescription(e.target.value)} - placeholder="e.g. Help developers troubleshoot Kubernetes deployments, check pod logs, and suggest fixes..." - disabled={generating} - inputProps={{ maxLength: 2000 }} - size="small" - sx={{ mb: 2 }} - /> - - - - { - selectedCapsRef.current = caps; - }} - disabled={generating} - /> - - - - - - - setSelectedModel(v)} - getOptionLabel={opt => (typeof opt === 'string' ? opt : '')} - loading={modelsLoading} - disabled={generating} - renderOption={(props, option) => ( -
  3. - - - {option} - - {option === effectiveModel && ( - - )} - -
  4. - )} - renderInput={params => ( - - {modelsLoading && } - {params.InputProps.endAdornment} - - ), - }} - /> - )} - sx={{ flex: 1 }} - /> - - - - - - - -
    - {!selectedModel && effectiveModel && ( - - Will use active model: {effectiveModel} - - )} -
    - - - - - - {description.length}/2000 - - -
    - - {genError && ( - - {genError} - - )} -
    - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/KagentiConnectionSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/KagentiConnectionSection.tsx deleted file mode 100644 index 830ee940610..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/KagentiConnectionSection.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useEffect } from 'react'; -import Box from '@mui/material/Box'; -import TextField from '@mui/material/TextField'; -import Alert from '@mui/material/Alert'; -import Snackbar from '@mui/material/Snackbar'; -import { useAdminConfig } from '../../../hooks'; -import { AdminSection } from '../shared/AdminSection'; - -const URL_RE = /^https?:\/\/.+/; - -interface Props { - effectiveConfig?: Record | null; - onConfigSaved?: () => void; -} - -export const KagentiConnectionSection = ({ - effectiveConfig, - onConfigSaved, -}: Props) => { - const kagentiUrlConfig = useAdminConfig('kagentiBaseUrl'); - - const effectiveKagentiUrl = (effectiveConfig?.kagentiBaseUrl as string) ?? ''; - - const [kagentiUrl, setKagentiUrl] = useState(''); - const [initialized, setInitialized] = useState(false); - const [toast, setToast] = useState<{ - message: string; - severity: 'success' | 'error'; - } | null>(null); - - useEffect(() => { - if (!initialized && !kagentiUrlConfig.loading) { - const dbValue = kagentiUrlConfig.entry?.configValue as string | undefined; - setKagentiUrl(dbValue ?? effectiveKagentiUrl); - setInitialized(true); - } - }, [ - initialized, - kagentiUrlConfig.loading, - kagentiUrlConfig.entry, - effectiveKagentiUrl, - ]); - - const handleSave = useCallback(async () => { - if (kagentiUrl.trim() && !URL_RE.test(kagentiUrl.trim())) { - setToast({ - message: 'Kagenti URL must start with http:// or https://', - severity: 'error', - }); - return; - } - try { - await kagentiUrlConfig.save(kagentiUrl.trim()); - } catch { - return; - } - setToast({ message: 'Kagenti connection URL saved', severity: 'success' }); - onConfigSaved?.(); - }, [kagentiUrl, kagentiUrlConfig, onConfigSaved]); - - const handleReset = useCallback(async () => { - try { - await kagentiUrlConfig.reset(); - } catch { - return; - } - setKagentiUrl(effectiveKagentiUrl); - setInitialized(false); - setToast({ message: 'Reset to YAML default', severity: 'success' }); - }, [kagentiUrlConfig, effectiveKagentiUrl]); - - if (kagentiUrlConfig.loading) return null; - - return ( - <> - - - setKagentiUrl(e.target.value)} - placeholder="e.g. https://kagenti-backend.apps.example.com" - helperText="Kagenti backend endpoint (overrides app-config.yaml)" - error={!!kagentiUrl && !URL_RE.test(kagentiUrl)} - /> - - - - setToast(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - {toast ? ( - setToast(null)} - severity={toast.severity} - variant="filled" - sx={{ width: '100%' }} - > - {toast.message} - - ) : undefined} - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServerCard.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServerCard.tsx deleted file mode 100644 index be4e2e5c623..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServerCard.tsx +++ /dev/null @@ -1,350 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import IconButton from '@mui/material/IconButton'; -import Chip from '@mui/material/Chip'; -import Tooltip from '@mui/material/Tooltip'; -import Collapse from '@mui/material/Collapse'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import ErrorIcon from '@mui/icons-material/Error'; -import LockIcon from '@mui/icons-material/Lock'; -import BuildIcon from '@mui/icons-material/Build'; -import BlockIcon from '@mui/icons-material/Block'; -import DeleteIcon from '@mui/icons-material/Delete'; -import EditIcon from '@mui/icons-material/Edit'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import ExpandLessIcon from '@mui/icons-material/ExpandLess'; -import GppGoodIcon from '@mui/icons-material/GppGood'; -import { useTheme, alpha } from '@mui/material/styles'; -import type { Theme } from '@mui/material/styles'; -import type { MCPServerStatus } from '../../../types'; -import type { McpServer } from './mcpServerTypes'; - -function chipLabel( - isDisabled: boolean | undefined, - isOverridden: boolean | undefined, - isYaml: boolean, -): string { - if (isDisabled) return 'Disabled'; - if (isOverridden) return 'Overridden'; - if (isYaml) return 'YAML'; - return 'Admin'; -} - -function chipBgColor( - theme: Theme, - isDisabled: boolean | undefined, - isOverridden: boolean | undefined, - isYaml: boolean, -): string { - if (isDisabled) return alpha(theme.palette.text.disabled, 0.1); - if (isOverridden) return alpha(theme.palette.warning.main, 0.15); - if (isYaml) return alpha(theme.palette.info.main, 0.1); - return alpha(theme.palette.warning.main, 0.1); -} - -function chipFgColor( - theme: Theme, - isDisabled: boolean | undefined, - isOverridden: boolean | undefined, - isYaml: boolean, -): string { - if (isDisabled) return theme.palette.text.disabled; - if (isOverridden) return theme.palette.warning.dark; - if (isYaml) return theme.palette.info.main; - return theme.palette.warning.main; -} - -export interface McpServerCardProps { - server: McpServer; - statusInfo?: MCPServerStatus; - isYaml: boolean; - isOverridden?: boolean; - isDisabled?: boolean; - onRemove?: () => void; - onEdit?: () => void; - onDisable?: () => void; -} - -export function McpServerCard({ - server, - statusInfo, - isYaml, - isOverridden, - isDisabled, - onRemove, - onEdit, - onDisable, -}: McpServerCardProps) { - const theme = useTheme(); - const [expanded, setExpanded] = useState(false); - const connected = statusInfo?.connected ?? false; - const tools = statusInfo?.tools ?? []; - const toolCount = statusInfo?.toolCount ?? tools.length; - - return ( - - {/* Header row */} - 0 ? 'pointer' : 'default', - }} - onClick={() => { - if (connected && toolCount > 0) setExpanded(!expanded); - }} - > - {/* Status indicator */} - - - {connected ? ( - - ) : ( - - )} - - - - {/* Server name and ID */} - - - - {server.name || server.id} - - {isYaml && !isOverridden && !isDisabled && ( - - - - )} - - - {server.id} · {server.type} - {server.allowedTools && server.allowedTools.length > 0 && ( - <> · {server.allowedTools.length} tools allowed - )} - - - - {/* Tools badge */} - {connected && toolCount > 0 && ( - } - label={`${toolCount} tool${toolCount !== 1 ? 's' : ''}`} - size="small" - variant="outlined" - color="success" - sx={{ '&.MuiChip-root': { fontSize: '0.75rem' } }} - /> - )} - - {/* HITL badge */} - {server.requireApproval === 'always' && ( - - } - label="HITL" - size="small" - variant="outlined" - color="info" - sx={{ '&.MuiChip-root': { fontSize: '0.7rem' } }} - /> - - )} - - {/* Type badge */} - - - {/* Source badge */} - - - {/* Actions */} - {onEdit && !isDisabled && ( - - { - e.stopPropagation(); - onEdit(); - }} - > - - - - )} - {isYaml && onDisable && ( - - { - e.stopPropagation(); - onDisable(); - }} - > - - - - )} - {!isYaml && onRemove && ( - - { - e.stopPropagation(); - onRemove(); - }} - > - - - - )} - - {/* Expand toggle for tools */} - {connected && toolCount > 0 && ( - - {expanded ? ( - - ) : ( - - )} - - )} - - - {/* Error message for disconnected servers */} - {!connected && statusInfo?.error && ( - - - {statusInfo.error} - - - )} - - {/* Expandable tools list */} - - - - Available Tools - - - {tools.map(tool => ( - - - - ))} - - - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServerFormDialog.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServerFormDialog.tsx deleted file mode 100644 index ea1d78e9ca4..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServerFormDialog.tsx +++ /dev/null @@ -1,302 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { Dispatch, SetStateAction } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import DialogContent from '@mui/material/DialogContent'; -import DialogActions from '@mui/material/DialogActions'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import Chip from '@mui/material/Chip'; -import type { McpTestConnectionResult } from '../../../types'; -import type { McpServer } from './mcpServerTypes'; -import { AllowedToolsSelector } from './AllowedToolsSelector'; - -function testResultSeverity( - result: McpTestConnectionResult, -): 'error' | 'warning' | 'success' { - if (!result.success) return 'error'; - if (result.warning) return 'warning'; - return 'success'; -} - -function testResultMessage(result: McpTestConnectionResult): string { - if (!result.success) return result.error || 'Connection failed'; - if (result.warning) return result.warning; - const count = result.toolCount ?? 0; - return `Connected successfully — ${count} tool${ - count !== 1 ? 's' : '' - } discovered`; -} - -export interface McpServerFormDialogProps { - open: boolean; - onClose: () => void; - draft: McpServer; - setDraft: Dispatch>; - isEditing: boolean; - testResult: McpTestConnectionResult | null; - testing: boolean; - onTestConnection: () => void; - onUrlChange: (val: string) => void; - onClearTestResult: () => void; - urlError: string | null; - onSubmit: () => void; - draftValid: boolean; - showYamlOverrideInfo: boolean; -} - -export function McpServerFormDialog({ - open, - onClose, - draft, - setDraft, - isEditing, - testResult, - testing, - onTestConnection, - onUrlChange, - onClearTestResult, - urlError, - onSubmit, - draftValid, - showYamlOverrideInfo, -}: McpServerFormDialogProps) { - return ( - - - {isEditing ? 'Edit MCP Server' : 'Add MCP Server'} - - - - setDraft(prev => ({ ...prev, id: e.target.value }))} - placeholder="e.g. my-mcp-server" - required - disabled={isEditing} - helperText={isEditing ? 'ID cannot be changed' : undefined} - /> - - setDraft(prev => ({ ...prev, name: e.target.value })) - } - placeholder="e.g. My MCP Server" - /> - - - Server Type - - { - if (val) - setDraft(prev => ({ - ...prev, - type: val as McpServer['type'], - })); - }} - size="small" - fullWidth - > - - streamable-http - - sse - - - { - const val = e.target.value; - setDraft(prev => ({ ...prev, url: val })); - onUrlChange(val); - onClearTestResult(); - }} - placeholder="e.g. http://localhost:3000/mcp" - required - error={!!urlError} - helperText={urlError} - /> - { - const raw = e.target.value.trim().replace(/^Bearer\s+/i, ''); - setDraft(prev => { - if (!raw) { - const { Authorization: _, ...rest } = prev.headers ?? {}; - const remaining = - Object.keys(rest).length > 0 ? rest : undefined; - return { ...prev, headers: remaining }; - } - return { - ...prev, - headers: { - ...(prev.headers ?? {}), - Authorization: `Bearer ${raw}`, - }, - }; - }); - onClearTestResult(); - }} - placeholder="Leave blank if no auth required" - helperText="Token sent as Authorization: Bearer header" - /> - - - - Tool Approval (HITL) - - { - if (val) - setDraft(prev => ({ - ...prev, - requireApproval: val as 'always' | 'never', - })); - }} - size="small" - fullWidth - > - Auto-execute - Require Approval - - - Require Approval pauses each tool call for human confirmation - before executing. - - - - {/* Test connection button */} - - - {/* Test result */} - {testResult && ( - - {testResultMessage(testResult)} - {testResult.success && - !testResult.warning && - testResult.tools && - testResult.tools.length > 0 && ( - - {testResult.tools.map(t => ( - - ))} - - )} - - )} - - {/* Allowed Tools filter — shown after a successful test with tools */} - {testResult?.success && - !testResult.warning && - testResult.tools && - testResult.tools.length > 0 && ( - - )} - - {showYamlOverrideInfo && ( - - This server is defined in YAML. Your changes will be stored as an - admin override. Authentication config from YAML is preserved. - - )} - - - - - - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServersSection.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServersSection.test.tsx deleted file mode 100644 index f97fd34a857..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServersSection.test.tsx +++ /dev/null @@ -1,296 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { TestApiProvider } from '@backstage/test-utils'; -import { augmentApiRef } from '../../../api'; -import { createAdminMockApi } from '../../../test-utils/factories'; -import { McpServersSection } from './McpServersSection'; - -const theme = createTheme(); - -const DEFAULT_ADMIN_RESPONSE = { entry: null, source: 'default' }; - -/** Wraps getAdminConfig so disabledMcpServerIds always returns default. */ -function wrapGetAdminConfigForMcp(mcpResponse: { - entry: { configKey: string; configValue: unknown }; - source: string; -}) { - return (key: string) => { - if (key === 'disabledMcpServerIds') { - return Promise.resolve(DEFAULT_ADMIN_RESPONSE); - } - if (key === 'mcpServers') { - return Promise.resolve(mcpResponse); - } - return Promise.resolve(DEFAULT_ADMIN_RESPONSE); - }; -} - -function renderSection( - api = createAdminMockApi(), - effectiveConfig: Record = { mcpServers: [] }, -) { - return render( - - - - - , - ); -} - -describe('McpServersSection', () => { - it('renders empty state', async () => { - renderSection(); - await waitFor(() => { - expect(screen.getByText(/No additional servers/)).toBeInTheDocument(); - expect(screen.getByText('Add Server')).toBeInTheDocument(); - }); - }); - - it('renders existing admin servers from config', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation( - wrapGetAdminConfigForMcp({ - entry: { - configKey: 'mcpServers', - configValue: [ - { - id: 'srv-1', - name: 'Server One', - type: 'sse', - url: 'http://example.com/mcp', - }, - ], - }, - source: 'database', - }), - ); - renderSection(api); - - await waitFor(() => { - expect(screen.getByText('Server One')).toBeInTheDocument(); - expect(screen.getByText('sse')).toBeInTheDocument(); - }); - }); - - it('opens add dialog on button click', async () => { - renderSection(); - - await waitFor(() => { - expect(screen.getByText('Add Server')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Add Server')); - - await waitFor(() => { - expect(screen.getByText('Add MCP Server')).toBeInTheDocument(); - expect( - screen.getByPlaceholderText('e.g. my-mcp-server'), - ).toBeInTheDocument(); - expect( - screen.getByPlaceholderText('e.g. http://localhost:3000/mcp'), - ).toBeInTheDocument(); - }); - }); - - it('removes an admin server from the list', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation( - wrapGetAdminConfigForMcp({ - entry: { - configKey: 'mcpServers', - configValue: [ - { id: 'srv-a', name: 'A', type: 'sse', url: 'https://a.com' }, - { - id: 'srv-b', - name: 'B', - type: 'streamable-http', - url: 'https://b.com', - }, - ], - }, - source: 'database', - }), - ); - renderSection(api); - - await waitFor(() => { - expect(screen.getByText('A')).toBeInTheDocument(); - expect(screen.getByText('B')).toBeInTheDocument(); - }); - - const deleteButtons = screen.getAllByRole('button', { - name: /Remove server/, - }); - fireEvent.click(deleteButtons[0]); - - expect(screen.queryByText('A')).not.toBeInTheDocument(); - expect(screen.getByText('B')).toBeInTheDocument(); - }); - - it('calls setAdminConfig when saving', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation( - wrapGetAdminConfigForMcp({ - entry: { - configKey: 'mcpServers', - configValue: [ - { id: 'srv-x', name: 'X', type: 'sse', url: 'https://x.com' }, - ], - }, - source: 'database', - }), - ); - renderSection(api); - - await waitFor(() => { - expect(screen.getByText('X')).toBeInTheDocument(); - }); - - const saveButtons = screen.getAllByText('Save'); - fireEvent.click(saveButtons[saveButtons.length - 1]); - - await waitFor(() => { - expect(api.setAdminConfig).toHaveBeenCalledWith( - 'mcpServers', - expect.any(Array), - ); - }); - }); - - it('shows YAML servers with edit and disable buttons but no remove', async () => { - const api = createAdminMockApi(); - (api.getStatus as jest.Mock).mockResolvedValue({ - provider: { - connected: true, - model: 'test-model', - baseUrl: 'https://localhost', - }, - vectorStore: { connected: false }, - mcpServers: [ - { - id: 'yaml-srv', - name: 'YAML Server', - url: 'https://yaml.com', - connected: true, - source: 'yaml', - tools: [{ name: 'tool1', description: 'A tool' }], - toolCount: 1, - }, - ], - timestamp: new Date().toISOString(), - ready: true, - }); - renderSection(api, { - mcpServers: [ - { - id: 'yaml-srv', - name: 'YAML Server', - type: 'streamable-http', - url: 'https://yaml.com', - }, - ], - }); - - await waitFor(() => { - expect(screen.getByText('YAML Server')).toBeInTheDocument(); - expect(screen.getByText('YAML')).toBeInTheDocument(); - }); - - // YAML servers have edit and disable, but not remove - expect( - screen.getByRole('button', { name: /Edit server/ }), - ).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: /Disable server/ }), - ).toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: /Remove server/ }), - ).not.toBeInTheDocument(); - }); - - it('shows HITL badge on server card when requireApproval is "always"', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation( - wrapGetAdminConfigForMcp({ - entry: { - configKey: 'mcpServers', - configValue: [ - { - id: 'hitl-srv', - name: 'HITL Server', - type: 'sse', - url: 'https://hitl.com', - requireApproval: 'always', - }, - ], - }, - source: 'database', - }), - ); - renderSection(api); - - await waitFor(() => { - expect(screen.getByText('HITL Server')).toBeInTheDocument(); - expect(screen.getByText('HITL')).toBeInTheDocument(); - }); - }); - - it('does not show HITL badge when requireApproval is "never"', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation( - wrapGetAdminConfigForMcp({ - entry: { - configKey: 'mcpServers', - configValue: [ - { - id: 'auto-srv', - name: 'Auto Server', - type: 'sse', - url: 'https://auto.com', - requireApproval: 'never', - }, - ], - }, - source: 'database', - }), - ); - renderSection(api); - - await waitFor(() => { - expect(screen.getByText('Auto Server')).toBeInTheDocument(); - }); - expect(screen.queryByText('HITL')).not.toBeInTheDocument(); - }); - - it('shows Tool Approval toggle in add dialog', async () => { - renderSection(); - - await waitFor(() => { - expect(screen.getByText('Add Server')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Add Server')); - - await waitFor(() => { - expect(screen.getByText('Tool Approval (HITL)')).toBeInTheDocument(); - expect(screen.getByText('Auto-execute')).toBeInTheDocument(); - expect(screen.getByText('Require Approval')).toBeInTheDocument(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServersSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServersSection.tsx deleted file mode 100644 index 110499d46a1..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpServersSection.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Snackbar from '@mui/material/Snackbar'; -import AddIcon from '@mui/icons-material/Add'; -import { AdminSection } from '../shared/AdminSection'; -import type { McpServer } from './mcpServerTypes'; -import { McpServerCard } from './McpServerCard'; -import { McpServerFormDialog } from './McpServerFormDialog'; -import { useMcpServers } from './useMcpServers'; - -interface Props { - effectiveConfig?: Record | null; - onConfigSaved?: () => void; -} - -export const McpServersSection = ({ - effectiveConfig, - onConfigSaved, -}: Props) => { - const { servers, config, section, actions, ui } = useMcpServers({ - effectiveConfig, - onConfigSaved, - }); - - if (config.loading) return null; - - const { - yamlServers, - pureAdminServers, - adminServers, - statusMap, - overriddenYamlIds, - disabledIds, - } = servers; - - const { hasChanges, totalTools, connectedCount, totalCount } = section; - - const { - handleSave, - handleReset, - handleTestConnection, - openAddDialog, - openEditDialog, - handleDialogSubmit, - handleRemoveAdmin, - handleToggleDisable, - closeDialog, - handleUrlChange, - setDraft, - setToast, - clearTestResult, - } = actions; - - const { - draft, - dialogOpen, - toast, - testResult, - testing, - urlError, - isEditing, - draftValid, - showYamlOverrideInfo, - } = ui; - - return ( - <> - 0 - ? `${connectedCount}/${totalCount} connected · ${totalTools} tool${ - totalTools !== 1 ? 's' : '' - } available` - : 'Configure Model Context Protocol servers for tool use.' - } - source={config.source} - saving={config.saving} - error={config.error} - onSave={handleSave} - onReset={hasChanges ? handleReset : undefined} - > - {/* YAML Servers */} - {yamlServers.length > 0 && ( - - - YAML Configured ({yamlServers.length}) - - {yamlServers.map((s: McpServer) => { - const disabled = disabledIds.has(s.id); - const overridden = overriddenYamlIds.has(s.id); - const display = overridden - ? (adminServers.find(a => a.id === s.id) ?? s) - : s; - return ( - openEditDialog(display)} - onDisable={() => handleToggleDisable(s.id)} - /> - ); - })} - - )} - - {/* Admin-Added Servers */} - - - Admin Added ({pureAdminServers.length}) - - - {pureAdminServers.length > 0 ? ( - pureAdminServers.map(s => ( - openEditDialog(s)} - onRemove={() => handleRemoveAdmin(s.id)} - /> - )) - ) : ( - - No additional servers. Click "Add Server" to add one. - - )} - - - - - - - - setToast(null)} - message={toast} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpToolsSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpToolsSection.tsx deleted file mode 100644 index ae1be45e263..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/McpToolsSection.tsx +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState, useMemo } from 'react'; -import Box from '@mui/material/Box'; -import Chip from '@mui/material/Chip'; -import Typography from '@mui/material/Typography'; -import Collapse from '@mui/material/Collapse'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import BuildIcon from '@mui/icons-material/Build'; -import type { AgentCapabilities, DiscoveredTool } from '../../../hooks'; - -export interface McpToolsSectionProps { - capabilities: Pick; - selectedTools: Set; - onToolToggle: (key: string) => void; - disabled?: boolean; -} - -/** - * McpToolsSection - MCP servers and tools selector with expandable tool list - */ -export function McpToolsSection({ - capabilities, - selectedTools, - onToolToggle, - disabled, -}: McpToolsSectionProps) { - const [toolsExpanded, setToolsExpanded] = useState(false); - - const toolsByServer = useMemo(() => { - const map = new Map(); - for (const t of capabilities.mcpTools) { - const key = t.serverLabel; - if (!map.has(key)) map.set(key, []); - map.get(key)!.push(t); - } - return map; - }, [capabilities.mcpTools]); - - return ( - - 0 ? 'pointer' : 'default', - }} - onClick={() => { - if (capabilities.mcpTools.length > 0) setToolsExpanded(e => !e); - }} - role="button" - tabIndex={0} - > - - - MCP Servers ({capabilities.mcpServers.length}) - {capabilities.mcpTools.length > 0 && - ` · ${selectedTools.size}/${capabilities.mcpTools.length} tools selected`} - - {capabilities.mcpTools.length > 0 && ( - - - - )} - - - {/* Server chips (always visible) */} - - {capabilities.mcpServers.map(server => ( - - - - ))} - - - {/* Expandable tool list (only when tools are discovered) */} - {capabilities.mcpTools.length > 0 && ( - - - {Array.from(toolsByServer.entries()).map(([serverLabel, tools]) => ( - - - {serverLabel} - - - {tools.map(tool => { - const key = `${tool.serverId}::${tool.name}`; - const selected = selectedTools.has(key); - return ( - - onToolToggle(key)} - disabled={disabled} - sx={{ fontSize: '0.75rem' }} - /> - - ); - })} - - - ))} - - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelConnectionSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelConnectionSection.tsx deleted file mode 100644 index 8cb021ce220..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelConnectionSection.tsx +++ /dev/null @@ -1,280 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useEffect } from 'react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Alert from '@mui/material/Alert'; -import Snackbar from '@mui/material/Snackbar'; -import Typography from '@mui/material/Typography'; -import CircularProgress from '@mui/material/CircularProgress'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; -import { useApi } from '@backstage/core-plugin-api'; -import { augmentApiRef } from '../../../api'; -import { normalizeErrorMessage } from '../../../utils'; -import { useAdminConfig, useModels } from '../../../hooks'; -import { AdminSection } from '../shared/AdminSection'; -import { ModelSelector } from './ModelSelector'; - -const URL_RE = /^https?:\/\/.+/; - -interface Props { - effectiveConfig?: Record | null; - onConfigSaved?: () => void; - providerName: string; -} - -function modelConnectionStatusText(result: TestResult): string { - if (result.canGenerate) return 'Connected · Model found · Can generate'; - if (result.modelFound) { - const suffix = result.error ? `: ${result.error}` : ''; - return `Connected · Model found · Cannot generate${suffix}`; - } - if (result.connected) { - return `Connected · ${result.error || 'Model not found'}`; - } - const suffix = result.error ? `: ${result.error}` : ''; - return `Not connected${suffix}`; -} - -type TestResult = { - connected: boolean; - modelFound: boolean; - canGenerate: boolean; - error?: string; -}; - -export const ModelConnectionSection = ({ - effectiveConfig, - onConfigSaved, - providerName, -}: Props) => { - const api = useApi(augmentApiRef); - const modelConfig = useAdminConfig('model'); - const baseUrlConfig = useAdminConfig('baseUrl'); - const { - models, - loading: modelsLoading, - refresh: refreshModels, - } = useModels(); - - const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState(null); - - const effectiveModel = (effectiveConfig?.model as string) ?? ''; - const effectiveBaseUrl = (effectiveConfig?.baseUrl as string) ?? ''; - - const [model, setModel] = useState(''); - const [baseUrl, setBaseUrl] = useState(''); - const [toast, setToast] = useState<{ - message: string; - severity: 'success' | 'warning' | 'error'; - } | null>(null); - const [initialized, setInitialized] = useState({ - model: false, - baseUrl: false, - }); - - useEffect(() => { - if (!initialized.model && !modelConfig.loading) { - const dbValue = modelConfig.entry?.configValue as string | undefined; - const isStale = - dbValue === 'unused' || dbValue === 'unknown' || dbValue === ''; - setModel(isStale ? effectiveModel : (dbValue ?? effectiveModel)); - setInitialized(prev => ({ ...prev, model: true })); - } - }, [ - initialized.model, - modelConfig.loading, - modelConfig.entry, - effectiveModel, - ]); - - useEffect(() => { - if (!initialized.baseUrl && !baseUrlConfig.loading) { - const dbValue = baseUrlConfig.entry?.configValue as string | undefined; - setBaseUrl(dbValue ?? effectiveBaseUrl); - setInitialized(prev => ({ ...prev, baseUrl: true })); - } - }, [ - initialized.baseUrl, - baseUrlConfig.loading, - baseUrlConfig.entry, - effectiveBaseUrl, - ]); - - const source = - modelConfig.source === 'database' || baseUrlConfig.source === 'database' - ? 'database' - : 'default'; - const saving = modelConfig.saving || baseUrlConfig.saving; - const error = modelConfig.error || baseUrlConfig.error; - - const modelOptions = models.map(m => m.id).filter(Boolean) as string[]; - - const handleTestConnection = useCallback(async () => { - setTesting(true); - setTestResult(null); - try { - const trimmedUrl = baseUrl.trim() || undefined; - const result = await api.testModelConnection( - model.trim() || undefined, - trimmedUrl, - ); - setTestResult(result); - } catch (err) { - setTestResult({ - connected: false, - modelFound: false, - canGenerate: false, - error: normalizeErrorMessage(err, 'Test failed'), - }); - } finally { - setTesting(false); - } - }, [api, model, baseUrl]); - - const handleSave = useCallback(async () => { - const allWarnings: string[] = []; - if (baseUrl.trim() && !URL_RE.test(baseUrl.trim())) { - setToast({ - message: 'Base URL must start with http:// or https://', - severity: 'error', - }); - return; - } - try { - if (model.trim()) { - const result = await modelConfig.save(model.trim()); - if (result.warnings?.length) { - allWarnings.push(...result.warnings); - } - } - if (baseUrl.trim()) { - await baseUrlConfig.save(baseUrl.trim()); - } - } catch { - return; - } - if (allWarnings.length > 0) { - setToast({ message: allWarnings.join(' '), severity: 'warning' }); - } else { - setToast({ message: 'Model connection saved', severity: 'success' }); - } - onConfigSaved?.(); - }, [model, baseUrl, modelConfig, baseUrlConfig, onConfigSaved]); - - const handleReset = useCallback(async () => { - try { - await Promise.all([modelConfig.reset(), baseUrlConfig.reset()]); - } catch { - return; - } - setModel(effectiveModel); - setBaseUrl(effectiveBaseUrl); - setInitialized({ model: false, baseUrl: false }); - setToast({ message: 'Reset to YAML defaults', severity: 'success' }); - }, [modelConfig, baseUrlConfig, effectiveModel, effectiveBaseUrl]); - - if (modelConfig.loading || baseUrlConfig.loading) return null; - - return ( - <> - - - - setBaseUrl(e.target.value)} - placeholder="e.g. http://localhost:8321" - helperText={`${providerName} server endpoint`} - error={!!baseUrl && !URL_RE.test(baseUrl)} - /> - - - - {testResult && ( - - {testResult.canGenerate ? ( - - ) : ( - - )} - - {modelConnectionStatusText(testResult)} - - - )} - - - - setToast(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - {toast ? ( - setToast(null)} - severity={toast.severity} - variant="filled" - sx={{ width: '100%' }} - > - {toast.message} - - ) : undefined} - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelSelector.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelSelector.tsx deleted file mode 100644 index fa71699d840..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelSelector.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Box from '@mui/material/Box'; -import TextField from '@mui/material/TextField'; -import Autocomplete from '@mui/material/Autocomplete'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import Typography from '@mui/material/Typography'; -import CircularProgress from '@mui/material/CircularProgress'; -import RefreshIcon from '@mui/icons-material/Refresh'; - -export interface ModelSelectorProps { - model: string; - onModelChange: (value: string) => void; - modelOptions: string[]; - loading: boolean; - onRefresh: () => void; - effectiveModel?: string; -} - -/** - * Autocomplete for model selection with refresh button - */ -export function ModelSelector({ - model, - onModelChange, - modelOptions, - loading, - onRefresh, - effectiveModel = '', -}: ModelSelectorProps) { - return ( - - onModelChange(newValue)} - getOptionLabel={opt => (typeof opt === 'string' ? opt : '')} - loading={loading} - renderOption={(props, option) => ( -
  5. - - - {option} - - {option === effectiveModel && ( - - active - - )} - -
  6. - )} - renderInput={params => ( - - {loading ? : null} - {params.InputProps.endAdornment} - - ), - }} - /> - )} - sx={{ flex: 1 }} - /> - - - - - -
    - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelToolsPanel.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelToolsPanel.test.tsx deleted file mode 100644 index bf641896aee..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelToolsPanel.test.tsx +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { TestApiProvider } from '@backstage/test-utils'; -import { augmentApiRef } from '../../../api'; -import { createAdminMockApi } from '../../../test-utils/factories'; -import { AgentConfigPanel } from './ModelToolsPanel'; - -const theme = createTheme(); - -function renderPanel(api = createAdminMockApi()) { - const apiWithProviders = { - ...api, - listProviders: - (api as any).listProviders ?? - jest.fn().mockResolvedValue({ - providers: [ - { - id: 'test', - displayName: 'Test Provider', - capabilities: { - safety: true, - mcpTools: true, - rag: true, - evaluation: true, - tools: true, - }, - implemented: true, - }, - ], - activeProviderId: 'test', - }), - }; - return render( - - - - - , - ); -} - -describe('AgentConfigPanel', () => { - it('renders page title', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Platform')).toBeInTheDocument(); - }); - }); - - it('renders all 5 sub-tabs', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByRole('tab', { name: 'Model' })).toBeInTheDocument(); - expect( - screen.getByRole('tab', { name: 'MCP Servers' }), - ).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Safety' })).toBeInTheDocument(); - expect( - screen.getByRole('tab', { name: 'Evaluation' }), - ).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'RAG' })).toBeInTheDocument(); - }); - }); - - it('shows Model Connection section by default', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'model') - return Promise.resolve({ - entry: { configKey: 'model', configValue: 'llama-3' }, - source: 'database', - }); - return Promise.resolve({ entry: null, source: 'default' }); - }); - renderPanel(api); - - await waitFor(() => { - const modelInput = screen.getByLabelText('Model') as HTMLInputElement; - expect(modelInput.value).toBe('llama-3'); - }); - }); - - it('switches to Safety tab', async () => { - renderPanel(); - - await waitFor(() => { - expect(screen.getByRole('tab', { name: 'Safety' })).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('tab', { name: 'Safety' })); - - await waitFor(() => { - expect(screen.getByText('Test Provider Shields')).toBeInTheDocument(); - }); - }); - - it('renders model field as autocomplete with dropdown', async () => { - const api = createAdminMockApi(); - renderPanel(api); - - await waitFor(() => { - expect(screen.getByLabelText('Model')).toBeInTheDocument(); - }); - - expect(api.listModels).toHaveBeenCalled(); - - const modelInput = screen.getByLabelText('Model'); - expect(modelInput).toHaveAttribute('role', 'combobox'); - }); - - it('shows refresh button for models', async () => { - renderPanel(); - - await waitFor(() => { - expect(screen.getByLabelText('Model')).toBeInTheDocument(); - }); - - const refreshButtons = screen.getAllByRole('button'); - const refreshBtn = refreshButtons.find( - btn => btn.querySelector('[data-testid="RefreshIcon"]') !== null, - ); - expect(refreshBtn).toBeDefined(); - }); - - it('pre-populates Model Connection fields from effective config', async () => { - const api = createAdminMockApi(); - renderPanel(api); - - await waitFor(() => { - const modelInput = screen.getByLabelText('Model') as HTMLInputElement; - expect(modelInput.value).toBe('meta-llama/Llama-3.3-8B-Instruct'); - }); - - const baseUrlInput = screen.getByLabelText('Base URL') as HTMLInputElement; - expect(baseUrlInput.value).toBe('http://localhost:8321'); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelToolsPanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelToolsPanel.tsx deleted file mode 100644 index 11d959b53a0..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ModelToolsPanel.tsx +++ /dev/null @@ -1,256 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useEffect, useMemo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import { Progress } from '@backstage/core-components'; -import type { ProviderCapabilities } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { useEffectiveConfig, useProviders } from '../../../hooks'; -import { ProviderSelector } from '../ProviderSelector'; -import { ProviderPlaceholder } from '../ProviderPlaceholder'; -import { ModelConnectionSection } from './ModelConnectionSection'; -import { McpServersSection } from './McpServersSection'; -import { SafetyEvalPanel } from '../SafetyEvalPanel/SafetyEvalPanel'; -import { EvaluationSection } from '../SafetyEvalPanel/EvaluationSection'; -import { KnowledgeBasePanel } from '../KnowledgeBasePanel'; -import { - CONTENT_MAX_WIDTH, - PAGE_TITLE_SX, - PAGE_SUBTITLE_SX, -} from '../shared/commandCenterStyles'; -import { PanelIntroBanner } from '../shared/PanelIntroBanner'; - -type SubTab = - | 'connection' - | 'tools' - | 'mcp' - | 'safety' - | 'evaluation' - | 'knowledge'; - -interface TabDef { - readonly label: string; - readonly value: SubTab; - readonly capability?: keyof ProviderCapabilities; -} - -const ALL_TABS: readonly TabDef[] = [ - { label: 'Model', value: 'connection' }, - { label: 'MCP Servers', value: 'mcp', capability: 'mcpTools' }, - { label: 'RAG', value: 'knowledge', capability: 'rag' }, - { label: 'Safety', value: 'safety', capability: 'safety' }, - { label: 'Evaluation', value: 'evaluation', capability: 'evaluation' }, -] as const; - -const CONTENT_SX = { - px: 3, - py: 2, - maxWidth: CONTENT_MAX_WIDTH, - mx: 'auto', -} as const; - -const HEADER_OUTER_SX = { - px: 3, - pt: 3, - maxWidth: CONTENT_MAX_WIDTH, - mx: 'auto', -} as const; - -const HEADER_ROW_SX = { - display: 'flex', - alignItems: 'flex-start', - justifyContent: 'space-between', - flexWrap: 'wrap', - gap: 2, - mb: 1.5, -} as const; - -const TABS_SX = { - minHeight: 40, - '& .MuiTab-root': { - minHeight: 40, - textTransform: 'none', - fontSize: '0.8125rem' /* typeScale.bodySmall */, - minWidth: 'auto', - px: 2, - mr: 0.5, - }, -} as const; - -export const AgentConfigPanel = () => { - const [activeTab, setActiveTab] = useState('connection'); - const { - config: effectiveConfig, - loading: ecLoading, - refresh: refreshConfig, - } = useEffectiveConfig(); - const { - providers, - activeProviderId, - activeProvider, - switchProvider, - loading: provLoading, - } = useProviders(); - - const providerName = activeProvider?.displayName ?? 'Agent Platform'; - - const visibleTabs = useMemo( - () => - ALL_TABS.filter( - t => !t.capability || activeProvider?.capabilities[t.capability], - ), - [activeProvider], - ); - - // Reset to a valid tab when capabilities change (e.g. provider switch) - useEffect(() => { - if ( - visibleTabs.length > 0 && - !visibleTabs.some(t => t.value === activeTab) - ) { - setActiveTab('connection'); - } - }, [visibleTabs, activeTab]); - - const handleTabChange = useCallback( - (_: unknown, v: string) => { - setActiveTab(v as SubTab); - refreshConfig(); - }, - [refreshConfig], - ); - - if (ecLoading || provLoading) { - return ; - } - - if (activeProvider && !activeProvider.implemented) { - return ( - - - - - Platform - - - - - - - ); - } - - return ( - - - - - - Platform - - - Configure the shared infrastructure — model, tools, - knowledge base, and safety — that powers all agents. - - - - - - - - - Platform Config controls the shared AI infrastructure — model - endpoints, tool registrations, RAG pipelines, and safety guardrails — - used by all agents. - - - - - - {visibleTabs.map(t => ( - - ))} - - - - {activeTab === 'connection' && ( - - - {/* Agent Ops (KagentiConnectionSection) moved to its own sub-tab */} - - )} - {activeTab === 'mcp' && ( - - - - )} - {activeTab === 'knowledge' && ( - - - - )} - {activeTab === 'safety' && ( - - )} - {activeTab === 'evaluation' && ( - - - - )} - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/SystemPromptSection.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/SystemPromptSection.test.tsx deleted file mode 100644 index 20fed753ed5..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/SystemPromptSection.test.tsx +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { TestApiProvider } from '@backstage/test-utils'; -import { augmentApiRef } from '../../../api'; -import { createAdminMockApi } from '../../../test-utils/factories'; -import { SystemPromptSection } from './SystemPromptSection'; - -const theme = createTheme(); - -function renderSection( - api = createAdminMockApi(), - effectiveConfig: Record = { - systemPrompt: 'You are helpful', - model: 'model-a', - }, -) { - return render( - - - - - , - ); -} - -describe('SystemPromptSection', () => { - it('renders with effective config value pre-populated', async () => { - renderSection(); - await waitFor(() => { - expect(screen.getByDisplayValue('You are helpful')).toBeInTheDocument(); - }); - }); - - it('shows Current Instructions and Generate tabs', async () => { - renderSection(); - await waitFor(() => { - expect(screen.getByText('Current Instructions')).toBeInTheDocument(); - expect(screen.getByText('Generate from Description')).toBeInTheDocument(); - }); - }); - - it('shows editor by default, switches to generate tab', async () => { - renderSection(); - - await waitFor(() => { - expect(screen.getByDisplayValue('You are helpful')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Generate from Description')); - - await waitFor(() => { - expect( - screen.getByPlaceholderText(/Help developers troubleshoot/i), - ).toBeInTheDocument(); - expect( - screen.getByLabelText(/Model for generation/i), - ).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Current Instructions')); - - await waitFor(() => { - expect(screen.getByDisplayValue('You are helpful')).toBeInTheDocument(); - }); - }); - - it('disables Generate button when description is empty', async () => { - renderSection(); - - await waitFor(() => { - expect(screen.getByText('Generate from Description')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Generate from Description')); - - await waitFor(() => { - expect(screen.getByText('Generate')).toBeInTheDocument(); - }); - - const generateBtn = screen.getByRole('button', { name: /^Generate$/ }); - expect(generateBtn).toBeDisabled(); - }); - - it('calls API and switches to editor on successful generation', async () => { - const api = createAdminMockApi(); - renderSection(api); - - await waitFor(() => { - expect(screen.getByText('Generate from Description')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Generate from Description')); - - await waitFor(() => { - expect( - screen.getByPlaceholderText(/Help developers troubleshoot/i), - ).toBeInTheDocument(); - }); - - const descInput = screen.getByPlaceholderText( - /Help developers troubleshoot/i, - ); - fireEvent.change(descInput, { - target: { value: 'Help with Kubernetes' }, - }); - - const generateBtn = screen.getByRole('button', { name: /^Generate$/ }); - expect(generateBtn).not.toBeDisabled(); - - fireEvent.click(generateBtn); - - await waitFor(() => { - expect(api.generateSystemPrompt).toHaveBeenCalledWith( - 'Help with Kubernetes', - undefined, - expect.anything(), - ); - }); - - await waitFor(() => { - expect( - screen.getByDisplayValue('Generated prompt text'), - ).toBeInTheDocument(); - }); - }); - - it('renders model dropdown in generate tab', async () => { - renderSection(); - - await waitFor(() => { - expect(screen.getByText('Generate from Description')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Generate from Description')); - - await waitFor(() => { - expect( - screen.getByLabelText(/Model for generation/i), - ).toBeInTheDocument(); - }); - }); - - it('shows error alert when generation fails', async () => { - const api = createAdminMockApi(); - (api.generateSystemPrompt as jest.Mock).mockRejectedValue( - new Error('LLM unreachable'), - ); - renderSection(api); - - await waitFor(() => { - expect(screen.getByText('Generate from Description')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Generate from Description')); - - await waitFor(() => { - expect( - screen.getByPlaceholderText(/Help developers troubleshoot/i), - ).toBeInTheDocument(); - }); - - const descInput = screen.getByPlaceholderText( - /Help developers troubleshoot/i, - ); - fireEvent.change(descInput, { target: { value: 'Help me' } }); - - fireEvent.click(screen.getByRole('button', { name: /^Generate$/ })); - - await waitFor(() => { - expect(screen.getByText('LLM unreachable')).toBeInTheDocument(); - }); - }); - - it('shows character count on generate tab', async () => { - renderSection(); - - await waitFor(() => { - expect(screen.getByText('Generate from Description')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Generate from Description')); - - await waitFor(() => { - expect(screen.getByText('0/2000')).toBeInTheDocument(); - }); - - const descInput = screen.getByPlaceholderText( - /Help developers troubleshoot/i, - ); - fireEvent.change(descInput, { target: { value: 'Hello' } }); - - await waitFor(() => { - expect(screen.getByText('5/2000')).toBeInTheDocument(); - }); - }); - - it('shows copy button when prompt has content', async () => { - renderSection(); - - await waitFor(() => { - expect(screen.getByDisplayValue('You are helpful')).toBeInTheDocument(); - }); - - expect(screen.getByLabelText('Copy prompt')).toBeInTheDocument(); - }); - - it('copies prompt to clipboard on click', async () => { - Object.assign(window.navigator, { - clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, - }); - - renderSection(); - - await waitFor(() => { - expect(screen.getByDisplayValue('You are helpful')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByLabelText('Copy prompt')); - - await waitFor(() => { - expect(window.navigator.clipboard.writeText).toHaveBeenCalledWith( - 'You are helpful', - ); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/SystemPromptSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/SystemPromptSection.tsx deleted file mode 100644 index 6657705c150..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/SystemPromptSection.tsx +++ /dev/null @@ -1,229 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useEffect } from 'react'; -import TextField from '@mui/material/TextField'; -import Snackbar from '@mui/material/Snackbar'; -import Box from '@mui/material/Box'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import EditNoteIcon from '@mui/icons-material/EditNote'; -import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; -import Alert from '@mui/material/Alert'; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import { - useAdminConfig, - useStatus, - useAgentCapabilities, -} from '../../../hooks'; -import { AdminSection } from '../shared/AdminSection'; -import { GeneratePromptForm } from './GeneratePromptForm'; - -const INNER_TABS_SX = { - minHeight: 32, - '& .MuiTab-root': { - minHeight: 32, - textTransform: 'none', - fontSize: '0.8125rem', - px: 2, - mr: 0.5, - }, -} as const; - -interface Props { - effectiveConfig?: Record | null; - onConfigSaved?: () => void; - /** When true, suppresses the AdminSection title/description (parent provides context). */ - embedded?: boolean; -} - -export const SystemPromptSection = ({ - effectiveConfig, - onConfigSaved, - embedded = false, -}: Props) => { - const config = useAdminConfig('systemPrompt'); - const { status } = useStatus(); - const agentCaps = useAgentCapabilities(effectiveConfig, status?.mcpServers); - - const effectiveValue = (effectiveConfig?.systemPrompt as string) ?? ''; - const effectiveModel = (effectiveConfig?.model as string) ?? ''; - - const [value, setValue] = useState(''); - const [toast, setToast] = useState(null); - const [initialized, setInitialized] = useState(false); - const [innerTab, setInnerTab] = useState<'editor' | 'generate'>('editor'); - - useEffect(() => { - if (!initialized && !config.loading) { - const dbValue = config.entry?.configValue as string | undefined; - setValue(dbValue ?? effectiveValue); - setInitialized(true); - } - }, [initialized, config.loading, config.entry, effectiveValue]); - - const handleSave = useCallback(async () => { - if (!value.trim()) { - setToast('Instructions cannot be empty'); - return; - } - try { - await config.save(value); - } catch { - return; - } - setToast('Instructions saved'); - onConfigSaved?.(); - }, [value, config, onConfigSaved]); - - const handleReset = useCallback(async () => { - try { - await config.reset(); - } catch { - return; - } - setValue(effectiveValue); - setInitialized(false); - setToast('Reset to YAML default'); - }, [config, effectiveValue]); - - const handleGenerated = useCallback((prompt: string) => { - setValue(prompt); - setInnerTab('editor'); - setToast('Prompt generated — review and save when ready'); - }, []); - - const handleCopy = useCallback(async () => { - if (!value) return; - try { - await window.navigator.clipboard.writeText(value); - setToast('Copied to clipboard'); - } catch { - setToast('Failed to copy'); - } - }, [value]); - - if (config.loading) return null; - - return ( - <> - - {!!effectiveConfig?.agents && - Object.keys(effectiveConfig.agents as object).length > 0 && ( - } - sx={{ mb: 2, fontSize: '0.8125rem' }} - > - Agents are configured. When agents are active, each agent uses its - own instructions. This system prompt is used as the fallback when - no agents are configured (single-agent mode). - - )} - - - setInnerTab(v)} - sx={INNER_TABS_SX} - > - } - iconPosition="start" - label="Current Instructions" - value="editor" - /> - } - iconPosition="start" - label="Generate from Description" - value="generate" - /> - - - - {innerTab === 'editor' && ( - - setValue(e.target.value)} - placeholder="Enter the shared instructions for the AI assistant..." - /> - {value && ( - - - - - - )} - - )} - - {innerTab === 'generate' && ( - - )} - - - setToast(null)} - message={toast} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ToolsSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ToolsSection.tsx deleted file mode 100644 index 11b83790695..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/ToolsSection.tsx +++ /dev/null @@ -1,264 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useEffect } from 'react'; -import Box from '@mui/material/Box'; -import TextField from '@mui/material/TextField'; -import Autocomplete from '@mui/material/Autocomplete'; -import Typography from '@mui/material/Typography'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import { ToggleSwitch as Switch } from '../shared/ToggleSwitch'; -import Snackbar from '@mui/material/Snackbar'; -import { useAdminConfig } from '../../../hooks'; -import { AdminSection } from '../shared/AdminSection'; - -const TOOL_CHOICE_OPTIONS = ['auto', 'required', 'none'] as const; -type ToolChoice = (typeof TOOL_CHOICE_OPTIONS)[number]; - -const TOOL_CHOICE_DESCRIPTIONS: Record = { - auto: 'Model decides when to call tools', - required: 'Always call tools', - none: 'Never call tools', -}; - -function isValidToolChoice(value: unknown): value is ToolChoice { - return ( - typeof value === 'string' && - TOOL_CHOICE_OPTIONS.includes(value as ToolChoice) - ); -} - -interface Props { - effectiveConfig?: Record | null; - onConfigSaved?: () => void; -} - -export const ToolsSection = ({ effectiveConfig, onConfigSaved }: Props) => { - const toolChoiceConfig = useAdminConfig('toolChoice'); - const webSearchConfig = useAdminConfig('enableWebSearch'); - const codeInterpreterConfig = useAdminConfig('enableCodeInterpreter'); - - const effectiveToolChoice = (effectiveConfig?.toolChoice as string) ?? 'auto'; - const effectiveWebSearch = - (effectiveConfig?.enableWebSearch as boolean) ?? false; - const effectiveCodeInterpreter = - (effectiveConfig?.enableCodeInterpreter as boolean) ?? false; - - const [toolChoice, setToolChoice] = useState('auto'); - const [enableWebSearch, setEnableWebSearch] = useState(false); - const [enableCodeInterpreter, setEnableCodeInterpreter] = useState(false); - const [toast, setToast] = useState(null); - const [initialized, setInitialized] = useState({ - toolChoice: false, - webSearch: false, - codeInterpreter: false, - }); - - useEffect(() => { - if (!initialized.toolChoice && !toolChoiceConfig.loading) { - const dbValue = toolChoiceConfig.entry?.configValue; - const raw = dbValue ?? effectiveToolChoice; - setToolChoice(isValidToolChoice(raw) ? raw : 'auto'); - setInitialized(prev => ({ ...prev, toolChoice: true })); - } - }, [ - initialized.toolChoice, - toolChoiceConfig.loading, - toolChoiceConfig.entry, - effectiveToolChoice, - ]); - - useEffect(() => { - if (!initialized.webSearch && !webSearchConfig.loading) { - const dbValue = webSearchConfig.entry?.configValue as boolean | undefined; - setEnableWebSearch(dbValue ?? effectiveWebSearch); - setInitialized(prev => ({ ...prev, webSearch: true })); - } - }, [ - initialized.webSearch, - webSearchConfig.loading, - webSearchConfig.entry, - effectiveWebSearch, - ]); - - useEffect(() => { - if (!initialized.codeInterpreter && !codeInterpreterConfig.loading) { - const dbValue = codeInterpreterConfig.entry?.configValue as - | boolean - | undefined; - setEnableCodeInterpreter(dbValue ?? effectiveCodeInterpreter); - setInitialized(prev => ({ ...prev, codeInterpreter: true })); - } - }, [ - initialized.codeInterpreter, - codeInterpreterConfig.loading, - codeInterpreterConfig.entry, - effectiveCodeInterpreter, - ]); - - const source = - toolChoiceConfig.source === 'database' || - webSearchConfig.source === 'database' || - codeInterpreterConfig.source === 'database' - ? 'database' - : 'default'; - - const saving = - toolChoiceConfig.saving || - webSearchConfig.saving || - codeInterpreterConfig.saving; - - const error = - toolChoiceConfig.error || - webSearchConfig.error || - codeInterpreterConfig.error; - - const handleSave = useCallback(async () => { - const results = await Promise.allSettled([ - toolChoiceConfig.save(toolChoice), - webSearchConfig.save(enableWebSearch), - codeInterpreterConfig.save(enableCodeInterpreter), - ]); - - const labels = ['Tool Choice', 'Web Search', 'Code Interpreter']; - const failed = results - .map((r, i) => (r.status === 'rejected' ? labels[i] : null)) - .filter(Boolean); - - if (failed.length > 0) { - setToast(`Failed to save: ${failed.join(', ')}`); - } else { - setToast('Tools configuration saved'); - onConfigSaved?.(); - } - }, [ - toolChoice, - enableWebSearch, - enableCodeInterpreter, - toolChoiceConfig, - webSearchConfig, - codeInterpreterConfig, - onConfigSaved, - ]); - - const handleReset = useCallback(async () => { - try { - await Promise.all([ - toolChoiceConfig.reset(), - webSearchConfig.reset(), - codeInterpreterConfig.reset(), - ]); - } catch { - return; - } - setToolChoice(effectiveToolChoice); - setEnableWebSearch(effectiveWebSearch); - setEnableCodeInterpreter(effectiveCodeInterpreter); - setInitialized({ - toolChoice: false, - webSearch: false, - codeInterpreter: false, - }); - setToast('Reset to YAML defaults'); - }, [ - toolChoiceConfig, - webSearchConfig, - codeInterpreterConfig, - effectiveToolChoice, - effectiveWebSearch, - effectiveCodeInterpreter, - ]); - - const loading = - toolChoiceConfig.loading || - webSearchConfig.loading || - codeInterpreterConfig.loading; - - if (loading) return null; - - return ( - <> - - - { - if (newValue) setToolChoice(newValue); - }} - renderOption={(props, option) => ( -
  7. - - - {option} - - - {TOOL_CHOICE_DESCRIPTIONS[option]} - - -
  8. - )} - renderInput={params => ( - - )} - sx={{ maxWidth: 300 }} - /> - - setEnableWebSearch(checked)} - /> - } - label="Enable Web Search" - /> - - setEnableCodeInterpreter(checked)} - /> - } - label="Enable Code Interpreter" - /> -
    -
    - - setToast(null)} - message={toast} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/mcpServerTypes.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/mcpServerTypes.ts deleted file mode 100644 index 6124f55b9fe..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/mcpServerTypes.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** MCP server configuration used in admin panel */ -export interface McpServer { - id: string; - name: string; - type: 'streamable-http' | 'sse'; - url: string; - headers?: Record; - allowedTools?: string[]; - /** HITL approval: 'always' requires human confirmation, 'never' auto-executes */ - requireApproval?: 'always' | 'never'; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/useMcpServers.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/useMcpServers.test.tsx deleted file mode 100644 index a917badf62d..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/useMcpServers.test.tsx +++ /dev/null @@ -1,725 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { renderHook, act, waitFor } from '@testing-library/react'; -import { useMcpServers } from './useMcpServers'; -import { - createApiTestWrapper, - createAdminMockApi, - createMockStatus, -} from '../../../test-utils'; - -describe('useMcpServers', () => { - const createWrapper = (api: ReturnType) => { - return createApiTestWrapper(api); - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('initial state', () => { - it('loads initial state from config when entry exists', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ - entry: { - configKey: 'mcpServers', - configValue: [ - { - id: 'server-1', - name: 'Test Server', - type: 'streamable-http', - url: 'https://example.com/mcp', - }, - ], - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ - entry: null, - source: 'default', - }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - - (api.getStatus as jest.Mock).mockResolvedValue(createMockStatus()); - - const { result } = renderHook( - () => useMcpServers({ effectiveConfig: null }), - { wrapper: createWrapper(api) }, - ); - - await waitFor(() => { - expect(result.current.servers.adminServers.length).toBeGreaterThan(0); - }); - - expect(result.current.servers.adminServers[0].id).toBe('server-1'); - expect(result.current.servers.adminServers[0].name).toBe('Test Server'); - expect(result.current.servers.adminServers[0].type).toBe( - 'streamable-http', - ); - expect(result.current.servers.adminServers[0].url).toBe( - 'https://example.com/mcp', - ); - }); - }); - - describe('normalizeServerType', () => { - it('returns streamable-http by default for unknown type', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ - entry: { - configKey: 'mcpServers', - configValue: [ - { - id: 's1', - name: 'Server', - type: 'unknown', - url: 'https://example.com', - }, - ], - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue(createMockStatus()); - - const { result } = renderHook( - () => useMcpServers({ effectiveConfig: null }), - { wrapper: createWrapper(api) }, - ); - - await waitFor(() => { - expect(result.current.servers.adminServers.length).toBeGreaterThan(0); - }); - - expect(result.current.servers.adminServers[0].type).toBe( - 'streamable-http', - ); - }); - - it('returns sse when type is sse in effectiveConfig', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ entry: null, source: 'default' }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue( - createMockStatus({ - mcpServers: [ - { - id: 'sse-server', - name: 'SSE Server', - url: 'https://sse.example.com', - connected: true, - source: 'yaml', - toolCount: 0, - }, - ], - }), - ); - - const effectiveConfig = { - mcpServers: [ - { - id: 'sse-server', - name: 'SSE Server', - type: 'sse', - url: 'https://sse.example.com', - }, - ], - }; - - const { result } = renderHook(() => useMcpServers({ effectiveConfig }), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.servers.yamlServers.length).toBeGreaterThan(0); - }); - - expect(result.current.servers.yamlServers[0].type).toBe('sse'); - }); - }); - - describe('dialog state', () => { - it('openAddDialog opens dialog', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ entry: null, source: 'default' }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue(createMockStatus()); - - const { result } = renderHook( - () => useMcpServers({ effectiveConfig: null }), - { wrapper: createWrapper(api) }, - ); - - await waitFor(() => { - expect(result.current.ui.dialogOpen).toBe(false); - }); - - act(() => { - result.current.actions.openAddDialog(); - }); - - expect(result.current.ui.dialogOpen).toBe(true); - expect(result.current.ui.editingId).toBeNull(); - expect(result.current.ui.draft.id).toBe(''); - expect(result.current.ui.draft.url).toBe(''); - }); - - it('openEditDialog opens dialog with server data', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ - entry: { - configKey: 'mcpServers', - configValue: [ - { - id: 'edit-me', - name: 'Edit Server', - type: 'sse', - url: 'https://edit.example.com', - }, - ], - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue(createMockStatus()); - - const { result } = renderHook( - () => useMcpServers({ effectiveConfig: null }), - { wrapper: createWrapper(api) }, - ); - - await waitFor(() => { - expect(result.current.servers.adminServers.length).toBeGreaterThan(0); - }); - - const server = result.current.servers.adminServers[0]; - - act(() => { - result.current.actions.openEditDialog(server); - }); - - expect(result.current.ui.dialogOpen).toBe(true); - expect(result.current.ui.editingId).toBe('edit-me'); - expect(result.current.ui.draft.id).toBe('edit-me'); - expect(result.current.ui.draft.id).toBe('edit-me'); - expect(result.current.ui.draft.url).toBe('https://edit.example.com'); - }); - - it('closeDialog closes dialog', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ entry: null, source: 'default' }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue(createMockStatus()); - - const { result } = renderHook( - () => useMcpServers({ effectiveConfig: null }), - { wrapper: createWrapper(api) }, - ); - - await waitFor(() => { - expect(result.current.ui.dialogOpen).toBe(false); - }); - - act(() => { - result.current.actions.openAddDialog(); - }); - - expect(result.current.ui.dialogOpen).toBe(true); - - act(() => { - result.current.actions.closeDialog(); - }); - - expect(result.current.ui.dialogOpen).toBe(false); - expect(result.current.ui.editingId).toBeNull(); - }); - }); - - describe('handleDialogSubmit', () => { - it('adds new server when dialog is in add mode', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ entry: null, source: 'default' }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue(createMockStatus()); - - const { result } = renderHook( - () => useMcpServers({ effectiveConfig: null }), - { wrapper: createWrapper(api) }, - ); - - await waitFor(() => { - expect(result.current.servers.adminServers.length).toBe(0); - }); - - act(() => { - result.current.actions.openAddDialog(); - }); - - act(() => { - result.current.actions.setDraft({ - id: 'new-server', - name: 'New Server', - type: 'streamable-http', - url: 'https://new.example.com', - }); - }); - - act(() => { - result.current.actions.handleDialogSubmit(); - }); - - expect(result.current.servers.adminServers).toHaveLength(1); - expect(result.current.servers.adminServers[0].id).toBe('new-server'); - expect(result.current.servers.adminServers[0].url).toBe( - 'https://new.example.com', - ); - expect(result.current.ui.dialogOpen).toBe(false); - }); - - it('shows toast when adding duplicate server ID', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ - entry: { - configKey: 'mcpServers', - configValue: [ - { - id: 'existing', - name: 'Existing', - type: 'streamable-http', - url: 'https://existing.example.com', - }, - ], - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue(createMockStatus()); - - const { result } = renderHook( - () => useMcpServers({ effectiveConfig: null }), - { wrapper: createWrapper(api) }, - ); - - await waitFor(() => { - expect(result.current.servers.adminServers.length).toBeGreaterThan(0); - }); - - act(() => { - result.current.actions.openAddDialog(); - }); - - act(() => { - result.current.actions.setDraft({ - id: 'existing', - name: 'Duplicate', - type: 'streamable-http', - url: 'https://dup.example.com', - }); - }); - - act(() => { - result.current.actions.handleDialogSubmit(); - }); - - expect(result.current.ui.toast).toContain('already exists'); - expect(result.current.servers.adminServers).toHaveLength(1); - }); - - it('updates existing server when editing', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ - entry: { - configKey: 'mcpServers', - configValue: [ - { - id: 'to-edit', - name: 'Original', - type: 'streamable-http', - url: 'https://original.example.com', - }, - ], - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue(createMockStatus()); - - const { result } = renderHook( - () => useMcpServers({ effectiveConfig: null }), - { wrapper: createWrapper(api) }, - ); - - await waitFor(() => { - expect(result.current.servers.adminServers.length).toBeGreaterThan(0); - }); - - const server = result.current.servers.adminServers[0]; - - act(() => { - result.current.actions.openEditDialog(server); - }); - - act(() => { - result.current.actions.setDraft({ - ...server, - name: 'Updated Name', - url: 'https://updated.example.com', - }); - }); - - act(() => { - result.current.actions.handleDialogSubmit(); - }); - - expect(result.current.servers.adminServers[0].name).toBe('Updated Name'); - expect(result.current.servers.adminServers[0].url).toBe( - 'https://updated.example.com', - ); - }); - }); - - describe('handleRemoveAdmin', () => { - it('removes a server from admin list', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ - entry: { - configKey: 'mcpServers', - configValue: [ - { - id: 'remove-1', - name: 'Remove Me', - type: 'streamable-http', - url: 'https://remove.example.com', - }, - ], - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue(createMockStatus()); - - const { result } = renderHook( - () => useMcpServers({ effectiveConfig: null }), - { wrapper: createWrapper(api) }, - ); - - await waitFor(() => { - expect(result.current.servers.adminServers.length).toBe(1); - }); - - act(() => { - result.current.actions.handleRemoveAdmin('remove-1'); - }); - - expect(result.current.servers.adminServers).toHaveLength(0); - }); - }); - - describe('handleToggleDisable', () => { - it('toggles disabled state for a server', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ entry: null, source: 'default' }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue( - createMockStatus({ - mcpServers: [ - { - id: 'toggle-1', - name: 'Toggle Server', - url: 'https://toggle.example.com', - connected: true, - source: 'yaml', - toolCount: 0, - }, - ], - }), - ); - - const effectiveConfig = { - mcpServers: [ - { - id: 'toggle-1', - name: 'Toggle Server', - type: 'streamable-http', - url: 'https://toggle.example.com', - }, - ], - }; - - const { result } = renderHook(() => useMcpServers({ effectiveConfig }), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.servers.yamlServers.length).toBeGreaterThan(0); - }); - - expect(result.current.servers.disabledIds.has('toggle-1')).toBe(false); - - act(() => { - result.current.actions.handleToggleDisable('toggle-1'); - }); - - expect(result.current.servers.disabledIds.has('toggle-1')).toBe(true); - - act(() => { - result.current.actions.handleToggleDisable('toggle-1'); - }); - - expect(result.current.servers.disabledIds.has('toggle-1')).toBe(false); - }); - }); - - describe('validateUrl', () => { - it('accepts valid http URL', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ entry: null, source: 'default' }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue(createMockStatus()); - - const { result } = renderHook( - () => useMcpServers({ effectiveConfig: null }), - { wrapper: createWrapper(api) }, - ); - - await waitFor(() => { - expect(result.current.ui.dialogOpen).toBeDefined(); - }); - - const error = result.current.actions.validateUrl( - 'https://valid.example.com/mcp', - ); - expect(error).toBeNull(); - }); - - it('rejects invalid URL format', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ entry: null, source: 'default' }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue(createMockStatus()); - - const { result } = renderHook( - () => useMcpServers({ effectiveConfig: null }), - { wrapper: createWrapper(api) }, - ); - - await waitFor(() => { - expect(result.current.actions.validateUrl).toBeDefined(); - }); - - const error = result.current.actions.validateUrl('not-a-url'); - expect(error).toBe('Invalid URL format'); - }); - - it('rejects non-http protocol', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ entry: null, source: 'default' }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue(createMockStatus()); - - const { result } = renderHook( - () => useMcpServers({ effectiveConfig: null }), - { wrapper: createWrapper(api) }, - ); - - await waitFor(() => { - expect(result.current.actions.validateUrl).toBeDefined(); - }); - - const error = result.current.actions.validateUrl('sftp://example.com'); - expect(error).toContain('URL must start with'); - }); - }); - - describe('draftValid', () => { - it('is false when adding with empty id', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ entry: null, source: 'default' }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue(createMockStatus()); - - const { result } = renderHook( - () => useMcpServers({ effectiveConfig: null }), - { wrapper: createWrapper(api) }, - ); - - await waitFor(() => { - expect(result.current.ui.dialogOpen).toBeDefined(); - }); - - act(() => { - result.current.actions.openAddDialog(); - }); - - expect(result.current.ui.draftValid).toBe(false); - }); - - it('is true when adding with valid id and url', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockImplementation((key: string) => { - if (key === 'mcpServers') { - return Promise.resolve({ entry: null, source: 'default' }); - } - if (key === 'disabledMcpServerIds') { - return Promise.resolve({ entry: null, source: 'default' }); - } - return Promise.resolve({ entry: null, source: 'default' }); - }); - (api.getStatus as jest.Mock).mockResolvedValue(createMockStatus()); - - const { result } = renderHook( - () => useMcpServers({ effectiveConfig: null }), - { wrapper: createWrapper(api) }, - ); - - await waitFor(() => { - expect(result.current.ui.dialogOpen).toBeDefined(); - }); - - act(() => { - result.current.actions.openAddDialog(); - }); - - act(() => { - result.current.actions.setDraft({ - id: 'valid-id', - name: 'Valid', - type: 'streamable-http', - url: 'https://valid.example.com', - }); - }); - - expect(result.current.ui.draftValid).toBe(true); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/useMcpServers.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/useMcpServers.ts deleted file mode 100644 index 7deebe86760..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ModelToolsPanel/useMcpServers.ts +++ /dev/null @@ -1,386 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useMemo, useEffect } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import { augmentApiRef } from '../../../api'; -import { asStringArray, normalizeErrorMessage } from '../../../utils'; -import { useAdminConfig, useStatus } from '../../../hooks'; -import type { MCPServerStatus, McpTestConnectionResult } from '../../../types'; -import type { McpServer } from './mcpServerTypes'; - -const EMPTY_SERVER: McpServer = { - id: '', - name: '', - type: 'streamable-http', - url: '', -}; - -function normalizeServerType(type: unknown): 'streamable-http' | 'sse' { - return type === 'sse' ? 'sse' : 'streamable-http'; -} - -function normalizeApproval(raw: unknown): 'always' | 'never' | undefined { - return raw === 'always' || raw === 'never' ? raw : undefined; -} - -function normalizeServer(s: Record): McpServer { - const approval = normalizeApproval(s.requireApproval); - return { - id: String(s.id ?? ''), - name: String(s.name ?? ''), - type: normalizeServerType(s.type), - url: String(s.url ?? ''), - ...(s.headers && - typeof s.headers === 'object' && - Object.keys(s.headers as object).length > 0 - ? { headers: s.headers as Record } - : {}), - ...(asStringArray(s.allowedTools).length > 0 - ? { allowedTools: asStringArray(s.allowedTools) } - : {}), - requireApproval: approval ?? 'never', - }; -} - -export interface UseMcpServersProps { - effectiveConfig?: Record | null; - onConfigSaved?: () => void; -} - -export function useMcpServers({ - effectiveConfig, - onConfigSaved, -}: UseMcpServersProps) { - const api = useApi(augmentApiRef); - const config = useAdminConfig('mcpServers'); - const disabledConfig = useAdminConfig('disabledMcpServerIds'); - const { status, loading: statusLoading } = useStatus(); - - const effectiveServers = useMemo( - () => - ( - (effectiveConfig?.mcpServers as - | Record[] - | undefined) ?? [] - ).map(normalizeServer), - [effectiveConfig?.mcpServers], - ); - - const statusMap = useMemo(() => { - const map = new Map(); - for (const s of status?.mcpServers ?? []) { - map.set(s.id, s); - } - return map; - }, [status?.mcpServers]); - - const yamlServerIds = useMemo(() => { - const ids = new Set(); - for (const s of status?.mcpServers ?? []) { - if (s.source === 'yaml') ids.add(s.id); - } - return ids; - }, [status?.mcpServers]); - - const yamlServers = useMemo( - () => effectiveServers.filter(s => yamlServerIds.has(s.id)), - [effectiveServers, yamlServerIds], - ); - - const [adminServers, setAdminServers] = useState([]); - const [disabledIds, setDisabledIds] = useState>(new Set()); - const [dialogOpen, setDialogOpen] = useState(false); - const [draft, setDraft] = useState({ ...EMPTY_SERVER }); - const [editingId, setEditingId] = useState(null); - const [toast, setToast] = useState(null); - const [initialized, setInitialized] = useState(false); - const [testResult, setTestResult] = useState( - null, - ); - const [testing, setTesting] = useState(false); - const [urlError, setUrlError] = useState(null); - - const overriddenYamlIds = useMemo( - () => - new Set(adminServers.filter(s => yamlServerIds.has(s.id)).map(s => s.id)), - [adminServers, yamlServerIds], - ); - - const pureAdminServers = useMemo( - () => adminServers.filter(s => !yamlServerIds.has(s.id)), - [adminServers, yamlServerIds], - ); - - useEffect(() => { - if ( - initialized || - config.loading || - disabledConfig.loading || - statusLoading - ) { - return; - } - if (config.entry && Array.isArray(config.entry.configValue)) { - setAdminServers( - (config.entry.configValue as Record[]).map( - normalizeServer, - ), - ); - } else { - const adminOnly = effectiveServers.filter(s => !yamlServerIds.has(s.id)); - setAdminServers(adminOnly); - } - if ( - disabledConfig.entry && - Array.isArray(disabledConfig.entry.configValue) - ) { - setDisabledIds(new Set(asStringArray(disabledConfig.entry.configValue))); - } - setInitialized(true); - }, [ - initialized, - config.loading, - config.entry, - disabledConfig.loading, - disabledConfig.entry, - statusLoading, - effectiveServers, - yamlServerIds, - ]); - - const handleSave = useCallback(async () => { - try { - await config.save(adminServers); - if (disabledIds.size > 0) { - await disabledConfig.save(Array.from(disabledIds)); - } else { - await disabledConfig.reset(); - } - setToast('MCP servers saved'); - onConfigSaved?.(); - } catch (err) { - setToast(`Save failed: ${normalizeErrorMessage(err, 'Unknown error')}`); - } - }, [adminServers, config, disabledIds, disabledConfig, onConfigSaved]); - - const handleReset = useCallback(async () => { - try { - await config.reset(); - await disabledConfig.reset(); - setAdminServers([]); - setDisabledIds(new Set()); - setInitialized(false); - setToast('All overrides cleared — using YAML config only'); - } catch (err) { - setToast(`Reset failed: ${normalizeErrorMessage(err, 'Unknown error')}`); - } - }, [config, disabledConfig]); - - const handleTestConnection = useCallback(async () => { - if (!draft.url.trim()) return; - setTesting(true); - setTestResult(null); - try { - const result = await api.testMcpConnection( - draft.url.trim(), - draft.type, - draft.headers, - ); - setTestResult(result); - } catch (err) { - setTestResult({ - success: false, - error: normalizeErrorMessage(err, 'Test failed'), - }); - } finally { - setTesting(false); - } - }, [api, draft.url, draft.type, draft.headers]); - - const validateUrl = useCallback((raw: string): string | null => { - const trimmed = raw.trim(); - if (!trimmed) return null; - try { - const parsed = new URL(trimmed); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return 'URL must start with http:// or https://'; - } - return null; - } catch { - return 'Invalid URL format'; - } - }, []); - - const openAddDialog = useCallback(() => { - setDraft({ ...EMPTY_SERVER }); - setEditingId(null); - setTestResult(null); - setUrlError(null); - setDialogOpen(true); - }, []); - - const openEditDialog = useCallback((server: McpServer) => { - setDraft({ ...server, type: normalizeServerType(server.type) }); - setEditingId(server.id); - setTestResult(null); - setUrlError(null); - setDialogOpen(true); - }, []); - - const closeDialog = useCallback(() => { - setDialogOpen(false); - setEditingId(null); - setTestResult(null); - }, []); - - const handleDialogSubmit = useCallback(() => { - const trimmedId = draft.id.trim(); - if (!trimmedId || !draft.url.trim()) return; - - const newServer: McpServer = { - id: trimmedId, - name: draft.name.trim() || trimmedId, - type: draft.type, - url: draft.url.trim(), - ...(draft.headers && Object.keys(draft.headers).length > 0 - ? { headers: draft.headers } - : {}), - ...(draft.allowedTools && draft.allowedTools.length > 0 - ? { allowedTools: draft.allowedTools } - : {}), - requireApproval: - draft.requireApproval === 'always' - ? ('always' as const) - : ('never' as const), - }; - - if (editingId) { - setAdminServers(prev => { - const exists = prev.some(s => s.id === editingId); - if (exists) { - return prev.map(s => (s.id === editingId ? newServer : s)); - } - return [...prev, newServer]; - }); - } else { - const allIds = new Set([ - ...adminServers.map(s => s.id), - ...Array.from(yamlServerIds), - ]); - if (allIds.has(trimmedId)) { - setToast(`Server ID "${trimmedId}" already exists`); - return; - } - setAdminServers(prev => [...prev, newServer]); - } - - setDraft({ ...EMPTY_SERVER }); - setEditingId(null); - setDialogOpen(false); - }, [draft, editingId, adminServers, yamlServerIds]); - - const handleRemoveAdmin = useCallback((id: string) => { - setAdminServers(prev => prev.filter(s => s.id !== id)); - }, []); - - const handleToggleDisable = useCallback((id: string) => { - setDisabledIds(prev => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }, []); - - const handleUrlChange = useCallback( - (val: string) => { - setTestResult(null); - setUrlError(validateUrl(val)); - }, - [validateUrl], - ); - - const isEditing = editingId !== null; - const draftValid = isEditing - ? draft.url.trim() !== '' && !urlError - : draft.id.trim() !== '' && draft.url.trim() !== '' && !urlError; - - const totalTools = (status?.mcpServers ?? []).reduce( - (sum, s) => sum + (s.toolCount ?? 0), - 0, - ); - const connectedCount = (status?.mcpServers ?? []).filter( - s => s.connected, - ).length; - const totalCount = (status?.mcpServers ?? []).length; - const hasChanges = config.source === 'database' || disabledIds.size > 0; - const showYamlOverrideInfo = - isEditing && editingId !== null && yamlServerIds.has(editingId); - - return { - servers: { - yamlServers, - pureAdminServers, - adminServers, - statusMap, - overriddenYamlIds, - disabledIds, - }, - config: { - loading: config.loading || disabledConfig.loading, - saving: config.saving, - error: config.error, - source: config.source, - }, - section: { - hasChanges, - totalTools, - connectedCount, - totalCount, - }, - actions: { - handleSave, - handleReset, - handleTestConnection, - validateUrl, - openAddDialog, - openEditDialog, - handleDialogSubmit, - handleRemoveAdmin, - handleToggleDisable, - closeDialog, - handleUrlChange, - setDraft, - setToast, - clearTestResult: useCallback(() => setTestResult(null), []), - }, - ui: { - draft, - dialogOpen, - editingId, - toast, - testResult, - testing, - urlError, - isEditing, - draftValid, - showYamlOverrideInfo, - }, - }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/CardEditor.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/CardEditor.tsx deleted file mode 100644 index 240f9916943..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/CardEditor.tsx +++ /dev/null @@ -1,252 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useCallback } from 'react'; -import Box from '@mui/material/Box'; -import Paper from '@mui/material/Paper'; -import TextField from '@mui/material/TextField'; -import Typography from '@mui/material/Typography'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import DeleteIcon from '@mui/icons-material/Delete'; -import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; -import { useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import type { EditableCard, ValidationErrors } from '../usePromptGroupsEditor'; -import { IconPicker } from './IconPicker'; - -// --------------------------------------------------------------------------- -// Props -// --------------------------------------------------------------------------- - -export interface CardEditorProps { - readonly card: EditableCard; - readonly groupKey: string; - readonly validationErrors: ValidationErrors; - readonly onUpdate: ( - groupKey: string, - cardKey: string, - updates: Partial>, - ) => void; - readonly onRemove: (groupKey: string, cardKey: string) => void; -} - -// --------------------------------------------------------------------------- -// Styles -// --------------------------------------------------------------------------- - -const PAPER_SX = { - px: 2, - py: 1.5, - mb: 1, - borderRadius: 2, -} as const; - -const ROW_SX = { - display: 'flex', - gap: 1, - alignItems: 'flex-start', -} as const; - -const CARD_DRAG_HANDLE_SX = { - cursor: 'grab', - color: 'text.disabled', - display: 'flex', - alignItems: 'center', - flexShrink: 0, - mt: 0.75, - p: 0.25, - borderRadius: 0.5, - '&:hover': { color: 'text.secondary', bgcolor: 'action.hover' }, - '&:active': { cursor: 'grabbing' }, -} as const; - -const FIELDS_SX = { - flex: 1, - display: 'flex', - flexDirection: 'column', - gap: 1.25, - minWidth: 0, -} as const; - -const ACTIONS_SX = { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: 0.5, - pt: 0.5, -} as const; - -const CHAR_COUNT_SX = { - fontSize: '0.7rem', - color: 'text.disabled', - textAlign: 'right', - mt: -0.5, -} as const; - -// --------------------------------------------------------------------------- -// Drag overlay shown when dragging a card (compact summary) -// --------------------------------------------------------------------------- - -export const CardDragOverlay: React.FC<{ card: EditableCard }> = ({ card }) => ( - - - - - {card.title || 'Untitled Card'} - - {card.prompt && ( - - — {card.prompt} - - )} - - -); - -// --------------------------------------------------------------------------- -// Main CardEditor -// --------------------------------------------------------------------------- - -export const CardEditor: React.FC = ({ - card, - groupKey, - validationErrors, - onUpdate, - onRemove, -}) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: card._key }); - - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.4 : 1, - position: 'relative', - zIndex: isDragging ? 0 : 'auto', - }; - - const titleError = validationErrors.cards.get(`${card._key}:title`); - const promptError = validationErrors.cards.get(`${card._key}:prompt`); - - const handleField = useCallback( - (field: keyof Omit) => - (e: React.ChangeEvent) => { - onUpdate(groupKey, card._key, { [field]: e.target.value }); - }, - [groupKey, card._key, onUpdate], - ); - - return ( - - - - - - - - - - - {card.prompt.length > 0 && ( - - {card.prompt.length} characters - - )} - - - - onUpdate(groupKey, card._key, { icon })} - sx={{ width: 140 }} - /> - - - - - - onRemove(groupKey, card._key)} - aria-label="Remove card" - > - - - - - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/ColorPicker.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/ColorPicker.test.tsx deleted file mode 100644 index 6dffcd6739d..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/ColorPicker.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { render, screen, fireEvent } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { ColorPicker } from './ColorPicker'; - -const theme = createTheme(); - -function renderPicker(value?: string) { - const onChange = jest.fn(); - const result = render( - - - , - ); - return { ...result, onChange }; -} - -describe('ColorPicker', () => { - it('renders color text field with current value', () => { - renderPicker('#d32f2f'); - const input = screen.getByLabelText('Color') as HTMLInputElement; - expect(input.value).toBe('#d32f2f'); - }); - - it('renders preset color swatch buttons', () => { - renderPicker(); - expect(screen.getByLabelText('Select color #1976d2')).toBeInTheDocument(); - expect(screen.getByLabelText('Select color #388e3c')).toBeInTheDocument(); - expect(screen.getByLabelText('Select color #d32f2f')).toBeInTheDocument(); - }); - - it('calls onChange when a swatch is clicked', () => { - const { onChange } = renderPicker(); - fireEvent.click(screen.getByLabelText('Select color #388e3c')); - expect(onChange).toHaveBeenCalledWith('#388e3c'); - }); - - it('calls onChange when typing a hex value', () => { - const { onChange } = renderPicker('#1976d2'); - const input = screen.getByLabelText('Color'); - fireEvent.change(input, { target: { value: '#ff5722' } }); - expect(onChange).toHaveBeenCalledWith('#ff5722'); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/ColorPicker.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/ColorPicker.tsx deleted file mode 100644 index 9b71046fea8..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/ColorPicker.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useCallback } from 'react'; -import Box from '@mui/material/Box'; -import TextField from '@mui/material/TextField'; -import Tooltip from '@mui/material/Tooltip'; - -export interface ColorPickerProps { - readonly value: string | undefined; - readonly onChange: (color: string) => void; -} - -const PRESET_COLORS = [ - '#1976d2', - '#388e3c', - '#f57c00', - '#d32f2f', - '#7b1fa2', - '#00838f', - '#5d4037', - '#455a64', -] as const; - -const SWATCHES_SX = { - display: 'flex', - gap: 0.5, - flexWrap: 'wrap', -} as const; - -const ROW_SX = { - display: 'flex', - flexDirection: 'column', - gap: 0.75, -} as const; - -function swatchSx(color: string, selected: boolean) { - return { - width: 20, - height: 20, - borderRadius: '50%', - backgroundColor: color, - cursor: 'pointer', - border: selected ? '2px solid' : '1px solid', - borderColor: selected ? 'text.primary' : 'divider', - transition: 'transform 0.1s', - '&:hover': { transform: 'scale(1.2)' }, - flexShrink: 0, - } as const; -} - -export const ColorPicker: React.FC = ({ - value, - onChange, -}) => { - const handleInputChange = useCallback( - (e: React.ChangeEvent) => onChange(e.target.value), - [onChange], - ); - - return ( - - - - {PRESET_COLORS.map(color => ( - - onChange(color)} - /> - - ))} - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/DeleteConfirmButton.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/DeleteConfirmButton.test.tsx deleted file mode 100644 index a687c6ee2ad..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/DeleteConfirmButton.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { render, screen, fireEvent, act } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { DeleteConfirmButton } from './DeleteConfirmButton'; - -const theme = createTheme(); - -function renderButton( - props: Partial> = {}, -) { - const onConfirm = jest.fn(); - const result = render( - - - , - ); - return { ...result, onConfirm }; -} - -describe('DeleteConfirmButton', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - afterEach(() => { - jest.useRealTimers(); - }); - - it('shows delete icon button initially', () => { - renderButton(); - expect(screen.getByLabelText('Delete')).toBeInTheDocument(); - expect(screen.queryByText('Delete lane?')).not.toBeInTheDocument(); - }); - - it('shows confirm button on first click', () => { - renderButton(); - fireEvent.click(screen.getByLabelText('Delete')); - expect(screen.getByText('Delete lane?')).toBeInTheDocument(); - }); - - it('calls onConfirm on second click', () => { - const { onConfirm } = renderButton(); - fireEvent.click(screen.getByLabelText('Delete')); - fireEvent.click(screen.getByText('Delete lane?')); - expect(onConfirm).toHaveBeenCalledTimes(1); - }); - - it('does not call onConfirm on first click', () => { - const { onConfirm } = renderButton(); - fireEvent.click(screen.getByLabelText('Delete')); - expect(onConfirm).not.toHaveBeenCalled(); - }); - - it('auto-dismisses confirmation after timeout', () => { - renderButton(); - fireEvent.click(screen.getByLabelText('Delete')); - expect(screen.getByText('Delete lane?')).toBeInTheDocument(); - - act(() => { - jest.advanceTimersByTime(3000); - }); - - expect(screen.queryByText('Delete lane?')).not.toBeInTheDocument(); - expect(screen.getByLabelText('Delete')).toBeInTheDocument(); - }); - - it('uses custom tooltip title', () => { - renderButton({ tooltipTitle: 'Remove item' }); - expect(screen.getByLabelText('Remove item')).toBeInTheDocument(); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/DeleteConfirmButton.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/DeleteConfirmButton.tsx deleted file mode 100644 index adf3b3f00a4..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/DeleteConfirmButton.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useEffect, useRef } from 'react'; -import Button from '@mui/material/Button'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import DeleteIcon from '@mui/icons-material/Delete'; - -export interface DeleteConfirmButtonProps { - readonly confirmLabel: string; - readonly tooltipTitle?: string; - readonly onConfirm: () => void; -} - -const AUTO_DISMISS_MS = 3000; - -const CONFIRM_SX = { textTransform: 'none', whiteSpace: 'nowrap' } as const; - -export const DeleteConfirmButton: React.FC = ({ - confirmLabel, - tooltipTitle = 'Delete', - onConfirm, -}) => { - const [confirming, setConfirming] = useState(false); - const timerRef = useRef>(); - - useEffect(() => { - return () => { - if (timerRef.current) clearTimeout(timerRef.current); - }; - }, []); - - const handleFirstClick = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - setConfirming(true); - timerRef.current = setTimeout(() => setConfirming(false), AUTO_DISMISS_MS); - }, []); - - const handleConfirm = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - if (timerRef.current) clearTimeout(timerRef.current); - setConfirming(false); - onConfirm(); - }, - [onConfirm], - ); - - if (confirming) { - return ( - - ); - } - - return ( - - - - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/GroupEditor.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/GroupEditor.tsx deleted file mode 100644 index 91c7a0002d4..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/GroupEditor.tsx +++ /dev/null @@ -1,368 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useCallback, useMemo, useState } from 'react'; -import Box from '@mui/material/Box'; -import Paper from '@mui/material/Paper'; -import TextField from '@mui/material/TextField'; -import Button from '@mui/material/Button'; -import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; -import Divider from '@mui/material/Divider'; -import AddIcon from '@mui/icons-material/Add'; -import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; -import { useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragOverlay, -} from '@dnd-kit/core'; -import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; -import { - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { - restrictToVerticalAxis, - restrictToParentElement, -} from '@dnd-kit/modifiers'; -import { getIconForName } from '../../WelcomeScreen/iconUtils'; -import type { - EditableGroup, - EditableCard, - ValidationErrors, -} from '../usePromptGroupsEditor'; -import { CardEditor, CardDragOverlay } from './CardEditor'; -import { ColorPicker } from './ColorPicker'; -import { DeleteConfirmButton } from './DeleteConfirmButton'; -import { IconPicker } from './IconPicker'; - -// --------------------------------------------------------------------------- -// Props -// --------------------------------------------------------------------------- - -export interface GroupEditorProps { - readonly group: EditableGroup; - readonly validationErrors: ValidationErrors; - readonly onUpdateGroup: ( - groupKey: string, - updates: Partial>, - ) => void; - readonly onUpdateCard: ( - groupKey: string, - cardKey: string, - updates: Partial>, - ) => void; - readonly onAddCard: (groupKey: string) => void; - readonly onRemoveCard: (groupKey: string, cardKey: string) => void; - readonly onRemoveGroup: (groupKey: string) => void; - readonly onMoveCard: ( - groupKey: string, - fromIndex: number, - toIndex: number, - ) => void; -} - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const DEFAULT_GROUP_COLOR = '#1976d2'; - -const CARD_MODIFIERS = [restrictToVerticalAxis, restrictToParentElement]; - -const HEADER_SX = { - display: 'flex', - alignItems: 'center', - gap: 1, - mb: 2, -} as const; - -const GROUP_DRAG_HANDLE_SX = { - cursor: 'grab', - color: 'text.disabled', - display: 'flex', - alignItems: 'center', - flexShrink: 0, - p: 0.25, - borderRadius: 0.5, - '&:hover': { color: 'text.secondary', bgcolor: 'action.hover' }, - '&:active': { cursor: 'grabbing' }, -} as const; - -const ICON_PREVIEW_SX = { - width: 28, - height: 28, - borderRadius: 1, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - color: 'common.white', - '& svg': { fontSize: 16 }, - flexShrink: 0, -} as const; - -const GROUP_FIELDS_SX = { - display: 'flex', - gap: 1.5, - mb: 2, - flexWrap: 'wrap', -} as const; - -// --------------------------------------------------------------------------- -// Drag overlay shown when dragging a group (just the header bar) -// --------------------------------------------------------------------------- - -export const GroupDragOverlay: React.FC<{ group: EditableGroup }> = ({ - group, -}) => { - const groupColor = group.color || DEFAULT_GROUP_COLOR; - return ( - - - - - {getIconForName(group.icon)} - - - {group.title || 'Untitled Group'} - - - - - ); -}; - -// --------------------------------------------------------------------------- -// Main GroupEditor -// --------------------------------------------------------------------------- - -export const GroupEditor: React.FC = ({ - group, - validationErrors, - onUpdateGroup, - onUpdateCard, - onAddCard, - onRemoveCard, - onRemoveGroup, - onMoveCard, -}) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: group._key }); - - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.4 : 1, - position: 'relative', - zIndex: isDragging ? 0 : 'auto', - }; - - const [activeCardId, setActiveCardId] = useState(null); - - const titleError = validationErrors.groups.get(`${group._key}:title`); - const cardsError = validationErrors.groups.get(`${group._key}:cards`); - const groupColor = group.color || DEFAULT_GROUP_COLOR; - - const handleGroupField = useCallback( - (field: string) => (e: React.ChangeEvent) => { - onUpdateGroup(group._key, { [field]: e.target.value }); - }, - [group._key, onUpdateGroup], - ); - - const cardIds = useMemo(() => group.cards.map(c => c._key), [group.cards]); - - const cardSensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - const handleCardDragStart = useCallback((event: DragStartEvent) => { - setActiveCardId(String(event.active.id)); - }, []); - - const handleCardDragEnd = useCallback( - (event: DragEndEvent) => { - setActiveCardId(null); - const { active, over } = event; - if (!over || active.id === over.id) return; - const fromIndex = group.cards.findIndex(c => c._key === active.id); - const toIndex = group.cards.findIndex(c => c._key === over.id); - if (fromIndex !== -1 && toIndex !== -1) { - onMoveCard(group._key, fromIndex, toIndex); - } - }, - [group._key, group.cards, onMoveCard], - ); - - const handleCardDragCancel = useCallback(() => { - setActiveCardId(null); - }, []); - - const activeCard = activeCardId - ? group.cards.find(c => c._key === activeCardId) - : undefined; - - return ( - - - - - - - - {getIconForName(group.icon)} - - - {group.title || 'Untitled Group'} - - - {cardsError && ( - - {cardsError} - - )} - onRemoveGroup(group._key)} - /> - - - - - onUpdateGroup(group._key, { icon })} - sx={{ width: 150 }} - /> - onUpdateGroup(group._key, { color })} - /> - - - - - - - {group.cards.map(card => ( - - ))} - - - - {activeCard ? : null} - - - - - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/IconPicker.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/IconPicker.test.tsx deleted file mode 100644 index d3bed2858b6..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/IconPicker.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { render, screen, fireEvent, within } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { IconPicker } from './IconPicker'; - -const theme = createTheme(); - -function renderPicker( - props: Partial> = {}, -) { - const onChange = jest.fn(); - const result = render( - - - , - ); - return { ...result, onChange }; -} - -describe('IconPicker', () => { - it('renders a combobox with the default label', () => { - renderPicker(); - expect(screen.getByRole('combobox', { name: 'Icon' })).toBeInTheDocument(); - }); - - it('renders with a custom label', () => { - renderPicker({ label: 'Lane Icon' }); - expect( - screen.getByRole('combobox', { name: 'Lane Icon' }), - ).toBeInTheDocument(); - }); - - it('shows icon options when opened', () => { - renderPicker(); - fireEvent.mouseDown(screen.getByRole('combobox')); - const listbox = screen.getByRole('listbox'); - expect(within(listbox).getByText('rocket')).toBeInTheDocument(); - expect(within(listbox).getByText('code')).toBeInTheDocument(); - expect(within(listbox).getByText('search')).toBeInTheDocument(); - }); - - it('calls onChange when an icon is selected', () => { - const { onChange } = renderPicker(); - fireEvent.mouseDown(screen.getByRole('combobox')); - fireEvent.click(screen.getByText('code')); - expect(onChange).toHaveBeenCalledWith('code'); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/IconPicker.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/IconPicker.tsx deleted file mode 100644 index 7af2f3ff6e9..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/IconPicker.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useCallback, useId, type FC } from 'react'; -import Box from '@mui/material/Box'; -import FormControl from '@mui/material/FormControl'; -import InputLabel from '@mui/material/InputLabel'; -import Select from '@mui/material/Select'; -import MenuItem from '@mui/material/MenuItem'; -import type { SelectChangeEvent } from '@mui/material/Select'; -import { ICON_NAMES, getIconForName } from '../../WelcomeScreen/iconUtils'; -import { SELECT_MENU_PROPS } from '../shared/selectMenuProps'; - -export interface IconPickerProps { - readonly value: string | undefined; - readonly onChange: (icon: string) => void; - readonly label?: string; - readonly size?: 'small' | 'medium'; - readonly sx?: object; -} - -const OPTION_SX = { - display: 'flex', - alignItems: 'center', - gap: 1, - '& svg': { fontSize: 18 }, -} as const; - -const RENDER_SX = { - display: 'flex', - alignItems: 'center', - gap: 0.75, - '& svg': { fontSize: 16 }, -} as const; - -export const IconPicker: FC = ({ - value, - onChange, - label = 'Icon', - size = 'small', - sx, -}) => { - const labelId = useId(); - - const handleChange = useCallback( - (e: SelectChangeEvent) => onChange(e.target.value), - [onChange], - ); - - const renderValue = useCallback( - (selected: string) => ( - - {getIconForName(selected)} - {selected} - - ), - [], - ); - - return ( - - {label} - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/LivePreview.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/LivePreview.tsx deleted file mode 100644 index c2a98d98af9..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/LivePreview.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { FC } from 'react'; -import Box from '@mui/material/Box'; -import Paper from '@mui/material/Paper'; -import Typography from '@mui/material/Typography'; -import Collapse from '@mui/material/Collapse'; -import { useTheme } from '@mui/material/styles'; -import type { PromptGroup } from '../../../types'; -import { PromptGroupRow } from '../../WelcomeScreen/PromptGroupRow'; - -export interface LivePreviewProps { - readonly open: boolean; - readonly groups: readonly PromptGroup[]; -} - -const PREVIEW_PAPER_SX = { - mb: 2, - p: 2, - borderRadius: 2, - border: '1px dashed', - borderColor: 'divider', - bgcolor: 'background.default', -} as const; - -const LABEL_SX = { - fontSize: '0.7rem', - fontWeight: 600, - textTransform: 'uppercase', - letterSpacing: '0.08em', - color: 'text.disabled', - mb: 1.5, -} as const; - -const EMPTY_SX = { - py: 3, - textAlign: 'center', - color: 'text.disabled', - fontSize: '0.8125rem', -} as const; - -const noop = () => {}; - -export const LivePreview: FC = ({ open, groups }) => { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const validGroups = groups.filter(g => g.cards.length > 0); - - return ( - - - Preview -- What users will see - {validGroups.length > 0 ? ( - - {validGroups.map(group => ( - - ))} - - ) : ( - - Nothing to preview yet. Add a group with at least one card. - - )} - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/PromptsPanel.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/PromptsPanel.test.tsx deleted file mode 100644 index de0cb3963f2..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/PromptsPanel.test.tsx +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { TestApiProvider } from '@backstage/test-utils'; -import { augmentApiRef } from '../../../api'; -import { createAdminMockApi } from '../../../test-utils/factories'; -import { PromptsPanel } from './PromptsPanel'; -import type { PromptGroup } from '../../../types'; - -const theme = createTheme(); - -const SAMPLE_GROUPS: PromptGroup[] = [ - { - id: 'ops', - title: 'Ansible Ops', - icon: 'terminal', - color: '#d32f2f', - cards: [ - { title: 'View Projects', prompt: 'List Projects', description: 'Test' }, - { title: 'List Inventories', prompt: 'List Inventories' }, - ], - order: 1, - }, - { - id: 'dev', - title: 'Development', - cards: [{ title: 'Build', prompt: 'Run build' }], - order: 2, - }, -]; - -function createApi(groups: PromptGroup[] = SAMPLE_GROUPS) { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: { key: 'promptGroups', configValue: groups }, - source: 'database', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue(groups); - return api; -} - -function renderPanel(api = createApi()) { - return render( - - - - - , - ); -} - -describe('PromptsPanel', () => { - it('renders header and groups after loading', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Prompts & Actions')).toBeInTheDocument(); - }); - expect(screen.getByText('Ansible Ops')).toBeInTheDocument(); - expect(screen.getByText('Development')).toBeInTheDocument(); - }); - - it('shows loading spinner initially', () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockReturnValue(new Promise(() => {})); - renderPanel(api); - expect(screen.getByRole('progressbar')).toBeInTheDocument(); - }); - - it('renders Customized chip when source is database', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Customized')).toBeInTheDocument(); - }); - }); - - it('disables Save button when no changes made', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Saved')).toBeInTheDocument(); - }); - expect(screen.getByText('Saved').closest('button')).toBeDisabled(); - }); - - it('enables Save button after editing a field', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Ansible Ops')).toBeInTheDocument(); - }); - - const titleInputs = screen.getAllByLabelText('Group Title *'); - fireEvent.change(titleInputs[0], { target: { value: 'Modified Group' } }); - - await waitFor(() => { - expect(screen.getByText('Save Changes')).toBeInTheDocument(); - expect( - screen.getByText('Save Changes').closest('button'), - ).not.toBeDisabled(); - }); - }); - - it('adds a new group when Add Prompt Group is clicked', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Add Prompt Group')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Add Prompt Group')); - - await waitFor(() => { - expect(screen.getByText('Untitled Group')).toBeInTheDocument(); - }); - }); - - it('adds a card when Add Card is clicked', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Ansible Ops')).toBeInTheDocument(); - }); - - const addCardButtons = screen.getAllByText('Add Card'); - const initialCards = screen.getAllByLabelText('Remove card'); - fireEvent.click(addCardButtons[0]); - - await waitFor(() => { - expect(screen.getAllByLabelText('Remove card').length).toBe( - initialCards.length + 1, - ); - }); - }); - - it('removes a card when delete button is clicked', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Ansible Ops')).toBeInTheDocument(); - }); - - const initialCards = screen.getAllByLabelText('Remove card'); - fireEvent.click(initialCards[0]); - - await waitFor(() => { - expect(screen.getAllByLabelText('Remove card').length).toBe( - initialCards.length - 1, - ); - }); - }); - - it('calls save with cleaned groups on save', async () => { - const api = createApi(); - renderPanel(api); - await waitFor(() => { - expect(screen.getByText('Ansible Ops')).toBeInTheDocument(); - }); - - const titleInputs = screen.getAllByLabelText('Group Title *'); - fireEvent.change(titleInputs[0], { target: { value: 'Updated Ops' } }); - - await waitFor(() => { - expect(screen.getByText('Save Changes')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Save Changes')); - - await waitFor(() => { - expect(api.setAdminConfig).toHaveBeenCalledWith( - 'promptGroups', - expect.arrayContaining([ - expect.objectContaining({ title: 'Updated Ops' }), - ]), - ); - }); - }); - - it('shows error alert when error occurs', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: null, - source: 'default', - }); - (api.getPromptGroups as jest.Mock).mockRejectedValue( - new Error('Network error'), - ); - renderPanel(api); - - await waitFor(() => { - expect(screen.getByText('Add Prompt Group')).toBeInTheDocument(); - }); - }); - - it('renders all groups expanded (no accordion)', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Ansible Ops')).toBeInTheDocument(); - }); - - const allGroupTitles = screen.getAllByLabelText('Group Title *'); - expect(allGroupTitles).toHaveLength(2); - }); - - it('shows color swatch next to color field', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Ansible Ops')).toBeInTheDocument(); - }); - - const colorInput = screen.getAllByLabelText('Color')[0] as HTMLInputElement; - expect(colorInput.value).toBe('#d32f2f'); - }); - - it('shows empty state when no groups exist', async () => { - const api = createApi([]); - renderPanel(api); - await waitFor(() => { - expect( - screen.getByText('No prompt groups configured yet.'), - ).toBeInTheDocument(); - }); - expect(screen.getByText('Add Prompt Group')).toBeInTheDocument(); - }); - - it('shows card count chip in group editor summary', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('2 cards')).toBeInTheDocument(); - expect(screen.getByText('1 card')).toBeInTheDocument(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/PromptsPanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/PromptsPanel.tsx deleted file mode 100644 index 02b7c2ca34f..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/PromptsPanel.tsx +++ /dev/null @@ -1,252 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useMemo } from 'react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Typography from '@mui/material/Typography'; -import Alert from '@mui/material/Alert'; -import Snackbar from '@mui/material/Snackbar'; -import CircularProgress from '@mui/material/CircularProgress'; -import AddIcon from '@mui/icons-material/Add'; -import ViewCarouselOutlinedIcon from '@mui/icons-material/ViewCarouselOutlined'; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragOverlay, -} from '@dnd-kit/core'; -import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; -import { - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; -import { usePromptGroupsEditor } from '../usePromptGroupsEditor'; -import { PromptsPanelHeader } from './PromptsPanelHeader'; -import { GroupEditor, GroupDragOverlay } from './GroupEditor'; -import { LivePreview } from './LivePreview'; - -const CONTAINER_SX = { - p: 3, - width: '100%', - maxWidth: 960, - mx: 'auto', -} as const; - -const LOADING_SX = { - p: 4, - display: 'flex', - justifyContent: 'center', -} as const; - -const EMPTY_STATE_SX = { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: 1.5, - py: 6, - px: 2, -} as const; - -const EMPTY_ICON_SX = { - fontSize: 48, - color: 'text.disabled', -} as const; - -const SNACKBAR_ANCHOR = { - vertical: 'bottom' as const, - horizontal: 'center' as const, -}; - -const GROUP_MODIFIERS = [restrictToVerticalAxis]; - -export const PromptsPanel = () => { - const { - groups, - previewGroups, - dirty, - loading, - saving, - error, - source, - toast, - validationErrors, - handleSave, - handleReset, - updateGroup, - updateCard, - addCard, - removeCard, - addGroup, - removeGroup, - moveGroup, - moveCard, - dismissToast, - } = usePromptGroupsEditor(); - - const [showPreview, setShowPreview] = useState(false); - const [activeGroupId, setActiveGroupId] = useState(null); - - const togglePreview = useCallback(() => setShowPreview(prev => !prev), []); - - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - const groupIds = useMemo(() => groups.map(g => g._key), [groups]); - - const handleGroupDragStart = useCallback((event: DragStartEvent) => { - setActiveGroupId(String(event.active.id)); - }, []); - - const handleGroupDragEnd = useCallback( - (event: DragEndEvent) => { - setActiveGroupId(null); - const { active, over } = event; - if (!over || active.id === over.id) return; - const fromIndex = groups.findIndex(g => g._key === active.id); - const toIndex = groups.findIndex(g => g._key === over.id); - if (fromIndex !== -1 && toIndex !== -1) { - moveGroup(fromIndex, toIndex); - } - }, - [groups, moveGroup], - ); - - const handleGroupDragCancel = useCallback(() => { - setActiveGroupId(null); - }, []); - - if (loading) { - return ( - - - - ); - } - - const hasGroups = groups.length > 0; - const activeGroup = activeGroupId - ? groups.find(g => g._key === activeGroupId) - : undefined; - - return ( - - - - {error && ( - - {error} - - )} - - - - {hasGroups ? ( - <> - - - {groups.map(group => ( - - ))} - - - - {activeGroup ? : null} - - - - - - ) : ( - - - - No prompt groups configured yet. - - - Create prompt groups to help users get started with common tasks. - - - - )} - - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/PromptsPanelHeader.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/PromptsPanelHeader.tsx deleted file mode 100644 index 18887d398ca..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/PromptsPanelHeader.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { FC } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import CircularProgress from '@mui/material/CircularProgress'; -import SaveIcon from '@mui/icons-material/Save'; -import RestoreIcon from '@mui/icons-material/Restore'; -import VisibilityIcon from '@mui/icons-material/Visibility'; -import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; - -export interface PromptsPanelHeaderProps { - readonly source: 'database' | 'default'; - readonly dirty: boolean; - readonly saving: boolean; - readonly showPreview: boolean; - readonly onSave: () => void; - readonly onReset: () => void; - readonly onTogglePreview: () => void; -} - -const HEADER_SX = { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'flex-start', - mb: 2, - gap: 2, - flexWrap: 'wrap', -} as const; - -const ACTIONS_SX = { - display: 'flex', - gap: 1, - flexShrink: 0, -} as const; - -export const PromptsPanelHeader: FC = ({ - source, - dirty, - saving, - showPreview, - onSave, - onReset, - onTogglePreview, -}) => ( - - - - Prompts & Actions - - - Configure prompt groups and clickable prompts shown on the welcome - screen.{' '} - {source === 'database' && ( - - )} - - - - - - - - -); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/index.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/index.ts deleted file mode 100644 index 0071c12e9ce..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/PromptsPanel/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { PromptsPanel } from './PromptsPanel'; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ProviderPlaceholder.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ProviderPlaceholder.tsx deleted file mode 100644 index db513e77c76..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ProviderPlaceholder.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import ConstructionIcon from '@mui/icons-material/Construction'; -import { useTheme, alpha } from '@mui/material/styles'; -import type { ProviderDescriptor } from '@red-hat-developer-hub/backstage-plugin-augment-common'; - -/** @public */ -export interface ProviderPlaceholderProps { - readonly provider: ProviderDescriptor; -} - -const CONTAINER_SX = { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - py: 8, - px: 4, - textAlign: 'center', - gap: 2, -} as const; - -/** - * Placeholder displayed when an unimplemented provider is selected. - * Shows the provider name and description. - */ -export function ProviderPlaceholder({ provider }: ProviderPlaceholderProps) { - const theme = useTheme(); - - return ( - - - - {provider.displayName} - - - The {provider.displayName} provider is not yet configured. Configuration - options will appear here once the integration is available. - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ProviderSelector.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ProviderSelector.tsx deleted file mode 100644 index 81df8db1760..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ProviderSelector.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState, useCallback } from 'react'; -import Box from '@mui/material/Box'; -import FormControl from '@mui/material/FormControl'; -import InputLabel from '@mui/material/InputLabel'; -import Select from '@mui/material/Select'; -import MenuItem from '@mui/material/MenuItem'; -import CircularProgress from '@mui/material/CircularProgress'; -import type { SelectChangeEvent } from '@mui/material/Select'; -import type { ProviderDescriptor } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { SELECT_MENU_PROPS } from './shared/selectMenuProps'; - -/** @public */ -export interface ProviderSelectorProps { - readonly providers: readonly ProviderDescriptor[]; - readonly activeProviderId: string; - readonly onSwitch: (id: string) => Promise; -} - -const WRAPPER_SX = { - display: 'flex', - alignItems: 'center', - gap: 1, -} as const; - -/** - * Dropdown selector for choosing the active agent platform. - * Selecting an unimplemented provider shows a placeholder in the config panel. - */ -export function ProviderSelector({ - providers, - activeProviderId, - onSwitch, -}: ProviderSelectorProps) { - const [switching, setSwitching] = useState(false); - - const handleChange = useCallback( - async (event: SelectChangeEvent) => { - const value = event.target.value; - if (value === activeProviderId || switching) return; - setSwitching(true); - try { - await onSwitch(value); - window.dispatchEvent( - new CustomEvent('augment:provider-switched', { detail: value }), - ); - } finally { - setSwitching(false); - } - }, - [activeProviderId, switching, onSwitch], - ); - - return ( - - - Agent Platform - - - {switching && } - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/RagQueryForm.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/RagQueryForm.tsx deleted file mode 100644 index d3ee57eaa50..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/RagQueryForm.tsx +++ /dev/null @@ -1,288 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import { ToggleSwitch as Switch } from './shared/ToggleSwitch'; -import TextField from '@mui/material/TextField'; -import MenuItem from '@mui/material/MenuItem'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; -import SearchIcon from '@mui/icons-material/Search'; -import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; -import StorageIcon from '@mui/icons-material/Storage'; -import CircularProgress from '@mui/material/CircularProgress'; -import type { ActiveVectorStore } from '../../hooks'; -import type { RagTestMode } from './KBRagTest'; -import { SELECT_MENU_PROPS } from './shared/selectMenuProps'; - -const MAX_RESULTS_OPTIONS = [3, 5, 10, 15, 20]; - -export interface RagQueryFormProps { - ragQuery: string; - onRagQueryChange: (value: string) => void; - maxResults: number; - onMaxResultsChange: (value: number) => void; - searchAllStores: boolean; - onSearchAllStoresChange: (value: boolean) => void; - queryHistory: string[]; - onSearch: () => void; - onHistoryClick: (query: string) => void; - loading: boolean; - stores: ActiveVectorStore[]; - selectedStore: ActiveVectorStore | null; - onSelectStore?: (storeId: string) => void; - mode: RagTestMode; - onModeChange: (mode: RagTestMode) => void; -} - -export function RagQueryForm({ - ragQuery, - onRagQueryChange, - maxResults, - onMaxResultsChange, - searchAllStores, - onSearchAllStoresChange, - queryHistory, - onSearch, - onHistoryClick, - loading, - stores, - selectedStore, - onSelectStore, - mode, - onModeChange, -}: RagQueryFormProps) { - const isGenerate = mode === 'generate'; - - const searchContextLabel = (() => { - if (searchAllStores) { - return ( - <> - Searching{' '} - - all {stores.length} store - {stores.length !== 1 ? 's' : ''} - - - ); - } - if (selectedStore) { - const storeLabel = - selectedStore.name !== selectedStore.id - ? selectedStore.name - : selectedStore.id; - const fileSuffix = selectedStore.fileCount !== 1 ? 's' : ''; - const fileCountLabel = - selectedStore.fileCount > 0 - ? ` (${selectedStore.fileCount} file${fileSuffix})` - : ' (empty)'; - return ( - <> - Searching {storeLabel} - {fileCountLabel} - - ); - } - return null; - })(); - - return ( - <> - {/* Mode toggle + store context bar */} - - { - if (v) onModeChange(v); - }} - size="small" - sx={{ mr: 1 }} - > - - - Search - - - - Generate Answer - - - - - {!searchAllStores && stores.length > 1 && onSelectStore ? ( - onSelectStore(e.target.value)} - sx={{ minWidth: 160, flexGrow: 1 }} - SelectProps={{ - displayEmpty: true, - MenuProps: SELECT_MENU_PROPS, - }} - > - {stores.map(s => ( - - {s.name !== s.id ? s.name : s.id} - {s.fileCount > 0 - ? ` (${s.fileCount} file${s.fileCount !== 1 ? 's' : ''})` - : ' (empty)'} - - ))} - - ) : ( - - {searchContextLabel} - - )} - {stores.length > 1 && ( - onSearchAllStoresChange(v)} - /> - } - label={All stores} - /> - )} - - - {/* Search controls */} - - onRagQueryChange(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') onSearch(); - }} - inputProps={{ maxLength: 2000 }} - /> - onMaxResultsChange(Number(e.target.value))} - sx={{ minWidth: 88 }} - SelectProps={{ MenuProps: SELECT_MENU_PROPS }} - > - {MAX_RESULTS_OPTIONS.map(n => ( - - {n} - - ))} - - - - - {/* Query history */} - {queryHistory.length > 0 && ( - - - Recent: - - {queryHistory.map(q => ( - 30 ? `${q.slice(0, 30)}\u2026` : q} - size="small" - variant="outlined" - onClick={() => onHistoryClick(q)} - sx={{ - fontSize: '0.7rem', - height: 22, - cursor: 'pointer', - '&:hover': { - bgcolor: 'action.hover', - borderColor: 'primary.main', - }, - }} - /> - ))} - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/RagResultsTable.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/RagResultsTable.tsx deleted file mode 100644 index de647666f56..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/RagResultsTable.tsx +++ /dev/null @@ -1,411 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useMemo, useCallback } from 'react'; -import { useTheme } from '@mui/material/styles'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Paper from '@mui/material/Paper'; -import Chip from '@mui/material/Chip'; -import Slider from '@mui/material/Slider'; -import Tooltip from '@mui/material/Tooltip'; -import IconButton from '@mui/material/IconButton'; -import AccessTimeIcon from '@mui/icons-material/AccessTime'; -import DownloadIcon from '@mui/icons-material/Download'; -import type { RagTestResult } from '../../types'; -import type { ActiveVectorStore } from '../../hooks'; -import { ChunkCard } from './ChunkCard'; -import { getScoreColor } from './ragResultsUtils'; - -const CHUNK_PREVIEW_LENGTH = 300; - -export interface RagResultsTableProps { - result: RagTestResult; - searchTimeMs: number | null; - searchAllStores: boolean; - stores: ActiveVectorStore[]; - expandedChunks: Set; - onToggleChunk: (index: number) => void; - onExpandCollapseAll: () => void; - scoreThreshold: number; - onScoreThresholdChange: (value: number) => void; -} - -function ScoreStats({ chunks }: { chunks: RagTestResult['chunks'] }) { - const theme = useTheme(); - const scores = chunks - .map(c => c.score) - .filter((s): s is number => s !== undefined); - - if (scores.length === 0) return null; - - const min = Math.min(...scores); - const max = Math.max(...scores); - const avg = scores.reduce((a, b) => a + b, 0) / scores.length; - - const items = [ - { label: 'Min', value: min }, - { label: 'Avg', value: avg }, - { label: 'Max', value: max }, - ]; - - return ( - - {items.map(item => ( - - - {item.label}: - - - {(item.value * 100).toFixed(1)}% - - - ))} - - - Spread: - - - - - - - ); -} - -export function RagResultsTable({ - result, - searchTimeMs, - searchAllStores, - stores, - expandedChunks, - onToggleChunk, - onExpandCollapseAll, - scoreThreshold, - onScoreThresholdChange, -}: RagResultsTableProps) { - const theme = useTheme(); - const storeNameMap = new Map( - stores.map(s => [s.id, s.name !== s.id ? s.name : s.id]), - ); - - const chunks = useMemo(() => result.chunks ?? [], [result.chunks]); - const longChunkIndices = chunks - .map((c, i) => (c.text.length > CHUNK_PREVIEW_LENGTH ? i : -1)) - .filter(i => i >= 0); - const allExpanded = - longChunkIndices.length > 0 && - longChunkIndices.every(i => expandedChunks.has(i)); - - const passingCount = useMemo( - () => - scoreThreshold > 0 - ? chunks.filter(c => (c.score ?? 0) >= scoreThreshold).length - : chunks.length, - [chunks, scoreThreshold], - ); - - const handleExportJson = useCallback(() => { - const exportData = { - ...result, - exportedAt: new Date().toISOString(), - searchTimeMs, - }; - const blob = new Blob([JSON.stringify(exportData, null, 2)], { - type: 'application/json', - }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `rag-test-${Date.now()}.json`; - a.click(); - URL.revokeObjectURL(url); - }, [result, searchTimeMs]); - - return ( - - {/* Results summary header */} - - - {result.totalResults} result - {result.totalResults !== 1 ? 's' : ''} - {!searchAllStores && result.vectorStoreId && ( - <> - {' '} - from{' '} - - - )} - {searchAllStores && <> across {stores.length} stores} - - - {searchTimeMs !== null && ( - - - - {searchTimeMs < 1000 - ? `${searchTimeMs}ms` - : `${(searchTimeMs / 1000).toFixed(2)}s`} - - - )} - - - - - - - - - {/* No results guidance */} - {chunks.length === 0 ? ( - - - No matching chunks found. - - - Try broader search terms, check that your documents cover this - topic, or verify that files are properly ingested and chunked. - - - ) : ( - <> - {/* Score statistics */} - - - {/* Score legend */} - - {[ - { label: 'High (\u226570%)', color: theme.palette.success.main }, - { - label: 'Medium (40\u201370%)', - color: theme.palette.warning.main, - }, - { label: 'Low (<40%)', color: theme.palette.error.main }, - ].map(item => ( - - - - {item.label} - - - ))} - - - {/* Score threshold slider */} - - - Score threshold: - - onScoreThresholdChange(v as number)} - min={0} - max={1} - step={0.05} - valueLabelDisplay="auto" - valueLabelFormat={v => `${(v * 100).toFixed(0)}%`} - sx={{ flex: 1, maxWidth: 200 }} - /> - - {(scoreThreshold * 100).toFixed(0)}% - - {scoreThreshold > 0 && ( - - )} - - - {/* Chunk cards */} - {chunks.map((chunk, i) => { - const belowThreshold = - scoreThreshold > 0 && (chunk.score ?? 0) < scoreThreshold; - return ( - - onToggleChunk(i)} - storeName={ - chunk.vectorStoreId - ? storeNameMap.get(chunk.vectorStoreId) - : undefined - } - showStore={searchAllStores} - theme={theme} - belowThreshold={belowThreshold} - /> - - ); - })} - - {/* Expand/collapse all (when multiple long chunks) */} - {longChunkIndices.length > 1 && ( - - - - )} - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/EvaluationSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/EvaluationSection.tsx deleted file mode 100644 index cedb298c22b..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/EvaluationSection.tsx +++ /dev/null @@ -1,247 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useMemo, useEffect } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import { ToggleSwitch as Switch } from '../shared/ToggleSwitch'; -import Slider from '@mui/material/Slider'; -import Snackbar from '@mui/material/Snackbar'; -import Collapse from '@mui/material/Collapse'; -import { asStringArray } from '../../../utils'; -import { useAdminConfig, useEvaluationStatus } from '../../../hooks'; -import { AdminSection } from '../shared/AdminSection'; -import { ServerItemPicker } from '../shared/ServerItemPicker'; - -interface Props { - providerName: string; - effectiveConfig?: Record | null; - onConfigSaved?: () => void; -} - -export const EvaluationSection = ({ - providerName, - effectiveConfig, - onConfigSaved, -}: Props) => { - const enabledConfig = useAdminConfig('evaluationEnabled'); - const scoringConfig = useAdminConfig('scoringFunctions'); - const thresholdConfig = useAdminConfig('minScoreThreshold'); - const evalStatus = useEvaluationStatus(); - - const effectiveEnabled = - (effectiveConfig?.evaluationEnabled as boolean) ?? false; - const effectiveScoring = useMemo( - () => asStringArray(effectiveConfig?.scoringFunctions), - [effectiveConfig?.scoringFunctions], - ); - const effectiveThreshold = - (effectiveConfig?.minScoreThreshold as number) ?? 0.5; - - const [enabled, setEnabled] = useState(false); - const [scoringFunctions, setScoringFunctions] = useState([]); - const [threshold, setThreshold] = useState(0.5); - const [toast, setToast] = useState(null); - const [initialized, setInitialized] = useState({ - enabled: false, - scoring: false, - threshold: false, - }); - - useEffect(() => { - if (!initialized.enabled && !enabledConfig.loading) { - const dbValue = enabledConfig.entry?.configValue as boolean | undefined; - setEnabled(dbValue ?? effectiveEnabled); - setInitialized(prev => ({ ...prev, enabled: true })); - } - }, [ - initialized.enabled, - enabledConfig.loading, - enabledConfig.entry, - effectiveEnabled, - ]); - - useEffect(() => { - if (!initialized.scoring && !scoringConfig.loading) { - const dbValue = scoringConfig.entry?.configValue; - setScoringFunctions( - dbValue !== undefined ? asStringArray(dbValue) : [...effectiveScoring], - ); - setInitialized(prev => ({ ...prev, scoring: true })); - } - }, [ - initialized.scoring, - scoringConfig.loading, - scoringConfig.entry, - effectiveScoring, - ]); - - useEffect(() => { - if (!initialized.threshold && !thresholdConfig.loading) { - const dbValue = thresholdConfig.entry?.configValue as number | undefined; - setThreshold(dbValue ?? effectiveThreshold); - setInitialized(prev => ({ ...prev, threshold: true })); - } - }, [ - initialized.threshold, - thresholdConfig.loading, - thresholdConfig.entry, - effectiveThreshold, - ]); - - const source = - enabledConfig.source === 'database' || - scoringConfig.source === 'database' || - thresholdConfig.source === 'database' - ? 'database' - : 'default'; - - const saving = - enabledConfig.saving || scoringConfig.saving || thresholdConfig.saving; - const error = - enabledConfig.error || scoringConfig.error || thresholdConfig.error; - - const handleSave = useCallback(async () => { - const results = await Promise.allSettled([ - enabledConfig.save(enabled), - scoringConfig.save(scoringFunctions), - thresholdConfig.save(threshold), - ]); - - const labels = ['Enabled toggle', 'Scoring Functions', 'Score Threshold']; - const failed = results - .map((r, i) => (r.status === 'rejected' ? labels[i] : null)) - .filter(Boolean); - - if (failed.length > 0) { - setToast(`Failed to save: ${failed.join(', ')}`); - } else { - setToast('Evaluation config saved'); - onConfigSaved?.(); - } - }, [ - enabled, - scoringFunctions, - threshold, - enabledConfig, - scoringConfig, - thresholdConfig, - onConfigSaved, - ]); - - const handleReset = useCallback(async () => { - try { - await Promise.all([ - enabledConfig.reset(), - scoringConfig.reset(), - thresholdConfig.reset(), - ]); - } catch { - return; - } - setEnabled(effectiveEnabled); - setScoringFunctions([...effectiveScoring]); - setThreshold(effectiveThreshold); - setInitialized({ enabled: false, scoring: false, threshold: false }); - setToast('Reset to YAML defaults'); - }, [ - enabledConfig, - scoringConfig, - thresholdConfig, - effectiveEnabled, - effectiveScoring, - effectiveThreshold, - ]); - - const loading = - enabledConfig.loading || scoringConfig.loading || thresholdConfig.loading; - if (loading) return null; - - return ( - <> - - setEnabled(checked)} - /> - } - label={ - - {enabled ? 'Evaluation enabled' : 'Evaluation disabled'} - - } - sx={{ mb: 1 }} - /> - - - - - - - - Minimum Score Threshold: {threshold.toFixed(2)} - - setThreshold(v as number)} - valueLabelDisplay="auto" - aria-label="Minimum score threshold" - getAriaValueText={v => `${v.toFixed(2)}`} - sx={{ mt: 0.5 }} - /> - - Responses scoring below this threshold will be flagged as low - quality. - - - - - - - setToast(null)} - message={toast} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyEvalPanel.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyEvalPanel.test.tsx deleted file mode 100644 index 40c210cc3d0..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyEvalPanel.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { TestApiProvider } from '@backstage/test-utils'; -import { augmentApiRef } from '../../../api'; -import { createAdminMockApi } from '../../../test-utils/factories'; -import { SafetyEvalPanel } from './SafetyEvalPanel'; - -const theme = createTheme(); - -const defaultEffective = { - safetyEnabled: false, - inputShields: [], - outputShields: [], - safetyPatterns: [], -}; - -function renderPanel( - api = createAdminMockApi(), - effectiveConfig: Record = defaultEffective, -) { - return render( - - - - - , - ); -} - -describe('SafetyEvalPanel', () => { - it('renders both sub-tabs', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Shields')).toBeInTheDocument(); - expect(screen.getByText('Patterns')).toBeInTheDocument(); - }); - }); - - it('renders page title', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Safety')).toBeInTheDocument(); - }); - }); - - it('shows shields section by default with toggle', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Safety shields disabled')).toBeInTheDocument(); - }); - }); - - it('shows shield editors when safety is toggled on', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Safety shields disabled')).toBeInTheDocument(); - }); - - const toggle = screen.getByRole('checkbox', { name: /safety shields/i }); - fireEvent.click(toggle); - - expect(screen.getByText('Input Shields')).toBeInTheDocument(); - expect(screen.getByText('Output Shields')).toBeInTheDocument(); - }); - - it('switches to Patterns tab', async () => { - renderPanel(); - await waitFor(() => { - expect(screen.getByText('Patterns')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Patterns')); - - await waitFor(() => { - expect( - screen.getByPlaceholderText('Add a safety pattern...'), - ).toBeInTheDocument(); - }); - }); - - it('calls setAdminConfig when saving shields section', async () => { - const api = createAdminMockApi(); - renderPanel(api); - - await waitFor(() => { - expect(screen.getByText('Agent Platform Shields')).toBeInTheDocument(); - }); - - const saveButtons = screen.getAllByText('Save'); - fireEvent.click(saveButtons[0]); - - await waitFor(() => { - expect(api.setAdminConfig).toHaveBeenCalled(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyEvalPanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyEvalPanel.tsx deleted file mode 100644 index e3bd30ffe8f..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyEvalPanel.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import ShieldIcon from '@mui/icons-material/Shield'; -import WarningIcon from '@mui/icons-material/Warning'; -import { SafetyShieldsSection } from './SafetyShieldsSection'; -import { SafetyPatternsSection } from './SafetyPatternsSection'; - -const TABS_SX = { - minHeight: 32, - '& .MuiTab-root': { - minHeight: 32, - textTransform: 'none', - fontSize: '0.8125rem', - minWidth: 'auto', - px: 2, - mr: 0.5, - }, -} as const; - -type SubTab = 'shields' | 'patterns'; - -interface Props { - providerName?: string; - effectiveConfig?: Record | null; - onConfigSaved?: () => void; -} - -export const SafetyEvalPanel = ({ - providerName = 'Agent Platform', - effectiveConfig, - onConfigSaved, -}: Props) => { - const [activeTab, setActiveTab] = useState('shields'); - - return ( - - - Safety - - - {`Configure ${providerName} safety shields and keyword-based guardrails.`} - - - - setActiveTab(v as SubTab)} - variant="scrollable" - scrollButtons="auto" - allowScrollButtonsMobile - sx={TABS_SX} - > - } - iconPosition="start" - label="Shields" - value="shields" - /> - } - iconPosition="start" - label="Patterns" - value="patterns" - /> - - - - {activeTab === 'shields' && ( - - )} - {activeTab === 'patterns' && ( - - )} - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyPatternsSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyPatternsSection.tsx deleted file mode 100644 index 50092c48623..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyPatternsSection.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useMemo, useEffect } from 'react'; -import Snackbar from '@mui/material/Snackbar'; -import { asStringArray } from '../../../utils'; -import { useAdminConfig } from '../../../hooks'; -import { AdminSection } from '../shared/AdminSection'; -import { StringListEditor } from '../shared/StringListEditor'; - -interface Props { - effectiveConfig?: Record | null; - onConfigSaved?: () => void; -} - -export const SafetyPatternsSection = ({ - effectiveConfig, - onConfigSaved, -}: Props) => { - const config = useAdminConfig('safetyPatterns'); - const effectivePatterns = useMemo( - () => asStringArray(effectiveConfig?.safetyPatterns), - [effectiveConfig?.safetyPatterns], - ); - - const [patterns, setPatterns] = useState([]); - const [toast, setToast] = useState(null); - const [initialized, setInitialized] = useState(false); - - useEffect(() => { - if (!initialized && !config.loading) { - const dbValue = config.entry?.configValue; - if (dbValue !== undefined) { - setPatterns(asStringArray(dbValue)); - } else { - setPatterns([...effectivePatterns]); - } - setInitialized(true); - } - }, [initialized, config.loading, config.entry, effectivePatterns]); - - const handleSave = useCallback(async () => { - const cleaned = patterns.filter(p => p.trim().length > 0); - try { - await config.save(cleaned); - } catch { - return; - } - setToast('Safety patterns saved'); - onConfigSaved?.(); - }, [patterns, config, onConfigSaved]); - - const handleReset = useCallback(async () => { - try { - await config.reset(); - } catch { - return; - } - setPatterns([...effectivePatterns]); - setInitialized(false); - setToast('Reset to YAML default'); - }, [config, effectivePatterns]); - - if (config.loading) return null; - - return ( - <> - - - - - setToast(null)} - message={toast} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyShieldsSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyShieldsSection.tsx deleted file mode 100644 index eff1799df4b..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyEvalPanel/SafetyShieldsSection.tsx +++ /dev/null @@ -1,253 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useMemo, useEffect } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import { ToggleSwitch as Switch } from '../shared/ToggleSwitch'; -import Snackbar from '@mui/material/Snackbar'; -import Collapse from '@mui/material/Collapse'; -import Alert from '@mui/material/Alert'; -import { asStringArray } from '../../../utils'; -import { useAdminConfig, useSafetyStatus } from '../../../hooks'; -import { AdminSection } from '../shared/AdminSection'; -import { ServerItemPicker } from '../shared/ServerItemPicker'; - -interface Props { - providerName: string; - effectiveConfig?: Record | null; - onConfigSaved?: () => void; -} - -export const SafetyShieldsSection = ({ - providerName, - effectiveConfig, - onConfigSaved, -}: Props) => { - const enabledConfig = useAdminConfig('safetyEnabled'); - const inputShieldsConfig = useAdminConfig('inputShields'); - const outputShieldsConfig = useAdminConfig('outputShields'); - const safetyStatus = useSafetyStatus(); - - const effectiveEnabled = (effectiveConfig?.safetyEnabled as boolean) ?? false; - const effectiveInput = useMemo( - () => asStringArray(effectiveConfig?.inputShields), - [effectiveConfig?.inputShields], - ); - const effectiveOutput = useMemo( - () => asStringArray(effectiveConfig?.outputShields), - [effectiveConfig?.outputShields], - ); - - const [enabled, setEnabled] = useState(false); - const [inputShields, setInputShields] = useState([]); - const [outputShields, setOutputShields] = useState([]); - const [toast, setToast] = useState(null); - const [initialized, setInitialized] = useState({ - enabled: false, - input: false, - output: false, - }); - - useEffect(() => { - if (!initialized.enabled && !enabledConfig.loading) { - const dbValue = enabledConfig.entry?.configValue as boolean | undefined; - setEnabled(dbValue ?? effectiveEnabled); - setInitialized(prev => ({ ...prev, enabled: true })); - } - }, [ - initialized.enabled, - enabledConfig.loading, - enabledConfig.entry, - effectiveEnabled, - ]); - - useEffect(() => { - if (!initialized.input && !inputShieldsConfig.loading) { - const dbValue = inputShieldsConfig.entry?.configValue; - setInputShields( - dbValue !== undefined ? asStringArray(dbValue) : [...effectiveInput], - ); - setInitialized(prev => ({ ...prev, input: true })); - } - }, [ - initialized.input, - inputShieldsConfig.loading, - inputShieldsConfig.entry, - effectiveInput, - ]); - - useEffect(() => { - if (!initialized.output && !outputShieldsConfig.loading) { - const dbValue = outputShieldsConfig.entry?.configValue; - setOutputShields( - dbValue !== undefined ? asStringArray(dbValue) : [...effectiveOutput], - ); - setInitialized(prev => ({ ...prev, output: true })); - } - }, [ - initialized.output, - outputShieldsConfig.loading, - outputShieldsConfig.entry, - effectiveOutput, - ]); - - const source = - enabledConfig.source === 'database' || - inputShieldsConfig.source === 'database' || - outputShieldsConfig.source === 'database' - ? 'database' - : 'default'; - - const saving = - enabledConfig.saving || - inputShieldsConfig.saving || - outputShieldsConfig.saving; - const error = - enabledConfig.error || - inputShieldsConfig.error || - outputShieldsConfig.error; - - const handleSave = useCallback(async () => { - const results = await Promise.allSettled([ - enabledConfig.save(enabled), - inputShieldsConfig.save(inputShields), - outputShieldsConfig.save(outputShields), - ]); - - const labels = ['Enabled toggle', 'Input Shields', 'Output Shields']; - const failed = results - .map((r, i) => (r.status === 'rejected' ? labels[i] : null)) - .filter(Boolean); - - if (failed.length > 0) { - setToast(`Failed to save: ${failed.join(', ')}`); - } else { - setToast('Safety shields saved'); - onConfigSaved?.(); - } - }, [ - enabled, - inputShields, - outputShields, - enabledConfig, - inputShieldsConfig, - outputShieldsConfig, - onConfigSaved, - ]); - - const handleReset = useCallback(async () => { - try { - await Promise.all([ - enabledConfig.reset(), - inputShieldsConfig.reset(), - outputShieldsConfig.reset(), - ]); - } catch { - return; - } - setEnabled(effectiveEnabled); - setInputShields([...effectiveInput]); - setOutputShields([...effectiveOutput]); - setInitialized({ enabled: false, input: false, output: false }); - setToast('Reset to YAML defaults'); - }, [ - enabledConfig, - inputShieldsConfig, - outputShieldsConfig, - effectiveEnabled, - effectiveInput, - effectiveOutput, - ]); - - const loading = - enabledConfig.loading || - inputShieldsConfig.loading || - outputShieldsConfig.loading; - if (loading) return null; - - return ( - <> - - setEnabled(checked)} - /> - } - label={ - - {enabled ? 'Safety shields enabled' : 'Safety shields disabled'} - - } - sx={{ mb: 1 }} - /> - - {enabled && - safetyStatus.shields.length === 0 && - !safetyStatus.loading && ( - - {`No safety shields are registered on the ${providerName} server. Safety checks will not run until shields are configured in the server's run.yaml (e.g. llama-guard, prompt-guard providers).`} - - )} - - - - - - - - - - setToast(null)} - message={toast} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyPatternsSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyPatternsSection.tsx deleted file mode 100644 index d9b408719d3..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/SafetyPatternsSection.tsx +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Paper from '@mui/material/Paper'; -import TextField from '@mui/material/TextField'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import IconButton from '@mui/material/IconButton'; -import Chip from '@mui/material/Chip'; -import Divider from '@mui/material/Divider'; -import AddIcon from '@mui/icons-material/Add'; -import SaveIcon from '@mui/icons-material/Save'; -import RestoreIcon from '@mui/icons-material/Restore'; - -export interface SafetyPatternsSectionProps { - patterns: string[]; - onAddPattern: (pattern: string) => void; - onRemovePattern: (index: number) => void; - newPattern: string; - onNewPatternChange: (value: string) => void; - onSave: () => void; - onReset: () => void; - configSource: 'database' | 'default'; - saving: boolean; - error: string | null; -} - -export const SafetyPatternsSection = ({ - patterns, - onAddPattern, - onRemovePattern, - newPattern, - onNewPatternChange, - onSave, - onReset, - configSource, - saving, - error, -}: SafetyPatternsSectionProps) => { - const handleAddPattern = () => { - if (newPattern.trim()) { - onAddPattern(newPattern.trim()); - onNewPatternChange(''); - } - }; - - return ( - - - Safety Patterns - - {configSource === 'database' && ( - - )} - - - - - Words or phrases that trigger safety guardrails when detected in user - input. - - - - {patterns.map((p, i) => ( - onRemovePattern(i)} - size="small" - /> - ))} - - - onNewPatternChange(e.target.value)} - placeholder="Add a safety pattern..." - onKeyDown={e => { - if (e.key === 'Enter') handleAddPattern(); - }} - sx={{ flex: 1 }} - /> - - - - - - {error && ( - - {error} - - )} - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ScoreBar.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ScoreBar.tsx deleted file mode 100644 index 7f47e0851e1..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ScoreBar.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Tooltip from '@mui/material/Tooltip'; -import type { Theme } from '@mui/material/styles'; -import { getScoreColor, getScoreLabel } from './ragResultsUtils'; - -export interface ScoreBarProps { - score: number; - theme: Theme; -} - -export const ScoreBar = ({ score, theme }: ScoreBarProps) => { - const pct = score * 100; - const color = getScoreColor(score, theme); - const label = getScoreLabel(score); - return ( - - - - - - - {pct.toFixed(1)}% - - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/SettingsPanel.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/SettingsPanel.tsx deleted file mode 100644 index ee958dd0c55..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/SettingsPanel.tsx +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useMemo } from 'react'; -import { useTheme } from '@mui/material/styles'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Paper from '@mui/material/Paper'; -import TextField from '@mui/material/TextField'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import Chip from '@mui/material/Chip'; -import Snackbar from '@mui/material/Snackbar'; -import SaveIcon from '@mui/icons-material/Save'; -import RestoreIcon from '@mui/icons-material/Restore'; -import { BrandingSection, type BrandingState } from './BrandingSection'; -import { SafetyPatternsSection } from './SafetyEvalPanel/SafetyPatternsSection'; -import { - useAdminConfig, - useEffectiveConfig, - useToast, - useFormState, - useConfigSync, -} from '../../hooks'; - -const HEX_RE = /^#[0-9a-fA-F]{6}$/; - -export const SettingsPanel = () => { - const theme = useTheme(); - const defaultBranding = useMemo( - () => ({ - appName: '', - tagline: '', - primaryColor: theme.palette.primary.main, - secondaryColor: theme.palette.text.secondary, - }), - [theme], - ); - const systemPromptConfig = useAdminConfig('systemPrompt'); - const brandingConfig = useAdminConfig('branding'); - const { config: effectiveConfig } = useEffectiveConfig(); - - const { toast, showToast, closeToast } = useToast(); - - const [systemPrompt, setSystemPrompt] = useState(''); - const brandingForm = useFormState(defaultBranding); - - useConfigSync( - systemPromptConfig.loading, - systemPromptConfig.entry?.configValue as string | undefined, - useCallback((v: string) => setSystemPrompt(v), []), - ); - - useConfigSync( - brandingConfig.loading, - brandingConfig.entry?.configValue as BrandingState | undefined, - useCallback( - (v: BrandingState) => - brandingForm.setValues({ - appName: v.appName || '', - tagline: v.tagline || '', - primaryColor: v.primaryColor || theme.palette.primary.main, - secondaryColor: v.secondaryColor || theme.palette.text.secondary, - }), - [theme, brandingForm], - ), - ); - - const handleSavePrompt = useCallback(async () => { - if (!systemPrompt.trim()) { - showToast('System prompt cannot be empty', 'error'); - return; - } - try { - await systemPromptConfig.save(systemPrompt); - } catch { - return; - } - showToast('System prompt saved'); - }, [systemPrompt, systemPromptConfig, showToast]); - - const handleSaveBranding = useCallback(async () => { - const { primaryColor, secondaryColor } = brandingForm.values; - if (primaryColor && !HEX_RE.test(primaryColor)) { - showToast( - `Primary color must be a valid hex color (e.g. ${theme.palette.primary.main})`, - 'error', - ); - return; - } - if (secondaryColor && !HEX_RE.test(secondaryColor)) { - showToast('Secondary color must be a valid hex color', 'error'); - return; - } - try { - await brandingConfig.save(brandingForm.values); - } catch { - return; - } - showToast('Branding saved'); - }, [ - brandingForm.values, - brandingConfig, - showToast, - theme.palette.primary.main, - ]); - - const loading = systemPromptConfig.loading || brandingConfig.loading; - - if (loading) { - return ( - - - - ); - } - - return ( - - - Settings - - - {/* Shared Instructions */} - - - Shared Instructions - - {systemPromptConfig.source === 'database' && ( - - )} - - - - setSystemPrompt(e.target.value)} - placeholder="Enter the system prompt for the AI assistant..." - sx={{ mb: 1 }} - /> - - {systemPromptConfig.error && ( - - {systemPromptConfig.error} - - )} - - - brandingForm.setField(field, value)} - onSave={handleSaveBranding} - onReset={async () => { - await brandingConfig.reset(); - brandingForm.setValues(defaultBranding); - showToast('Reset to YAML default'); - }} - configSource={brandingConfig.source} - saving={brandingConfig.saving} - error={brandingConfig.error} - /> - - - - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/StoreConfigFields.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/StoreConfigFields.tsx deleted file mode 100644 index 7ca424a0942..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/StoreConfigFields.tsx +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Box from '@mui/material/Box'; -import TextField from '@mui/material/TextField'; -import Autocomplete from '@mui/material/Autocomplete'; -import type { VectorStoreConfig } from '../../types'; -import { HybridSearchConfig } from './HybridSearchConfig'; - -export interface StoreConfigFieldsProps { - localConfig: Partial; - onUpdate: ( - key: K, - value: VectorStoreConfig[K], - ) => void; -} - -/** - * Search mode and chunking configuration fields. - */ -export function StoreConfigFields({ - localConfig, - onUpdate, -}: StoreConfigFieldsProps) { - const isStatic = localConfig.chunkingStrategy === 'static'; - const isHybrid = localConfig.searchMode === 'hybrid'; - - return ( - - - opt === 'keyword' - ? 'Keyword (BM25)' - : opt.charAt(0).toUpperCase() + opt.slice(1) - } - value={ - (localConfig.searchMode ?? 'semantic') as - | 'semantic' - | 'keyword' - | 'hybrid' - } - onChange={(_e, v) => - onUpdate('searchMode', v as VectorStoreConfig['searchMode']) - } - renderInput={params => ( - - )} - /> - opt.charAt(0).toUpperCase() + opt.slice(1)} - value={(localConfig.chunkingStrategy ?? 'auto') as 'auto' | 'static'} - onChange={(_e, v) => - onUpdate('chunkingStrategy', v as 'auto' | 'static') - } - renderInput={params => ( - - )} - /> - {isStatic ? ( - - onUpdate('maxChunkSizeTokens', parseInt(e.target.value, 10) || 200) - } - helperText="tokens" - /> - ) : ( - { - const v = parseInt(e.target.value, 10); - onUpdate( - 'fileSearchMaxResults', - Number.isFinite(v) ? Math.min(50, Math.max(1, v)) : undefined, - ); - }} - helperText="1-50 chunks/query" - /> - )} - {isStatic && ( - <> - - onUpdate('chunkOverlapTokens', parseInt(e.target.value, 10) || 50) - } - helperText="tokens" - /> - { - const v = parseInt(e.target.value, 10); - onUpdate( - 'fileSearchMaxResults', - Number.isFinite(v) ? Math.min(50, Math.max(1, v)) : undefined, - ); - }} - helperText="1-50 chunks/query" - /> - - )} - { - const v = parseFloat(e.target.value); - onUpdate( - 'fileSearchScoreThreshold', - Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : undefined, - ); - }} - helperText="0.0 – 1.0" - /> - {isHybrid && ( - - onUpdate('bm25Weight', v)} - onSemanticWeightChange={v => onUpdate('semanticWeight', v)} - /> - - )} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/StoresTable.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/StoresTable.tsx deleted file mode 100644 index c58c04beb88..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/StoresTable.tsx +++ /dev/null @@ -1,493 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback } from 'react'; -import Typography from '@mui/material/Typography'; -import Box from '@mui/material/Box'; -import Paper from '@mui/material/Paper'; -import Alert from '@mui/material/Alert'; -import CircularProgress from '@mui/material/CircularProgress'; -import IconButton from '@mui/material/IconButton'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; -import Chip from '@mui/material/Chip'; -import Tooltip from '@mui/material/Tooltip'; -import TextField from '@mui/material/TextField'; -import LinearProgress from '@mui/material/LinearProgress'; -import DeleteIcon from '@mui/icons-material/Delete'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; -import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; -import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; -import EditIcon from '@mui/icons-material/Edit'; -import CheckIcon from '@mui/icons-material/Check'; -import CloseIcon from '@mui/icons-material/Close'; -import StorageIcon from '@mui/icons-material/Storage'; -import type { ActiveVectorStore } from '../../hooks'; - -function formatBytes(bytes: number | undefined): string { - if (bytes === undefined || bytes === 0) return '-'; - const units = ['B', 'KB', 'MB', 'GB']; - let unitIndex = 0; - let value = bytes; - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex++; - } - return `${value.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`; -} - -function formatRelativeTime(epochSeconds: number | undefined): string { - if (!epochSeconds) return '-'; - const diff = Date.now() - epochSeconds * 1000; - const minutes = Math.floor(diff / 60000); - if (minutes < 1) return 'just now'; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - return new Date(epochSeconds * 1000).toLocaleDateString(); -} - -const STATUS_CONFIG: Record< - string, - { - color: 'success' | 'warning' | 'error' | 'default'; - icon: React.ReactNode; - label: string; - } -> = { - completed: { color: 'success', icon: , label: 'Ready' }, - in_progress: { - color: 'warning', - icon: , - label: 'Processing', - }, - failed: { color: 'error', icon: , label: 'Failed' }, - unknown: { color: 'default', icon: , label: 'Unknown' }, -}; - -function FileCountBar({ - fileCounts, -}: { - fileCounts?: ActiveVectorStore['fileCounts']; -}) { - if (!fileCounts || fileCounts.total === 0) return null; - const completedPct = (fileCounts.completed / fileCounts.total) * 100; - const inProgressPct = (fileCounts.inProgress / fileCounts.total) * 100; - - if (fileCounts.total === fileCounts.completed) return null; - - return ( - - - 0 ? 'error' : 'primary'} - sx={{ width: '100%', height: 4, borderRadius: 2 }} - /> - - - ); -} - -export interface StoresTableProps { - stores: ActiveVectorStore[]; - selectedStoreId: string | null; - onSelectStore: (id: string) => void; - onDeleteStore: (store: ActiveVectorStore) => void; - onRenameStore?: (storeId: string, newName: string) => Promise; - deleteInProgress: string | null; - loading?: boolean; -} - -export const StoresTable = ({ - stores, - selectedStoreId, - onSelectStore, - onDeleteStore, - onRenameStore, - deleteInProgress, - loading = false, -}: StoresTableProps) => { - const [editingId, setEditingId] = useState(null); - const [editName, setEditName] = useState(''); - const [renaming, setRenaming] = useState(false); - const [renameError, setRenameError] = useState(null); - - const startRename = useCallback( - (store: ActiveVectorStore, e: React.MouseEvent) => { - e.stopPropagation(); - setEditingId(store.id); - setEditName(store.name !== store.id ? store.name : ''); - }, - [], - ); - - const cancelRename = useCallback(() => { - setEditingId(null); - setEditName(''); - }, []); - - const confirmRename = useCallback( - async (storeId: string) => { - const trimmed = editName.trim(); - if (!trimmed || !onRenameStore) { - cancelRename(); - return; - } - setRenaming(true); - setRenameError(null); - try { - await onRenameStore(storeId, trimmed); - cancelRename(); - } catch (err) { - setRenameError( - err instanceof Error ? err.message : 'Failed to rename store', - ); - } finally { - setRenaming(false); - } - }, - [editName, onRenameStore, cancelRename], - ); - - if (stores.length === 0) { - return ( - - No vector stores active. Use the Create New tab. - - ); - } - - return ( - <> - {renameError && ( - setRenameError(null)} - > - {renameError} - - )} - - - - - Store - Status - Files - Model - Activity - - - - - {stores.map(store => { - const statusCfg = - STATUS_CONFIG[store.status] ?? STATUS_CONFIG.unknown; - const isEditing = editingId === store.id; - const displayName = store.name !== store.id ? store.name : ''; - - return ( - onSelectStore(store.id)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onSelectStore(store.id); - } - }} - sx={{ - cursor: 'pointer', - '&:last-child td': { borderBottom: 0 }, - '&:focus-visible': { - outline: '2px solid', - outlineColor: 'primary.main', - outlineOffset: -2, - }, - }} - > - - {isEditing ? ( - e.stopPropagation()} - > - setEditName(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') void confirmRename(store.id); - if (e.key === 'Escape') cancelRename(); - }} - // eslint-disable-next-line jsx-a11y/no-autofocus - autoFocus - disabled={renaming} - placeholder="Enter name..." - sx={{ - '& .MuiInputBase-input': { - py: 0.5, - fontSize: '0.8125rem', - }, - }} - /> - void confirmRename(store.id)} - disabled={renaming || !editName.trim()} - > - {renaming ? ( - - ) : ( - - )} - - - - - - ) : ( - <> - - - {displayName || ( - - Unnamed - - )} - - {onRenameStore && ( - - startRename(store, e)} - sx={{ - p: 0.25, - opacity: 0.5, - '&:hover': { opacity: 1 }, - }} - > - - - - )} - - - {store.id} - - {store.providerType && ( - - } - label={store.providerType} - size="small" - variant="outlined" - sx={{ - height: 18, - fontSize: '0.625rem', - '& .MuiChip-icon': { fontSize: 12 }, - }} - /> - - )} - - )} - - - - - - - - - {store.fileCount} - - - {store.usageBytes !== undefined && store.usageBytes > 0 && ( - - {formatBytes(store.usageBytes)} - - )} - - - - {store.embeddingModel ? ( - - - {store.embeddingModel.split('/').pop()} - - - ) : ( - - - - - )} - {store.embeddingDimension && ( - - {store.embeddingDimension}d - - )} - - - - - - {formatRelativeTime( - store.lastActiveAt || store.createdAt, - )} - - - - - - - { - e.stopPropagation(); - onDeleteStore(store); - }} - disabled={deleteInProgress === store.id || loading} - color="error" - sx={{ opacity: 0.6, '&:hover': { opacity: 1 } }} - > - {deleteInProgress === store.id ? ( - - ) : ( - - )} - - - - - ); - })} - -
    -
    - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/index.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/index.ts deleted file mode 100644 index d7c63b03e23..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { AgentConfigPanel } from './ModelToolsPanel/ModelToolsPanel'; -export { BrandingPanel } from './BrandingPanel'; -export { AgentsPanel } from './AgentsPanel'; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ragResultsUtils.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ragResultsUtils.test.tsx deleted file mode 100644 index ad9b22b6d75..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ragResultsUtils.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render } from '@testing-library/react'; -import { createTheme } from '@mui/material/styles'; -import { - getScoreColor, - getScoreLabel, - highlightQueryTerms, -} from './ragResultsUtils'; - -const theme = createTheme(); - -describe('ragResultsUtils', () => { - describe('getScoreColor', () => { - it('returns success color for high scores (>= 0.7)', () => { - expect(getScoreColor(0.7, theme)).toBe(theme.palette.success.main); - expect(getScoreColor(0.8, theme)).toBe(theme.palette.success.main); - expect(getScoreColor(1, theme)).toBe(theme.palette.success.main); - }); - - it('returns warning color for medium scores (>= 0.4 and < 0.7)', () => { - expect(getScoreColor(0.4, theme)).toBe(theme.palette.warning.main); - expect(getScoreColor(0.5, theme)).toBe(theme.palette.warning.main); - expect(getScoreColor(0.69, theme)).toBe(theme.palette.warning.main); - }); - - it('returns error color for low scores (< 0.4)', () => { - expect(getScoreColor(0, theme)).toBe(theme.palette.error.main); - expect(getScoreColor(0.2, theme)).toBe(theme.palette.error.main); - expect(getScoreColor(0.39, theme)).toBe(theme.palette.error.main); - }); - - it('handles boundary values', () => { - expect(getScoreColor(0.699999, theme)).toBe(theme.palette.warning.main); - expect(getScoreColor(0.400001, theme)).toBe(theme.palette.warning.main); - }); - }); - - describe('getScoreLabel', () => { - it('returns "High" for scores >= 0.7', () => { - expect(getScoreLabel(0.7)).toBe('High'); - expect(getScoreLabel(0.9)).toBe('High'); - expect(getScoreLabel(1)).toBe('High'); - }); - - it('returns "Medium" for scores >= 0.4 and < 0.7', () => { - expect(getScoreLabel(0.4)).toBe('Medium'); - expect(getScoreLabel(0.55)).toBe('Medium'); - expect(getScoreLabel(0.69)).toBe('Medium'); - }); - - it('returns "Low" for scores < 0.4', () => { - expect(getScoreLabel(0)).toBe('Low'); - expect(getScoreLabel(0.2)).toBe('Low'); - expect(getScoreLabel(0.39)).toBe('Low'); - }); - - it('handles boundary values', () => { - expect(getScoreLabel(0.699999)).toBe('Medium'); - expect(getScoreLabel(0.400001)).toBe('Medium'); - }); - }); - - describe('highlightQueryTerms', () => { - it('returns text as single element when query is empty', () => { - const result = highlightQueryTerms('Hello world', '', '#ffff00'); - expect(result).toEqual(['Hello world']); - }); - - it('returns text as single element when query is only whitespace', () => { - const result = highlightQueryTerms('Hello world', ' ', '#ffff00'); - expect(result).toEqual(['Hello world']); - }); - - it('returns text as single element when all query words are <= 2 chars', () => { - const result = highlightQueryTerms('Hello world', 'a be', '#ffff00'); - expect(result).toEqual(['Hello world']); - }); - - it('highlights matching words in text', () => { - const result = highlightQueryTerms( - 'The quick brown fox', - 'quick fox', - '#ffff00', - ); - const { container } = render(<>{result}); - expect(container.textContent).toBe('The quick brown fox'); - expect(container.querySelectorAll('mark')).toHaveLength(2); - }); - - it('handles case-insensitive matching', () => { - const result = highlightQueryTerms('Hello WORLD', 'world', '#ffff00'); - const { container } = render(<>{result}); - expect(container.textContent).toBe('Hello WORLD'); - expect(container.querySelectorAll('mark')).toHaveLength(1); - }); - - it('handles multiple occurrences of same word', () => { - const result = highlightQueryTerms('test test test', 'test', '#ffff00'); - const { container } = render(<>{result}); - expect(container.textContent).toBe('test test test'); - expect(container.querySelectorAll('mark')).toHaveLength(3); - }); - - it('handles query with special regex characters', () => { - const result = highlightQueryTerms( - 'Price is $10.00 (approx)', - '$10.00 (approx)', - '#ffff00', - ); - const { container } = render(<>{result}); - expect(container.textContent).toBe('Price is $10.00 (approx)'); - }); - - it('filters out short words from query', () => { - const result = highlightQueryTerms('Hello world', 'He wo', '#ffff00'); - // "He" and "wo" are <= 2 chars, so no highlighting - expect(result).toEqual(['Hello world']); - }); - - it('uses words longer than 2 chars for highlighting', () => { - const result = highlightQueryTerms('Hello world', 'Hel world', '#ffff00'); - const { container } = render(<>{result}); - expect(container.textContent).toBe('Hello world'); - expect(container.querySelectorAll('mark')).toHaveLength(2); - }); - - it('handles text with no matches', () => { - const result = highlightQueryTerms( - 'Completely different text', - 'nomatch', - '#ffff00', - ); - const { container } = render(<>{result}); - expect(container.textContent).toBe('Completely different text'); - expect(container.querySelectorAll('mark')).toHaveLength(0); - }); - - it('handles empty text', () => { - const result = highlightQueryTerms('', 'query', '#ffff00'); - const { container } = render(<>{result}); - expect(container.textContent).toBe(''); - }); - - it('handles single matching word', () => { - const result = highlightQueryTerms('Only one match', 'only', '#ffff00'); - const { container } = render(<>{result}); - expect(container.textContent).toBe('Only one match'); - expect(container.querySelectorAll('mark')).toHaveLength(1); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/ragResultsUtils.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/ragResultsUtils.tsx deleted file mode 100644 index 70f4840db9b..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/ragResultsUtils.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Fragment } from 'react'; -import Box from '@mui/material/Box'; -import type { Theme } from '@mui/material/styles'; - -export function getScoreColor(score: number, theme: Theme): string { - if (score >= 0.7) return theme.palette.success.main; - if (score >= 0.4) return theme.palette.warning.main; - return theme.palette.error.main; -} - -export function getScoreLabel(score: number): string { - if (score >= 0.7) return 'High'; - if (score >= 0.4) return 'Medium'; - return 'Low'; -} - -export function highlightQueryTerms( - text: string, - query: string, - highlightColor: string, -): React.ReactNode[] { - if (!query.trim()) return [text]; - const words = query - .trim() - .split(/\s+/) - .filter(w => w.length > 2); - if (words.length === 0) return [text]; - - const escaped = words.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); - const pattern = new RegExp(`(${escaped.join('|')})`, 'gi'); - const parts = text.split(pattern); - const lowerWords = new Set(words.map(w => w.toLowerCase())); - - return parts.map((part, i) => { - if (part && lowerWords.has(part.toLowerCase())) { - return ( - - {part} - - ); - } - return {part}; - }); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/AdminSection.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/AdminSection.test.tsx deleted file mode 100644 index d0972a24afa..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/AdminSection.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { render, screen, fireEvent } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { AdminSection } from './AdminSection'; - -const theme = createTheme(); - -function renderSection( - props: Partial> = {}, -) { - const defaults: React.ComponentProps = { - title: 'Test Section', - source: 'default', - saving: false, - error: null, - onSave: jest.fn(), - onReset: jest.fn(), - children:
    child content
    , - ...props, - }; - return render( - - - , - ); -} - -describe('AdminSection', () => { - it('renders title and children', () => { - renderSection({ title: 'My Title' }); - expect(screen.getByText('My Title')).toBeInTheDocument(); - expect(screen.getByTestId('child')).toBeInTheDocument(); - }); - - it('renders description when provided', () => { - renderSection({ description: 'A helpful description' }); - expect(screen.getByText('A helpful description')).toBeInTheDocument(); - }); - - it('shows Customized chip when source is database', () => { - renderSection({ source: 'database' }); - expect(screen.getByText('Customized')).toBeInTheDocument(); - }); - - it('hides Customized chip when source is default', () => { - renderSection({ source: 'default' }); - expect(screen.queryByText('Customized')).not.toBeInTheDocument(); - }); - - it('disables Reset when source is default', () => { - renderSection({ source: 'default' }); - expect(screen.getByText('Reset').closest('button')).toBeDisabled(); - }); - - it('enables Reset when source is database', () => { - renderSection({ source: 'database' }); - expect(screen.getByText('Reset').closest('button')).not.toBeDisabled(); - }); - - it('calls onSave when Save is clicked', () => { - const onSave = jest.fn(); - renderSection({ onSave }); - fireEvent.click(screen.getByText('Save')); - expect(onSave).toHaveBeenCalledTimes(1); - }); - - it('calls onReset when Reset is clicked', () => { - const onReset = jest.fn(); - renderSection({ source: 'database', onReset }); - fireEvent.click(screen.getByText('Reset')); - expect(onReset).toHaveBeenCalledTimes(1); - }); - - it('disables Save while saving', () => { - renderSection({ saving: true }); - expect(screen.getByText('Save').closest('button')).toBeDisabled(); - }); - - it('shows error alert when error is set', () => { - renderSection({ error: 'Something went wrong' }); - expect(screen.getByText('Something went wrong')).toBeInTheDocument(); - }); - - it('does not show error alert when error is null', () => { - renderSection({ error: null }); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/AdminSection.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/AdminSection.tsx deleted file mode 100644 index 72d497a73e1..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/AdminSection.tsx +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { ReactNode } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Alert from '@mui/material/Alert'; -import Chip from '@mui/material/Chip'; -import CircularProgress from '@mui/material/CircularProgress'; -import LinearProgress from '@mui/material/LinearProgress'; -import SaveIcon from '@mui/icons-material/Save'; -import RestoreIcon from '@mui/icons-material/Restore'; -import { useTheme, alpha } from '@mui/material/styles'; -import { glassSurface, borderRadius, transitions } from '../../../theme/tokens'; - -export interface AdminSectionProps { - title: string; - description?: string; - source: 'database' | 'default'; - saving: boolean; - error: string | null; - onSave: () => void; - onReset?: () => void; - /** Disable the save button (e.g. when form is invalid) */ - saveDisabled?: boolean; - children: ReactNode; -} - -/** - * Reusable card wrapper for admin config sections. - * Provides a consistent header, customized chip, save/reset buttons, - * and error display. - */ -export const AdminSection = ({ - title, - description, - source, - saving, - error, - onSave, - onReset, - saveDisabled, - children, -}: AdminSectionProps) => { - const hasHeader = !!title; - - const actionCluster = ( - - {source === 'database' && ( - - )} - {onReset && ( - - )} - - ); - - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const glass = glassSurface(theme, 8); - - return ( - - {/* Saving progress indicator */} - {saving && ( - - )} - - {/* Header bar — only when a title is provided */} - {hasHeader && ( - - - {title} - - {actionCluster} - - )} - - {/* Section body */} - - {description && ( - - {description} - - )} - - {children} - - - - {/* When no header bar, render actions inline with Save */} - {!hasHeader && actionCluster} - - - {error && ( - - {error} - - )} - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/AnimatedList.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/AnimatedList.tsx deleted file mode 100644 index 5ec98921505..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/AnimatedList.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Children, type ReactNode } from 'react'; -import Box from '@mui/material/Box'; -import { animations, staggerDelay, reducedMotion } from '../../../theme/tokens'; - -interface AnimatedListProps { - children: ReactNode; - /** Base delay between items in ms (default 50) */ - baseDelay?: number; - /** Animation variant (default fadeSlideIn) */ - variant?: 'fadeSlideIn' | 'fadeInUp' | 'scaleIn'; -} - -/** - * Wraps each child with a staggered entrance animation. - * Respects `prefers-reduced-motion`. - */ -export function AnimatedList({ - children, - baseDelay = 50, - variant = 'fadeSlideIn', -}: AnimatedListProps) { - let keyframes; - if (variant === 'fadeInUp') { - keyframes = animations.fadeInUp; - } else if (variant === 'scaleIn') { - keyframes = { - '@keyframes augmentScaleIn': { - from: { opacity: 0, transform: 'scale(0.95)' }, - to: { opacity: 1, transform: 'scale(1)' }, - }, - animation: 'augmentScaleIn 0.25s ease-out', - }; - } else { - keyframes = animations.fadeSlideIn; - } - - return ( - <> - {Children.map(children, (child, index) => { - if (!child) return null; - return ( - - {child} - - ); - })} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/CardGridSkeleton.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/CardGridSkeleton.tsx deleted file mode 100644 index 7ec6acbd7dd..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/CardGridSkeleton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Box from '@mui/material/Box'; -import Skeleton from '@mui/material/Skeleton'; -import { borderRadius } from '../../../theme/tokens'; - -interface CardGridSkeletonProps { - cards?: number; - cardHeight?: number; -} - -/** - * Skeleton placeholder mimicking a grid of cards. - */ -export function CardGridSkeleton({ - cards = 4, - cardHeight = 120, -}: CardGridSkeletonProps) { - return ( - - {Array.from({ length: cards }).map((_, i) => ( - - ))} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ColorInput.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ColorInput.tsx deleted file mode 100644 index 30ca38be6ad..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ColorInput.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Box from '@mui/material/Box'; -import TextField from '@mui/material/TextField'; -import InputAdornment from '@mui/material/InputAdornment'; - -const HEX_RE = /^#[0-9a-fA-F]{6}$/; - -export interface ColorInputProps { - label: string; - value: string; - onChange: (value: string) => void; - defaultValue?: string; -} - -export const ColorInput = ({ - label, - value, - onChange, - defaultValue, -}: ColorInputProps) => { - const isValid = HEX_RE.test(value); - const displayColor = isValid ? value : (defaultValue ?? '#000000'); - - return ( - onChange(e.target.value)} - error={!!value && !isValid} - helperText={ - value && !isValid ? 'Must be a hex color (e.g. #1e40af)' : undefined - } - sx={{ width: 180 }} - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ConfirmDialog.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ConfirmDialog.tsx deleted file mode 100644 index a99dc480141..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ConfirmDialog.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import DialogContent from '@mui/material/DialogContent'; -import DialogActions from '@mui/material/DialogActions'; -import Button from '@mui/material/Button'; -import Typography from '@mui/material/Typography'; - -export interface ConfirmDialogProps { - open: boolean; - title: string; - message: string; - confirmLabel?: string; - cancelLabel?: string; - confirmColor?: 'error' | 'primary' | 'warning'; - onConfirm: () => void; - onCancel: () => void; -} - -/** - * Reusable confirmation dialog for destructive or irreversible actions. - */ -export function ConfirmDialog({ - open, - title, - message, - confirmLabel = 'Confirm', - cancelLabel = 'Cancel', - confirmColor = 'error', - onConfirm, - onCancel, -}: ConfirmDialogProps) { - return ( - - {title} - - {message} - - - - - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/GlassCard.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/GlassCard.tsx deleted file mode 100644 index 3918d358616..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/GlassCard.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { ReactNode } from 'react'; -import Box from '@mui/material/Box'; -import { useTheme } from '@mui/material/styles'; -import type { SxProps, Theme } from '@mui/material/styles'; -import { - glassSurface, - glassHoverGlow, - transitions, - borderRadius, -} from '../../../theme/tokens'; - -interface GlassCardProps { - children: ReactNode; - /** Enable glow border on hover */ - hover?: boolean; - /** Custom glow color (defaults to theme primary) */ - glowColor?: string; - /** Backdrop blur intensity in px */ - blur?: number; - /** Additional sx overrides */ - sx?: SxProps; - /** Click handler (adds pointer cursor when set) */ - onClick?: () => void; -} - -/** - * A glassmorphism card with translucent background, backdrop blur, - * and optional hover glow. Theme-aware for light/dark modes. - */ -export const GlassCard = ({ - children, - hover = false, - glowColor, - blur, - sx, - onClick, -}: GlassCardProps) => { - const theme = useTheme(); - const glass = glassSurface(theme, blur); - const hoverSx = hover || onClick ? glassHoverGlow(theme, glowColor) : {}; - - return ( - - {children} - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/InfoTip.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/InfoTip.tsx deleted file mode 100644 index 9b58b0ea7eb..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/InfoTip.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Tooltip from '@mui/material/Tooltip'; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; - -export function InfoTip({ text }: { text: string }) { - return ( - - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/InlineAgentChat.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/InlineAgentChat.tsx deleted file mode 100644 index ca4344e63d4..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/InlineAgentChat.tsx +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState, useCallback, useRef } from 'react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Typography from '@mui/material/Typography'; -import { useTheme, alpha } from '@mui/material/styles'; -import { useApi, configApiRef, fetchApiRef } from '@backstage/core-plugin-api'; - -export interface InlineAgentChatProps { - readonly agentId: string; - readonly agentName: string; -} - -export function InlineAgentChat({ agentId, agentName }: InlineAgentChatProps) { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const configApi = useApi(configApiRef); - const { fetch: authFetch } = useApi(fetchApiRef); - const [chatMessages, setChatMessages] = useState< - Array<{ role: 'user' | 'agent'; text: string }> - >([]); - const [input, setInput] = useState(''); - const [sending, setSending] = useState(false); - const [sessionId, setSessionId] = useState(); - const streamingTextRef = useRef(''); - - const handleSend = useCallback(async () => { - if (!input.trim() || sending) return; - const userMsg = input.trim(); - setInput(''); - setChatMessages(prev => [...prev, { role: 'user', text: userMsg }]); - setSending(true); - streamingTextRef.current = ''; - - const agentMsgIndex = chatMessages.length + 1; - - try { - const backendUrl = configApi.getString('backend.baseUrl'); - const resp = await authFetch(`${backendUrl}/api/augment/chat/stream`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Backstage-Request': 'augment', - }, - body: JSON.stringify({ - messages: [{ role: 'user', content: userMsg }], - model: agentId, - sessionId, - }), - }); - if (!resp.ok) { - const errText = await resp.text().catch(() => `HTTP ${resp.status}`); - throw new Error(errText || `HTTP ${resp.status}`); - } - - const reader = resp.body?.getReader(); - if (!reader) throw new Error('No readable stream in response'); - - const decoder = new TextDecoder(); - let buffer = ''; - - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const payload = line.slice(6).trim(); - if (!payload || payload === '[DONE]') continue; - - let evt: { - type?: string; - delta?: string; - responseId?: string; - error?: string; - }; - try { - evt = JSON.parse(payload); - } catch { - continue; - } - - if (evt.type === 'stream.text.delta' && evt.delta) { - streamingTextRef.current += evt.delta; - const snapshot = streamingTextRef.current; - setChatMessages(prev => { - const updated = [...prev]; - if (updated.length > agentMsgIndex) { - updated[agentMsgIndex] = { role: 'agent', text: snapshot }; - } else { - updated.push({ role: 'agent', text: snapshot }); - } - return updated; - }); - } else if (evt.type === 'stream.started' && evt.responseId) { - setSessionId(evt.responseId); - } else if (evt.type === 'stream.error') { - throw new Error(evt.error ?? 'Stream error'); - } - } - } - - if (streamingTextRef.current) { - const finalText = streamingTextRef.current; - setChatMessages(prev => { - const updated = [...prev]; - if (updated.length > agentMsgIndex) { - updated[agentMsgIndex] = { role: 'agent', text: finalText }; - } else { - updated.push({ role: 'agent', text: finalText }); - } - return updated; - }); - } - } catch (err) { - const errMsg = err instanceof Error ? err.message : 'Unknown error'; - const isNetwork = - errMsg.includes('fetch') || - errMsg.includes('network') || - errMsg.includes('ECONNREFUSED'); - setChatMessages(prev => [ - ...prev.filter((_m, i) => i !== agentMsgIndex), - { - role: 'error' as 'agent', - text: isNetwork - ? 'Unable to reach the agent. The pod may still be starting up, or K8s credentials may need to be configured. Try again in a moment.' - : `Something went wrong: ${errMsg}`, - }, - ]); - } finally { - setSending(false); - } - }, [ - agentId, - input, - sending, - sessionId, - chatMessages.length, - configApi, - authFetch, - ]); - - return ( - - - {chatMessages.length === 0 && ( - - - Send a message to test {agentName} - - - )} - {chatMessages.map((msg, i) => { - const isError = - msg.text.startsWith('Unable to reach') || - msg.text.startsWith('Something went wrong'); - return ( - - - {msg.text} - - - ); - })} - {sending && ( - - - {agentName} is thinking... - - - )} - - - setInput(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }} - placeholder={`Message ${agentName}...`} - disabled={sending} - size="small" - fullWidth - sx={{ flex: 1 }} - /> - - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/PanelIntroBanner.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/PanelIntroBanner.tsx deleted file mode 100644 index 96a2e2b01ce..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/PanelIntroBanner.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState } from 'react'; -import Alert from '@mui/material/Alert'; -import Collapse from '@mui/material/Collapse'; -import { useTheme } from '@mui/material/styles'; -import { glassSurface, borderRadius } from '../../../theme/tokens'; - -interface PanelIntroBannerProps { - storageKey: string; - children: React.ReactNode; -} - -export function PanelIntroBanner({ - storageKey, - children, -}: PanelIntroBannerProps) { - const theme = useTheme(); - const fullKey = `augment:intro-${storageKey}`; - const [visible, setVisible] = useState(() => { - try { - return !localStorage.getItem(fullKey); - } catch { - return true; - } - }); - - const dismiss = () => { - setVisible(false); - try { - localStorage.setItem(fullKey, '1'); - } catch { - /* noop */ - } - }; - - return ( - - - {children} - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ServerItemPicker.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ServerItemPicker.test.tsx deleted file mode 100644 index dcf405d42cc..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ServerItemPicker.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { render, screen, fireEvent } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { ServerItemPicker } from './ServerItemPicker'; - -const theme = createTheme(); - -const defaultProps = { - selected: [] as string[], - onChange: jest.fn(), - serverItems: ['shield-a', 'shield-b', 'shield-c'], - serverLoading: false, - serverError: null, - onRefresh: jest.fn(), - label: 'Test Items', - itemLabel: 'items', -}; - -function renderPicker(overrides = {}) { - const props = { ...defaultProps, ...overrides }; - return render( - - - , - ); -} - -describe('ServerItemPicker', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders server items as chips', () => { - renderPicker(); - expect(screen.getByText('shield-a')).toBeInTheDocument(); - expect(screen.getByText('shield-b')).toBeInTheDocument(); - expect(screen.getByText('shield-c')).toBeInTheDocument(); - }); - - it('shows count of available items', () => { - renderPicker(); - expect(screen.getByText('3 available on server')).toBeInTheDocument(); - }); - - it('toggles item on click', () => { - const onChange = jest.fn(); - renderPicker({ onChange }); - fireEvent.click(screen.getByText('shield-a')); - expect(onChange).toHaveBeenCalledWith(['shield-a']); - }); - - it('removes item when already selected', () => { - const onChange = jest.fn(); - renderPicker({ onChange, selected: ['shield-a', 'shield-b'] }); - fireEvent.click(screen.getByText('shield-a')); - expect(onChange).toHaveBeenCalledWith(['shield-b']); - }); - - it('shows loading spinner when serverLoading is true', () => { - renderPicker({ serverLoading: true }); - expect(screen.getByRole('progressbar')).toBeInTheDocument(); - }); - - it('shows error alert when serverError is set', () => { - renderPicker({ serverError: 'Connection failed' }); - expect( - screen.getByText(/Could not discover items: Connection failed/), - ).toBeInTheDocument(); - }); - - it('shows info alert when no server items and no error', () => { - renderPicker({ serverItems: [] }); - expect( - screen.getByText(/No items registered on the .* server/), - ).toBeInTheDocument(); - }); - - it('adds custom item via text field', () => { - const onChange = jest.fn(); - renderPicker({ onChange }); - const input = screen.getByPlaceholderText('Add custom item...'); - fireEvent.change(input, { target: { value: 'my-custom-shield' } }); - fireEvent.click(screen.getByLabelText('add custom item')); - expect(onChange).toHaveBeenCalledWith(['my-custom-shield']); - }); - - it('shows custom items with warning chip variant', () => { - renderPicker({ selected: ['custom-item'], serverItems: ['shield-a'] }); - expect(screen.getByText('custom-item')).toBeInTheDocument(); - expect(screen.getByText('Custom (not on server)')).toBeInTheDocument(); - }); - - it('calls onRefresh when refresh button is clicked', () => { - const onRefresh = jest.fn(); - renderPicker({ onRefresh }); - fireEvent.click(screen.getByLabelText('Refresh items')); - expect(onRefresh).toHaveBeenCalled(); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ServerItemPicker.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ServerItemPicker.tsx deleted file mode 100644 index 2334cf73e50..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ServerItemPicker.tsx +++ /dev/null @@ -1,252 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback } from 'react'; -import Box from '@mui/material/Box'; -import Chip from '@mui/material/Chip'; -import TextField from '@mui/material/TextField'; -import IconButton from '@mui/material/IconButton'; -import Typography from '@mui/material/Typography'; -import Tooltip from '@mui/material/Tooltip'; -import CircularProgress from '@mui/material/CircularProgress'; -import Alert from '@mui/material/Alert'; -import AddIcon from '@mui/icons-material/Add'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import { alpha, useTheme } from '@mui/material/styles'; - -export interface ServerItemPickerProps { - /** Currently selected items */ - selected: string[]; - /** Callback when selection changes */ - onChange: (items: string[]) => void; - /** Items discovered from the server */ - serverItems: string[]; - /** Whether server items are still loading */ - serverLoading: boolean; - /** Error loading server items */ - serverError: string | null; - /** Refresh server items */ - onRefresh: () => void; - /** Label for the section */ - label: string; - /** Placeholder for custom item input */ - placeholder?: string; - /** Disabled state */ - disabled?: boolean; - /** What to call the items (e.g. "shields", "scoring functions") */ - itemLabel?: string; - /** Name of the provider/server */ - providerName?: string; -} - -/** - * A picker that shows server-discovered items as toggleable chips, - * with the ability to also add custom entries manually. - * Selected items appear as filled chips; available-but-unselected as outlined. - */ -export const ServerItemPicker = ({ - selected, - onChange, - serverItems, - serverLoading, - serverError, - onRefresh, - label, - placeholder = 'Add custom item...', - disabled, - itemLabel = 'items', - providerName = 'the server', -}: ServerItemPickerProps) => { - const theme = useTheme(); - const [draft, setDraft] = useState(''); - - const toggleItem = useCallback( - (item: string) => { - if (disabled) return; - if (selected.includes(item)) { - onChange(selected.filter(s => s !== item)); - } else { - onChange([...selected, item]); - } - }, - [selected, onChange, disabled], - ); - - const addCustom = useCallback(() => { - const trimmed = draft.trim(); - if (trimmed && !selected.includes(trimmed)) { - onChange([...selected, trimmed]); - setDraft(''); - } - }, [draft, selected, onChange]); - - const removeItem = useCallback( - (item: string) => { - onChange(selected.filter(s => s !== item)); - }, - [selected, onChange], - ); - - const customSelected = selected.filter(s => !serverItems.includes(s)); - - return ( - - - - {label} - - {serverLoading ? ( - - ) : ( - - - - - - )} - {!serverLoading && serverItems.length > 0 && ( - - {serverItems.length} available on server - - )} - - - {serverError && ( - - Could not discover {itemLabel}: {serverError} - - )} - - {!serverLoading && serverItems.length === 0 && !serverError && ( - - No {itemLabel} registered on the {providerName} server. You can still - add IDs manually below. - - )} - - {serverItems.length > 0 && ( - - {serverItems.map(item => { - const isSelected = selected.includes(item); - return ( - - ) : undefined - } - onClick={() => toggleItem(item)} - color={isSelected ? 'primary' : 'default'} - variant={isSelected ? 'filled' : 'outlined'} - disabled={disabled} - sx={{ - cursor: disabled ? 'default' : 'pointer', - transition: 'all 0.15s ease', - fontWeight: isSelected ? 600 : 400, - '&:hover': disabled - ? {} - : { - backgroundColor: isSelected - ? undefined - : alpha(theme.palette.primary.main, 0.08), - borderColor: theme.palette.primary.main, - }, - }} - /> - ); - })} - - )} - - {customSelected.length > 0 && ( - - - Custom (not on server) - - - {customSelected.map(item => ( - removeItem(item)} - disabled={disabled} - /> - ))} - - - )} - - - setDraft(e.target.value)} - placeholder={placeholder} - disabled={disabled} - onKeyDown={e => { - if (e.key === 'Enter') { - e.preventDefault(); - addCustom(); - } - }} - sx={{ flex: 1 }} - /> - - - - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/StringListEditor.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/StringListEditor.test.tsx deleted file mode 100644 index 5d27f5bdd1a..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/StringListEditor.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { render, screen, fireEvent } from '@testing-library/react'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { StringListEditor } from './StringListEditor'; - -const theme = createTheme(); - -function renderEditor( - props: Partial> = {}, -) { - const defaults: React.ComponentProps = { - items: [], - onChange: jest.fn(), - ...props, - }; - return render( - - - , - ); -} - -describe('StringListEditor', () => { - it('renders existing items as chips', () => { - renderEditor({ items: ['alpha', 'beta'] }); - expect(screen.getByText('alpha')).toBeInTheDocument(); - expect(screen.getByText('beta')).toBeInTheDocument(); - }); - - it('renders label when provided', () => { - renderEditor({ label: 'Shield IDs' }); - expect(screen.getByText('Shield IDs')).toBeInTheDocument(); - }); - - it('adds an item when the add button is clicked', () => { - const onChange = jest.fn(); - renderEditor({ items: ['existing'], onChange }); - - const input = screen.getByPlaceholderText('Add an item...'); - fireEvent.change(input, { target: { value: 'new-item' } }); - fireEvent.click(screen.getByLabelText('add item')); - - expect(onChange).toHaveBeenCalledWith(['existing', 'new-item']); - }); - - it('adds an item on Enter key', () => { - const onChange = jest.fn(); - renderEditor({ items: [], onChange }); - - const input = screen.getByPlaceholderText('Add an item...'); - fireEvent.change(input, { target: { value: 'enter-item' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - - expect(onChange).toHaveBeenCalledWith(['enter-item']); - }); - - it('does not add duplicate items', () => { - const onChange = jest.fn(); - renderEditor({ items: ['duplicate'], onChange }); - - const input = screen.getByPlaceholderText('Add an item...'); - fireEvent.change(input, { target: { value: 'duplicate' } }); - fireEvent.click(screen.getByLabelText('add item')); - - expect(onChange).not.toHaveBeenCalled(); - }); - - it('does not add empty items', () => { - const onChange = jest.fn(); - renderEditor({ items: [], onChange }); - - fireEvent.click(screen.getByLabelText('add item')); - expect(onChange).not.toHaveBeenCalled(); - }); - - it('removes an item when chip delete is clicked', () => { - const onChange = jest.fn(); - renderEditor({ items: ['a', 'b', 'c'], onChange }); - - const deleteButtons = screen.getAllByTestId('CancelIcon'); - fireEvent.click(deleteButtons[1]); - - expect(onChange).toHaveBeenCalledWith(['a', 'c']); - }); - - it('disables input and add button when disabled', () => { - renderEditor({ items: [], disabled: true }); - - const input = screen.getByPlaceholderText('Add an item...'); - expect(input).toBeDisabled(); - expect(screen.getByLabelText('add item')).toBeDisabled(); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/StringListEditor.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/StringListEditor.tsx deleted file mode 100644 index eb93e188306..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/StringListEditor.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback } from 'react'; -import Box from '@mui/material/Box'; -import Chip from '@mui/material/Chip'; -import TextField from '@mui/material/TextField'; -import IconButton from '@mui/material/IconButton'; -import Typography from '@mui/material/Typography'; -import AddIcon from '@mui/icons-material/Add'; - -export interface StringListEditorProps { - items: string[]; - onChange: (items: string[]) => void; - label?: string; - placeholder?: string; - disabled?: boolean; -} - -/** - * Chip-based editor for a list of strings. - * Renders existing items as deletable chips, with an input + add button - * for appending new entries. - */ -export const StringListEditor = ({ - items, - onChange, - label, - placeholder = 'Add an item...', - disabled, -}: StringListEditorProps) => { - const [draft, setDraft] = useState(''); - - const addItem = useCallback(() => { - const trimmed = draft.trim(); - if (trimmed && !items.includes(trimmed)) { - onChange([...items, trimmed]); - setDraft(''); - } - }, [draft, items, onChange]); - - const removeItem = useCallback( - (index: number) => { - onChange(items.filter((_, i) => i !== index)); - }, - [items, onChange], - ); - - return ( - - {label && ( - - {label} - - )} - - {items.length > 0 && ( - - {items.map((item, i) => ( - removeItem(i)} - disabled={disabled} - /> - ))} - - )} - - - setDraft(e.target.value)} - placeholder={placeholder} - disabled={disabled} - onKeyDown={e => { - if (e.key === 'Enter') { - e.preventDefault(); - addItem(); - } - }} - sx={{ flex: 1 }} - /> - - - - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TableSkeleton.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TableSkeleton.tsx deleted file mode 100644 index dd6a9ba630a..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TableSkeleton.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Box from '@mui/material/Box'; -import Skeleton from '@mui/material/Skeleton'; -import { useTheme, alpha } from '@mui/material/styles'; -import { borderRadius } from '../../../theme/tokens'; - -interface TableSkeletonProps { - rows?: number; - columns?: number; -} - -/** - * Skeleton placeholder mimicking a data table layout. - * Displays animated rows with column-like blocks. - */ -export function TableSkeleton({ rows = 5, columns = 4 }: TableSkeletonProps) { - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - - return ( - - {/* Header */} - - {Array.from({ length: columns }).map((_, i) => ( - - ))} - - {/* Rows */} - {Array.from({ length: rows }).map((_, rowIdx) => ( - - {Array.from({ length: columns }).map((_col, colIdx) => ( - - ))} - - ))} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ToggleSwitch.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ToggleSwitch.tsx deleted file mode 100644 index 8ae2f6938d2..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/ToggleSwitch.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useCallback, type ChangeEvent, type InputHTMLAttributes } from 'react'; -import Box from '@mui/material/Box'; -import { useTheme } from '@mui/material/styles'; -import type { SxProps, Theme } from '@mui/material/styles'; - -/** - * onChange matches MUI Switch's signature so existing consumers that - * destructure `(event, checked)` keep working. - */ -interface ToggleSwitchProps { - readonly checked: boolean; - readonly onChange: ( - event: ChangeEvent, - checked: boolean, - ) => void; - readonly disabled?: boolean; - readonly sx?: SxProps; - readonly inputProps?: InputHTMLAttributes; -} - -const TRACK_W = 36; -const TRACK_H = 20; -const THUMB_SIZE = 16; -const THUMB_OFFSET = 2; -const TRAVEL = TRACK_W - THUMB_SIZE - THUMB_OFFSET * 2; - -/** - * MUI FormControlLabel applies `marginLeft: -11px` to the control - * (intended for MUI Switch/Checkbox whose internal padding absorbs it). - * This left margin on the root cancels it, preventing clipping of the - * unchecked thumb inside `overflow: hidden` ancestors. - */ -const FCL_MARGIN = 11; - -/** - * Self-contained toggle switch that renders identically in any - * Backstage theme. Built from plain DOM elements to side-step CSS - * specificity conflicts with MUI Switch's internal emotion styles. - * - * A hidden native checkbox covers the full toggle area for click - * handling, keyboard focus, form semantics, and screen-reader support. - */ -export function ToggleSwitch({ - checked, - onChange, - disabled = false, - sx, - inputProps, -}: ToggleSwitchProps) { - const theme = useTheme(); - - let trackBg: string; - if (checked) { - trackBg = theme.palette.primary.main; - } else if (theme.palette.mode === 'dark') { - trackBg = 'rgba(255,255,255,0.3)'; - } else { - trackBg = 'rgba(0,0,0,0.38)'; - } - - const handleChange = useCallback( - (e: ChangeEvent) => { - onChange(e, e.target.checked); - }, - [onChange], - ); - - return ( - ), - }} - > - - - {/* Track */} - - - {/* Thumb */} - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TourController.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TourController.tsx deleted file mode 100644 index 5060dc7e17f..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TourController.tsx +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createContext, useCallback, useContext, useMemo, useRef } from 'react'; -import type { AdminPanel } from '../../../hooks'; - -export interface TourControllerAPI { - navigatePanel: (panel: AdminPanel) => void; - openAgentIntent: () => void; - selectAgentIntent: (cardId: string) => void; - openToolIntent: () => void; - selectToolDeploy: () => void; - closeAllDialogs: () => void; - setWizardStep: (step: number) => void; - setDeployMethod: (method: string) => void; - returnToGuidedExperience: () => void; - switchToMarketplace: () => void; - switchToCommandCenter: () => void; - waitForSelector: ( - selector: string, - timeoutMs?: number, - ) => Promise; -} - -const noop = () => {}; -const noopAsync = () => Promise.resolve(null as Element | null); - -const TourControllerContext = createContext({ - navigatePanel: noop, - openAgentIntent: noop, - selectAgentIntent: noop, - openToolIntent: noop, - selectToolDeploy: noop, - closeAllDialogs: noop, - setWizardStep: noop, - setDeployMethod: noop, - returnToGuidedExperience: noop, - switchToMarketplace: noop, - switchToCommandCenter: noop, - waitForSelector: noopAsync, -}); - -export function useTourController() { - return useContext(TourControllerContext); -} - -export interface TourControllerCallbacks { - navigatePanel: (panel: AdminPanel) => void; - openAgentIntent: () => void; - selectAgentIntent: (cardId: string) => void; - openToolIntent: () => void; - selectToolDeploy: () => void; - closeAllDialogs: () => void; - setWizardStep: (step: number) => void; - setDeployMethod: (method: string) => void; - returnToGuidedExperience: () => void; - switchToMarketplace: () => void; - switchToCommandCenter: () => void; -} - -interface Props { - callbacks: TourControllerCallbacks; - children: React.ReactNode; -} - -const DEFAULT_TIMEOUT = 3000; -const POLL_INTERVAL = 80; - -function waitForSelectorImpl( - selector: string, - timeoutMs: number = DEFAULT_TIMEOUT, -): Promise { - const el = document.querySelector(selector); - if (el) return Promise.resolve(el); - - return new Promise(resolve => { - const start = Date.now(); - const timer = setInterval(() => { - const found = document.querySelector(selector); - if (found) { - clearInterval(timer); - resolve(found); - } else if (Date.now() - start >= timeoutMs) { - clearInterval(timer); - resolve(null); - } - }, POLL_INTERVAL); - }); -} - -export function TourControllerProvider({ callbacks, children }: Props) { - const cbRef = useRef(callbacks); - cbRef.current = callbacks; - - const navigatePanel = useCallback( - (panel: AdminPanel) => cbRef.current.navigatePanel(panel), - [], - ); - const openAgentIntent = useCallback( - () => cbRef.current.openAgentIntent(), - [], - ); - const selectAgentIntent = useCallback( - (cardId: string) => cbRef.current.selectAgentIntent(cardId), - [], - ); - const openToolIntent = useCallback(() => cbRef.current.openToolIntent(), []); - const selectToolDeploy = useCallback( - () => cbRef.current.selectToolDeploy(), - [], - ); - const closeAllDialogs = useCallback( - () => cbRef.current.closeAllDialogs(), - [], - ); - const setWizardStep = useCallback( - (step: number) => cbRef.current.setWizardStep(step), - [], - ); - const setDeployMethod = useCallback( - (method: string) => cbRef.current.setDeployMethod(method), - [], - ); - const returnToGuidedExperience = useCallback( - () => cbRef.current.returnToGuidedExperience(), - [], - ); - const switchToMarketplace = useCallback( - () => cbRef.current.switchToMarketplace(), - [], - ); - const switchToCommandCenter = useCallback( - () => cbRef.current.switchToCommandCenter(), - [], - ); - const waitForSelector = useCallback( - (selector: string, timeoutMs?: number) => - waitForSelectorImpl(selector, timeoutMs), - [], - ); - - const value = useMemo( - () => ({ - navigatePanel, - openAgentIntent, - selectAgentIntent, - openToolIntent, - selectToolDeploy, - closeAllDialogs, - setWizardStep, - setDeployMethod, - returnToGuidedExperience, - switchToMarketplace, - switchToCommandCenter, - waitForSelector, - }), - [ - navigatePanel, - openAgentIntent, - selectAgentIntent, - openToolIntent, - selectToolDeploy, - closeAllDialogs, - setWizardStep, - setDeployMethod, - returnToGuidedExperience, - switchToMarketplace, - switchToCommandCenter, - waitForSelector, - ], - ); - - return ( - - {children} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TourLauncherDialog.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TourLauncherDialog.tsx deleted file mode 100644 index da9d1327875..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TourLauncherDialog.tsx +++ /dev/null @@ -1,645 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useCallback, useEffect, useMemo, useState } from 'react'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogActions from '@mui/material/DialogActions'; -import Button from '@mui/material/Button'; -import Typography from '@mui/material/Typography'; -import Box from '@mui/material/Box'; -import Card from '@mui/material/Card'; -import CardActionArea from '@mui/material/CardActionArea'; -import Chip from '@mui/material/Chip'; -import Grow from '@mui/material/Grow'; -import { useTheme, alpha } from '@mui/material/styles'; -import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; -import CloudDownloadOutlinedIcon from '@mui/icons-material/CloudDownloadOutlined'; -import TerminalOutlinedIcon from '@mui/icons-material/TerminalOutlined'; -import HubOutlinedIcon from '@mui/icons-material/HubOutlined'; -import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded'; -import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; -import ExploreOutlinedIcon from '@mui/icons-material/ExploreOutlined'; -import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'; -import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; -import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; -import AccessTimeIcon from '@mui/icons-material/AccessTime'; -import VolumeUpIcon from '@mui/icons-material/VolumeUp'; -import VolumeOffIcon from '@mui/icons-material/VolumeOff'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import type { TourId } from './tourDefinitions'; -import { useTour } from './TourProvider'; -import { DEFAULT_TOURS, type TourDefinition } from './defaultTours'; - -const WELCOME_STORAGE_PREFIX = 'augment:welcome-seen'; - -function getWelcomeKey(page?: string): string { - return page ? `${WELCOME_STORAGE_PREFIX}:${page}` : WELCOME_STORAGE_PREFIX; -} - -function hasSeenWelcome(page?: string): boolean { - try { - return localStorage.getItem(getWelcomeKey(page)) === '1'; - } catch { - return false; - } -} - -function markWelcomeSeen(page?: string): void { - try { - localStorage.setItem(getWelcomeKey(page), '1'); - } catch { - /* noop */ - } -} - -const TOUR_ICONS: Record = { - 'marketplace-welcome': , - 'marketplace-import-agent': , - 'marketplace-develop-agent': , - 'marketplace-configure-agent': , - 'marketplace-create-tool': , - 'cc-overview': , - 'cc-agents': , - 'cc-platform': , - 'cc-settings': , -}; - -interface TourLauncherDialogProps { - open: boolean; - onClose: () => void; - tours?: TourDefinition[]; - persona?: 'developer' | 'admin'; - page?: 'marketplace' | 'command-center'; -} - -export function TourLauncherDialog({ - open, - onClose, - tours, - persona, - page, -}: TourLauncherDialogProps) { - const allTours = tours && tours.length > 0 ? tours : DEFAULT_TOURS; - const TOUR_LIST = useMemo( - () => - allTours.filter(t => { - if ( - persona && - t.persona && - t.persona !== 'both' && - t.persona !== persona - ) - return false; - if (page && t.page && t.page !== 'any' && t.page !== page) return false; - return true; - }), - [allTours, persona, page], - ); - const categories = useMemo(() => { - const seen = new Set(); - const cats: { key: string; label: string }[] = []; - for (const t of TOUR_LIST) { - const cat = t.category || 'general'; - if (!seen.has(cat)) { - seen.add(cat); - cats.push({ - key: cat, - label: cat - .split('-') - .map(w => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' '), - }); - } - } - return cats; - }, [TOUR_LIST]); - const getTourStepCount = (id: string) => - TOUR_LIST.find(t => t.id === id)?.steps.length ?? 0; - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const { startTour, isCompleted, startAutoPlay, voice } = useTour(); - const [, setVersion] = useState(0); - - useEffect(() => { - if (open) setVersion(v => v + 1); - }, [open]); - - const handleLaunch = useCallback( - (id: TourId) => { - onClose(); - setTimeout(() => startTour(id), 300); - }, - [onClose, startTour], - ); - - const handleAutoPlay = useCallback(() => { - onClose(); - const ids = TOUR_LIST.map(t => t.id); - setTimeout(() => startAutoPlay(ids), 300); - }, [onClose, startAutoPlay, TOUR_LIST]); - - const totalMinutes = useMemo( - () => TOUR_LIST.reduce((sum, t) => sum + (t.estimatedMinutes ?? 0), 0), - [TOUR_LIST], - ); - - const grouped = useMemo(() => { - const map = new Map(); - for (const cat of categories) { - map.set( - cat.key, - TOUR_LIST.filter(t => (t.category || 'general') === cat.key), - ); - } - return map; - }, [categories, TOUR_LIST]); - - const completedCount = TOUR_LIST.filter(t => isCompleted(t.id)).length; - - return ( - - {/* Hero header — compact */} - - - - - - - - Guided Experience - - - Choose a walkthrough to learn the platform step by step - - - {completedCount > 0 && ( - - )} - - - - {voice.isSupported && ( - - voice.setVoiceEnabled(!voice.isVoiceEnabled)} - sx={{ - color: '#fff', - bgcolor: voice.isVoiceEnabled - ? 'rgba(255,255,255,0.25)' - : 'rgba(255,255,255,0.1)', - border: '1px solid rgba(255,255,255,0.2)', - width: 30, - height: 30, - '&:hover': { - bgcolor: 'rgba(255,255,255,0.3)', - }, - }} - > - {voice.isVoiceEnabled ? ( - - ) : ( - - )} - - - )} - - ~{totalMinutes} min - {voice.isSupported && ( - <> · {voice.isVoiceEnabled ? 'Voice on' : 'Voice off'} - )} - - - - - {/* Tour cards */} - - {categories.map(cat => { - const catTours = grouped.get(cat.key); - if (!catTours?.length) return null; - return ( - - - {cat.label} - - - {catTours.map(tour => { - const done = isCompleted(tour.id); - const steps = getTourStepCount(tour.id); - return ( - - handleLaunch(tour.id)} - sx={{ - px: 1.5, - py: 1.25, - display: 'flex', - alignItems: 'flex-start', - gap: 1.5, - }} - > - .MuiSvgIcon-root': { - fontSize: 18, - }, - }} - > - {TOUR_ICONS[tour.id]} - {done && ( - - )} - - - - - {tour.title} - - {voice.isSupported && ( - - { - e.stopPropagation(); - voice.setVoiceEnabledForTour( - tour.id, - !voice.isVoiceEnabledForTour(tour.id), - ); - setVersion(v => v + 1); - }} - sx={{ - p: 0.25, - flexShrink: 0, - color: voice.isVoiceEnabledForTour(tour.id) - ? 'primary.main' - : 'text.disabled', - }} - > - {voice.isVoiceEnabledForTour(tour.id) ? ( - - ) : ( - - )} - - - )} - - - - {tour.description} - - - - - - - ~{tour.estimatedMinutes} min - - - - - - - ); - })} - - - ); - })} - - - - - - - ); -} - -export function useFirstVisitTourDialog( - page?: 'marketplace' | 'command-center', -) { - const [open, setOpen] = useState(false); - - useEffect(() => { - if (!hasSeenWelcome(page)) { - const timer = setTimeout(() => setOpen(true), 600); - return () => clearTimeout(timer); - } - return undefined; - }, [page]); - - const close = useCallback(() => { - setOpen(false); - markWelcomeSeen(page); - }, [page]); - - const openDialog = useCallback(() => { - setOpen(true); - }, []); - - return { open, close, openDialog }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TourProvider.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TourProvider.tsx deleted file mode 100644 index b018c2ffd0d..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/TourProvider.tsx +++ /dev/null @@ -1,481 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - createContext, - useCallback, - useContext, - useMemo, - useRef, - useState, -} from 'react'; -import { useTheme } from '@mui/material/styles'; -import { driver, type DriveStep, type Driver } from 'driver.js'; -import 'driver.js/dist/driver.css'; -import './tourStyles.css'; -import type { TourId, EnhancedDriveStep, TourAction } from './tourDefinitions'; -import { useTourController, type TourControllerAPI } from './TourController'; -import type { TourDefinition, TourStepConfig } from './defaultTours'; -import { DEFAULT_TOURS } from './defaultTours'; -import { useTourVoice, type TourVoice } from './useTourVoice'; - -const STORAGE_PREFIX = 'augment:tour-completed-'; - -function isTourCompleted(id: TourId): boolean { - try { - return localStorage.getItem(`${STORAGE_PREFIX}${id}`) === '1'; - } catch { - return false; - } -} - -function markTourCompleted(id: TourId): void { - try { - localStorage.setItem(`${STORAGE_PREFIX}${id}`, '1'); - } catch { - /* noop */ - } -} - -interface TourContextValue { - startTour: (id: TourId, autoAdvance?: boolean) => void; - isCompleted: (id: TourId) => boolean; - activeTour: TourId | null; - /** Auto-play tours. Pass specific IDs to only play a subset. */ - startAutoPlay: (tourIds?: TourId[]) => void; - stopAutoPlay: () => void; - isAutoPlaying: boolean; - voice: TourVoice; -} - -const noopVoice: TourVoice = { - isVoiceEnabled: false, - isSupported: false, - setVoiceEnabled: () => {}, - setActiveTourId: () => {}, - isVoiceEnabledForTour: () => false, - setVoiceEnabledForTour: () => {}, - speak: () => Promise.resolve(), - stop: () => {}, -}; - -const TourContext = createContext({ - startTour: () => {}, - isCompleted: () => false, - activeTour: null, - startAutoPlay: () => {}, - stopAutoPlay: () => {}, - isAutoPlaying: false, - voice: noopVoice, -}); - -export function useTour() { - return useContext(TourContext); -} - -async function executeTourAction( - action: TourAction, - ctrl: TourControllerAPI, -): Promise { - switch (action.type) { - case 'navigate': - ctrl.navigatePanel(action.panel); - break; - case 'openAgentIntent': - ctrl.openAgentIntent(); - break; - case 'selectAgentIntent': - ctrl.selectAgentIntent(action.cardId); - break; - case 'openToolIntent': - ctrl.openToolIntent(); - break; - case 'selectToolDeploy': - ctrl.selectToolDeploy(); - break; - case 'closeDialogs': - ctrl.closeAllDialogs(); - break; - case 'setWizardStep': - ctrl.setWizardStep(action.step); - break; - case 'setDeployMethod': - ctrl.setDeployMethod(action.method); - break; - case 'clickSelector': { - const el = document.querySelector(action.selector); - if (el instanceof HTMLElement) el.click(); - break; - } - case 'switchToMarketplace': - ctrl.switchToMarketplace(); - break; - case 'switchToCommandCenter': - ctrl.switchToCommandCenter(); - break; - default: - break; - } -} - -type AutoAdvanceHook = (d: Driver, speechDone: Promise) => void; -type SpeakFn = (title: string, desc: string) => Promise; - -interface VoiceToggle { - isEnabled: () => boolean; - toggle: () => void; -} - -function injectVoiceButton(toggle: VoiceToggle): void { - requestAnimationFrame(() => { - const popover = document.querySelector('.driver-popover'); - if (!popover || popover.querySelector('.tour-voice-btn')) return; - - const footer = popover.querySelector('.driver-popover-footer'); - if (!footer) return; - - const btn = document.createElement('button'); - btn.className = 'tour-voice-btn'; - btn.type = 'button'; - btn.title = toggle.isEnabled() ? 'Mute voice' : 'Unmute voice'; - btn.innerHTML = toggle.isEnabled() - ? '' - : ''; - - btn.addEventListener('click', e => { - e.stopPropagation(); - toggle.toggle(); - btn.title = toggle.isEnabled() ? 'Mute voice' : 'Unmute voice'; - btn.innerHTML = toggle.isEnabled() - ? '' - : ''; - }); - - const progress = footer.querySelector('.driver-popover-progress-text'); - if (progress) { - progress.parentElement?.insertBefore(btn, progress.nextSibling); - } else { - footer.prepend(btn); - } - }); -} - -function configToEnhancedSteps(steps: TourStepConfig[]): EnhancedDriveStep[] { - return steps.map(s => { - const enhanced: EnhancedDriveStep = { - element: s.target || undefined, - popover: { - title: s.title, - description: s.description, - side: s.side as 'top' | 'bottom' | 'left' | 'right' | undefined, - }, - }; - if (s.action) { - enhanced.tourAction = s.action as TourAction; - } - if (s.waitFor) { - enhanced.tourSelector = s.waitFor; - } else if (s.target && s.action) { - enhanced.tourSelector = s.target; - } - return enhanced; - }); -} - -function buildDriverSteps( - steps: EnhancedDriveStep[], - ctrl: TourControllerAPI, - speakFn?: SpeakFn, - voiceToggle?: VoiceToggle, - onStepShown?: AutoAdvanceHook, -): DriveStep[] { - return steps.map((step): DriveStep => { - const hasAction = !!step.tourAction; - const selector = step.tourSelector; - const needsCustomHook = hasAction || selector || !!onStepShown || !!speakFn; - - if (!needsCustomHook) { - return step as DriveStep; - } - - const result: DriveStep = { - ...step, - popover: step.popover ? { ...step.popover } : undefined, - }; - - if (selector) { - result.element = () => - document.querySelector(selector) as unknown as Element; - } - - result.onHighlightStarted = (_el, _step, opts) => { - const { driver: d } = opts; - const run = async () => { - if (step.tourAction) { - await executeTourAction(step.tourAction, ctrl); - } - if (selector) { - await ctrl.waitForSelector(selector, 3000); - } - if (hasAction || selector) { - d.refresh(); - } - if (voiceToggle) injectVoiceButton(voiceToggle); - const speechDone = speakFn - ? speakFn( - step.popover?.title?.toString() ?? '', - step.popover?.description?.toString() ?? '', - ) - : Promise.resolve(); - onStepShown?.(d, speechDone); - }; - run(); - }; - - delete (result as Record).tourAction; - delete (result as Record).tourSelector; - - return result; - }); -} - -const AUTO_ADVANCE_DELAY = 4000; -const INTER_TOUR_DELAY = 2000; - -export function TourProvider({ - children, - tours, -}: { - children: React.ReactNode; - tours?: TourDefinition[]; -}) { - const tourData = tours && tours.length > 0 ? tours : DEFAULT_TOURS; - const tourStepsMap = useMemo(() => { - const map: Record = {}; - for (const t of tourData) { - map[t.id] = t.steps; - } - return map; - }, [tourData]); - const tourOrder = useMemo(() => tourData.map(t => t.id), [tourData]); - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - const [activeTour, setActiveTour] = useState(null); - const [isAutoPlaying, setIsAutoPlaying] = useState(false); - const [, setVersion] = useState(0); - const driverRef = useRef(null); - const autoPlayQueueRef = useRef([]); - const autoPlayFullListRef = useRef([]); - const autoAdvanceTimerRef = useRef | null>( - null, - ); - const startTourRef = useRef<(id: TourId, autoAdvance?: boolean) => void>( - () => {}, - ); - const ctrl = useTourController(); - const voice = useTourVoice(); - - const clearAutoAdvanceTimer = useCallback(() => { - if (autoAdvanceTimerRef.current !== null) { - clearTimeout(autoAdvanceTimerRef.current); - autoAdvanceTimerRef.current = null; - } - }, []); - - const stopAutoPlay = useCallback(() => { - autoPlayQueueRef.current = []; - clearAutoAdvanceTimer(); - setIsAutoPlaying(false); - }, [clearAutoAdvanceTimer]); - - const startTour = useCallback( - (id: TourId, autoAdvance?: boolean) => { - const rawSteps = tourStepsMap[id]; - if (!rawSteps?.length) return; - - if (driverRef.current) { - driverRef.current.destroy(); - } - - const autoAdvanceHook: AutoAdvanceHook | undefined = autoAdvance - ? (dInstance: Driver, speechDone: Promise) => { - clearAutoAdvanceTimer(); - const timerDelay = new Promise(r => - setTimeout(r, AUTO_ADVANCE_DELAY), - ); - Promise.all([timerDelay, speechDone]).then(() => { - if (dInstance.hasNextStep()) { - dInstance.moveNext(); - } else { - clearAutoAdvanceTimer(); - driverRef.current = null; - voice.stop(); - voice.setActiveTourId(null); - dInstance.destroy(); - ctrl.closeAllDialogs(); - markTourCompleted(id); - setActiveTour(null); - setVersion(v => v + 1); - - const queue = autoPlayQueueRef.current; - if (queue.length > 0) { - const nextId = queue.shift()!; - setTimeout( - () => startTourRef.current(nextId, true), - INTER_TOUR_DELAY, - ); - } else { - setIsAutoPlaying(false); - ctrl.returnToGuidedExperience(); - } - } - }); - } - : undefined; - - voice.setActiveTourId(id); - - const speakFn: SpeakFn = (title, desc) => voice.speak(title, desc); - const voiceToggle: VoiceToggle = { - isEnabled: () => voice.isVoiceEnabledForTour(id), - toggle: () => { - const next = !voice.isVoiceEnabledForTour(id); - voice.setVoiceEnabled(next); - }, - }; - const enhancedSteps = configToEnhancedSteps(rawSteps); - const builtSteps = buildDriverSteps( - enhancedSteps, - ctrl, - speakFn, - voiceToggle, - autoAdvanceHook, - ); - - const steps: DriveStep[] = []; - if (autoAdvance) { - const meta = tourData.find(t => t.id === id); - const playList = autoPlayFullListRef.current; - const tourIndex = playList.indexOf(id) + 1; - const tourTotal = playList.length; - const titleCard: DriveStep = { - popover: { - title: `▶ Tour ${tourIndex || 1} of ${tourTotal || 1}: ${meta?.title ?? id}`, - description: meta?.description ?? '', - }, - }; - if (autoAdvanceHook) { - titleCard.onHighlightStarted = (_el, _step, opts) => { - injectVoiceButton(voiceToggle); - const speechDone = speakFn( - meta?.title ?? id, - meta?.description ?? '', - ); - autoAdvanceHook(opts.driver, speechDone); - }; - } - steps.push(titleCard); - } - steps.push(...builtSteps); - - let destroying = false; - const d = driver({ - showProgress: true, - animate: true, - allowClose: true, - overlayColor: isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.5)', - stagePadding: 8, - stageRadius: 8, - popoverClass: `augment-tour-popover${isDark ? ' augment-tour-dark' : ''}`, - progressText: '{{current}} of {{total}}', - nextBtnText: autoAdvance ? 'Next ⏵' : 'Next', - prevBtnText: 'Back', - doneBtnText: 'Done', - steps, - onDestroyStarted: () => { - if (destroying) return; - destroying = true; - const finished = !d.hasNextStep(); - clearAutoAdvanceTimer(); - voice.stop(); - voice.setActiveTourId(null); - driverRef.current = null; - d.destroy(); - ctrl.closeAllDialogs(); - - if (finished) { - markTourCompleted(id); - } - - setActiveTour(null); - setVersion(v => v + 1); - - if (!finished) { - autoPlayQueueRef.current = []; - setIsAutoPlaying(false); - } - ctrl.returnToGuidedExperience(); - }, - }); - - setActiveTour(id); - d.drive(); - driverRef.current = d; - }, - [isDark, ctrl, clearAutoAdvanceTimer, voice, tourData, tourStepsMap], - ); - - startTourRef.current = startTour; - - const startAutoPlay = useCallback( - (tourIds?: TourId[]) => { - const fullList = tourIds ?? [...tourOrder]; - const queue = [...fullList]; - const first = queue.shift(); - if (!first) return; - autoPlayFullListRef.current = fullList; - autoPlayQueueRef.current = queue; - setIsAutoPlaying(true); - startTourRef.current(first, true); - }, - [tourOrder], - ); - - const isCompleted = useCallback((id: TourId) => isTourCompleted(id), []); - - const value = useMemo( - () => ({ - startTour, - isCompleted, - activeTour, - startAutoPlay, - stopAutoPlay, - isAutoPlaying, - voice, - }), - [ - startTour, - isCompleted, - activeTour, - startAutoPlay, - stopAutoPlay, - isAutoPlaying, - voice, - ], - ); - - return {children}; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/commandCenterStyles.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/commandCenterStyles.ts deleted file mode 100644 index 5ef6d4563c9..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/commandCenterStyles.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Theme, SxProps } from '@mui/material/styles'; -import { alpha } from '@mui/material/styles'; -import { - layout, - typeScale, - typography as typographyTokens, - subtleBorder as canonicalSubtleBorder, - surface, -} from '../../../theme/tokens'; - -export const CONTENT_MAX_WIDTH = layout.content.adminMaxWidth; - -export const HOVER_TRANSITION = - 'background-color 0.15s ease, border-color 0.15s ease'; - -export const PAGE_TITLE_SX: SxProps = { - ...typeScale.pageTitle, - color: 'text.primary', -}; - -export const PAGE_SUBTITLE_SX: SxProps = { - ...typeScale.bodySmall, - color: 'text.secondary', - mt: 0.25, -}; - -export const SECTION_LABEL_SX: SxProps = { - ...typeScale.micro, - fontWeight: typographyTokens.fontWeight.semibold, - textTransform: 'uppercase', - color: 'text.secondary', -}; - -export const TABLE_HEADER_CELL_SX = { - fontWeight: typographyTokens.fontWeight.semibold, - fontSize: typeScale.caption.fontSize, - textTransform: 'uppercase', - letterSpacing: '0.02em', - bgcolor: 'background.default', -} as const; - -export const TABLE_CELL_NAME_SX = { - fontWeight: typographyTokens.fontWeight.semibold, - fontSize: typeScale.body.fontSize, -} as const; - -export const CHIP_SX = { - height: 24, - fontSize: typeScale.caption.fontSize, -} as const; - -function surfaceBg(theme: Theme): string { - return surface(theme, 'raised'); -} - -/** - * Returns a full CSS border declaration. - * Delegates to the canonical subtleBorder from tokens. - */ -export function subtleBorder(theme: Theme): string { - return canonicalSubtleBorder(theme, 'medium'); -} - -export function cardOutlineSx(theme: Theme): SxProps { - return { - border: subtleBorder(theme), - bgcolor: surfaceBg(theme), - }; -} - -export function sectionCardSx(theme: Theme): SxProps { - return { - bgcolor: surfaceBg(theme), - border: subtleBorder(theme), - borderRadius: 2, - p: 2.5, - }; -} - -export function tableContainerSx(theme: Theme): SxProps { - return { - border: subtleBorder(theme), - borderRadius: 2, - bgcolor: surfaceBg(theme), - }; -} - -export function emptyStateSx(theme: Theme): SxProps { - return { - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - alignItems: 'center', - py: 8, - border: `1px dashed ${alpha(theme.palette.divider, 0.3)}`, - borderRadius: 2, - bgcolor: surfaceBg(theme), - gap: 2, - }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/defaultTours.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/defaultTours.ts deleted file mode 100644 index 90e2cffff97..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/defaultTours.ts +++ /dev/null @@ -1,522 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Tour step configuration -- matches the YAML schema. - */ -export interface TourStepConfig { - target?: string; - title: string; - description?: string; - side?: string; - action?: { - type: string; - panel?: string; - selector?: string; - step?: number; - method?: string; - cardId?: string; - }; - waitFor?: string; -} - -/** - * Tour definition -- matches the YAML schema. - * Used by both backend (from YAML) and frontend (defaults + fetched). - */ -export interface TourDefinition { - id: string; - title: string; - description?: string; - category?: string; - estimatedMinutes?: number; - /** Which persona this tour is relevant to: developer, admin, or both */ - persona?: 'developer' | 'admin' | 'both'; - /** Which page this tour is designed for: marketplace, command-center, or any */ - page?: 'marketplace' | 'command-center' | 'any'; - steps: TourStepConfig[]; -} - -/** - * Built-in default tours aligned with the current Command Center UI. - * These are used when no YAML tours are configured. - * All targets reference [data-tour="..."] attributes on current components. - * - * Tell-Show-Tell format: - * - Title: short action label (what you're about to see) - * - Description: context + what to do next - */ -export const DEFAULT_TOURS: TourDefinition[] = [ - // ========================================================================= - // MARKETPLACE WELCOME -- orientation for all users - // ========================================================================= - { - id: 'marketplace-welcome', - title: 'Explore the Marketplace', - description: - 'Get oriented with the Agent Marketplace -- browse, search, and chat with AI agents.', - category: 'getting-started', - persona: 'both', - page: 'marketplace', - estimatedMinutes: 2, - steps: [ - { - title: 'Welcome to the Agent Marketplace', - description: - 'This is your front door to AI agents. Browse published agents, manage your own, create new ones, and start conversations.', - action: { type: 'switchToMarketplace' }, - }, - { - target: '[data-tour="marketplace-hero"]', - title: 'Marketplace Header', - description: - 'The hero area shows the marketplace title. Admins also see a "Command Center" link to access platform operations.', - side: 'bottom', - }, - { - target: '[data-tour="marketplace-tabs"]', - title: 'Explore and My Agents', - description: - 'Two tabs: Explore shows all published agents and tools. My Agents shows everything you have created, with lifecycle tracking (Draft, In Review, Published).', - side: 'bottom', - }, - { - target: '[data-tour="marketplace-search"]', - title: 'Search and Filter', - description: - 'Type to search agents by name or description. Use the framework chips below to filter by framework (LangGraph, ADK, CrewAI, etc.).', - side: 'bottom', - }, - { - target: '[data-tour="marketplace-agent-grid"]', - title: 'Agent Catalog', - description: - 'Each card is an agent. The colored bar indicates the framework, and a green dot means the agent is ready. Click any card or the chat icon to start a conversation.', - side: 'top', - }, - ], - }, - // ========================================================================= - // MARKETPLACE IMPORT AGENT -- deploy from image or Git source - // ========================================================================= - { - id: 'marketplace-import-agent', - title: 'Import & Deploy an Agent', - description: - 'Deploy an agent from a container image or Git repository, step by step.', - category: 'agent-journeys', - persona: 'both', - page: 'marketplace', - estimatedMinutes: 3, - steps: [ - { - target: '[data-tour="marketplace-create-agent-btn"]', - title: 'Start Agent Creation', - description: - 'Click "+ Create Agent" to open the creation dialog. We will walk through the Import path.', - side: 'bottom', - action: { type: 'switchToMarketplace' }, - }, - { - target: '[data-tour="intent-dialog"]', - title: 'Choose Import', - description: - 'Three options appear: Import, Develop, and Configure. Select "Import" to deploy from a container image or build from Git source.', - side: 'bottom', - action: { type: 'openAgentIntent' }, - waitFor: '[data-tour="intent-dialog"]', - }, - { - target: '[data-tour="intent-import"]', - title: 'Import Path', - description: - 'This is the most common path for bringing existing agents to the platform. Click "Import" to continue to the creation wizard.', - side: 'bottom', - action: { type: 'selectAgentIntent', cardId: 'deploy' }, - }, - { - target: '[data-tour="agent-name"]', - title: 'Name and Configure', - description: - 'Enter a unique name (lowercase, alphanumeric, hyphens). Choose a namespace, communication protocol (A2A, MCP, or HTTP), and framework (LangGraph, ADK, CrewAI, etc.).', - side: 'right', - waitFor: '[data-tour="agent-name"]', - }, - { - target: '[data-tour="deploy-method"]', - title: 'Deployment Method', - description: - 'Choose "Container Image" for a pre-built image, or "Source from Git" to build automatically via Shipwright. Each path has its own configuration fields below.', - side: 'left', - waitFor: '[data-tour="deploy-method"]', - }, - { - title: 'Complete the Wizard', - description: - 'Fill in the image URL or Git details, then proceed to Runtime to set the workload type, environment variables, ports, and security toggles. Click "Create" to deploy your agent.', - action: { type: 'closeDialogs' }, - }, - ], - }, - // ========================================================================= - // MARKETPLACE DEVELOP AGENT -- templates and DevSpaces - // ========================================================================= - { - id: 'marketplace-develop-agent', - title: 'Develop from Templates', - description: - 'Start building an agent from a starter template or cloud IDE.', - category: 'agent-journeys', - persona: 'developer', - page: 'marketplace', - estimatedMinutes: 2, - steps: [ - { - target: '[data-tour="marketplace-create-agent-btn"]', - title: 'Start Agent Creation', - description: - 'Click "+ Create Agent" to open the creation dialog. We will walk through the Develop path.', - side: 'bottom', - action: { type: 'switchToMarketplace' }, - }, - { - target: '[data-tour="intent-dialog"]', - title: 'Choose Develop', - description: - 'Three options appear: Import, Develop, and Configure. Select "Develop" to start from a template or cloud IDE.', - side: 'bottom', - action: { type: 'openAgentIntent' }, - waitFor: '[data-tour="intent-dialog"]', - }, - { - target: '[data-tour="intent-develop"]', - title: 'Develop Path', - description: - 'Select "Develop" to see two sub-options: "From Template" scaffolds a new project with best-practice code, and "Agent DevSpace" launches a cloud IDE with everything pre-configured.', - side: 'bottom', - }, - { - target: '[data-tour="intent-templates"]', - title: 'From Template', - description: - 'Browse starter templates for frameworks like LangGraph, Google ADK, CrewAI, and Semantic Kernel. Each template includes pre-configured agent code, dependencies, and a Dockerfile.', - side: 'bottom', - }, - { - target: '[data-tour="intent-devspaces"]', - title: 'Agent DevSpace', - description: - 'Launch a cloud IDE (Red Hat Dev Spaces) with the template pre-loaded. Your agent code lives in Git -- develop locally or in the cloud, then deploy when ready.', - side: 'bottom', - action: { type: 'closeDialogs' }, - }, - ], - }, - // ========================================================================= - // MARKETPLACE CONFIGURE AGENT -- visual workflow builder - // ========================================================================= - { - id: 'marketplace-configure-agent', - title: 'Configure with Workflow Builder', - description: - 'Build multi-agent orchestration visually using the drag-and-drop editor.', - category: 'agent-journeys', - persona: 'developer', - page: 'marketplace', - estimatedMinutes: 1, - steps: [ - { - target: '[data-tour="marketplace-create-agent-btn"]', - title: 'Start Agent Creation', - description: - 'Click "+ Create Agent" to open the creation dialog. We will walk through the Configure path.', - side: 'bottom', - action: { type: 'switchToMarketplace' }, - }, - { - target: '[data-tour="intent-dialog"]', - title: 'Choose Configure', - description: - 'Three options appear: Import, Develop, and Configure. Select "Configure" to use the visual workflow builder.', - side: 'bottom', - action: { type: 'openAgentIntent' }, - waitFor: '[data-tour="intent-dialog"]', - }, - { - target: '[data-tour="intent-configure"]', - title: 'Visual Workflow Builder', - description: - 'Select "Configure" to open the drag-and-drop workflow editor. Design multi-agent orchestration by connecting agent nodes, defining handoffs, attaching MCP tools, and setting guardrails.', - side: 'bottom', - action: { type: 'closeDialogs' }, - }, - ], - }, - // ========================================================================= - // MARKETPLACE TOOL CREATION -- end-to-end from front door - // ========================================================================= - { - id: 'marketplace-create-tool', - title: 'Create an MCP Tool', - description: - 'Walk through registering an MCP tool server that extends agent capabilities.', - category: 'agent-journeys', - persona: 'both', - page: 'marketplace', - estimatedMinutes: 2, - steps: [ - { - target: '[data-tour="marketplace-create-tool-btn"]', - title: 'Start Tool Creation', - description: 'Click "+ Create Tool" to register a new MCP tool server.', - side: 'bottom', - action: { type: 'switchToMarketplace' }, - }, - { - target: '[data-tour="tool-intent-dialog"]', - title: 'Choose Your Path', - description: - 'Two options: Develop to start from a template or DevSpace, or Deploy to deploy from a container image or Git source.', - side: 'bottom', - action: { type: 'openToolIntent' }, - waitFor: '[data-tour="tool-intent-dialog"]', - }, - { - target: '[data-tour="tool-intent-deploy"]', - title: 'Deploy a Tool', - description: - 'Select "Deploy" to register a tool from a container image or Git source. The wizard walks you through naming, namespace, deployment method, and runtime config.', - side: 'bottom', - action: { type: 'selectToolDeploy' }, - }, - { - target: '[data-tour="tool-name"]', - title: 'Configure Your Tool', - description: - 'Name your tool, select a namespace, and provide a description. Choose a deployment method and configure runtime settings.', - side: 'right', - waitFor: '[data-tour="tool-name"]', - }, - { - title: 'Complete and Deploy', - description: - 'Fill in deployment details, then configure runtime (workload type, environment variables, ports). Click "Create" to deploy. Tools start as Draft and need admin approval to be published.', - action: { type: 'closeDialogs' }, - }, - ], - }, - // ========================================================================= - // CC: OVERVIEW -- Command Center orientation (admin) - // ========================================================================= - { - id: 'cc-overview', - title: 'Command Center Overview', - description: - 'Get oriented with the Command Center layout, navigation, and key metrics.', - category: 'getting-started', - persona: 'admin', - page: 'command-center', - estimatedMinutes: 2, - steps: [ - { - target: '[data-tour="nav-overview"]', - title: 'Command Center Navigation', - description: - 'This top bar is your control panel. Four tabs: Overview for platform health, Agents for lifecycle management, Platform for infrastructure, Settings for customization.', - side: 'bottom', - }, - { - target: '[data-tour="status-bar"]', - title: 'Live Status Indicator', - description: - 'Shows real-time platform health: Operational (green), Degraded (amber), or Critical (red). The ratio shows how many agents are ready.', - side: 'bottom', - action: { type: 'navigate', panel: 'ops-home' }, - waitFor: '[data-tour="status-bar"]', - }, - { - target: '[data-tour="metric-tiles"]', - title: 'Fleet Metrics', - description: - 'Four key numbers: total Agents deployed, how many are Ready, how many are Published to marketplace, and the review Queue count. Click Queue to jump to pending reviews.', - side: 'bottom', - }, - { - target: '[data-tour="fleet-health"]', - title: 'Agent Fleet Health', - description: - 'Each colored tile is one agent. Green = healthy, amber = building, red = error. Hover any tile to see the agent name and status.', - side: 'top', - }, - { - target: '[data-tour="back-to-marketplace"]', - title: 'Back to Marketplace', - description: - 'Click "Marketplace" anytime to return to the developer-facing Agent Marketplace where agents are browsed and chatted with.', - side: 'bottom', - }, - ], - }, - // ========================================================================= - // CC: AGENTS -- Review Queue, Registry, Builds (admin) - // ========================================================================= - { - id: 'cc-agents', - title: 'Agent Lifecycle Management', - description: - 'Manage the full agent lifecycle: review submissions, browse the registry, and monitor builds.', - category: 'admin-operations', - persona: 'admin', - page: 'command-center', - estimatedMinutes: 2, - steps: [ - { - target: '[data-tour="nav-agents"]', - title: 'Agents Tab', - description: - 'The Agents tab is where you manage the full lifecycle of agents on the platform. It has three sub-sections: Review Queue, Registry, and Builds.', - side: 'bottom', - action: { type: 'navigate', panel: 'ops-review-queue' }, - }, - { - target: '[data-tour="subtab-review-queue"]', - title: 'Review Queue', - description: - 'Agents created by developers start as Draft. When submitted, they appear here for review. Approve to publish them to the marketplace, or reject with feedback.', - side: 'bottom', - waitFor: '[data-tour="subtab-review-queue"]', - }, - { - target: '[data-tour="subtab-registry"]', - title: 'Agent Registry', - description: - 'Browse all registered agents across all namespaces. See their status, framework, protocol, and lifecycle stage. Click any agent to view details or manage it.', - side: 'bottom', - action: { type: 'navigate', panel: 'ops-registry' }, - }, - { - target: '[data-tour="subtab-builds"]', - title: 'Build Pipelines', - description: - 'Monitor Shipwright build pipelines for agents deployed from Git source. Track build status, view logs, and retry failed builds.', - side: 'bottom', - action: { type: 'navigate', panel: 'kagenti-builds' }, - }, - ], - }, - // ========================================================================= - // CC: PLATFORM -- Tools, Tool Review, Config, Observability (admin) - // ========================================================================= - { - id: 'cc-platform', - title: 'Platform Infrastructure', - description: - 'Tour the platform: MCP tools, configuration, and observability dashboards.', - category: 'admin-operations', - persona: 'admin', - page: 'command-center', - estimatedMinutes: 2, - steps: [ - { - target: '[data-tour="nav-platform"]', - title: 'Platform Tab', - description: - 'The Platform tab manages shared AI infrastructure used by all agents. It has four sub-sections: Tools, Tool Review, Config, and Observability.', - side: 'bottom', - action: { type: 'navigate', panel: 'kagenti-tools' }, - }, - { - target: '[data-tour="subtab-tools"]', - title: 'MCP Tools', - description: - 'View and manage all registered MCP tool servers. Each tool shows its name, status, workload type, and actions (connect, invoke, delete). Click "New Tool" to register a new one.', - side: 'bottom', - waitFor: '[data-tour="subtab-tools"]', - }, - { - target: '[data-tour="subtab-tool-review"]', - title: 'Tool Review', - description: - 'Tools submitted by developers appear here for admin review. Approve to make them available to agents, or reject with feedback.', - side: 'bottom', - action: { type: 'navigate', panel: 'ops-tool-review' }, - }, - { - target: '[data-tour="subtab-config"]', - title: 'Platform Config', - description: - 'Configure the LLM model endpoint, register MCP servers, set up RAG document sources, and define safety and evaluation rules.', - side: 'bottom', - action: { type: 'navigate', panel: 'ops-platform' }, - }, - { - target: '[data-tour="subtab-observability"]', - title: 'Observability', - description: - 'Access Grafana dashboards for agent health and performance, distributed traces (Jaeger/Tempo), and service mesh traffic (Kiali).', - side: 'bottom', - action: { type: 'navigate', panel: 'ops-observability' }, - }, - ], - }, - // ========================================================================= - // CC: SETTINGS -- Branding, Administration, Documentation (admin) - // ========================================================================= - { - id: 'cc-settings', - title: 'Settings & Administration', - description: - 'Customize branding, manage admin access, and browse platform documentation.', - category: 'admin-operations', - persona: 'admin', - page: 'command-center', - estimatedMinutes: 2, - steps: [ - { - target: '[data-tour="nav-settings"]', - title: 'Settings Tab', - description: - 'The Settings tab lets you customize the platform appearance, manage access, and access documentation. It has three sub-sections.', - side: 'bottom', - action: { type: 'navigate', panel: 'ops-branding' }, - }, - { - target: '[data-tour="subtab-branding"]', - title: 'Branding', - description: - 'Customize the platform look and feel: change the app name, tagline, primary colors, bot avatar, and theme preset. Changes apply immediately across the UI.', - side: 'bottom', - waitFor: '[data-tour="subtab-branding"]', - }, - { - target: '[data-tour="subtab-admin"]', - title: 'Administration', - description: - 'Manage Kubernetes namespaces, configure admin access, and view platform-wide settings like security mode and provider configuration.', - side: 'bottom', - action: { type: 'navigate', panel: 'ops-admin' }, - }, - { - target: '[data-tour="subtab-docs"]', - title: 'Documentation', - description: - 'Browse built-in platform documentation and guides. Covers architecture, API reference, configuration options, and troubleshooting.', - side: 'bottom', - action: { type: 'navigate', panel: 'ops-docs' }, - }, - ], - }, -]; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/selectMenuProps.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/selectMenuProps.ts deleted file mode 100644 index 2fc58005cd7..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/selectMenuProps.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { SelectProps } from '@mui/material/Select'; - -/** - * Shared MenuProps for all MUI ``. - */ -export const SELECT_MENU_PROPS: SelectProps['MenuProps'] = { - PaperProps: { - sx: { - '& .MuiList-root': { - display: 'flex', - flexDirection: 'column', - boxSizing: 'border-box', - }, - '& .MuiMenuItem-root': { - display: 'flex', - boxSizing: 'border-box', - }, - }, - }, -}; diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/tourDefinitions.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/tourDefinitions.ts deleted file mode 100644 index ef0610986b2..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/tourDefinitions.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Tour type definitions used by TourProvider at runtime. - * Tour DATA is now in defaultTours.ts (built-in defaults) or loaded from YAML. - * This file only contains the types and action union needed by the tour engine. - */ - -import type { DriveStep } from 'driver.js'; -import type { AdminPanel } from '../../../hooks'; - -/** - * Tour identifier -- string-based to support dynamic tours from YAML. - */ -export type TourId = string; - -/** - * Tour category for grouping in the launcher UI. - */ -export type TourCategory = string; - -/** - * Page context for filtering tours by where they should appear. - */ -export type TourPage = 'marketplace' | 'command-center' | 'any'; - -/** - * Actions that the tour engine can execute before highlighting a step. - * These map to TourControllerAPI methods in TourController.tsx. - */ -export type TourAction = - | { type: 'navigate'; panel: AdminPanel } - | { type: 'openAgentIntent' } - | { type: 'selectAgentIntent'; cardId: string } - | { type: 'openToolIntent' } - | { type: 'selectToolDeploy' } - | { type: 'closeDialogs' } - | { type: 'setWizardStep'; step: number } - | { type: 'setDeployMethod'; method: string } - | { type: 'clickSelector'; selector: string } - | { type: 'switchToMarketplace' } - | { type: 'switchToCommandCenter' }; - -/** - * Extended driver.js step with tour-specific action and selector metadata. - */ -export interface EnhancedDriveStep extends DriveStep { - tourAction?: TourAction; - tourSelector?: string; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/tourStyles.css b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/tourStyles.css deleted file mode 100644 index 90cd40e2e2e..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/tourStyles.css +++ /dev/null @@ -1,136 +0,0 @@ -.augment-tour-popover.driver-popover { - max-width: 360px; - border-radius: 12px; - background-color: #fff !important; - color: #1e1e1e !important; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.16); -} - -.augment-tour-popover .driver-popover-title { - font-size: 0.9375rem; - font-weight: 700; - letter-spacing: -0.01em; - color: #1e1e1e !important; -} - -.augment-tour-popover .driver-popover-description { - font-size: 0.8125rem; - line-height: 1.5; - color: #555 !important; -} - -.augment-tour-popover .driver-popover-progress-text { - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.04em; - color: #888 !important; -} - -.augment-tour-popover .driver-popover-close-btn { - color: #888 !important; -} - -.augment-tour-popover .driver-popover-close-btn:hover { - color: #1e1e1e !important; -} - -.augment-tour-popover .driver-popover-navigation-btns button { - border-radius: 6px; - font-size: 0.8125rem; - font-weight: 600; - text-transform: none; - padding: 4px 16px; -} - -.augment-tour-popover .driver-popover-prev-btn { - color: #555 !important; - border-color: #ccc !important; -} - -.augment-tour-popover .driver-popover-arrow-side-left, -.augment-tour-popover .driver-popover-arrow-side-right, -.augment-tour-popover .driver-popover-arrow-side-top, -.augment-tour-popover .driver-popover-arrow-side-bottom { - border-color: #fff !important; -} - -/* Dark mode overrides */ -.augment-tour-dark.driver-popover { - background-color: #1e1e1e !important; - color: #e0e0e0 !important; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); - border: 1px solid rgba(255, 255, 255, 0.1); -} - -.augment-tour-dark .driver-popover-title { - color: #fff !important; -} - -.augment-tour-dark .driver-popover-description { - color: #b0b0b0 !important; -} - -.augment-tour-dark .driver-popover-progress-text { - color: #777 !important; -} - -.augment-tour-dark .driver-popover-close-btn { - color: #777 !important; -} - -.augment-tour-dark .driver-popover-close-btn:hover { - color: #fff !important; -} - -.augment-tour-dark .driver-popover-prev-btn { - color: #b0b0b0 !important; - border-color: #444 !important; -} - -.augment-tour-dark .driver-popover-next-btn, -.augment-tour-dark .driver-popover-done-btn { - background-color: #1976d2 !important; - color: #fff !important; -} - -.augment-tour-dark .driver-popover-arrow-side-left, -.augment-tour-dark .driver-popover-arrow-side-right, -.augment-tour-dark .driver-popover-arrow-side-top, -.augment-tour-dark .driver-popover-arrow-side-bottom { - border-color: #1e1e1e !important; -} - -/* Voice toggle button in popover footer */ -.tour-voice-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - border: none; - border-radius: 6px; - background: rgba(0, 0, 0, 0.06); - color: #888; - cursor: pointer; - padding: 0; - margin-left: 6px; - vertical-align: middle; - transition: - background 0.15s, - color 0.15s; -} - -.tour-voice-btn:hover { - background: rgba(0, 0, 0, 0.12); - color: #555; -} - -.augment-tour-dark .tour-voice-btn { - background: rgba(255, 255, 255, 0.08); - color: #999; -} - -.augment-tour-dark .tour-voice-btn:hover { - background: rgba(255, 255, 255, 0.15); - color: #ccc; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/useTourVoice.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/useTourVoice.ts deleted file mode 100644 index 1cc8dbedf6f..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/shared/useTourVoice.ts +++ /dev/null @@ -1,190 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useCallback, useEffect, useRef, useState } from 'react'; - -const GLOBAL_STORAGE_KEY = 'augment:tour-voice'; -const PER_TOUR_PREFIX = 'augment:tour-voice:'; -const synth = - typeof window !== 'undefined' ? window.speechSynthesis : undefined; - -function loadGlobalVoicePref(): boolean { - try { - return localStorage.getItem(GLOBAL_STORAGE_KEY) !== '0'; - } catch { - return true; - } -} - -function saveGlobalVoicePref(enabled: boolean): void { - try { - localStorage.setItem(GLOBAL_STORAGE_KEY, enabled ? '1' : '0'); - } catch { - /* noop */ - } -} - -export function loadTourVoicePref(tourId: string): boolean | null { - try { - const val = localStorage.getItem(`${PER_TOUR_PREFIX}${tourId}`); - if (val === '1') return true; - if (val === '0') return false; - return null; - } catch { - return null; - } -} - -export function saveTourVoicePref(tourId: string, enabled: boolean): void { - try { - localStorage.setItem(`${PER_TOUR_PREFIX}${tourId}`, enabled ? '1' : '0'); - } catch { - /* noop */ - } -} - -function resolveVoiceEnabled( - globalEnabled: boolean, - activeTourId: string | null, -): boolean { - if (!activeTourId) return globalEnabled; - const perTour = loadTourVoicePref(activeTourId); - return perTour ?? globalEnabled; -} - -function pickVoice(): SpeechSynthesisVoice | undefined { - if (!synth) return undefined; - const voices = synth.getVoices(); - return ( - voices.find(v => v.lang === 'en-US' && v.localService) ?? - voices.find(v => v.lang.startsWith('en-') && v.localService) ?? - voices.find(v => v.lang.startsWith('en-')) ?? - voices[0] - ); -} - -function stripHtml(html: string): string { - const doc = new DOMParser().parseFromString(html, 'text/html'); - return doc.body.textContent ?? ''; -} - -export interface TourVoice { - isVoiceEnabled: boolean; - isSupported: boolean; - setVoiceEnabled: (enabled: boolean) => void; - setActiveTourId: (id: string | null) => void; - isVoiceEnabledForTour: (tourId: string) => boolean; - setVoiceEnabledForTour: (tourId: string, enabled: boolean) => void; - speak: (title: string, description: string) => Promise; - stop: () => void; -} - -export function useTourVoice(): TourVoice { - const [globalEnabled, setGlobalEnabled] = useState(loadGlobalVoicePref); - const [activeTourId, setActiveTourIdState] = useState(null); - const [, setVersion] = useState(0); - const voiceRef = useRef(undefined); - const globalEnabledRef = useRef(globalEnabled); - globalEnabledRef.current = globalEnabled; - const activeTourIdRef = useRef(activeTourId); - activeTourIdRef.current = activeTourId; - - const isVoiceEnabled = resolveVoiceEnabled(globalEnabled, activeTourId); - const enabledRef = useRef(isVoiceEnabled); - enabledRef.current = isVoiceEnabled; - - useEffect(() => { - if (!synth) return undefined; - const load = () => { - voiceRef.current = pickVoice(); - }; - load(); - synth.addEventListener('voiceschanged', load); - return () => synth.removeEventListener('voiceschanged', load); - }, []); - - const setVoiceEnabled = useCallback((enabled: boolean) => { - const tourId = activeTourIdRef.current; - if (tourId) { - saveTourVoicePref(tourId, enabled); - } else { - setGlobalEnabled(enabled); - saveGlobalVoicePref(enabled); - } - setVersion(v => v + 1); - if (!enabled) synth?.cancel(); - }, []); - - const setActiveTourId = useCallback((id: string | null) => { - setActiveTourIdState(id); - activeTourIdRef.current = id; - }, []); - - const isVoiceEnabledForTour = useCallback( - (tourId: string): boolean => { - const perTour = loadTourVoicePref(tourId); - return perTour ?? globalEnabled; - }, - [globalEnabled], - ); - - const setVoiceEnabledForTour = useCallback( - (tourId: string, enabled: boolean) => { - saveTourVoicePref(tourId, enabled); - setVersion(v => v + 1); - }, - [], - ); - - const stop = useCallback(() => { - synth?.cancel(); - }, []); - - const speak = useCallback( - (title: string, description: string): Promise => { - const resolved = resolveVoiceEnabled( - globalEnabledRef.current, - activeTourIdRef.current, - ); - if (!synth || !resolved) return Promise.resolve(); - - synth.cancel(); - const text = `${stripHtml(title)}. ${stripHtml(description)}`; - const utterance = new SpeechSynthesisUtterance(text); - utterance.rate = 0.95; - utterance.pitch = 1; - if (voiceRef.current) utterance.voice = voiceRef.current; - - return new Promise(resolve => { - utterance.onend = () => resolve(); - utterance.onerror = () => resolve(); - synth.speak(utterance); - }); - }, - [], - ); - - return { - isVoiceEnabled, - isSupported: !!synth, - setVoiceEnabled, - setActiveTourId, - isVoiceEnabledForTour, - setVoiceEnabledForTour, - speak, - stop, - }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/useIngestDropZone.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/useIngestDropZone.test.tsx deleted file mode 100644 index 547f8379e84..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/useIngestDropZone.test.tsx +++ /dev/null @@ -1,264 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { renderHook, act } from '@testing-library/react'; -import { useIngestDropZone } from './useIngestDropZone'; -import { createApiTestWrapper } from '../../test-utils/renderWithApi'; -import type { AugmentApi } from '../../api'; - -function createMockApi( - overrides: Partial = {}, -): Partial { - return { - uploadDocument: jest - .fn() - .mockResolvedValue({ id: 'doc-1', fileName: 'test.txt' }), - ...overrides, - }; -} - -function createFileList(files: File[]): FileList { - const list = { - length: files.length, - item: (i: number) => files[i] ?? null, - *[Symbol.iterator]() { - for (let i = 0; i < files.length; i++) { - yield files[i]; - } - }, - }; - for (let i = 0; i < files.length; i++) { - (list as Record)[i] = files[i]; - } - return list as FileList; -} - -describe('useIngestDropZone', () => { - const validFile = new File(['content'], 'test.txt', { type: 'text/plain' }); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('initial state', () => { - it('returns dragOver false and exposes handlers', async () => { - const mockApi = createMockApi(); - const { result } = renderHook( - () => useIngestDropZone({ vectorStoreId: 'vs-1' }), - { wrapper: createApiTestWrapper(mockApi) }, - ); - - expect(result.current.dragOver).toBe(false); - expect(result.current.uploading).toBe(false); - expect(result.current.error).toBeNull(); - expect(typeof result.current.onFileUpload).toBe('function'); - expect(typeof result.current.onDragOver).toBe('function'); - expect(typeof result.current.onDragLeave).toBe('function'); - expect(typeof result.current.onDrop).toBe('function'); - expect(typeof result.current.onClearError).toBe('function'); - expect(result.current.fileInputRef).toBeDefined(); - }); - }); - - describe('drag state', () => { - it('sets dragOver true on onDragOver', () => { - const mockApi = createMockApi(); - const { result } = renderHook( - () => useIngestDropZone({ vectorStoreId: null }), - { wrapper: createApiTestWrapper(mockApi) }, - ); - - act(() => { - result.current.onDragOver({ - preventDefault: jest.fn(), - } as unknown as React.DragEvent); - }); - - expect(result.current.dragOver).toBe(true); - }); - - it('sets dragOver false on onDragLeave', () => { - const mockApi = createMockApi(); - const { result } = renderHook( - () => useIngestDropZone({ vectorStoreId: null }), - { wrapper: createApiTestWrapper(mockApi) }, - ); - - act(() => { - result.current.onDragOver({ - preventDefault: jest.fn(), - } as unknown as React.DragEvent); - }); - expect(result.current.dragOver).toBe(true); - - act(() => { - result.current.onDragLeave(); - }); - expect(result.current.dragOver).toBe(false); - }); - - it('sets dragOver false on onDrop', async () => { - const mockApi = createMockApi(); - const { result } = renderHook( - () => useIngestDropZone({ vectorStoreId: 'vs-1' }), - { wrapper: createApiTestWrapper(mockApi) }, - ); - - act(() => { - result.current.onDragOver({ - preventDefault: jest.fn(), - } as unknown as React.DragEvent); - }); - - await act(async () => { - result.current.onDrop({ - preventDefault: jest.fn(), - dataTransfer: { files: createFileList([validFile]) }, - } as unknown as React.DragEvent); - }); - - expect(result.current.dragOver).toBe(false); - }); - }); - - describe('file upload', () => { - it('uploads files and calls onUploadComplete on success', async () => { - const onUploadComplete = jest.fn(); - const mockApi = createMockApi(); - const { result } = renderHook( - () => - useIngestDropZone({ - vectorStoreId: 'vs-1', - onUploadComplete, - }), - { wrapper: createApiTestWrapper(mockApi) }, - ); - - await act(async () => { - await result.current.onFileUpload(createFileList([validFile])); - }); - - expect(mockApi.uploadDocument).toHaveBeenCalledWith( - validFile, - 'vs-1', - undefined, - ); - expect(onUploadComplete).toHaveBeenCalledWith( - '1 file ingested successfully.', - ); - }); - - it('uses singular message for one file, plural for multiple', async () => { - const onUploadComplete = jest.fn(); - const mockApi = createMockApi(); - const file2 = new File(['content2'], 'test2.txt', { type: 'text/plain' }); - const { result } = renderHook( - () => - useIngestDropZone({ - vectorStoreId: 'vs-1', - onUploadComplete, - }), - { wrapper: createApiTestWrapper(mockApi) }, - ); - - await act(async () => { - await result.current.onFileUpload(createFileList([validFile, file2])); - }); - - expect(onUploadComplete).toHaveBeenCalledWith( - '2 files ingested successfully.', - ); - }); - - it('passes vectorStoreId undefined when null', async () => { - const mockApi = createMockApi(); - const { result } = renderHook( - () => useIngestDropZone({ vectorStoreId: null }), - { wrapper: createApiTestWrapper(mockApi) }, - ); - - await act(async () => { - await result.current.onFileUpload(createFileList([validFile])); - }); - - expect(mockApi.uploadDocument).toHaveBeenCalledWith( - validFile, - undefined, - undefined, - ); - }); - - it('does nothing when files is null or empty', async () => { - const mockApi = createMockApi(); - const { result } = renderHook( - () => useIngestDropZone({ vectorStoreId: 'vs-1' }), - { wrapper: createApiTestWrapper(mockApi) }, - ); - - await act(async () => { - await result.current.onFileUpload(null); - }); - expect(mockApi.uploadDocument).not.toHaveBeenCalled(); - - await act(async () => { - await result.current.onFileUpload(createFileList([])); - }); - expect(mockApi.uploadDocument).not.toHaveBeenCalled(); - }); - - it('does not call onUploadComplete when no uploads succeed', async () => { - const onUploadComplete = jest.fn(); - const mockApi = createMockApi({ - uploadDocument: jest.fn().mockRejectedValue(new Error('Upload failed')), - }); - const { result } = renderHook( - () => - useIngestDropZone({ - vectorStoreId: 'vs-1', - onUploadComplete, - }), - { wrapper: createApiTestWrapper(mockApi) }, - ); - - await act(async () => { - await result.current.onFileUpload(createFileList([validFile])); - }); - - expect(onUploadComplete).not.toHaveBeenCalled(); - }); - }); - - describe('onDrop', () => { - it('calls preventDefault and uploads dataTransfer.files', async () => { - const preventDefault = jest.fn(); - const mockApi = createMockApi(); - const { result } = renderHook( - () => useIngestDropZone({ vectorStoreId: 'vs-1' }), - { wrapper: createApiTestWrapper(mockApi) }, - ); - - await act(async () => { - result.current.onDrop({ - preventDefault, - dataTransfer: { files: createFileList([validFile]) }, - } as unknown as React.DragEvent); - }); - - expect(preventDefault).toHaveBeenCalled(); - expect(mockApi.uploadDocument).toHaveBeenCalled(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/useIngestDropZone.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/useIngestDropZone.ts deleted file mode 100644 index 83d0e37ff8a..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/useIngestDropZone.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useRef, type DragEvent } from 'react'; -import { useFileUpload } from '../../hooks/useFileUpload'; - -export interface UseIngestDropZoneParams { - vectorStoreId: string | null; - onUploadComplete?: (message: string) => void; -} - -export function useIngestDropZone({ - vectorStoreId, - onUploadComplete, -}: UseIngestDropZoneParams) { - const { - upload, - uploading, - error: uploadError, - clearError: clearUploadError, - } = useFileUpload(); - - const [dragOver, setDragOver] = useState(false); - const fileInputRef = useRef(null); - - const handleFileUpload = useCallback( - async (files: FileList | null) => { - if (!files || files.length === 0) return; - clearUploadError(); - let succeeded = 0; - const failedNames: string[] = []; - for (let i = 0; i < files.length; i++) { - const ok = await upload(files[i], vectorStoreId ?? undefined).then( - () => true, - () => false, - ); - if (ok) { - succeeded++; - } else { - failedNames.push(files[i].name); - } - } - if (fileInputRef.current) fileInputRef.current.value = ''; - const total = files.length; - if (succeeded === total) { - onUploadComplete?.( - `${succeeded} file${succeeded !== 1 ? 's' : ''} ingested successfully.`, - ); - } else if (succeeded > 0) { - onUploadComplete?.( - `${succeeded} of ${total} files ingested. Failed: ${failedNames.join(', ')}`, - ); - } - }, - [upload, clearUploadError, vectorStoreId, onUploadComplete], - ); - - const handleDrop = useCallback( - (e: DragEvent) => { - e.preventDefault(); - setDragOver(false); - handleFileUpload(e.dataTransfer.files); - }, - [handleFileUpload], - ); - - const handleDragOver = useCallback((e: DragEvent) => { - e.preventDefault(); - setDragOver(true); - }, []); - - const handleDragLeave = useCallback(() => { - setDragOver(false); - }, []); - - return { - onFileUpload: handleFileUpload, - uploading, - error: uploadError, - onClearError: clearUploadError, - dragOver, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, - fileInputRef, - }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/usePromptGroupsEditor.test.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/usePromptGroupsEditor.test.tsx deleted file mode 100644 index 0c78140b97c..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/usePromptGroupsEditor.test.tsx +++ /dev/null @@ -1,605 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { renderHook, act, waitFor } from '@testing-library/react'; -import { usePromptGroupsEditor } from './usePromptGroupsEditor'; -import { createApiTestWrapper, createAdminMockApi } from '../../test-utils'; -import type { PromptGroup } from '../../types'; - -describe('usePromptGroupsEditor', () => { - const createWrapper = (api: ReturnType) => { - return createApiTestWrapper(api); - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('initial state', () => { - it('starts with empty groups when no config', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: null, - source: 'default', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - expect(result.current.groups).toEqual([]); - expect(result.current.dirty).toBe(false); - }); - - it('loads groups from config entry when available', async () => { - const groups: PromptGroup[] = [ - { - id: 'group-1', - title: 'Test Group', - cards: [{ title: 'Card 1', prompt: 'Prompt 1' }], - order: 1, - }, - ]; - - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: { - configKey: 'promptGroups', - configValue: groups, - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - expect(result.current.groups).toHaveLength(1); - expect(result.current.groups[0].title).toBe('Test Group'); - expect(result.current.groups[0].cards).toHaveLength(1); - expect(result.current.groups[0].cards[0].title).toBe('Card 1'); - expect(result.current.groups[0].cards[0].prompt).toBe('Prompt 1'); - }); - }); - - describe('addGroup', () => { - it('creates a new group', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: null, - source: 'default', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - act(() => { - result.current.addGroup(); - }); - - expect(result.current.groups).toHaveLength(1); - expect(result.current.groups[0].title).toBe(''); - expect(result.current.groups[0].cards).toHaveLength(1); - expect(result.current.groups[0].cards[0].title).toBe(''); - expect(result.current.groups[0].cards[0].prompt).toBe(''); - }); - }); - - describe('removeGroup', () => { - it('removes a group', async () => { - const groups: PromptGroup[] = [ - { - id: 'group-1', - title: 'Group 1', - cards: [], - order: 1, - }, - { - id: 'group-2', - title: 'Group 2', - cards: [], - order: 2, - }, - ]; - - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: { - configKey: 'promptGroups', - configValue: groups, - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - const groupKey = result.current.groups[0]._key; - - act(() => { - result.current.removeGroup(groupKey); - }); - - expect(result.current.groups).toHaveLength(1); - expect(result.current.groups[0].title).toBe('Group 2'); - }); - }); - - describe('updateGroup', () => { - it('updates group properties', async () => { - const groups: PromptGroup[] = [ - { - id: 'group-1', - title: 'Original', - cards: [], - order: 1, - }, - ]; - - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: { - configKey: 'promptGroups', - configValue: groups, - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - const groupKey = result.current.groups[0]._key; - - act(() => { - result.current.updateGroup(groupKey, { - title: 'Updated Title', - description: 'Updated Description', - }); - }); - - expect(result.current.groups[0].title).toBe('Updated Title'); - expect(result.current.groups[0].description).toBe('Updated Description'); - }); - }); - - describe('addCard / removeCard / updateCard', () => { - it('addCard adds a card to a group', async () => { - const groups: PromptGroup[] = [ - { - id: 'group-1', - title: 'Group', - cards: [{ title: 'Card 1', prompt: 'P1' }], - order: 1, - }, - ]; - - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: { - configKey: 'promptGroups', - configValue: groups, - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - const groupKey = result.current.groups[0]._key; - - act(() => { - result.current.addCard(groupKey); - }); - - expect(result.current.groups[0].cards).toHaveLength(2); - expect(result.current.groups[0].cards[1].title).toBe(''); - expect(result.current.groups[0].cards[1].prompt).toBe(''); - }); - - it('removeCard removes a card from a group', async () => { - const groups: PromptGroup[] = [ - { - id: 'group-1', - title: 'Group', - cards: [ - { title: 'Card 1', prompt: 'P1' }, - { title: 'Card 2', prompt: 'P2' }, - ], - order: 1, - }, - ]; - - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: { - configKey: 'promptGroups', - configValue: groups, - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - const groupKey = result.current.groups[0]._key; - const cardKey = result.current.groups[0].cards[1]._key; - - act(() => { - result.current.removeCard(groupKey, cardKey); - }); - - expect(result.current.groups[0].cards).toHaveLength(1); - expect(result.current.groups[0].cards[0].title).toBe('Card 1'); - }); - - it('updateCard updates card properties', async () => { - const groups: PromptGroup[] = [ - { - id: 'group-1', - title: 'Group', - cards: [{ title: 'Card 1', prompt: 'P1' }], - order: 1, - }, - ]; - - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: { - configKey: 'promptGroups', - configValue: groups, - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - const groupKey = result.current.groups[0]._key; - const cardKey = result.current.groups[0].cards[0]._key; - - act(() => { - result.current.updateCard(groupKey, cardKey, { - title: 'Updated Card', - prompt: 'Updated Prompt', - }); - }); - - expect(result.current.groups[0].cards[0].title).toBe('Updated Card'); - expect(result.current.groups[0].cards[0].prompt).toBe('Updated Prompt'); - }); - }); - - describe('validation', () => { - it('handleSave with empty group title shows toast', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: null, - source: 'default', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - act(() => { - result.current.addGroup(); - }); - - await act(async () => { - await result.current.handleSave(); - }); - - expect(result.current.toast).toBe( - 'Please fix validation errors before saving', - ); - expect(result.current.validationErrors.groups.size).toBeGreaterThan(0); - }); - - it('handleSave with card missing prompt shows toast', async () => { - const groups: PromptGroup[] = [ - { - id: 'group-1', - title: 'Group', - cards: [{ title: 'Card 1', prompt: '' }], - order: 1, - }, - ]; - - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: { - configKey: 'promptGroups', - configValue: groups, - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - await act(async () => { - await result.current.handleSave(); - }); - - expect(result.current.toast).toBe( - 'Please fix validation errors before saving', - ); - expect(result.current.validationErrors.cards.size).toBeGreaterThan(0); - }); - - it('handleSave with no groups shows toast', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: null, - source: 'default', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - act(() => { - result.current.addGroup(); - }); - - await act(async () => { - await result.current.handleSave(); - }); - - expect(result.current.toast).toBe( - 'Please fix validation errors before saving', - ); - }); - }); - - describe('handleSave success', () => { - it('saves valid groups and shows success toast', async () => { - const groups: PromptGroup[] = [ - { - id: 'group-1', - title: 'Group', - cards: [{ title: 'Card 1', prompt: 'Prompt 1' }], - order: 1, - }, - ]; - - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: { - configKey: 'promptGroups', - configValue: groups, - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - (api.setAdminConfig as jest.Mock).mockResolvedValue({ - warnings: undefined, - }); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - await act(async () => { - await result.current.handleSave(); - }); - - expect(result.current.toast).toBe('Prompt groups saved successfully'); - expect(api.setAdminConfig).toHaveBeenCalledWith( - 'promptGroups', - expect.arrayContaining([ - expect.objectContaining({ - title: 'Group', - cards: expect.arrayContaining([ - expect.objectContaining({ title: 'Card 1', prompt: 'Prompt 1' }), - ]), - }), - ]), - ); - }); - }); - - describe('dirty tracking', () => { - it('is clean when no changes made', async () => { - const groups: PromptGroup[] = [ - { - id: 'group-1', - title: 'Group', - cards: [{ title: 'Card 1', prompt: 'P1' }], - order: 1, - }, - ]; - - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: { - configKey: 'promptGroups', - configValue: groups, - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - expect(result.current.dirty).toBe(false); - }); - - it('is dirty after modifying a group', async () => { - const groups: PromptGroup[] = [ - { - id: 'group-1', - title: 'Group', - cards: [{ title: 'Card 1', prompt: 'P1' }], - order: 1, - }, - ]; - - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: { - configKey: 'promptGroups', - configValue: groups, - updatedAt: '2025-01-01T00:00:00Z', - updatedBy: 'user:default/admin', - }, - source: 'database', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - const groupKey = result.current.groups[0]._key; - - act(() => { - result.current.updateGroup(groupKey, { title: 'Modified' }); - }); - - expect(result.current.dirty).toBe(true); - }); - }); - - describe('dismissToast', () => { - it('clears toast', async () => { - const api = createAdminMockApi(); - (api.getAdminConfig as jest.Mock).mockResolvedValue({ - entry: null, - source: 'default', - }); - (api.getPromptGroups as jest.Mock).mockResolvedValue([]); - - const { result } = renderHook(() => usePromptGroupsEditor(), { - wrapper: createWrapper(api), - }); - - await waitFor(() => { - expect(result.current.initialized).toBe(true); - }); - - act(() => { - result.current.addGroup(); - }); - - await act(async () => { - await result.current.handleSave(); - }); - - expect(result.current.toast).not.toBeNull(); - - act(() => { - result.current.dismissToast(); - }); - - expect(result.current.toast).toBeNull(); - }); - }); -}); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/usePromptGroupsEditor.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/usePromptGroupsEditor.ts deleted file mode 100644 index 5eb238b6f84..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/usePromptGroupsEditor.ts +++ /dev/null @@ -1,390 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import { augmentApiRef } from '../../api'; -import { useAdminConfig } from '../../hooks'; -import type { PromptGroup, PromptCard } from '../../types'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** Card with a stable React key for list identity. */ -export interface EditableCard extends PromptCard { - readonly _key: string; -} - -/** Group with a stable React key for list identity. */ -export interface EditableGroup extends Omit { - readonly _key: string; - cards: EditableCard[]; -} - -/** Per-field validation errors keyed by path. */ -export interface ValidationErrors { - readonly groups: ReadonlyMap; - readonly cards: ReadonlyMap; -} - -const EMPTY_VALIDATION: ValidationErrors = { - groups: new Map(), - cards: new Map(), -}; - -// --------------------------------------------------------------------------- -// Stable key generator (avoids Date.now() collisions) -// --------------------------------------------------------------------------- - -let nextId = 0; -function stableKey(prefix: string): string { - nextId += 1; - return `${prefix}-${nextId}`; -} - -// --------------------------------------------------------------------------- -// Data transforms -// --------------------------------------------------------------------------- - -function tagCard(c: PromptCard): EditableCard { - return { ...c, _key: stableKey('card') }; -} - -function tagGroup(g: PromptGroup): EditableGroup { - return { ...g, _key: stableKey('group'), cards: g.cards.map(tagCard) }; -} - -function toEditable(data: readonly PromptGroup[]): EditableGroup[] { - return data.map(tagGroup); -} - -function stripCard({ _key, ...rest }: EditableCard): PromptCard { - return rest; -} - -function toCleanGroups(groups: readonly EditableGroup[]): PromptGroup[] { - return groups - .filter(g => g.title.trim()) - .map(({ _key, ...rest }, i) => ({ - ...rest, - id: rest.id || `group-${i}`, - order: i + 1, - cards: rest.cards - .filter(c => c.title.trim() && c.prompt.trim()) - .map(stripCard), - })); -} - -function groupsEqual( - a: readonly PromptGroup[], - b: readonly PromptGroup[], -): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - const ga = a[i]; - const gb = b[i]; - if ( - ga.title !== gb.title || - ga.icon !== gb.icon || - ga.color !== gb.color || - ga.description !== gb.description || - ga.cards.length !== gb.cards.length - ) { - return false; - } - for (let j = 0; j < ga.cards.length; j++) { - const ca = ga.cards[j]; - const cb = gb.cards[j]; - if ( - ca.title !== cb.title || - ca.prompt !== cb.prompt || - ca.description !== cb.description || - ca.icon !== cb.icon - ) { - return false; - } - } - } - return true; -} - -// --------------------------------------------------------------------------- -// Validation -// --------------------------------------------------------------------------- - -function validate(groups: readonly EditableGroup[]): ValidationErrors { - const groupErrors = new Map(); - const cardErrors = new Map(); - - for (const group of groups) { - if (!group.title.trim()) { - groupErrors.set(`${group._key}:title`, 'Group title is required'); - } - const filledCards = group.cards.filter( - c => c.title.trim() || c.prompt.trim(), - ); - if (filledCards.length === 0 && group.title.trim()) { - groupErrors.set(`${group._key}:cards`, 'At least one card is required'); - } - for (const card of group.cards) { - if (card.title.trim() && !card.prompt.trim()) { - cardErrors.set(`${card._key}:prompt`, 'Prompt is required'); - } - if (!card.title.trim() && card.prompt.trim()) { - cardErrors.set(`${card._key}:title`, 'Title is required'); - } - } - } - - if (groupErrors.size === 0 && cardErrors.size === 0) { - return EMPTY_VALIDATION; - } - return { groups: groupErrors, cards: cardErrors }; -} - -// --------------------------------------------------------------------------- -// Hook -// --------------------------------------------------------------------------- - -export function usePromptGroupsEditor() { - const api = useApi(augmentApiRef); - const { entry, source, loading, saving, error, save, reset } = - useAdminConfig('promptGroups'); - - const [groups, setGroups] = useState([]); - const [initialized, setInitialized] = useState(false); - const [toast, setToast] = useState(null); - const [validationErrors, setValidationErrors] = - useState(EMPTY_VALIDATION); - const baselineRef = useRef([]); - - // Single initialization effect: DB entry takes priority, then YAML fallback - useEffect(() => { - if (loading || initialized) return undefined; - - if (entry) { - const data = entry.configValue as PromptGroup[]; - if (Array.isArray(data)) { - setGroups(toEditable(data)); - baselineRef.current = data; - } - setInitialized(true); - return undefined; - } - - let cancelled = false; - api - .getPromptGroups() - .then(yamlGroups => { - if (cancelled) return; - baselineRef.current = yamlGroups; - setGroups(yamlGroups.length > 0 ? toEditable(yamlGroups) : []); - setInitialized(true); - }) - .catch(() => { - if (!cancelled) setInitialized(true); - }); - - return () => { - cancelled = true; - }; - }, [loading, initialized, entry, api]); - - const handleSave = useCallback(async () => { - const errors = validate(groups); - setValidationErrors(errors); - if (errors.groups.size > 0 || errors.cards.size > 0) { - setToast('Please fix validation errors before saving'); - return; - } - - const cleanGroups = toCleanGroups(groups); - if (cleanGroups.length === 0) { - setToast('At least one group with a title is required'); - return; - } - - try { - await save(cleanGroups); - } catch { - return; - } - baselineRef.current = cleanGroups; - setToast('Prompt groups saved successfully'); - }, [groups, save]); - - const handleReset = useCallback(async () => { - try { - await reset(); - } catch { - return; - } - const yamlGroups = await api.getPromptGroups(); - baselineRef.current = yamlGroups; - setGroups(yamlGroups.length > 0 ? toEditable(yamlGroups) : []); - setValidationErrors(EMPTY_VALIDATION); - setToast('Reset to YAML defaults'); - }, [reset, api]); - - const updateGroup = useCallback( - ( - groupKey: string, - updates: Partial>, - ) => { - setGroups(prev => - prev.map(g => (g._key === groupKey ? { ...g, ...updates } : g)), - ); - setValidationErrors(EMPTY_VALIDATION); - }, - [], - ); - - const updateCard = useCallback( - ( - groupKey: string, - cardKey: string, - updates: Partial>, - ) => { - setGroups(prev => - prev.map(g => { - if (g._key !== groupKey) return g; - return { - ...g, - cards: g.cards.map(c => - c._key === cardKey ? { ...c, ...updates } : c, - ), - }; - }), - ); - setValidationErrors(EMPTY_VALIDATION); - }, - [], - ); - - const addCard = useCallback((groupKey: string) => { - const card = tagCard({ title: '', prompt: '', description: '' }); - setGroups(prev => - prev.map(g => - g._key === groupKey ? { ...g, cards: [...g.cards, card] } : g, - ), - ); - }, []); - - const removeCard = useCallback((groupKey: string, cardKey: string) => { - setGroups(prev => - prev.map(g => { - if (g._key !== groupKey) return g; - return { ...g, cards: g.cards.filter(c => c._key !== cardKey) }; - }), - ); - }, []); - - const addGroup = useCallback(() => { - const group = tagGroup({ - id: stableKey('group'), - title: '', - cards: [{ title: '', prompt: '', description: '' }], - order: 0, - }); - setGroups(prev => [...prev, group]); - }, []); - - const removeGroup = useCallback((groupKey: string) => { - setGroups(prev => prev.filter(g => g._key !== groupKey)); - }, []); - - const moveGroup = useCallback((fromIndex: number, toIndex: number) => { - setGroups(prev => { - const next = [...prev]; - const [moved] = next.splice(fromIndex, 1); - next.splice(toIndex, 0, moved); - return next; - }); - }, []); - - const moveCard = useCallback( - (groupKey: string, fromIndex: number, toIndex: number) => { - setGroups(prev => - prev.map(g => { - if (g._key !== groupKey) return g; - const cards = [...g.cards]; - const [moved] = cards.splice(fromIndex, 1); - cards.splice(toIndex, 0, moved); - return { ...g, cards }; - }), - ); - }, - [], - ); - - const dismissToast = useCallback(() => setToast(null), []); - - const previewGroups = useMemo(() => toCleanGroups(groups), [groups]); - - const dirty = useMemo( - () => !groupsEqual(previewGroups, baselineRef.current), - [previewGroups], - ); - - return useMemo( - () => ({ - groups, - previewGroups, - initialized, - dirty, - loading, - saving, - error, - source, - toast, - validationErrors, - handleSave, - handleReset, - updateGroup, - updateCard, - addCard, - removeCard, - addGroup, - removeGroup, - moveGroup, - moveCard, - dismissToast, - }), - [ - groups, - previewGroups, - initialized, - dirty, - loading, - saving, - error, - source, - toast, - validationErrors, - handleSave, - handleReset, - updateGroup, - updateCard, - addCard, - removeCard, - addGroup, - removeGroup, - moveGroup, - moveCard, - dismissToast, - ], - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/useStoreDocuments.ts b/workspaces/augment/plugins/augment/src/components/AdminPanels/useStoreDocuments.ts deleted file mode 100644 index 9dc29400956..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/useStoreDocuments.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState, useCallback } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import { augmentApiRef } from '../../api'; -import { useDocuments } from '../../hooks'; - -export interface UseStoreDocumentsParams { - selectedStoreId: string | null; - onRefresh: () => void; -} - -export function useStoreDocuments({ - selectedStoreId, - onRefresh, -}: UseStoreDocumentsParams) { - const api = useApi(augmentApiRef); - const { - documents, - loading: docsLoading, - error: docsError, - refresh: refreshDocs, - } = useDocuments(selectedStoreId); - - const [deleteInProgress, setDeleteInProgress] = useState(null); - const [deleteError, setDeleteError] = useState(null); - - const handleDelete = useCallback( - async (fileId: string) => { - setDeleteInProgress(fileId); - setDeleteError(null); - try { - await api.deleteDocument(fileId, selectedStoreId ?? undefined); - } catch (err) { - setDeleteError( - err instanceof Error ? err.message : 'Failed to delete document', - ); - return; - } finally { - setDeleteInProgress(null); - } - await refreshDocs(); - onRefresh(); - }, - [api, refreshDocs, selectedStoreId, onRefresh], - ); - - return { - documents, - docsLoading, - docsError, - refreshDocs, - deleteInProgress, - deleteError, - handleDelete, - }; -} diff --git a/workspaces/augment/plugins/augment/src/components/AgentStatusPanel/AgentStatusPanel.tsx b/workspaces/augment/plugins/augment/src/components/AgentStatusPanel/AgentStatusPanel.tsx deleted file mode 100644 index 7db53549146..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AgentStatusPanel/AgentStatusPanel.tsx +++ /dev/null @@ -1,239 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useState } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Tooltip from '@mui/material/Tooltip'; -import Collapse from '@mui/material/Collapse'; -import { useTheme, alpha } from '@mui/material/styles'; -import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import CloudIcon from '@mui/icons-material/Cloud'; -import StorageIcon from '@mui/icons-material/Storage'; -import BuildIcon from '@mui/icons-material/Build'; -import { useStatus, useDocuments } from '../../hooks'; -import { useBranding } from '../../hooks'; - -/** - * Agent Status Panel - Clean view of agent status - */ -export const AgentStatusPanel = () => { - const theme = useTheme(); - const { branding } = useBranding(); - const isDark = theme.palette.mode === 'dark'; - const [expanded, setExpanded] = useState(true); // Default expanded - const { status, loading } = useStatus(); - const { documents } = useDocuments(); - - const providerConnected = status?.provider.connected ?? false; - const vectorStoreConnected = status?.vectorStore.connected ?? false; - const mcpAvailable = status?.capabilities?.mcpTools.available ?? false; - const mcpConfigured = (status?.mcpServers?.length ?? 0) > 0; - - const getStatusText = () => { - if (loading) return 'Connecting...'; - if (!providerConnected) return 'Offline'; - return 'Ready'; - }; - - const getStatusColor = () => { - if (loading) return theme.palette.warning.main; - if (!providerConnected) return theme.palette.error.main; - return theme.palette.success.main; - }; - - const modelName = status?.provider.model || 'Unknown'; - const docCount = documents.length; - - return ( - - {/* Header */} - setExpanded(!expanded)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setExpanded(!expanded); - } - }} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1, - p: 1, - cursor: 'pointer', - '&:hover': { - backgroundColor: alpha(theme.palette.action.hover, 0.3), - }, - }} - > - - - {branding.appName} Agent - - - • {getStatusText()} - - - - - {/* Details */} - - - {/* LLM Provider */} - - - - {modelName} - - - - - {/* RAG / Vector Store (optional) */} - - - - - {docCount > 0 ? `RAG: ${docCount} docs` : 'RAG'} - - - - - - {/* MCP Tools (optional) */} - {mcpConfigured && ( - s.connected) - .map(s => s.name || s.id) - .join(', ')}` - : 'MCP tools unavailable (optional) — chat works without them' - } - placement="left" - > - - - - MCP Tools - - - - - )} - - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AgentStatusPanel/index.ts b/workspaces/augment/plugins/augment/src/components/AgentStatusPanel/index.ts deleted file mode 100644 index 3892d707c5d..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AgentStatusPanel/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { AgentStatusPanel } from './AgentStatusPanel'; diff --git a/workspaces/augment/plugins/augment/src/components/AgentStudio/AgentStudioPage.tsx b/workspaces/augment/plugins/augment/src/components/AgentStudio/AgentStudioPage.tsx deleted file mode 100644 index 7891e97b141..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AgentStudio/AgentStudioPage.tsx +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState, useCallback, useRef } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import { useTheme } from '@mui/material/styles'; -import { typeScale } from '../../theme/tokens'; -import { useAppState } from '../AugmentPage/AppStateProvider'; -import type { AgentPanelTourControl } from '../AdminPanels/KagentiPanels/KagentiAgentsPanel'; - -type StudioTab = 'catalog' | 'sandbox' | 'builds'; - -interface AgentStudioPageProps { - initialTab?: StudioTab; - onChatWithAgent?: (agentId: string) => void; - onFullScreenChange?: (fs: boolean) => void; -} - -/** - * Agent Studio page — unified view for agent catalog, sandbox, and builds. - * Combines what was previously separate flat panels into a tabbed experience. - */ -export function AgentStudioPage({ - initialTab = 'catalog', - onChatWithAgent, - onFullScreenChange, -}: AgentStudioPageProps) { - const theme = useTheme(); - const { kagentiNamespace } = useAppState(); - const [activeTab, setActiveTab] = useState(initialTab); - const agentTourRef = useRef(null); - - const handleTabChange = useCallback((_: unknown, val: string) => { - setActiveTab(val as StudioTab); - }, []); - - return ( - - - - Agent Studio - - - Build, test, and deploy AI agents. - - - - - - - - - - {activeTab === 'catalog' && ( - - )} - {activeTab === 'sandbox' && } - {activeTab === 'builds' && } - - ); -} - -// Lazy wrappers to avoid heavy imports until tab is active -import { lazy, Suspense } from 'react'; -import Skeleton from '@mui/material/Skeleton'; - -const KagentiAgentsPanelLazy = lazy(() => - import('../AdminPanels/KagentiPanels/KagentiAgentsPanel').then(m => ({ - default: m.KagentiAgentsPanel, - })), -); -const KagentiSandboxPanelLazy = lazy(() => - import('../AdminPanels/KagentiPanels/KagentiSandboxPanel').then(m => ({ - default: m.KagentiSandboxPanel, - })), -); -const KagentiBuildPipelinePanelLazy = lazy(() => - import('../AdminPanels/KagentiPanels/KagentiBuildPipelinePanel').then(m => ({ - default: m.KagentiBuildPipelinePanel, - })), -); - -function LoadingFallback() { - return ( - - - - - - ); -} - -function AgentCatalogLazy(props: { - namespace: string; - onChatWithAgent?: (id: string) => void; - onFullScreenChange?: (fs: boolean) => void; - tourControlRef: React.MutableRefObject; -}) { - return ( - }> - - - ); -} - -function SandboxLazy(props: { namespace: string }) { - if (!props.namespace) { - return ( - - - Select a namespace from the sidebar to manage sandbox sessions. - - - ); - } - return ( - }> - - - ); -} - -function BuildsLazy(props: { namespace: string }) { - return ( - }> - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AgentStudio/index.ts b/workspaces/augment/plugins/augment/src/components/AgentStudio/index.ts deleted file mode 100644 index e9da33c41b5..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AgentStudio/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { AgentStudioPage } from './AgentStudioPage'; diff --git a/workspaces/augment/plugins/augment/src/components/AugmentIcon/AugmentIcon.tsx b/workspaces/augment/plugins/augment/src/components/AugmentIcon/AugmentIcon.tsx deleted file mode 100644 index a917b12b20e..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentIcon/AugmentIcon.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon'; - -/** - * Augment icon component - */ -export const AugmentIconComponent = (props: SvgIconProps) => ( - - - -); diff --git a/workspaces/augment/plugins/augment/src/components/AugmentIcon/index.ts b/workspaces/augment/plugins/augment/src/components/AugmentIcon/index.ts deleted file mode 100644 index c826940b6e4..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentIcon/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { AugmentIconComponent } from './AugmentIcon'; diff --git a/workspaces/augment/plugins/augment/src/components/AugmentPage/AdminLayout.tsx b/workspaces/augment/plugins/augment/src/components/AugmentPage/AdminLayout.tsx deleted file mode 100644 index c2899ec3ad9..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentPage/AdminLayout.tsx +++ /dev/null @@ -1,583 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; -import Box from '@mui/material/Box'; -import Alert from '@mui/material/Alert'; -import { animations as tokenAnimations } from '../../theme/tokens'; -import { useAppState } from './AppStateProvider'; -import { CommandCenterHeader } from './CommandCenterHeader'; -import { CommandBar, ReviewQueue, ToolReviewQueue } from '../CommandCenter'; -import { NamespacePicker } from '../AdminPanels/KagentiPanels'; -import { AgentConfigPanel, BrandingPanel, AgentsPanel } from '../AdminPanels'; -import { - KagentiAgentsPanel, - KagentiAdminPanel, - KagentiDashboardLinks, - KagentiHomeDashboard, - KagentiBuildPipelinePanel, - KagentiSandboxPanel, -} from '../AdminPanels/KagentiPanels'; -import { KagentiConnectionSection } from '../AdminPanels/ModelToolsPanel/KagentiConnectionSection'; -import { DocsPanel } from '../AdminPanels/DocsPanel'; -import { AgentRegistryPanel } from '../AdminPanels/AgentRegistryPanel'; -import { OpsOverview } from '../CommandCenter'; -import type { AgentPanelTourControl } from '../AdminPanels/KagentiPanels/KagentiAgentsPanel'; -import type { ToolPanelTourControl } from '../AdminPanels/KagentiPanels/KagentiToolsPanel'; -import type { AdminPanel } from '../../hooks'; - -export interface AdminTourActions { - openAgentIntent: () => void; - selectAgentIntent: (cardId: string) => void; - openToolIntent: () => void; - selectToolDeploy: () => void; - closeAllDialogs: () => void; - setWizardStep: (step: number) => void; - setDeployMethod: (method: string) => void; -} - -interface AdminLayoutProps { - adminScrollSx: Record; - onOpenGuidedTours: () => void; - adminTourActionsRef: React.MutableRefObject; -} - -const AGENTS_PANELS: AdminPanel[] = [ - 'ops-review-queue', - 'ops-registry', - 'kagenti-builds', -]; -const PLATFORM_PANELS: AdminPanel[] = [ - 'ops-tool-review', - 'ops-platform', - 'kagenti-connection', - 'ops-observability', -]; -const SETTINGS_PANELS: AdminPanel[] = ['ops-branding', 'ops-admin', 'ops-docs']; - -/** - * Admin layout with sidebar + content area. - * Renders the appropriate sidebar (provider-aware) and panel content - * based on the current adminPanel state. - */ -export function AdminLayout({ - adminScrollSx, - onOpenGuidedTours, - adminTourActionsRef, -}: AdminLayoutProps) { - const { - isFullProvider, - adminPanel, - setAdminPanel, - switchToChat, - kagentiNamespace, - handleNamespaceChange, - chatContainerRef, - } = useAppState(); - - const [sidebarHidden, setSidebarHidden] = useState(false); - const [pendingCreateAgent, setPendingCreateAgent] = useState(false); - const [focusTarget, setFocusTarget] = useState(); - - const agentTourRef = useRef(null); - const toolTourRef = useRef(null); - - useEffect(() => { - adminTourActionsRef.current = { - openAgentIntent: () => agentTourRef.current?.openIntent(), - selectAgentIntent: (cardId: string) => - agentTourRef.current?.selectIntent(cardId), - openToolIntent: () => toolTourRef.current?.openIntent(), - selectToolDeploy: () => toolTourRef.current?.selectDeploy(), - closeAllDialogs: () => { - agentTourRef.current?.closeIntent(); - agentTourRef.current?.closeWizard(); - toolTourRef.current?.closeIntent(); - toolTourRef.current?.closeWizard(); - }, - setWizardStep: (step: number) => { - agentTourRef.current?.setWizardStep(step); - toolTourRef.current?.setWizardStep(step); - }, - setDeployMethod: (method: string) => - agentTourRef.current?.setDeployMethod(method), - }; - }); - - const handleFullScreenChange = useCallback((fullScreen: boolean) => { - setSidebarHidden(fullScreen); - }, []); - - const handleNavigateWithFocus = useCallback( - (panel: AdminPanel, name?: string) => { - setFocusTarget(name); - setAdminPanel(panel); - }, - [setAdminPanel], - ); - - const handleCreateAgent = useCallback(() => { - setFocusTarget(undefined); - setAdminPanel('kagenti-agents'); - setPendingCreateAgent(true); - }, [setAdminPanel]); - - const handleIntentOpened = useCallback(() => { - setPendingCreateAgent(false); - }, []); - - const handleChatWithAgent = useCallback( - (agentId: string) => { - switchToChat(); - chatContainerRef.current?.setSelectedModel(agentId); - }, - [switchToChat, chatContainerRef], - ); - - const handleOpenTours = useCallback(() => { - onOpenGuidedTours(); - }, [onOpenGuidedTours]); - - const namespacePicker = handleNamespaceChange ? ( - - ) : undefined; - - // 4 grouped command bar tabs - const commandBarItems = useMemo( - () => [ - { id: 'ops-home' as AdminPanel, label: 'Overview' }, - { id: 'ops-review-queue' as AdminPanel, label: 'Agents' }, - { id: 'ops-platform' as AdminPanel, label: 'Platform' }, - { id: 'ops-branding' as AdminPanel, label: 'Settings' }, - ], - [], - ); - - // Determine which top-level tab is active based on current panel - const activeTopTab = useMemo(() => { - if (adminPanel === 'ops-home' || adminPanel === 'kagenti-home') - return 'ops-home'; - if (AGENTS_PANELS.includes(adminPanel)) return 'ops-review-queue'; - if (PLATFORM_PANELS.includes(adminPanel)) return 'ops-platform'; - if (SETTINGS_PANELS.includes(adminPanel)) return 'ops-branding'; - return 'ops-home'; - }, [adminPanel]); - - const handleTopTabChange = useCallback( - (panel: AdminPanel) => { - // When switching top tabs, go to the first sub-panel of that group - if (panel === 'ops-review-queue' && !AGENTS_PANELS.includes(adminPanel)) - setAdminPanel('ops-review-queue'); - else if ( - panel === 'ops-platform' && - !PLATFORM_PANELS.includes(adminPanel) - ) - setAdminPanel('ops-platform'); - else if ( - panel === 'ops-branding' && - !SETTINGS_PANELS.includes(adminPanel) - ) - setAdminPanel('ops-branding'); - else setAdminPanel(panel); - }, - [adminPanel, setAdminPanel], - ); - - // Full provider with command bar navigation - if (isFullProvider) { - return ( - - {!sidebarHidden && ( - - )} - {/* Scrollable content area -- ONE centering container for sub-tabs + content */} - - - {/* Sub-tabs -- same container as content, same left edge */} - {AGENTS_PANELS.includes(adminPanel) && ( - - {[ - { - id: 'ops-review-queue' as AdminPanel, - label: 'Review Queue', - tour: 'subtab-review-queue', - }, - { - id: 'ops-registry' as AdminPanel, - label: 'Registry', - tour: 'subtab-registry', - }, - { - id: 'kagenti-builds' as AdminPanel, - label: 'Builds', - tour: 'subtab-builds', - }, - ].map(t => ( - setAdminPanel(t.id)} - sx={{ - px: 1.5, - py: 0.5, - borderRadius: 1, - cursor: 'pointer', - fontSize: '0.82rem', - fontWeight: adminPanel === t.id ? 700 : 500, - color: - adminPanel === t.id ? 'primary.main' : 'text.secondary', - bgcolor: - adminPanel === t.id ? 'action.selected' : 'transparent', - '&:hover': { bgcolor: 'action.hover' }, - }} - > - {t.label} - - ))} - {namespacePicker && ( - - {namespacePicker} - - )} - - )} - {PLATFORM_PANELS.includes(adminPanel) && ( - - {[ - { - id: 'ops-tool-review' as AdminPanel, - label: 'Tool Review', - tour: 'subtab-tool-review', - }, - { - id: 'ops-platform' as AdminPanel, - label: 'Config', - tour: 'subtab-config', - }, - { - id: 'kagenti-connection' as AdminPanel, - label: 'Agent Ops', - tour: 'subtab-agent-ops', - }, - { - id: 'ops-observability' as AdminPanel, - label: 'Observability', - tour: 'subtab-observability', - }, - ].map(t => ( - setAdminPanel(t.id)} - sx={{ - px: 1.5, - py: 0.5, - borderRadius: 1, - cursor: 'pointer', - fontSize: '0.82rem', - fontWeight: adminPanel === t.id ? 700 : 500, - color: - adminPanel === t.id ? 'primary.main' : 'text.secondary', - bgcolor: - adminPanel === t.id ? 'action.selected' : 'transparent', - '&:hover': { bgcolor: 'action.hover' }, - }} - > - {t.label} - - ))} - {namespacePicker && ( - - {namespacePicker} - - )} - - )} - {SETTINGS_PANELS.includes(adminPanel) && ( - - {[ - { - id: 'ops-branding' as AdminPanel, - label: 'Branding', - tour: 'subtab-branding', - }, - { - id: 'ops-admin' as AdminPanel, - label: 'Administration', - tour: 'subtab-admin', - }, - { - id: 'ops-docs' as AdminPanel, - label: 'Documentation', - tour: 'subtab-docs', - }, - ].map(t => ( - setAdminPanel(t.id)} - sx={{ - px: 1.5, - py: 0.5, - borderRadius: 1, - cursor: 'pointer', - fontSize: '0.82rem', - fontWeight: adminPanel === t.id ? 700 : 500, - color: - adminPanel === t.id ? 'primary.main' : 'text.secondary', - bgcolor: - adminPanel === t.id ? 'action.selected' : 'transparent', - '&:hover': { bgcolor: 'action.hover' }, - }} - > - {t.label} - - ))} - - )} - - {/* Panel content -- same centering container */} - setFocusTarget(undefined)} - agentTourRef={agentTourRef} - toolTourRef={toolTourRef} - /> - - - - ); - } - - // Simple provider with tab-based header - return ( - <> - - - - {adminPanel === 'platform' && } - {adminPanel === 'agents' && } - {adminPanel === 'branding' && } - - - - ); -} - -// --------------------------------------------------------------------------- -// Panel content renderer (keeps AdminLayout focused on layout) -// --------------------------------------------------------------------------- - -interface AdminPanelContentProps { - panel: AdminPanel; - namespace: string; - onNavigate: (panel: AdminPanel, name?: string) => void; - onCreateAgent: () => void; - onHelpTours: () => void; - onChatWithAgent: (agentId: string) => void; - onFullScreenChange: (fs: boolean) => void; - pendingCreateAgent: boolean; - onIntentOpened: () => void; - focusTarget: string | undefined; - onFocusConsumed: () => void; - agentTourRef: React.MutableRefObject; - toolTourRef: React.MutableRefObject; -} - -function AdminPanelContent({ - panel, - namespace, - onNavigate, - onCreateAgent, - onHelpTours, - onChatWithAgent, - onFullScreenChange, - pendingCreateAgent, - onIntentOpened, - focusTarget, - onFocusConsumed, - agentTourRef, - toolTourRef: _toolTourRef, -}: AdminPanelContentProps) { - switch (panel) { - // ── Agent Development (accessible when admin navigates here via Create Agent flow) ── - case 'kagenti-agents': - return ( - - ); - case 'kagenti-sandbox': - return namespace ? ( - - ) : ( - - - Namespace required — use the namespace dropdown at - the top of the sidebar to select a target namespace, then return - here to test your agents. - - - ); - case 'kagenti-builds': - return ; - - // ── Command Center (Ops) ── - case 'ops-home': - return ( - - ); - case 'kagenti-home': - return ( - - ); - case 'ops-review-queue': - return ; - case 'ops-tool-review': - return ; - case 'ops-registry': - case 'kagenti-registry': - return ; - case 'ops-platform': - case 'kagenti-platform': - case 'platform': - return ; - case 'kagenti-connection': - return ; - case 'ops-observability': - case 'kagenti-dashboards': - return ; - case 'ops-branding': - case 'kagenti-branding': - case 'branding': - return ; - case 'ops-admin': - case 'kagenti-admin': - return ; - case 'ops-docs': - case 'kagenti-docs': - return ; - case 'agents': - return ; - default: - return null; - } -} diff --git a/workspaces/augment/plugins/augment/src/components/AugmentPage/AdminOnboardingCard.tsx b/workspaces/augment/plugins/augment/src/components/AugmentPage/AdminOnboardingCard.tsx deleted file mode 100644 index c4d899c8d73..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentPage/AdminOnboardingCard.tsx +++ /dev/null @@ -1,282 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import { useTheme, alpha } from '@mui/material/styles'; -import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; -import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline'; -import type { BrandingConfig } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { useTranslation } from '../../hooks/useTranslation'; - -export interface AdminOnboardingCardProps { - branding: BrandingConfig; - onStartChat: () => void; - onOpenAdmin: () => void; -} - -export const AdminOnboardingCard = ({ - branding: _branding, - onStartChat, - onOpenAdmin, -}: AdminOnboardingCardProps) => { - const theme = useTheme(); - const { t } = useTranslation(); - - return ( - - - {/* Card header */} - - - - - - - {t('adminOnboarding.welcomeAdmin')} - - - - {t('adminOnboarding.adminAccessMessage')} - - - - {/* Option cards */} - - {/* Option 1: Continue to Chat */} - { - if (e.key === 'Enter' || e.key === ' ') onStartChat(); - }} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1.5, - p: 1.5, - mb: 1, - borderRadius: 2, - border: `1px solid ${alpha(theme.palette.divider, 0.3)}`, - cursor: 'pointer', - transition: 'all 0.15s ease', - '&:hover': { - borderColor: theme.palette.primary.main, - backgroundColor: alpha(theme.palette.primary.main, 0.04), - }, - '&:focus-visible': { - outline: `2px solid ${theme.palette.primary.main}`, - outlineOffset: 2, - }, - }} - > - - - - - - {t('adminOnboarding.continueToChat')} - - - {t('adminOnboarding.continueToChatDescription')} - - - - - {/* Option 2: Open Command Center */} - { - if (e.key === 'Enter' || e.key === ' ') onOpenAdmin(); - }} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1.5, - p: 1.5, - borderRadius: 2, - border: `1px solid ${alpha(theme.palette.divider, 0.3)}`, - cursor: 'pointer', - transition: 'all 0.15s ease', - '&:hover': { - borderColor: theme.palette.primary.main, - backgroundColor: alpha(theme.palette.primary.main, 0.04), - }, - '&:focus-visible': { - outline: `2px solid ${theme.palette.primary.main}`, - outlineOffset: 2, - }, - }} - > - - - - - - {t('adminOnboarding.openCommandCenter')} - - - {t('adminOnboarding.openCommandCenterDescription')} - - - - - - {/* Footer hint */} - - - {t('adminOnboarding.switchHint')} - - - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AugmentPage/AppRouter.tsx b/workspaces/augment/plugins/augment/src/components/AugmentPage/AppRouter.tsx deleted file mode 100644 index e0d7fee4970..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentPage/AppRouter.tsx +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useAppState } from './AppStateProvider'; -import { AdminLayout, type AdminTourActions } from './AdminLayout'; -import { ChatView } from './ChatView'; -import { TourProvider } from '../AdminPanels/shared/TourProvider'; -import { - TourControllerProvider, - type TourControllerCallbacks, -} from '../AdminPanels/shared/TourController'; -import { TourLauncherDialog } from '../AdminPanels/shared/TourLauncherDialog'; -import { useTours } from '../../hooks'; -import type { AdminPanel } from '../../hooks'; - -interface AppRouterProps { - adminScrollSx: Record; -} - -const noopAdminActions: AdminTourActions = { - openAgentIntent: () => {}, - selectAgentIntent: () => {}, - openToolIntent: () => {}, - selectToolDeploy: () => {}, - closeAllDialogs: () => {}, - setWizardStep: () => {}, - setDeployMethod: () => {}, -}; - -/** - * Top-level content router that delegates to either: - * - AdminLayout (when viewMode === 'admin' and user has admin access) - * - ChatView (default experience for all users) - * - * Also provides tour infrastructure (TourControllerProvider + TourProvider) - * at this level so tours work in both marketplace and command center views. - */ -export function AppRouter({ adminScrollSx }: AppRouterProps) { - const { - isAdmin, - viewMode, - liveStatus, - adminPanel, - setAdminPanel, - switchToAdmin, - switchToChat, - refreshStatus, - } = useAppState(); - - const { tours: tourDefinitions } = useTours(); - const adminTourActionsRef = useRef(noopAdminActions); - const marketplaceTourActionsRef = useRef(noopAdminActions); - const tourLaunchSourceRef = useRef<'marketplace' | 'command-center'>( - 'marketplace', - ); - - const [tourDialogOpen, setTourDialogOpen] = useState(false); - const [tourDialogPage, setTourDialogPage] = useState< - 'marketplace' | 'command-center' | undefined - >(); - - const handleOpenGuidedTours = useCallback( - (source?: 'marketplace' | 'command-center') => { - const resolved = - source ?? (viewMode === 'admin' ? 'command-center' : 'marketplace'); - tourLaunchSourceRef.current = resolved; - setTourDialogPage(resolved); - setTourDialogOpen(true); - }, - [viewMode], - ); - - const handleCloseTourDialog = useCallback(() => { - setTourDialogOpen(false); - }, []); - - // Refresh status when provider is switched via ProviderSelector - useEffect(() => { - const handler = () => refreshStatus(); - window.addEventListener('augment:provider-switched', handler); - return () => - window.removeEventListener('augment:provider-switched', handler); - }, [refreshStatus]); - - // Listen for the legacy event from older code paths - useEffect(() => { - const handler = () => handleOpenGuidedTours(); - window.addEventListener('augment:open-guided-tours', handler); - return () => - window.removeEventListener('augment:open-guided-tours', handler); - }, [handleOpenGuidedTours]); - - // Normalize adminPanel when the active provider changes - const prevProviderRef = useRef(liveStatus?.providerId); - useEffect(() => { - const currentProvider = liveStatus?.providerId; - const prevProvider = prevProviderRef.current; - prevProviderRef.current = currentProvider; - - if (!currentProvider || currentProvider === prevProvider) return; - - if (currentProvider === 'kagenti') { - const panelMap: Record = { - platform: 'kagenti-platform', - agents: 'kagenti-agents', - branding: 'kagenti-branding', - }; - setAdminPanel( - (panelMap[adminPanel] ?? 'kagenti-home') as typeof adminPanel, - ); - } else if (prevProvider === 'kagenti') { - const panelMap: Record = { - 'kagenti-platform': 'platform', - 'kagenti-agents': 'agents', - 'kagenti-branding': 'branding', - 'kagenti-home': 'platform', - 'kagenti-tools': 'platform', - 'kagenti-builds': 'platform', - 'kagenti-sandbox': 'platform', - 'kagenti-dashboards': 'platform', - 'kagenti-admin': 'platform', - 'kagenti-registry': 'platform', - 'kagenti-docs': 'platform', - }; - setAdminPanel((panelMap[adminPanel] ?? 'platform') as typeof adminPanel); - } - }, [liveStatus?.providerId]); // eslint-disable-line react-hooks/exhaustive-deps - - const getActiveActions = useCallback( - () => - viewMode === 'admin' - ? adminTourActionsRef.current - : marketplaceTourActionsRef.current, - [viewMode], - ); - - const tourCallbacks = useMemo( - () => ({ - navigatePanel: (panel: AdminPanel) => { - if (viewMode !== 'admin') { - switchToAdmin(undefined); - } - setTimeout(() => setAdminPanel(panel), viewMode !== 'admin' ? 200 : 0); - }, - openAgentIntent: () => getActiveActions().openAgentIntent(), - selectAgentIntent: (cardId: string) => - getActiveActions().selectAgentIntent(cardId), - openToolIntent: () => getActiveActions().openToolIntent(), - selectToolDeploy: () => getActiveActions().selectToolDeploy(), - closeAllDialogs: () => getActiveActions().closeAllDialogs(), - setWizardStep: (step: number) => getActiveActions().setWizardStep(step), - setDeployMethod: (method: string) => - getActiveActions().setDeployMethod(method), - returnToGuidedExperience: () => { - const source = tourLaunchSourceRef.current; - if (source === 'marketplace') { - switchToChat(); - } else { - setAdminPanel('ops-home'); - } - setTimeout(() => handleOpenGuidedTours(source), 300); - }, - switchToMarketplace: () => { - switchToChat(); - }, - switchToCommandCenter: () => { - switchToAdmin(undefined); - }, - }), - [ - setAdminPanel, - switchToChat, - switchToAdmin, - handleOpenGuidedTours, - viewMode, - getActiveActions, - ], - ); - - const showAdmin = isAdmin && viewMode === 'admin' && liveStatus; - - return ( - - - - {showAdmin ? ( - handleOpenGuidedTours('command-center')} - adminTourActionsRef={adminTourActionsRef} - /> - ) : ( - handleOpenGuidedTours('marketplace')} - marketplaceTourActionsRef={marketplaceTourActionsRef} - /> - )} - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AugmentPage/AppShell.tsx b/workspaces/augment/plugins/augment/src/components/AugmentPage/AppShell.tsx deleted file mode 100644 index c94a6599ccd..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentPage/AppShell.tsx +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useMemo, useEffect } from 'react'; -import Box from '@mui/material/Box'; -import LinearProgress from '@mui/material/LinearProgress'; -import { useTheme } from '@mui/material/styles'; -import { Content, Page } from '@backstage/core-components'; -import { typography, scrollbarStyles } from '../../theme/tokens'; -import { sanitizeBrandingUrl } from '../../theme/branding'; -import type { SecurityMode } from '../../types'; -import { SecurityGate } from './SecurityGate'; -import { useAppState } from './AppStateProvider'; -import { AppRouter } from './AppRouter'; -import './augment-isolation.css'; - -/** - * AppShell provides the outermost layout frame for the Augment plugin. - * It handles: - * - Security gate (auth check) - * - Global font/scrollbar/isolation styles - * - Branding (favicon) - * - Loading state when backend not yet ready - * - Delegates content rendering to AppRouter - */ -export function AppShell() { - const theme = useTheme(); - const { - securityLoading, - backendReady, - configurationErrors, - securityMode, - branding, - liveStatus, - viewMode, - isAdmin, - } = useAppState(); - - const adminScrollSx = useMemo(() => scrollbarStyles(theme), [theme]); - - useEffect(() => { - const safeFaviconUrl = sanitizeBrandingUrl(branding.faviconUrl); - if (!safeFaviconUrl) return undefined; - - let link = document.querySelector('link[rel="icon"]'); - const originalHref = link?.href; - - if (!link) { - link = document.createElement('link'); - link.rel = 'icon'; - document.head.appendChild(link); - } - link.href = safeFaviconUrl; - - return () => { - const currentLink = - document.querySelector('link[rel="icon"]'); - if (currentLink && originalHref) { - currentLink.href = originalHref; - } - }; - }, [branding.faviconUrl]); - - const showInitialLoading = isAdmin && viewMode === 'admin' && !liveStatus; - - return ( - - - - - {showInitialLoading ? ( - - - - ) : ( - - )} - - - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AugmentPage/AppStateProvider.tsx b/workspaces/augment/plugins/augment/src/components/AugmentPage/AppStateProvider.tsx deleted file mode 100644 index dfaa1776ac2..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentPage/AppStateProvider.tsx +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - createContext, - useContext, - useState, - useCallback, - useRef, - useMemo, - type ReactNode, - type RefObject, -} from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import { augmentApiRef } from '../../api'; -import { - useBranding, - useChatSessions, - useStatus, - useBackendStatus, - useAdminView, - type AdminPanel, -} from '../../hooks'; -import type { ChatContainerRef } from '../ChatContainer'; -import type { SecurityMode, Message } from '../../types'; - -export type UserPersona = 'developer' | 'ops' | 'super-admin'; - -export interface AppState { - // Persona & access - persona: UserPersona; - isAdmin: boolean; - - // Backend - securityMode: SecurityMode | null; - securityLoading: boolean; - backendReady: boolean | null; - configurationErrors: string[]; - providerId: string | undefined; - providerOffline: boolean; - - // Navigation - viewMode: 'chat' | 'admin'; - adminPanel: AdminPanel; - setAdminPanel: (panel: AdminPanel) => void; - switchToAdmin: (targetPanel?: AdminPanel) => void; - switchToChat: () => void; - showAdminBanner: boolean; - dismissAdminBanner: () => void; - - // Chat session - activeSessionId: string | undefined; - messages: Message[]; - loadingConversation: boolean; - messagesUnavailable: boolean; - sessionRefreshTrigger: number; - switchDialogOpen: boolean; - error: string | null; - setError: (e: string | null) => void; - handleNewChat: () => void; - handleMessagesChange: (msgs: Message[]) => void; - handleSessionCreated: (id: string) => void; - guardedSelectSession: (id: string) => void; - handleSwitchConfirm: () => void; - handleSwitchCancel: () => void; - chatContainerRef: RefObject; - - // Namespace (provider-specific workspace context) - kagentiNamespace: string; - handleNamespaceChange: (ns: string) => void; - - // Status - liveStatus: ReturnType['status']; - refreshStatus: () => void; - isFullProvider: boolean; - - // Branding - branding: ReturnType['branding']; -} - -const AppStateContext = createContext(null); - -export function useAppState(): AppState { - const ctx = useContext(AppStateContext); - if (!ctx) { - throw new Error('useAppState must be used within AppStateProvider'); - } - return ctx; -} - -interface Props { - children: ReactNode; -} - -export function AppStateProvider({ children }: Props) { - const api = useApi(augmentApiRef); - const { branding } = useBranding(); - const chatContainerRef = useRef(null); - - const [kagentiNamespace, setKagentiNamespace] = useState(() => { - try { - return sessionStorage.getItem('augment:kagenti-ns') || ''; - } catch { - return ''; - } - }); - - const handleNamespaceChange = useCallback((ns: string) => { - setKagentiNamespace(ns); - try { - sessionStorage.setItem('augment:kagenti-ns', ns); - } catch { - /* noop */ - } - }, []); - - const chatSessions = useChatSessions({ api, chatContainerRef }); - - const { - securityMode, - securityLoading, - backendReady, - configurationErrors, - isAdmin, - } = useBackendStatus(); - - const adminView = useAdminView({ isAdmin }); - - const { - status: liveStatus, - loading: statusPollLoading, - error: statusPollError, - refresh: refreshStatus, - } = useStatus(); - - const providerOffline = - !securityLoading && - !statusPollLoading && - backendReady !== false && - (liveStatus?.provider.connected === false || - (statusPollError !== null && !liveStatus)); - - const persona: UserPersona = useMemo(() => { - if (!isAdmin) return 'developer'; - return 'super-admin'; - }, [isAdmin]); - - const isFullProvider = liveStatus?.capabilities?.agentCards === true; - - const switchToAdmin = useCallback( - (targetPanel?: AdminPanel) => { - adminView.switchToAdmin(isFullProvider, targetPanel); - }, - [adminView, isFullProvider], - ); - - const value: AppState = useMemo( - () => ({ - persona, - isAdmin, - securityMode, - securityLoading, - backendReady, - configurationErrors, - providerId: liveStatus?.providerId, - providerOffline, - viewMode: adminView.viewMode, - adminPanel: adminView.adminPanel, - setAdminPanel: adminView.setAdminPanel, - switchToAdmin, - switchToChat: adminView.switchToChat, - showAdminBanner: adminView.showAdminBanner, - dismissAdminBanner: adminView.dismissAdminBanner, - activeSessionId: chatSessions.activeSessionId, - messages: chatSessions.messages, - loadingConversation: chatSessions.loadingConversation, - messagesUnavailable: chatSessions.messagesUnavailable, - sessionRefreshTrigger: chatSessions.sessionRefreshTrigger, - switchDialogOpen: chatSessions.switchDialogOpen, - error: chatSessions.error, - setError: chatSessions.setError, - handleNewChat: chatSessions.handleNewChat, - handleMessagesChange: chatSessions.handleMessagesChange, - handleSessionCreated: chatSessions.handleSessionCreated, - guardedSelectSession: chatSessions.guardedSelectSession, - handleSwitchConfirm: chatSessions.handleSwitchConfirm, - handleSwitchCancel: chatSessions.handleSwitchCancel, - chatContainerRef, - kagentiNamespace, - handleNamespaceChange, - liveStatus, - refreshStatus, - isFullProvider, - branding, - }), - [ - persona, - isAdmin, - securityMode, - securityLoading, - backendReady, - configurationErrors, - liveStatus, - providerOffline, - adminView, - switchToAdmin, - chatSessions, - kagentiNamespace, - handleNamespaceChange, - refreshStatus, - isFullProvider, - branding, - ], - ); - - return ( - - {children} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AugmentPage/AugmentErrorBoundary.tsx b/workspaces/augment/plugins/augment/src/components/AugmentPage/AugmentErrorBoundary.tsx deleted file mode 100644 index 8c4e819558d..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentPage/AugmentErrorBoundary.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Component, type ErrorInfo, type ReactNode } from 'react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import { Page, Content, ResponseErrorPanel } from '@backstage/core-components'; -import { debugError } from '../../utils'; - -interface Props { - children: ReactNode; -} - -interface State { - hasError: boolean; - error: Error | null; -} - -/** - * Top-level error boundary for the Augment page. - * Catches uncaught React render errors (including from hooks) - * and shows a recovery UI instead of a blank screen. - */ -export class AugmentErrorBoundary extends Component { - static getDerivedStateFromError(error: Error): State { - return { hasError: true, error }; - } - - constructor(props: Props) { - super(props); - this.state = { hasError: false, error: null }; - } - - componentDidCatch(error: Error, info: ErrorInfo): void { - debugError('Unhandled error:', error, info.componentStack); - } - - private handleReload = () => { - this.setState({ hasError: false, error: null }); - }; - - render() { - if (this.state.hasError) { - return ( - - - - {this.state.error && ( - - )} - - - - - - - ); - } - return this.props.children; - } -} diff --git a/workspaces/augment/plugins/augment/src/components/AugmentPage/AugmentPage.tsx b/workspaces/augment/plugins/augment/src/components/AugmentPage/AugmentPage.tsx deleted file mode 100644 index feac2f64765..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentPage/AugmentPage.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { AppStateProvider } from './AppStateProvider'; -import { AppShell } from './AppShell'; -import { AugmentErrorBoundary } from './AugmentErrorBoundary'; - -/** - * Main entry point for the Augment plugin page. - * - * Architecture: - * - AugmentErrorBoundary: catches unhandled errors - * - AppStateProvider: centralizes all app state (sessions, admin, branding, status) - * - AppShell: layout frame (security gate, fonts, branding) - * - AppRouter: delegates to ChatView or AdminLayout based on viewMode - */ -export const AugmentPage = () => ( - - - - - -); diff --git a/workspaces/augment/plugins/augment/src/components/AugmentPage/ChatView.tsx b/workspaces/augment/plugins/augment/src/components/AugmentPage/ChatView.tsx deleted file mode 100644 index 47e56c5c01d..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentPage/ChatView.tsx +++ /dev/null @@ -1,679 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState, useCallback, useEffect, useRef, type Ref } from 'react'; -import Box from '@mui/material/Box'; -import LinearProgress from '@mui/material/LinearProgress'; -import Alert from '@mui/material/Alert'; -import Snackbar from '@mui/material/Snackbar'; -import { ErrorBoundary } from '@backstage/core-components'; -import { - useApi, - discoveryApiRef, - fetchApiRef, -} from '@backstage/core-plugin-api'; -import { ChatContainer, type ChatContainerRef } from '../ChatContainer'; -import { RightPane } from '../RightPane'; -import { ChatViewModeProvider } from '../../hooks'; -import { SwitchSessionDialog } from './SwitchSessionDialog'; -import { ProviderOfflineBanner } from './ProviderOfflineBanner'; -import { useAppState } from './AppStateProvider'; -import Button from '@mui/material/Button'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { MarketplacePage } from '../Marketplace'; -import { AgentCreateIntentDialog } from '../AdminPanels/KagentiPanels/AgentCreateIntentDialog'; -import { CreateAgentWizard } from '../AdminPanels/KagentiPanels/CreateAgentWizard'; -import { ToolCreateIntentDialog } from '../AdminPanels/KagentiPanels/ToolCreateIntentDialog'; -import { CreateToolWizard } from '../AdminPanels/KagentiPanels/CreateToolWizard'; -import { AgentLifecycleDetail } from '../AdminPanels/KagentiPanels/AgentLifecycleDetail'; -import { WorkflowAgentDetail } from '../AdminPanels/KagentiPanels/WorkflowAgentDetail'; -import { WorkflowDashboard } from '../WorkflowBuilder/WorkflowDashboard'; -import { WorkflowEditor } from '../WorkflowBuilder/WorkflowEditor'; -import type { - KagentiAgentSummary, - WorkflowDefinition, -} from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import { WorkflowErrorBoundary } from '../WorkflowBuilder/WorkflowErrorBoundary'; -import type { DeploymentMethod } from '../AdminPanels/KagentiPanels/agentWizardTypes'; -import type { AdminTourActions } from './AdminLayout'; - -interface ChatViewProps { - onOpenGuidedTours?: () => void; - marketplaceTourActionsRef?: React.MutableRefObject; -} - -/** - * The Front Door experience. - * - * Shows the Agent Marketplace as the landing page. All user journeys - * (browse, chat, create agent) happen here without entering admin mode. - * Only the Command Center button (visible to admins) exits to admin mode. - */ -export function ChatView({ - onOpenGuidedTours, - marketplaceTourActionsRef, -}: ChatViewProps = {}) { - const { - isAdmin, - switchToAdmin, - providerOffline, - loadingConversation, - messagesUnavailable, - messages, - activeSessionId, - sessionRefreshTrigger, - switchDialogOpen, - error, - setError, - handleNewChat, - handleMessagesChange, - handleSessionCreated, - guardedSelectSession, - handleSwitchConfirm, - handleSwitchCancel, - chatContainerRef, - branding, - liveStatus, - kagentiNamespace, - } = useAppState(); - - const discoveryApi = useApi(discoveryApiRef); - const { fetch: authFetch } = useApi(fetchApiRef); - - const [rightPaneCollapsed, setRightPaneCollapsed] = useState(true); - const [currentAgent, setCurrentAgent] = useState(); - const [showMarketplace, setShowMarketplace] = useState(true); - const [marketplaceRefreshKey, setMarketplaceRefreshKey] = useState(0); - const [switchToMyAgents, setSwitchToMyAgents] = useState(0); - const justCreatedRef = useRef(false); - - // Agent creation state (all dialogs live in the front door) - const [intentDialogOpen, setIntentDialogOpen] = useState(false); - const [wizardOpen, setWizardOpen] = useState(false); - const [wizardDeployMethod, setWizardDeployMethod] = useState< - DeploymentMethod | undefined - >(); - const [showWorkflowBuilder, setShowWorkflowBuilder] = useState(false); - const [activeWorkflow, setActiveWorkflow] = - useState(null); - const [createSuccess, setCreateSuccess] = useState(null); - - // Tool creation state - const [toolIntentOpen, setToolIntentOpen] = useState(false); - const [toolWizardOpen, setToolWizardOpen] = useState(false); - - useEffect(() => { - if (marketplaceTourActionsRef) { - marketplaceTourActionsRef.current = { - openAgentIntent: () => setIntentDialogOpen(true), - selectAgentIntent: (_cardId: string) => { - setIntentDialogOpen(false); - setWizardOpen(true); - }, - openToolIntent: () => setToolIntentOpen(true), - selectToolDeploy: () => { - setToolIntentOpen(false); - setToolWizardOpen(true); - }, - closeAllDialogs: () => { - setIntentDialogOpen(false); - setWizardOpen(false); - setWizardDeployMethod(undefined); - setToolIntentOpen(false); - setToolWizardOpen(false); - }, - setWizardStep: () => {}, - setDeployMethod: (method: string) => - setWizardDeployMethod(method as DeploymentMethod), - }; - } - }); - - const handleOpenGuidedTour = useCallback(() => { - onOpenGuidedTours?.(); - }, [onOpenGuidedTours]); - - // Agent detail state (view draft agent details in front door) - const [detailAgent, setDetailAgent] = useState( - null, - ); - const [detailOrchAgentId, setDetailOrchAgentId] = useState( - null, - ); - - const handleCurrentAgentChange = useCallback( - (agent: string | undefined) => setCurrentAgent(agent), - [], - ); - - const toggleRightPane = () => setRightPaneCollapsed(prev => !prev); - - const handleSwitchToAdmin = useCallback(() => { - switchToAdmin(undefined); - }, [switchToAdmin]); - - const handleChatWithAgent = useCallback( - (agentId: string) => { - setShowMarketplace(false); - setDetailAgent(null); - setDetailOrchAgentId(null); - chatContainerRef.current?.setSelectedModel(agentId); - }, - [chatContainerRef], - ); - - const handleBackToMarketplace = useCallback(() => { - setShowMarketplace(true); - }, []); - - // --- Agent Creation Flow (stays in front door) --- - const handleOpenCreateAgent = useCallback(() => { - setIntentDialogOpen(true); - }, []); - - const handleCloseIntentDialog = useCallback(() => { - setIntentDialogOpen(false); - setMarketplaceRefreshKey(k => k + 1); - setSwitchToMyAgents(k => k + 1); - }, []); - - const handleSelectDeploy = useCallback((method?: DeploymentMethod) => { - setIntentDialogOpen(false); - setWizardDeployMethod(method); - setWizardOpen(true); - }, []); - - const handleSelectConfigure = useCallback(() => { - setIntentDialogOpen(false); - setShowMarketplace(false); - setShowWorkflowBuilder(true); - }, []); - - const handleWizardBack = useCallback(() => { - setWizardOpen(false); - setWizardDeployMethod(undefined); - if (justCreatedRef.current) { - justCreatedRef.current = false; - } else { - setIntentDialogOpen(true); - } - }, []); - - const handleCloseWorkflowBuilder = useCallback(() => { - setShowWorkflowBuilder(false); - setActiveWorkflow(null); - setShowMarketplace(true); - setMarketplaceRefreshKey(k => k + 1); - setSwitchToMyAgents(k => k + 1); - }, []); - - const handleAgentDetail = useCallback( - (agentId: string, framework?: string) => { - const isSkillOrWorkflow = - framework === 'docsclaw' || framework === 'workflow-builder'; - const parts = agentId.split('/'); - if (parts.length === 2 && !isSkillOrWorkflow) { - setDetailAgent({ - namespace: parts[0], - name: parts[1], - description: '', - status: '', - labels: {}, - } as KagentiAgentSummary); - setDetailOrchAgentId(null); - } else { - setDetailOrchAgentId(agentId); - setDetailAgent(null); - } - setShowMarketplace(false); - }, - [], - ); - - const handleCloseDetail = useCallback(() => { - setDetailAgent(null); - setDetailOrchAgentId(null); - setShowMarketplace(true); - }, []); - - const handleAgentCreated = useCallback(() => { - justCreatedRef.current = true; - setWizardOpen(false); - setWizardDeployMethod(undefined); - setCreateSuccess( - 'Agent created successfully! It will appear in your agents list.', - ); - setMarketplaceRefreshKey(k => k + 1); - setSwitchToMyAgents(k => k + 1); - }, []); - - const handleIntentCreated = useCallback(() => { - setIntentDialogOpen(false); - setMarketplaceRefreshKey(k => k + 1); - setSwitchToMyAgents(k => k + 1); - setTimeout(() => { - setCreateSuccess( - 'Agent created successfully! It will appear in your agents list.', - ); - }, 300); - }, []); - - // --- Tool Creation Flow --- - const handleOpenCreateTool = useCallback(() => { - setToolIntentOpen(true); - }, []); - - const handleToolSelectDeploy = useCallback(() => { - setToolIntentOpen(false); - setToolWizardOpen(true); - }, []); - - const handleToolWizardBack = useCallback(() => { - setToolWizardOpen(false); - setToolIntentOpen(true); - }, []); - - const handleToolCreated = useCallback(() => { - setToolWizardOpen(false); - setCreateSuccess( - 'Tool created successfully! It will appear in your My Tools list.', - ); - setMarketplaceRefreshKey(k => k + 1); - }, []); - - // Show marketplace overlay - const showMarketplaceView = showMarketplace; - - return ( - - - {loadingConversation && ( - - )} - - - - {/* Marketplace Front Door (overlays chat when active) */} - {showMarketplaceView && ( - - - - )} - - {/* Visual Workflow Builder (full-page, front door) */} - {showWorkflowBuilder && ( - - - - - - - {activeWorkflow ? ( - setActiveWorkflow(null)} - onPreview={() => {}} - onEnsurePersisted={async wf => { - try { - const baseUrl = - await discoveryApi.getBaseUrl('augment'); - const hdrs = { - 'Content-Type': 'application/json', - 'X-Backstage-Request': 'augment', - }; - const putResp = await authFetch( - `${baseUrl}/workflows/${wf.id}`, - { - method: 'PUT', - headers: hdrs, - body: JSON.stringify(wf), - }, - ); - if (putResp.ok) return true; - if (putResp.status === 404) { - const postResp = await authFetch( - `${baseUrl}/workflows`, - { - method: 'POST', - headers: hdrs, - body: JSON.stringify(wf), - }, - ); - return postResp.ok; - } - return false; - } catch { - return false; - } - }} - onSave={async wf => { - setActiveWorkflow(wf); - try { - const baseUrl = - await discoveryApi.getBaseUrl('augment'); - await authFetch(`${baseUrl}/workflows/${wf.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'X-Backstage-Request': 'augment', - }, - body: JSON.stringify(wf), - }); - } catch { - /* silent -- editor still works locally */ - } - }} - onPublish={async () => { - if (!activeWorkflow) return; - try { - const baseUrl = - await discoveryApi.getBaseUrl('augment'); - const hdrs = { - 'Content-Type': 'application/json', - 'X-Backstage-Request': 'augment', - }; - await authFetch( - `${baseUrl}/workflows/${activeWorkflow.id}/publish`, - { - method: 'POST', - headers: hdrs, - body: JSON.stringify({ - changelog: 'Published from Agent Builder', - }), - }, - ); - setShowWorkflowBuilder(false); - setActiveWorkflow(null); - setShowMarketplace(true); - setMarketplaceRefreshKey(k => k + 1); - setSwitchToMyAgents(k => k + 1); - setCreateSuccess( - 'Workflow saved! Find it in My Agents to test and submit for review.', - ); - } catch { - /* silent */ - } - }} - /> - ) : ( - setActiveWorkflow(wf)} - onCreateWorkflow={async wf => { - setActiveWorkflow(wf); - try { - const baseUrl = - await discoveryApi.getBaseUrl('augment'); - await authFetch(`${baseUrl}/workflows`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Backstage-Request': 'augment', - }, - body: JSON.stringify(wf), - }); - } catch { - /* silent -- workflow created locally */ - } - }} - /> - )} - - - - )} - - {/* Agent Detail View -- Kagenti agents (namespace/name) */} - {detailAgent && ( - - - - )} - - {/* Agent Detail View -- Workflow / Responses API agents */} - {detailOrchAgentId && ( - - { - try { - const baseUrl = await discoveryApi.getBaseUrl('augment'); - const resp = await authFetch(`${baseUrl}/workflows/${wfId}`, { - headers: { - 'X-Backstage-Request': 'augment', - }, - }); - if (resp.ok) { - const wf = await resp.json(); - setActiveWorkflow(wf); - setDetailOrchAgentId(null); - setShowMarketplace(false); - setShowWorkflowBuilder(true); - } - } catch { - /* silent */ - } - }} - /> - - )} - - {/* Agent Creation Dialogs (front door -- no admin required) */} - - - - - {/* Tool Creation Dialogs (front door -- no admin required) */} - setToolIntentOpen(false)} - onSelectDeploy={handleToolSelectDeploy} - /> - - - - {/* Chat Experience (always mounted so ref is available) */} - - - } - rightPaneCollapsed={rightPaneCollapsed} - messages={messages} - onMessagesChange={handleMessagesChange} - onNewChat={() => { - handleNewChat(); - handleBackToMarketplace(); - }} - onSessionCreated={handleSessionCreated} - loadingConversation={loadingConversation} - messagesUnavailable={messagesUnavailable} - onCurrentAgentChange={handleCurrentAgentChange} - activeSessionId={activeSessionId ?? undefined} - /> - - {!showMarketplaceView && - !showWorkflowBuilder && - !detailAgent && - !detailOrchAgentId && ( - { - setShowMarketplace(false); - guardedSelectSession(id); - }} - onActiveSessionDeleted={() => { - handleNewChat(); - handleBackToMarketplace(); - }} - activeSessionId={activeSessionId ?? undefined} - refreshTrigger={sessionRefreshTrigger} - isAdmin={isAdmin} - onAdminClick={handleSwitchToAdmin} - currentAgent={currentAgent} - messageCount={messages.length} - providerId={liveStatus?.providerId} - /> - )} - - - - {/* Success toast after agent creation */} - setCreateSuccess(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - setCreateSuccess(null)} - severity="success" - variant="filled" - sx={{ width: '100%' }} - > - {createSuccess} - - - - {/* Error toast */} - setError(null)} - anchorOrigin={{ vertical: 'top', horizontal: 'center' }} - > - setError(null)} - sx={{ width: '100%' }} - > - {error} - - - - - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AugmentPage/CommandCenterHeader.tsx b/workspaces/augment/plugins/augment/src/components/AugmentPage/CommandCenterHeader.tsx deleted file mode 100644 index e3d5751cee5..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentPage/CommandCenterHeader.tsx +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useMemo } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import Breadcrumbs from '@mui/material/Breadcrumbs'; -import NavigateNextIcon from '@mui/icons-material/NavigateNext'; -import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import TuneIcon from '@mui/icons-material/Tune'; -import PaletteIcon from '@mui/icons-material/Palette'; -import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; -import { useTheme, alpha } from '@mui/material/styles'; -import type { AdminPanel } from '../../hooks'; -import { useTranslation } from '../../hooks/useTranslation'; - -export interface CommandCenterHeaderProps { - adminPanel: AdminPanel; - onAdminPanelChange: (panel: AdminPanel) => void; - onBackToChat: () => void; -} - -/** - * Command center header with title, back button, and admin sub-tabs. - */ -export function CommandCenterHeader({ - adminPanel, - onAdminPanelChange, - onBackToChat, -}: CommandCenterHeaderProps) { - const theme = useTheme(); - const { t } = useTranslation(); - - const panelLabels = useMemo>>( - () => ({ - platform: t('commandCenter.platform'), - agents: t('commandCenter.agents'), - branding: t('commandCenter.branding'), - }), - [t], - ); - - const isDark = theme.palette.mode === 'dark'; - - return ( - <> - {/* Command Center Header — visually mirrors Backstage Header weight */} - - - - - {t('commandCenter.title')} - - } - aria-label="Admin navigation" - sx={{ - mt: 0.25, - '& .MuiBreadcrumbs-separator': { mx: 0.5 }, - }} - > - - {t('commandCenter.title')} - - - {panelLabels[adminPanel] ?? adminPanel} - - - - - - - {/* Command Center Sub-tabs */} - - onAdminPanelChange(v as AdminPanel)} - variant="scrollable" - scrollButtons="auto" - allowScrollButtonsMobile - sx={{ - minHeight: 44, - '& .MuiTab-root': { - minHeight: 44, - textTransform: 'none', - fontSize: '0.875rem', - fontWeight: 500, - minWidth: 'auto', - px: 2.5, - mr: 0.5, - }, - }} - > - } - iconPosition="start" - label={t('commandCenter.platform')} - value="platform" - /> - } - iconPosition="start" - label={t('commandCenter.agents')} - value="agents" - /> - } - iconPosition="start" - label={t('commandCenter.branding')} - value="branding" - /> - - - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AugmentPage/KagentiSidebar.tsx b/workspaces/augment/plugins/augment/src/components/AugmentPage/KagentiSidebar.tsx deleted file mode 100644 index e929b3b02fa..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentPage/KagentiSidebar.tsx +++ /dev/null @@ -1,485 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState, useCallback, useMemo, Fragment } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Tooltip from '@mui/material/Tooltip'; -import IconButton from '@mui/material/IconButton'; -import Drawer from '@mui/material/Drawer'; -import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme, alpha } from '@mui/material/styles'; -import HomeIcon from '@mui/icons-material/Home'; -import SmartToyIcon from '@mui/icons-material/SmartToy'; -import BuildIcon from '@mui/icons-material/Build'; -import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; -import ScienceIcon from '@mui/icons-material/Science'; -import TuneIcon from '@mui/icons-material/Tune'; -import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'; -import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined'; -import PaletteIcon from '@mui/icons-material/Palette'; -import StorefrontIcon from '@mui/icons-material/Storefront'; -import MenuBookIcon from '@mui/icons-material/MenuBook'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; -import ChevronRightIcon from '@mui/icons-material/ChevronRight'; -import type { AdminPanel } from '../../hooks'; -import { useTranslation } from '../../hooks/useTranslation'; -import { NamespacePicker } from '../AdminPanels/KagentiPanels'; -import { typeScale, iconSize } from '../../theme/tokens'; - -const SIDEBAR_WIDTH = 190; -const SIDEBAR_COLLAPSED_WIDTH = 48; - -interface NavItem { - id: AdminPanel; - label: string; - icon: React.ReactNode; - description?: string; - tourId: string; -} - -interface NavGroup { - title: string; - items: NavItem[]; -} - -export interface KagentiSidebarProps { - adminPanel: AdminPanel; - onAdminPanelChange: (panel: AdminPanel) => void; - onBackToChat: () => void; - kagentiNamespace?: string; - onKagentiNamespaceChange?: (ns: string) => void; -} - -export function KagentiSidebar({ - adminPanel, - onAdminPanelChange, - onBackToChat, - kagentiNamespace, - onKagentiNamespaceChange, -}: KagentiSidebarProps) { - const theme = useTheme(); - const { t } = useTranslation(); - const isDark = theme.palette.mode === 'dark'; - const isMobile = useMediaQuery(theme.breakpoints.down('md')); - const [collapsed, setCollapsed] = useState(false); - const [mobileOpen, setMobileOpen] = useState(false); - const effectiveCollapsed = isMobile ? false : collapsed; - const width = effectiveCollapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH; - - const navGroups = useMemo( - () => [ - { - title: '', - items: [ - { - id: 'kagenti-home' as AdminPanel, - label: t('commandCenter.home'), - icon: , - description: 'Dashboard overview and quick actions', - tourId: 'nav-home', - }, - ], - }, - { - title: 'Agent Development', - items: [ - { - id: 'kagenti-agents' as AdminPanel, - label: t('commandCenter.agentCatalog'), - icon: , - description: 'Deploy, manage, and chat with AI agents', - tourId: 'nav-agents', - }, - { - id: 'kagenti-tools' as AdminPanel, - label: t('commandCenter.toolCatalog'), - icon: , - description: 'Register MCP tool servers for your agents', - tourId: 'nav-tools', - }, - { - id: 'kagenti-builds' as AdminPanel, - label: t('commandCenter.buildPipelines'), - icon: , - description: 'View and trigger container image builds', - tourId: 'nav-builds', - }, - { - id: 'kagenti-sandbox' as AdminPanel, - label: t('commandCenter.sandbox'), - icon: , - description: 'Interactive testing sessions for agents', - tourId: 'nav-sandbox', - }, - ], - }, - { - title: 'Operations', - items: [ - { - id: 'kagenti-registry' as AdminPanel, - label: 'Agent Registry', - icon: , - description: 'Agent lifecycle — promote from draft to deployment', - tourId: 'nav-registry', - }, - { - id: 'kagenti-dashboards' as AdminPanel, - label: t('commandCenter.observability'), - icon: , - description: 'Dashboards for traces and monitoring', - tourId: 'nav-observability', - }, - { - id: 'kagenti-platform' as AdminPanel, - label: t('commandCenter.platformConfig'), - icon: , - description: 'Model, tools, RAG, and safety settings', - tourId: 'nav-platform', - }, - { - id: 'kagenti-branding' as AdminPanel, - label: t('commandCenter.branding'), - icon: , - description: - 'Customize appearance, prompt groups, and chat experience', - tourId: 'nav-branding', - }, - { - id: 'kagenti-admin' as AdminPanel, - label: t('commandCenter.administration'), - icon: ( - - ), - description: 'Users, namespaces, and build config', - tourId: 'nav-admin', - }, - { - id: 'kagenti-docs' as AdminPanel, - label: 'Documentation', - icon: , - description: 'Product guides, feature reference, and how-to docs', - tourId: 'nav-docs', - }, - ], - }, - ], - [t], - ); - - const handleNavClick = useCallback( - (panel: AdminPanel) => { - onAdminPanelChange(panel); - if (isMobile) setMobileOpen(false); - }, - [onAdminPanelChange, isMobile], - ); - - const sidebarContent = ( - - {/* Sidebar Header */} - - {!collapsed && ( - - {t('commandCenter.title')} - - )} - - setCollapsed(c => !c)} - aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} - sx={{ ml: collapsed ? 'auto' : 0, mr: collapsed ? 'auto' : 0 }} - > - {collapsed ? ( - - ) : ( - - )} - - - - - {/* Namespace Picker */} - {onKagentiNamespaceChange && !collapsed && ( - - - - )} - - {/* Navigation Groups */} - - {navGroups.map((group, groupIdx) => ( - - {groupIdx > 0 && groupIdx === navGroups.length - 1 && ( - - )} - - {!collapsed && group.title && ( - - {group.title} - - )} - - {group.items.map(item => { - const isActive = adminPanel === item.id; - const btn = ( - handleNavClick(item.id)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleNavClick(item.id); - } - }} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1.5, - px: collapsed ? 0 : 2, - py: 0.75, - mx: collapsed ? 0.5 : 0.75, - borderRadius: 1, - cursor: 'pointer', - justifyContent: collapsed ? 'center' : 'flex-start', - color: isActive - ? theme.palette.text.primary - : theme.palette.text.secondary, - bgcolor: isActive - ? alpha(theme.palette.text.primary, isDark ? 0.08 : 0.05) - : 'transparent', - fontWeight: isActive ? 600 : 400, - '&:hover': { - bgcolor: isActive - ? alpha(theme.palette.text.primary, isDark ? 0.1 : 0.07) - : alpha(theme.palette.action.hover, 0.5), - }, - transition: 'background-color 0.15s ease', - }} - > - - {item.icon} - - {!collapsed && ( - - {item.label} - - )} - - ); - - if (collapsed) { - return ( - - {btn} - - ); - } - if (item.description) { - return ( - - {btn} - - ); - } - return {btn}; - })} - - ))} - - - {/* Back to Chat */} - - {collapsed ? ( - - - - - - ) : ( - - )} - - - ); - - if (isMobile) { - return ( - <> - setMobileOpen(true)} - sx={{ - position: 'absolute', - top: 8, - left: 8, - zIndex: 10, - display: { xs: 'inline-flex', md: 'none' }, - }} - aria-label="Open navigation" - > - - - setMobileOpen(false)} - ModalProps={{ keepMounted: true }} - sx={{ - display: { xs: 'block', md: 'none' }, - '& .MuiDrawer-paper': { width: SIDEBAR_WIDTH }, - }} - > - {sidebarContent} - - - ); - } - - return sidebarContent; -} diff --git a/workspaces/augment/plugins/augment/src/components/AugmentPage/ProviderOfflineBanner.tsx b/workspaces/augment/plugins/augment/src/components/AugmentPage/ProviderOfflineBanner.tsx deleted file mode 100644 index 350bfaa23dd..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentPage/ProviderOfflineBanner.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Alert from '@mui/material/Alert'; -import AlertTitle from '@mui/material/AlertTitle'; -import { useTranslation } from '../../hooks/useTranslation'; - -export interface ProviderOfflineBannerProps { - /** Whether the AI provider is offline or unreachable */ - show: boolean; - /** Whether a conversation is currently loading (affects top offset for LinearProgress) */ - loadingConversation: boolean; - /** Whether the status poll failed (different message for connection vs model unreachable) */ - statusPollError: boolean; - /** App name for the error message */ - appName: string; -} - -/** - * Banner shown when the AI provider is offline or unreachable. - * Positioned at the top of the chat area to warn users before they send a message. - */ -export function ProviderOfflineBanner({ - show, - loadingConversation, - statusPollError, - appName, -}: ProviderOfflineBannerProps) { - const { t } = useTranslation(); - if (!show) return null; - - return ( - - {t('providerOffline.title')} - {statusPollError - ? t('providerOffline.backendUnreachable', { appName }) - : t('providerOffline.modelUnreachable')} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AugmentPage/SecurityGate.tsx b/workspaces/augment/plugins/augment/src/components/AugmentPage/SecurityGate.tsx deleted file mode 100644 index cd484e2063f..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentPage/SecurityGate.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ReactNode } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Alert from '@mui/material/Alert'; -import AlertTitle from '@mui/material/AlertTitle'; -import WarningAmberIcon from '@mui/icons-material/WarningAmber'; -import { useTheme } from '@mui/material/styles'; -import { Progress } from '@backstage/core-components'; -import { RequirePermission } from '@backstage/plugin-permission-react'; -import { augmentAccessPermission } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { BrandingConfig } from '@red-hat-developer-hub/backstage-plugin-augment-common'; -import type { SecurityMode } from '../../types'; -import { useTranslation } from '../../hooks/useTranslation'; - -export interface SecurityGateProps { - securityLoading: boolean; - backendReady: boolean | null; - configurationErrors: string[]; - securityMode: SecurityMode | null; - branding: BrandingConfig; - children: ReactNode; -} - -/** - * Handles security/permission gating for the augment page: - * - Loading spinner when configs/permissions are loading - * - Config error page display - * - Permission gate wrapping (RequirePermission) in plugin-only/full modes - */ -export function SecurityGate({ - securityLoading, - backendReady, - configurationErrors, - securityMode, - branding, - children, -}: SecurityGateProps) { - const theme = useTheme(); - const { t } = useTranslation(); - - if (securityLoading) { - return ; - } - - // Show configuration error page if backend is not ready - if (backendReady === false && configurationErrors.length > 0) { - return ( - - - - {t('securityGate.configurationRequired')} - - - {t('securityGate.configurationErrors', { appName: branding.appName })} - - - {configurationErrors.map(err => ( - - - {t('securityGate.configurationErrorLabel')} - - {err} - - ))} - - - {t('securityGate.configurationHint')} - - - ); - } - - // In 'none' mode, don't require any permissions - if (securityMode === 'none') { - return <>{children}; - } - - // In 'plugin-only' or 'full' modes, require the permission - return ( - - - {t('securityGate.accessDenied')} - - - {t('securityGate.accessDeniedMessage', { - appName: branding.appName, - })} - - - } - > - {children} - - ); -} diff --git a/workspaces/augment/plugins/augment/src/components/AugmentPage/SwitchSessionDialog.tsx b/workspaces/augment/plugins/augment/src/components/AugmentPage/SwitchSessionDialog.tsx deleted file mode 100644 index 609f60bbdb0..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentPage/SwitchSessionDialog.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import DialogContent from '@mui/material/DialogContent'; -import DialogContentText from '@mui/material/DialogContentText'; -import DialogActions from '@mui/material/DialogActions'; -import Button from '@mui/material/Button'; -import { useTranslation } from '../../hooks/useTranslation'; - -export interface SwitchSessionDialogProps { - open: boolean; - onConfirm: () => void; - onCancel: () => void; -} - -export const SwitchSessionDialog = ({ - open, - onConfirm, - onCancel, -}: SwitchSessionDialogProps) => { - const { t } = useTranslation(); - return ( - - - {t('switchDialog.title')} - - - {t('switchDialog.message')} - - - - - - - ); -}; diff --git a/workspaces/augment/plugins/augment/src/components/AugmentPage/augment-isolation.css b/workspaces/augment/plugins/augment/src/components/AugmentPage/augment-isolation.css deleted file mode 100644 index 9c7f7362357..00000000000 --- a/workspaces/augment/plugins/augment/src/components/AugmentPage/augment-isolation.css +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Plugin-level CSS isolation for the Augment plugin. - * - * Prevents PatternFly (PF5/PF6) global styles from interfering with - * MUI-based layout when deployed inside Red Hat Developer Hub (RHDH). - * - * All rules are scoped under .augment-plugin-root so they never leak - * into other Backstage plugins or the shell. - * - * Scrollbar styling is handled by scrollbarStyles() in theme/tokens.ts - * and applied via sx props — no CSS-level scrollbar overrides here. - */ - -/* ── CSS Custom Properties ───────────────────────────────────────── - * Host Backstage instances can override these on .augment-plugin-root - * to customize the plugin's appearance without forking. - * - * Example in host CSS: - * .augment-plugin-root { - * --augment-font-family: 'Your Font', sans-serif; - * --augment-radius-sm: 4px; - * --augment-content-max-width: 960px; - * } - */ -.augment-plugin-root { - --augment-font-family: - -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', - 'Helvetica Neue', 'Segoe UI', 'Roboto', sans-serif; - --augment-font-family-mono: - 'SF Mono', 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Monaco', - monospace; - --augment-radius-xs: 4px; - --augment-radius-sm: 8px; - --augment-radius-md: 12px; - --augment-radius-lg: 16px; - --augment-sidebar-width: 190px; - --augment-sidebar-width-collapsed: 48px; - --augment-content-max-width: 1200px; - --augment-admin-max-width: 960px; -} - -/* ── Typography resets ────────────────────────────────────────────── */ -.augment-plugin-root h1, -.augment-plugin-root h2, -.augment-plugin-root h3, -.augment-plugin-root h4, -.augment-plugin-root h5, -.augment-plugin-root h6 { - margin-top: 0; - margin-bottom: 0; - color: inherit; -} - -.augment-plugin-root p { - margin-top: 0; - margin-bottom: 0; - color: inherit; -} - -/* Prevent PF from overriding MUI text colors in dark mode */ -.pf-v5-c-page .augment-plugin-root, -.pf-v6-c-page .augment-plugin-root { - color: inherit; -} -.pf-v5-c-page .augment-plugin-root h1, -.pf-v5-c-page .augment-plugin-root h2, -.pf-v5-c-page .augment-plugin-root h3, -.pf-v5-c-page .augment-plugin-root h4, -.pf-v5-c-page .augment-plugin-root h5, -.pf-v5-c-page .augment-plugin-root h6, -.pf-v5-c-page .augment-plugin-root p, -.pf-v5-c-page .augment-plugin-root span, -.pf-v5-c-page .augment-plugin-root label, -.pf-v6-c-page .augment-plugin-root h1, -.pf-v6-c-page .augment-plugin-root h2, -.pf-v6-c-page .augment-plugin-root h3, -.pf-v6-c-page .augment-plugin-root h4, -.pf-v6-c-page .augment-plugin-root h5, -.pf-v6-c-page .augment-plugin-root h6, -.pf-v6-c-page .augment-plugin-root p, -.pf-v6-c-page .augment-plugin-root span, -.pf-v6-c-page .augment-plugin-root label { - color: inherit; -} - -/* ── PF page section padding override ───────────────────────────── - * RHDH wraps Content in .pf-v5-c-page__main-section which adds 24px - * padding. We neutralize it inside our root. */ -.augment-plugin-root .pf-v5-c-page__main-section, -.augment-plugin-root .pf-v6-c-page__main-section { - padding: 0; -} - -/* ── PatternFly-specific overrides ──────────────────────────────── - * These rules ONLY activate when PatternFly is present in the page - * (detected via .pf-v5-c-page or .pf-v6-c-page on an ancestor). - * Without PF, MUI styles work fine on their own — no overrides needed. */ - -/* PF overrides