diff --git a/workspaces/boost/plugins/boost-common/package.json b/workspaces/boost/plugins/boost-common/package.json new file mode 100644 index 0000000000..4ab9b0e1bb --- /dev/null +++ b/workspaces/boost/plugins/boost-common/package.json @@ -0,0 +1,56 @@ +{ + "name": "@red-hat-developer-hub/backstage-plugin-boost-common", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "description": "Shared types, permissions, and service ref for the Boost plugin", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "common-library", + "pluginId": "boost", + "pluginPackage": "@red-hat-developer-hub/backstage-plugin-boost-common", + "pluginPackages": [ + "@red-hat-developer-hub/backstage-plugin-boost", + "@red-hat-developer-hub/backstage-plugin-boost-backend", + "@red-hat-developer-hub/backstage-plugin-boost-common" + ] + }, + "repository": { + "type": "git", + "url": "https://github.com/redhat-developer/rhdh-plugins", + "directory": "workspaces/boost/plugins/boost-common" + }, + "keywords": [ + "backstage", + "plugin", + "agentic", + "ai", + "boost" + ], + "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/backend-plugin-api": "^1.3.0", + "@backstage/plugin-permission-common": "^0.9.2" + }, + "devDependencies": { + "@backstage/cli": "^0.34.5" + } +} diff --git a/workspaces/boost/plugins/boost-common/src/index.ts b/workspaces/boost/plugins/boost-common/src/index.ts new file mode 100644 index 0000000000..19ea06038b --- /dev/null +++ b/workspaces/boost/plugins/boost-common/src/index.ts @@ -0,0 +1,75 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + BOOST_AGENT_RESOURCE_TYPE, + BOOST_TOOL_RESOURCE_TYPE, + BOOST_PERMISSION_RULES, + boostAccessPermission, + boostAdminPermission, + boostAgentListPermission, + boostAgentRegisterPermission, + boostAgentPromotePermission, + boostAgentApprovePermission, + boostAgentDemotePermission, + boostAgentPublishPermission, + boostAgentUnpublishPermission, + boostAgentWithdrawPermission, + boostAgentDeletePermission, + boostAgentConfigurePermission, + boostToolPromotePermission, + boostToolApprovePermission, + boostToolDemotePermission, + boostToolPublishPermission, + boostToolUnpublishPermission, + boostKagentiAdminPermission, + boostChatReadPermission, + boostChatCreatePermission, + boostDocumentsManagePermission, + boostMcpManagePermission, + boostConfigManagePermission, + boostPermissions, +} from './permissions'; + +export { boostAiProviderServiceRef } from './services'; + +export type { + ProviderCapabilities, + ProviderDescriptor, + ResponseUsage, + ConversationCapability, + AgenticProvider, + InputItem, + ConversationSummary, + ConversationDetails, + NormalizedStreamEvent, + StreamStartedEvent, + StreamTextDeltaEvent, + StreamTextDoneEvent, + StreamReasoningDeltaEvent, + StreamReasoningDoneEvent, + StreamToolDiscoveryEvent, + StreamToolStartedEvent, + StreamToolDeltaEvent, + StreamToolCompletedEvent, + StreamToolFailedEvent, + StreamToolApprovalEvent, + StreamBackendToolExecutingEvent, + StreamRagResultsEvent, + StreamAgentHandoffEvent, + StreamCompletedEvent, + StreamErrorEvent, +} from './types'; diff --git a/workspaces/boost/plugins/boost-common/src/permissions.test.ts b/workspaces/boost/plugins/boost-common/src/permissions.test.ts new file mode 100644 index 0000000000..8684f3eb44 --- /dev/null +++ b/workspaces/boost/plugins/boost-common/src/permissions.test.ts @@ -0,0 +1,236 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + boostAccessPermission, + boostAdminPermission, + boostAgentListPermission, + boostAgentRegisterPermission, + boostAgentPromotePermission, + boostAgentApprovePermission, + boostAgentDemotePermission, + boostAgentPublishPermission, + boostAgentUnpublishPermission, + boostAgentWithdrawPermission, + boostAgentDeletePermission, + boostAgentConfigurePermission, + boostToolPromotePermission, + boostToolApprovePermission, + boostToolDemotePermission, + boostToolPublishPermission, + boostToolUnpublishPermission, + boostKagentiAdminPermission, + boostChatReadPermission, + boostChatCreatePermission, + boostDocumentsManagePermission, + boostMcpManagePermission, + boostConfigManagePermission, + boostPermissions, + BOOST_AGENT_RESOURCE_TYPE, + BOOST_TOOL_RESOURCE_TYPE, + BOOST_PERMISSION_RULES, +} from './permissions'; + +describe('permissions', () => { + describe('top-level permissions', () => { + it('boostAccessPermission has the correct name and action', () => { + expect(boostAccessPermission.name).toBe('boost.access'); + expect(boostAccessPermission.attributes).toEqual({ action: 'read' }); + }); + + it('boostAdminPermission has the correct name and action', () => { + expect(boostAdminPermission.name).toBe('boost.admin'); + expect(boostAdminPermission.attributes).toEqual({ action: 'update' }); + }); + }); + + describe('agent lifecycle permissions', () => { + it('defines all 10 agent permissions with correct names', () => { + expect(boostAgentListPermission.name).toBe('boost.agent.list'); + expect(boostAgentRegisterPermission.name).toBe('boost.agent.register'); + expect(boostAgentPromotePermission.name).toBe('boost.agent.promote'); + expect(boostAgentApprovePermission.name).toBe('boost.agent.approve'); + expect(boostAgentDemotePermission.name).toBe('boost.agent.demote'); + expect(boostAgentPublishPermission.name).toBe('boost.agent.publish'); + expect(boostAgentUnpublishPermission.name).toBe('boost.agent.unpublish'); + expect(boostAgentWithdrawPermission.name).toBe('boost.agent.withdraw'); + expect(boostAgentDeletePermission.name).toBe('boost.agent.delete'); + expect(boostAgentConfigurePermission.name).toBe( + 'boost.agent.configure', + ); + }); + + it('resource-based agent permissions declare the boost-agent resource type', () => { + const resourcePermissions = [ + boostAgentPromotePermission, + boostAgentApprovePermission, + boostAgentDemotePermission, + boostAgentPublishPermission, + boostAgentUnpublishPermission, + boostAgentWithdrawPermission, + boostAgentDeletePermission, + ]; + for (const perm of resourcePermissions) { + expect(perm.resourceType).toBe(BOOST_AGENT_RESOURCE_TYPE); + } + }); + + it('non-resource agent permissions do not have a resource type', () => { + expect('resourceType' in boostAgentListPermission).toBe(false); + expect('resourceType' in boostAgentRegisterPermission).toBe(false); + expect('resourceType' in boostAgentConfigurePermission).toBe(false); + }); + + it('boostAgentDeletePermission has delete action', () => { + expect(boostAgentDeletePermission.attributes).toEqual({ + action: 'delete', + }); + }); + }); + + describe('tool lifecycle permissions', () => { + it('defines all 5 tool permissions with correct names', () => { + expect(boostToolPromotePermission.name).toBe('boost.tool.promote'); + expect(boostToolApprovePermission.name).toBe('boost.tool.approve'); + expect(boostToolDemotePermission.name).toBe('boost.tool.demote'); + expect(boostToolPublishPermission.name).toBe('boost.tool.publish'); + expect(boostToolUnpublishPermission.name).toBe('boost.tool.unpublish'); + }); + + it('all tool permissions declare the boost-tool resource type', () => { + const toolPermissions = [ + boostToolPromotePermission, + boostToolApprovePermission, + boostToolDemotePermission, + boostToolPublishPermission, + boostToolUnpublishPermission, + ]; + for (const perm of toolPermissions) { + expect(perm.resourceType).toBe(BOOST_TOOL_RESOURCE_TYPE); + } + }); + }); + + describe('infrastructure permission', () => { + it('boostKagentiAdminPermission has the correct name', () => { + expect(boostKagentiAdminPermission.name).toBe('boost.kagenti.admin'); + expect(boostKagentiAdminPermission.attributes).toEqual({ + action: 'update', + }); + }); + }); + + describe('functional permissions', () => { + it('defines all 5 functional permissions', () => { + expect(boostChatReadPermission.name).toBe('boost.chat.read'); + expect(boostChatReadPermission.attributes).toEqual({ action: 'read' }); + + expect(boostChatCreatePermission.name).toBe('boost.chat.create'); + expect(boostChatCreatePermission.attributes).toEqual({ + action: 'create', + }); + + expect(boostDocumentsManagePermission.name).toBe( + 'boost.documents.manage', + ); + expect(boostDocumentsManagePermission.attributes).toEqual({ + action: 'update', + }); + + expect(boostMcpManagePermission.name).toBe('boost.mcp.manage'); + expect(boostMcpManagePermission.attributes).toEqual({ + action: 'update', + }); + + expect(boostConfigManagePermission.name).toBe('boost.config.manage'); + expect(boostConfigManagePermission.attributes).toEqual({ + action: 'update', + }); + }); + }); + + describe('resource types', () => { + it('defines the correct resource type strings', () => { + expect(BOOST_AGENT_RESOURCE_TYPE).toBe('boost-agent'); + expect(BOOST_TOOL_RESOURCE_TYPE).toBe('boost-tool'); + }); + }); + + describe('conditional permission rules', () => { + it('defines all three conditional rules', () => { + expect(BOOST_PERMISSION_RULES.IS_OWNER).toBe('IS_OWNER'); + expect(BOOST_PERMISSION_RULES.IS_NOT_CREATOR).toBe('IS_NOT_CREATOR'); + expect(BOOST_PERMISSION_RULES.HAS_LIFECYCLE_STAGE).toBe( + 'HAS_LIFECYCLE_STAGE', + ); + }); + }); + + describe('boostPermissions collection', () => { + it('contains all 23 permissions', () => { + expect(boostPermissions).toHaveLength(23); + }); + + it('includes top-level permissions', () => { + expect(boostPermissions).toContain(boostAccessPermission); + expect(boostPermissions).toContain(boostAdminPermission); + }); + + it('includes all agent lifecycle permissions', () => { + expect(boostPermissions).toContain(boostAgentListPermission); + expect(boostPermissions).toContain(boostAgentRegisterPermission); + expect(boostPermissions).toContain(boostAgentPromotePermission); + expect(boostPermissions).toContain(boostAgentApprovePermission); + expect(boostPermissions).toContain(boostAgentDemotePermission); + expect(boostPermissions).toContain(boostAgentPublishPermission); + expect(boostPermissions).toContain(boostAgentUnpublishPermission); + expect(boostPermissions).toContain(boostAgentWithdrawPermission); + expect(boostPermissions).toContain(boostAgentDeletePermission); + expect(boostPermissions).toContain(boostAgentConfigurePermission); + }); + + it('includes all tool lifecycle permissions', () => { + expect(boostPermissions).toContain(boostToolPromotePermission); + expect(boostPermissions).toContain(boostToolApprovePermission); + expect(boostPermissions).toContain(boostToolDemotePermission); + expect(boostPermissions).toContain(boostToolPublishPermission); + expect(boostPermissions).toContain(boostToolUnpublishPermission); + }); + + it('includes infrastructure permission', () => { + expect(boostPermissions).toContain(boostKagentiAdminPermission); + }); + + it('includes functional permissions', () => { + expect(boostPermissions).toContain(boostChatReadPermission); + expect(boostPermissions).toContain(boostChatCreatePermission); + expect(boostPermissions).toContain(boostDocumentsManagePermission); + expect(boostPermissions).toContain(boostMcpManagePermission); + expect(boostPermissions).toContain(boostConfigManagePermission); + }); + + it('all permissions have names starting with boost.', () => { + for (const perm of boostPermissions) { + expect(perm.name).toMatch(/^boost\./); + } + }); + + it('all permission names are unique', () => { + const names = boostPermissions.map(p => p.name); + expect(new Set(names).size).toBe(names.length); + }); + }); +}); diff --git a/workspaces/boost/plugins/boost-common/src/permissions.ts b/workspaces/boost/plugins/boost-common/src/permissions.ts new file mode 100644 index 0000000000..76d8b89518 --- /dev/null +++ b/workspaces/boost/plugins/boost-common/src/permissions.ts @@ -0,0 +1,415 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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'; + +// ============================================================================= +// Resource Types +// ============================================================================= + +/** + * Resource type for Boost agents subject to lifecycle governance. + * @public + */ +export const BOOST_AGENT_RESOURCE_TYPE = 'boost-agent'; + +/** + * Resource type for Boost tools (Kagenti tools) subject to lifecycle governance. + * @public + */ +export const BOOST_TOOL_RESOURCE_TYPE = 'boost-tool'; + +// ============================================================================= +// Top-Level Permissions +// ============================================================================= + +/** + * Permission to access the Boost plugin. + * + * Controls access to ALL Boost features. Serves as a top-level gate: + * if denied, all sub-permissions are denied. + * + * @public + */ +export const boostAccessPermission = createPermission({ + name: 'boost.access', + attributes: { + action: 'read', + }, +}); + +/** + * Permission for coarse-grained admin access. + * + * Deployments that prefer a single admin permission over fine-grained + * control can grant this. The `authorizeLifecycleAction` middleware + * falls back to this on fine-grained DENY. + * + * @public + */ +export const boostAdminPermission = createPermission({ + name: 'boost.admin', + attributes: { + action: 'update', + }, +}); + +// ============================================================================= +// Agent Lifecycle Permissions (Resource-Based) +// ============================================================================= + +/** + * Permission to view the agent list. + * @public + */ +export const boostAgentListPermission = createPermission({ + name: 'boost.agent.list', + attributes: { + action: 'read', + }, +}); + +/** + * Permission to register an agent for governance. + * @public + */ +export const boostAgentRegisterPermission = createPermission({ + name: 'boost.agent.register', + attributes: { + action: 'create', + }, +}); + +/** + * Permission to promote an agent (draft -> pending). + * Conditional rules: IS_OWNER, HAS_LIFECYCLE_STAGE. + * @public + */ +export const boostAgentPromotePermission = createPermission({ + name: 'boost.agent.promote', + attributes: { + action: 'update', + }, + resourceType: BOOST_AGENT_RESOURCE_TYPE, +}); + +/** + * Permission to approve a pending agent (pending -> published). + * Conditional rules: IS_NOT_CREATOR, HAS_LIFECYCLE_STAGE. + * @public + */ +export const boostAgentApprovePermission = createPermission({ + name: 'boost.agent.approve', + attributes: { + action: 'update', + }, + resourceType: BOOST_AGENT_RESOURCE_TYPE, +}); + +/** + * Permission to demote an agent (reject, request-unpublish, approve-unpublish). + * @public + */ +export const boostAgentDemotePermission = createPermission({ + name: 'boost.agent.demote', + attributes: { + action: 'update', + }, + resourceType: BOOST_AGENT_RESOURCE_TYPE, +}); + +/** + * Permission to publish an approved agent. + * @public + */ +export const boostAgentPublishPermission = createPermission({ + name: 'boost.agent.publish', + attributes: { + action: 'update', + }, + resourceType: BOOST_AGENT_RESOURCE_TYPE, +}); + +/** + * Permission to request unpublishing of an agent. + * Conditional rule: IS_OWNER. + * @public + */ +export const boostAgentUnpublishPermission = createPermission({ + name: 'boost.agent.unpublish', + attributes: { + action: 'update', + }, + resourceType: BOOST_AGENT_RESOURCE_TYPE, +}); + +/** + * Permission to withdraw a pending agent submission. + * Conditional rule: IS_OWNER. + * @public + */ +export const boostAgentWithdrawPermission = createPermission({ + name: 'boost.agent.withdraw', + attributes: { + action: 'update', + }, + resourceType: BOOST_AGENT_RESOURCE_TYPE, +}); + +/** + * Permission to delete an agent. + * Conditional rules: IS_OWNER, HAS_LIFECYCLE_STAGE. + * @public + */ +export const boostAgentDeletePermission = createPermission({ + name: 'boost.agent.delete', + attributes: { + action: 'delete', + }, + resourceType: BOOST_AGENT_RESOURCE_TYPE, +}); + +/** + * Permission to edit agent configuration. + * @public + */ +export const boostAgentConfigurePermission = createPermission({ + name: 'boost.agent.configure', + attributes: { + action: 'update', + }, +}); + +// ============================================================================= +// Tool Lifecycle Permissions (Resource-Based) +// ============================================================================= + +/** + * Permission to promote a tool's lifecycle stage. + * Conditional rule: IS_OWNER. + * @public + */ +export const boostToolPromotePermission = createPermission({ + name: 'boost.tool.promote', + attributes: { + action: 'update', + }, + resourceType: BOOST_TOOL_RESOURCE_TYPE, +}); + +/** + * Permission to approve tool promotion. + * Conditional rule: IS_NOT_CREATOR. + * @public + */ +export const boostToolApprovePermission = createPermission({ + name: 'boost.tool.approve', + attributes: { + action: 'update', + }, + resourceType: BOOST_TOOL_RESOURCE_TYPE, +}); + +/** + * Permission to demote a tool's lifecycle stage. + * @public + */ +export const boostToolDemotePermission = createPermission({ + name: 'boost.tool.demote', + attributes: { + action: 'update', + }, + resourceType: BOOST_TOOL_RESOURCE_TYPE, +}); + +/** + * Permission to publish a tool. + * @public + */ +export const boostToolPublishPermission = createPermission({ + name: 'boost.tool.publish', + attributes: { + action: 'update', + }, + resourceType: BOOST_TOOL_RESOURCE_TYPE, +}); + +/** + * Permission to unpublish a tool. + * @public + */ +export const boostToolUnpublishPermission = createPermission({ + name: 'boost.tool.unpublish', + attributes: { + action: 'update', + }, + resourceType: BOOST_TOOL_RESOURCE_TYPE, +}); + +// ============================================================================= +// Infrastructure Permission +// ============================================================================= + +/** + * Permission for Kagenti infrastructure operations. + * + * Covers namespace management, build pipelines, sandbox, and platform links. + * + * @public + */ +export const boostKagentiAdminPermission = createPermission({ + name: 'boost.kagenti.admin', + attributes: { + action: 'update', + }, +}); + +// ============================================================================= +// Functional Area Permissions (non-lifecycle) +// ============================================================================= + +/** + * Permission to view the chat interface and read messages. + * @public + */ +export const boostChatReadPermission = createPermission({ + name: 'boost.chat.read', + attributes: { + action: 'read', + }, +}); + +/** + * Permission to send messages and start sessions. + * @public + */ +export const boostChatCreatePermission = createPermission({ + name: 'boost.chat.create', + attributes: { + action: 'create', + }, +}); + +/** + * Permission to upload documents and sync RAG sources. + * @public + */ +export const boostDocumentsManagePermission = createPermission({ + name: 'boost.documents.manage', + attributes: { + action: 'update', + }, +}); + +/** + * Permission to configure MCP servers. + * @public + */ +export const boostMcpManagePermission = createPermission({ + name: 'boost.mcp.manage', + attributes: { + action: 'update', + }, +}); + +/** + * Permission to modify admin configuration. + * @public + */ +export const boostConfigManagePermission = createPermission({ + name: 'boost.config.manage', + attributes: { + action: 'update', + }, +}); + +// ============================================================================= +// Conditional Permission Rules +// ============================================================================= + +/** + * Conditional rule names for use in permission policies. + * + * These are string identifiers that the permission policy evaluates + * against loaded resources: + * + * - `IS_OWNER`: `resource.createdBy === currentUser` + * - `IS_NOT_CREATOR`: `resource.createdBy !== currentUser` (separation of duties) + * - `HAS_LIFECYCLE_STAGE`: `resource.lifecycleStage in allowedStages` + * + * @public + */ +export const BOOST_PERMISSION_RULES = { + IS_OWNER: 'IS_OWNER', + IS_NOT_CREATOR: 'IS_NOT_CREATOR', + HAS_LIFECYCLE_STAGE: 'HAS_LIFECYCLE_STAGE', +} as const; + +// ============================================================================= +// Permission Collection +// ============================================================================= + +/** + * All Boost permissions. + * + * Register these via `permissionsRegistry.addPermissions()` in the backend plugin. + * + * @example + * ```yaml + * permission: + * enabled: true + * rbac: + * policies: + * - g, group:default/boost-users, role:default/boost-user + * - p, role:default/boost-user, boost.access, read, allow + * - p, role:default/boost-user, boost.chat.read, read, allow + * - p, role:default/boost-user, boost.chat.create, create, allow + * - g, group:default/boost-admins, role:default/boost-admin + * - p, role:default/boost-admin, boost.admin, update, allow + * ``` + * + * @public + */ +export const boostPermissions = [ + // Top-level + boostAccessPermission, + boostAdminPermission, + // Agent lifecycle + boostAgentListPermission, + boostAgentRegisterPermission, + boostAgentPromotePermission, + boostAgentApprovePermission, + boostAgentDemotePermission, + boostAgentPublishPermission, + boostAgentUnpublishPermission, + boostAgentWithdrawPermission, + boostAgentDeletePermission, + boostAgentConfigurePermission, + // Tool lifecycle + boostToolPromotePermission, + boostToolApprovePermission, + boostToolDemotePermission, + boostToolPublishPermission, + boostToolUnpublishPermission, + // Infrastructure + boostKagentiAdminPermission, + // Functional + boostChatReadPermission, + boostChatCreatePermission, + boostDocumentsManagePermission, + boostMcpManagePermission, + boostConfigManagePermission, +]; diff --git a/workspaces/boost/plugins/boost-common/src/services.ts b/workspaces/boost/plugins/boost-common/src/services.ts new file mode 100644 index 0000000000..dd7a975648 --- /dev/null +++ b/workspaces/boost/plugins/boost-common/src/services.ts @@ -0,0 +1,33 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createServiceRef } from '@backstage/backend-plugin-api'; +import type { AgenticProvider } from './types'; + +/** + * Service ref for cross-plugin consumption of the active AI provider. + * + * Other Backstage plugins can declare a dependency on this service ref + * to receive the currently active {@link AgenticProvider} instance. + * The default factory is registered by the `boost-backend` plugin, + * which resolves to the `ProviderManager`'s active provider. + * + * @public + */ +export const boostAiProviderServiceRef = createServiceRef({ + id: 'boost.ai-provider', + scope: 'plugin', +}); diff --git a/workspaces/boost/plugins/boost-common/src/types.test.ts b/workspaces/boost/plugins/boost-common/src/types.test.ts new file mode 100644 index 0000000000..a835d4b5e1 --- /dev/null +++ b/workspaces/boost/plugins/boost-common/src/types.test.ts @@ -0,0 +1,251 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { + AgenticProvider, + ProviderCapabilities, + ProviderDescriptor, + InputItem, + ConversationSummary, + ConversationDetails, + NormalizedStreamEvent, +} from './types'; + +describe('types', () => { + describe('ProviderCapabilities', () => { + it('can be constructed with all required fields', () => { + const caps: ProviderCapabilities = { + chat: true, + rag: false, + safety: false, + evaluation: false, + conversations: true, + mcpTools: false, + tools: true, + agentCatalog: false, + namespaceScoping: false, + devSpaces: false, + buildPipelines: false, + }; + expect(caps.chat).toBe(true); + expect(caps.rag).toBe(false); + expect(caps.agentCatalog).toBe(false); + }); + }); + + describe('ProviderDescriptor', () => { + it('can be constructed with all required fields', () => { + const descriptor: ProviderDescriptor = { + id: 'test-provider', + displayName: 'Test Provider', + description: 'A test provider', + implemented: true, + capabilities: { + chat: true, + rag: false, + safety: false, + evaluation: false, + conversations: false, + mcpTools: false, + tools: false, + agentCatalog: false, + namespaceScoping: false, + devSpaces: false, + buildPipelines: false, + }, + }; + expect(descriptor.id).toBe('test-provider'); + expect(descriptor.implemented).toBe(true); + }); + }); + + describe('InputItem', () => { + it('supports system, user, and assistant roles', () => { + const items: InputItem[] = [ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' }, + ]; + expect(items).toHaveLength(3); + expect(items[0].role).toBe('system'); + expect(items[1].role).toBe('user'); + expect(items[2].role).toBe('assistant'); + }); + }); + + describe('ConversationSummary', () => { + it('can be constructed with all required fields', () => { + const summary: ConversationSummary = { + conversationId: 'conv-123', + preview: 'Hello, how are you?', + createdAt: new Date('2026-01-01'), + model: 'llama-3', + status: 'completed', + }; + expect(summary.conversationId).toBe('conv-123'); + expect(summary.status).toBe('completed'); + }); + }); + + describe('ConversationDetails', () => { + it('extends summary with messages', () => { + const details: ConversationDetails = { + conversationId: 'conv-123', + preview: 'Hello', + createdAt: new Date('2026-01-01'), + model: 'llama-3', + status: 'completed', + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi!' }, + ], + }; + expect(details.messages).toHaveLength(2); + }); + }); + + describe('NormalizedStreamEvent', () => { + it('supports all event types via discriminated union', () => { + const events: NormalizedStreamEvent[] = [ + { type: 'stream.started', responseId: 'resp-1' }, + { type: 'stream.text.delta', delta: 'Hello' }, + { type: 'stream.text.done', text: 'Hello world' }, + { type: 'stream.reasoning.delta', delta: 'thinking...' }, + { type: 'stream.reasoning.done', text: 'thought complete' }, + { + type: 'stream.tool.discovery', + status: 'completed', + toolCount: 5, + }, + { type: 'stream.tool.started', callId: 'call-1', name: 'search' }, + { type: 'stream.tool.delta', callId: 'call-1', delta: '{}' }, + { + type: 'stream.tool.completed', + callId: 'call-1', + name: 'search', + output: 'result', + }, + { + type: 'stream.tool.failed', + callId: 'call-1', + name: 'search', + error: 'timeout', + }, + { + type: 'stream.tool.approval', + callId: 'call-1', + name: 'deploy', + }, + { + type: 'stream.backend_tool.executing', + toolCount: 1, + tools: ['search'], + }, + { + type: 'stream.rag.results', + sources: [{ filename: 'doc.pdf' }], + }, + { type: 'stream.agent.handoff', toAgent: 'billing' }, + { type: 'stream.completed', responseId: 'resp-1' }, + { type: 'stream.error', error: 'something broke' }, + ]; + + expect(events).toHaveLength(16); + + // Verify discriminated union works via type narrowing + for (const event of events) { + switch (event.type) { + case 'stream.started': + expect(event.responseId).toBeDefined(); + break; + case 'stream.text.delta': + expect(event.delta).toBeDefined(); + break; + case 'stream.error': + expect(event.error).toBeDefined(); + break; + default: + // All other events are valid members of the union + break; + } + } + }); + }); + + describe('AgenticProvider interface', () => { + it('can be satisfied by a mock implementation', async () => { + const mockProvider: AgenticProvider = { + descriptor: { + id: 'mock', + displayName: 'Mock Provider', + description: 'A mock provider for testing', + implemented: true, + capabilities: { + chat: true, + rag: false, + safety: false, + evaluation: false, + conversations: false, + mcpTools: false, + tools: false, + agentCatalog: false, + namespaceScoping: false, + devSpaces: false, + buildPipelines: false, + }, + }, + chat: async () => ({ + content: 'Hello!', + responseId: 'resp-1', + }), + chatStream: async ({ onEvent }) => { + onEvent({ type: 'stream.started', responseId: 'resp-1' }); + onEvent({ type: 'stream.text.delta', delta: 'Hello!' }); + onEvent({ + type: 'stream.text.done', + text: 'Hello!', + }); + onEvent({ type: 'stream.completed', responseId: 'resp-1' }); + }, + }; + + const result = await mockProvider.chat({ + messages: [{ role: 'user', content: 'Hi' }], + userRef: 'user:default/test', + }); + expect(result.content).toBe('Hello!'); + expect(mockProvider.descriptor.id).toBe('mock'); + expect(mockProvider.conversations).toBeUndefined(); + }); + }); + + describe('no provider-specific types', () => { + it('does not export any Kagenti or LlamaStack specific types', () => { + // This test verifies task 1.6: no provider-specific types in common package. + // We check that the module's exports don't include provider-specific prefixes. + const exports = Object.keys( + require('./types'), + ); + const providerSpecific = exports.filter( + name => + name.startsWith('Kagenti') || + name.startsWith('LlamaStack') || + name.startsWith('Llama'), + ); + expect(providerSpecific).toEqual([]); + }); + }); +}); diff --git a/workspaces/boost/plugins/boost-common/src/types.ts b/workspaces/boost/plugins/boost-common/src/types.ts new file mode 100644 index 0000000000..d1fbf16e2f --- /dev/null +++ b/workspaces/boost/plugins/boost-common/src/types.ts @@ -0,0 +1,370 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 +// ============================================================================= + +/** + * Capability flags that a provider may declare. + * + * The frontend uses these to conditionally render UI sections. + * The backend uses them to gate API endpoints. + * + * @public + */ +export interface ProviderCapabilities { + /** Provider supports basic chat */ + readonly chat: boolean; + /** Provider supports RAG / file search */ + readonly rag: boolean; + /** Provider supports safety shields */ + readonly safety: boolean; + /** Provider supports response evaluation / scoring */ + readonly evaluation: boolean; + /** Provider supports conversation history */ + readonly conversations: boolean; + /** Provider supports MCP tool integration */ + readonly mcpTools: boolean; + /** Provider supports general tool calling */ + readonly tools: boolean; + /** Provider has an agent catalog (e.g., Kagenti agent registry) */ + readonly agentCatalog: boolean; + /** Provider supports namespace-scoped resources */ + readonly namespaceScoping: boolean; + /** Provider supports DevSpaces integration */ + readonly devSpaces: boolean; + /** Provider supports build pipelines */ + readonly buildPipelines: boolean; +} + +/** + * Describes an AI provider's identity, capabilities, and readiness. + * + * 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 (e.g., 'llamastack', 'kagenti') */ + readonly id: string; + /** 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; +} + +/** + * Token usage reported by the inference server for a single response turn. + * + * @public + */ +export interface ResponseUsage { + /** Total input tokens */ + input_tokens: number; + /** Total output tokens */ + output_tokens: number; + /** Sum of input_tokens + output_tokens */ + total_tokens: number; +} + +/** + * Optional conversation management capabilities. + * + * @public + */ +export interface ConversationCapability { + /** List conversations for the current user */ + listConversations(userRef: string): Promise; + /** Load full conversation details by ID */ + getConversation( + conversationId: string, + userRef: string, + ): Promise; + /** Delete a conversation */ + deleteConversation( + conversationId: string, + userRef: string, + ): Promise; +} + +/** + * The core contract between Boost and any AI platform backend. + * + * Chat and streaming are required capabilities. Optional capabilities + * (RAG, safety, evaluation, conversation management) are expressed + * as optional capability objects. + * + * @public + */ +export interface AgenticProvider { + /** Provider metadata */ + readonly descriptor: ProviderDescriptor; + + /** Send a non-streaming chat request */ + chat(options: { + messages: InputItem[]; + model?: string; + userRef: string; + conversationId?: string; + previousResponseId?: string; + }): Promise<{ + content: string; + responseId?: string; + conversationId?: string; + usage?: ResponseUsage; + }>; + + /** Send a streaming chat request, emitting normalized events */ + chatStream(options: { + messages: InputItem[]; + model?: string; + userRef: string; + conversationId?: string; + previousResponseId?: string; + onEvent: (event: NormalizedStreamEvent) => void; + signal?: AbortSignal; + }): Promise; + + /** Optional conversation management */ + readonly conversations?: ConversationCapability; +} + +// ============================================================================= +// Conversation Types +// ============================================================================= + +/** + * A single input item in a conversation turn. + * + * @public + */ +export interface InputItem { + /** Role of the message sender */ + role: 'system' | 'user' | 'assistant'; + /** Text content of the message */ + content: string; +} + +/** + * Conversation summary for UI display. + * + * @public + */ +export interface ConversationSummary { + /** Unique conversation identifier */ + conversationId: 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'; +} + +/** + * Full conversation details including message history. + * + * @public + */ +export interface ConversationDetails { + /** Unique conversation identifier */ + conversationId: string; + /** Preview of the conversation */ + preview: string; + /** When the conversation was created */ + createdAt: Date; + /** Model used */ + model: string; + /** Status */ + status: 'completed' | 'failed' | 'in_progress'; + /** Full message history */ + messages: InputItem[]; +} + +// ============================================================================= +// 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. +// +// 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; + 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. @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; + 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; + }>; +} + +/** An agent handoff occurred during multi-agent streaming. @public */ +export interface StreamAgentHandoffEvent { + type: 'stream.agent.handoff'; + fromAgent?: string; + toAgent: string; + reason?: string; +} + +/** The response is fully complete. @public */ +export interface StreamCompletedEvent { + type: 'stream.completed'; + responseId?: string; + usage?: ResponseUsage; + agentName?: string; +} + +/** An error occurred during streaming. @public */ +export interface StreamErrorEvent { + type: 'stream.error'; + error: string; + code?: string; +} + +/** + * Union of all normalized streaming events. + * + * This is the single source of truth for the streaming contract + * between the backend and frontend. + * + * @public + */ +export type NormalizedStreamEvent = + | StreamStartedEvent + | StreamTextDeltaEvent + | StreamTextDoneEvent + | StreamReasoningDeltaEvent + | StreamReasoningDoneEvent + | StreamToolDiscoveryEvent + | StreamToolStartedEvent + | StreamToolDeltaEvent + | StreamToolCompletedEvent + | StreamToolFailedEvent + | StreamToolApprovalEvent + | StreamBackendToolExecutingEvent + | StreamRagResultsEvent + | StreamAgentHandoffEvent + | StreamCompletedEvent + | StreamErrorEvent;