diff --git a/workspaces/ai-integrations/.changeset/ai-resource-agent-processor.md b/workspaces/ai-integrations/.changeset/ai-resource-agent-processor.md new file mode 100644 index 00000000000..74b711e330b --- /dev/null +++ b/workspaces/ai-integrations/.changeset/ai-resource-agent-processor.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-catalog-backend-module-ai-resource-agent': minor +--- + +Add `AiResourceAgentProcessor` to validate agent-specific fields on +`kind: AiResource` entities with `spec.type: agent` at catalog ingestion. +Register the processor alongside the existing agent catalog model source. diff --git a/workspaces/ai-integrations/.changeset/airesource-kind-spelling.md b/workspaces/ai-integrations/.changeset/airesource-kind-spelling.md new file mode 100644 index 00000000000..4da7b90cd3e --- /dev/null +++ b/workspaces/ai-integrations/.changeset/airesource-kind-spelling.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-catalog-backend-module-ai-resource-extensions': minor +--- + +Use the valid catalog kind spelling `AiResource` (not `AIResource`) in the +extensions processor, and rename public exports accordingly: +`AiResourceExtensionsProcessor` and `AiResourceScope`. diff --git a/workspaces/ai-integrations/.changeset/optional-agent-instructions.md b/workspaces/ai-integrations/.changeset/optional-agent-instructions.md new file mode 100644 index 00000000000..12024a3d591 --- /dev/null +++ b/workspaces/ai-integrations/.changeset/optional-agent-instructions.md @@ -0,0 +1,8 @@ +--- +'@red-hat-developer-hub/backstage-plugin-catalog-model-ai-resource-agent': minor +'@red-hat-developer-hub/backstage-plugin-catalog-backend-module-ai-resource-agent': minor +--- + +Make `spec.instructions` optional on agent AiResource entities so agents +with a baked-in default prompt can be registered without a catalog-side +system prompt. diff --git a/workspaces/ai-integrations/examples/ai-resource-agents.yaml b/workspaces/ai-integrations/examples/ai-resource-agents.yaml index 2201a735606..13a0505f875 100644 --- a/workspaces/ai-integrations/examples/ai-resource-agents.yaml +++ b/workspaces/ai-integrations/examples/ai-resource-agents.yaml @@ -3,7 +3,7 @@ # # Shape follows the upstream skill/rule discriminated-type pattern from # @backstage/catalog-model alpha (kind: AiResource, spec.type: agent) -# plus RHDH optional extensions (spec.scope for AIResourceExtensionsProcessor). +# plus RHDH optional extensions (spec.scope for AiResourceExtensionsProcessor). # # Demonstrates a router + specialist multi-agent handoff pattern using only # the design D3 field set (no Augment-only keys). diff --git a/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/design.md b/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/design.md index 862ca241a28..acd798c970c 100644 --- a/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/design.md +++ b/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/design.md @@ -1,10 +1,10 @@ -# Design: AIResource Catalog Entity Kind +# Design: AiResource Catalog Entity Kind ## Canonical Touchpoints -- RHDHPLAN-1113 — [Agentic] Agents & Skills (AIResource Kinds) in the RHDH Catalog (DP) -- RHIDP-13942 — AIResource: Catalog ingestion (OCI processor + git annotation path) -- RHIDP-15739 — Align OCI AIResource support with upstream source-location (`url:oci://…`); remove `spec.location` +- RHDHPLAN-1113 — [Agentic] Agents & Skills (AiResource Kinds) in the RHDH Catalog (DP) +- RHIDP-13942 — AiResource: Catalog ingestion (OCI processor + git annotation path) +- RHIDP-15739 — Align OCI AiResource support with upstream source-location (`url:oci://…`); remove `spec.location` ## Context @@ -29,7 +29,7 @@ The design must keep OCI ingest air-gap safe: validate the reference and index t **Goals:** -- Implement the upstream-aligned `AIResource` / `AiResource` kind in this workspace +- Implement the upstream-aligned `AiResource` kind in this workspace - Use `backstage.io/source-location` as the canonical content location - Validate OCI source-location values in location-ref form `url:oci://…` - Reuse existing catalog behavior for registering entity YAML from git/HTTPS/`file` @@ -48,7 +48,7 @@ The design must keep OCI ingest air-gap safe: validate the reference and index t ## Decisions -### D1 - Use upstream `AiResource` / `AIResource`, not `AIContext` +### D1 - Use upstream `AiResource`, not `AIContext` **Choice**: Implement the upstream `AiResource` entity kind rather than maintaining the older `AIContext` naming. @@ -58,7 +58,7 @@ The design must keep OCI ingest air-gap safe: validate the reference and index t **Choice**: Do not use an RHDH `spec.location` struct. Git/HTTPS and OCI content references both use `metadata.annotations["backstage.io/source-location"]`. -**Rationale**: Matches upstream AIResource design and Backstage feedback. Keeps OCI skill support compatible with future consumers that resolve content via `UrlReader` against the source-location target. +**Rationale**: Matches upstream AiResource design and Backstage feedback. Keeps OCI skill support compatible with future consumers that resolve content via `UrlReader` against the source-location target. ### D3 - OCI uses location-ref form `url:oci://…` @@ -76,11 +76,11 @@ Backstage `parseLocationRef` splits on the first `:`, yielding `{ type: 'url', t ### D4 - Reuse standard catalog registration for entity YAML -**Choice**: Entity descriptors are always fetched from normal catalog locations (`url` / `file` / git). No catalog location type `oci` and no OCI `UrlReader` are required to **register** OCI-referenced AIResources. +**Choice**: Entity descriptors are always fetched from normal catalog locations (`url` / `file` / git). No catalog location type `oci` and no OCI `UrlReader` are required to **register** OCI-referenced AiResources. **Rationale**: Registration indexes metadata. Fetching skill content from a registry is a separate, later capability. -**Verification**: Git/HTTPS-sourced AIResource ingestion via the existing `UrlReaderProcessor` and `backstage.io/source-location` annotation was verified in RHIDP-14557 (happy path, missing source-location warning, and standard catalog registration). That establishes the git skill path; OCI does not need a UrlReader for registration. +**Verification**: Git/HTTPS-sourced AiResource ingestion via the existing `UrlReaderProcessor` and `backstage.io/source-location` annotation was verified in RHIDP-14557 (happy path, missing source-location warning, and standard catalog registration). That establishes the git skill path; OCI does not need a UrlReader for registration. ### D5 - OCI ingestion performs format validation only @@ -102,15 +102,15 @@ Backstage `parseLocationRef` splits on the first `:`, yielding `{ type: 'url', t ### D8 - Use the standard entity page -**Choice**: Render AIResource entities through the existing `EntityPage` layout rather than creating a dedicated page. +**Choice**: Render AiResource entities through the existing `EntityPage` layout rather than creating a dedicated page. **Rationale**: The standard entity page already provides metadata rendering, relationships, and TechDocs integration. A custom AI catalog frontend is deferred to a future release. ### D9 - Discovery uses standard catalog APIs -**Choice**: AIResource entities participate in the existing entity list, by-name lookup, filter, and full-text search APIs. +**Choice**: AiResource entities participate in the existing entity list, by-name lookup, filter, and full-text search APIs. -**Rationale**: AIResource should behave like any other first-class catalog kind rather than requiring special endpoints. +**Rationale**: AiResource should behave like any other first-class catalog kind rather than requiring special endpoints. ### D10 - Deferred: OCI UrlReader @@ -118,13 +118,13 @@ Backstage `parseLocationRef` splits on the first `:`, yielding `{ type: 'url', t **Rationale**: A UrlReader is only needed when a consumer must fetch content (or catalog YAML) from `oci://…`. Registration and format validation do not require it. When added later, it registers via `urlReaderFactoriesServiceRef` with `predicate: (url) => url.protocol === 'oci:'`. -**Symmetry with git**: The existing Backstage URL readers already cover git/HTTPS skill sources; RHIDP-14557 confirmed that path for AIResource. Deferring an OCI UrlReader is therefore an intentional gap versus the verified git reader path, not an unverified assumption that “no reader is needed for either.” +**Symmetry with git**: The existing Backstage URL readers already cover git/HTTPS skill sources; RHIDP-14557 confirmed that path for AiResource. Deferring an OCI UrlReader is therefore an intentional gap versus the verified git reader path, not an unverified assumption that “no reader is needed for either.” ## Risks / Trade-offs | Risk | Mitigation | | ------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| Upstream AIResource evolves further | Keep the design aligned to upstream annotations; limit RHDH-specific additions to `spec.scope` | +| Upstream AiResource evolves further | Keep the design aligned to upstream annotations; limit RHDH-specific additions to `spec.scope` | | Wrong location-ref form (`oci://` without `url:`) | Validate `url:` prefix + `oci://` target; document examples clearly | | Users expect OCI content inspection | Document clearly that ingestion indexes metadata only and performs no registry I/O | | Stale `spec.location` examples confuse agents | Remove `spec.location` from schema, processor, docs, examples, and OpenSpec | diff --git a/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/proposal.md b/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/proposal.md index 3c0349309be..2d6b883d625 100644 --- a/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/proposal.md +++ b/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/proposal.md @@ -1,32 +1,32 @@ -# Proposal: AIResource Catalog Entity Kind +# Proposal: AiResource Catalog Entity Kind ## Why AI coding tools consume reusable assets such as skills, rules, prompts, and agent definitions that teams publish in source repositories or OCI images. There is no standard way in this workspace to register those assets as catalog entities, validate their location metadata safely, and make them discoverable through standard RHDH catalog APIs and entity pages. -Adding first-class `AIResource` catalog support closes that gap while staying aligned with the upstream Backstage `AiResource` kind. Upstream intentionally has no `spec.location` field — asset content is referenced via the standard `backstage.io/source-location` annotation. +Adding first-class `AiResource` catalog support closes that gap while staying aligned with the upstream Backstage `AiResource` kind. Upstream intentionally has no `spec.location` field — asset content is referenced via the standard `backstage.io/source-location` annotation. ## What Changes -- Introduce support for the upstream `AiResource` / `AIResource` catalog kind in this workspace. +- Introduce support for the upstream `AiResource` catalog kind in this workspace. - Use `backstage.io/source-location` as the canonical asset location for both git/HTTPS and OCI-backed entities. - Represent OCI-backed assets with the Backstage location-ref form `url:oci://…` (location type `url`, target scheme `oci://`). -- Validate AIResource entities during ingestion, including RHDH-specific `spec.scope` support and OCI source-location format rules. +- Validate AiResource entities during ingestion, including RHDH-specific `spec.scope` support and OCI source-location format rules. - Reuse existing Backstage catalog behavior for entity YAML registration (git/HTTPS/`file` catalog locations). - Add an OCI catalog processor that validates OCI `source-location` format only and makes zero outbound registry calls. - Do **not** introduce RHDH `spec.location` (removed / not used for OCI skill support). -- Render AIResource entities on the standard catalog entity page. -- Expose AIResource entities through standard catalog search and filter APIs. +- Render AiResource entities on the standard catalog entity page. +- Expose AiResource entities through standard catalog search and filter APIs. - Add local workspace documentation and OpenSpec artifacts so implementation agents can work from this workspace directly. ## Capabilities ### New Capabilities -- `ai-resource-entity-schema`: Schema and validation rules for the `AIResource` kind, including `spec.scope` as an RHDH extension and OCI `source-location` validation. -- `ai-resource-catalog-ingestion`: Catalog backend support for git/HTTPS-backed and OCI-referenced AIResource ingestion (format validation only for OCI). -- `ai-resource-entity-page`: Standard entity page rendering for AIResource entities, including source-aware location display and TechDocs integration. -- `ai-resource-discovery`: Catalog API, filtering, and full-text search support for AIResource entities. +- `ai-resource-entity-schema`: Schema and validation rules for the `AiResource` kind, including `spec.scope` as an RHDH extension and OCI `source-location` validation. +- `ai-resource-catalog-ingestion`: Catalog backend support for git/HTTPS-backed and OCI-referenced AiResource ingestion (format validation only for OCI). +- `ai-resource-entity-page`: Standard entity page rendering for AiResource entities, including source-aware location display and TechDocs integration. +- `ai-resource-discovery`: Catalog API, filtering, and full-text search support for AiResource entities. ### Modified Capabilities @@ -54,8 +54,8 @@ _(none - this is a net-new capability area in this workspace)_ ## Impact -- **Catalog backend**: Adds AIResource-specific ingestion and OCI `source-location` validation behavior -- **Schema handling**: Extends upstream AIResource behavior with RHDH-specific `spec.scope` validation only (no `spec.location`) -- **Frontend**: Reuses the standard entity page layout for AIResource entities -- **Catalog API**: No breaking API changes; AIResource appears through standard entity and search endpoints +- **Catalog backend**: Adds AiResource-specific ingestion and OCI `source-location` validation behavior +- **Schema handling**: Extends upstream AiResource behavior with RHDH-specific `spec.scope` validation only (no `spec.location`) +- **Frontend**: Reuses the standard entity page layout for AiResource entities +- **Catalog API**: No breaking API changes; AiResource appears through standard entity and search endpoints - **Documentation**: Adds local OpenSpec materials to make the workspace self-contained for implementation diff --git a/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-catalog-ingestion/spec.md b/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-catalog-ingestion/spec.md index 5de2fe3add6..55279d8f90e 100644 --- a/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-catalog-ingestion/spec.md +++ b/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-catalog-ingestion/spec.md @@ -2,48 +2,48 @@ ### Requirement: Entity YAML ingested via standard catalog locations -AIResource entity descriptors SHALL be registered through standard Backstage/RHDH catalog locations (`url`, `file`, or git discovery). Registration SHALL NOT require a catalog location type `oci` or an OCI `UrlReader`. +AiResource entity descriptors SHALL be registered through standard Backstage/RHDH catalog locations (`url`, `file`, or git discovery). Registration SHALL NOT require a catalog location type `oci` or an OCI `UrlReader`. #### Scenario: Entity registered via standard URL registration flow -- **WHEN** a user registers an AIResource `catalog-info.yaml` via the standard RHDH catalog URL registration UI +- **WHEN** a user registers an AiResource `catalog-info.yaml` via the standard RHDH catalog URL registration UI - **THEN** the entity is ingested and indexed within the normal catalog refresh cycle #### Scenario: Entity discovered through standard catalog-info flow -- **WHEN** an AIResource entity is declared in a standard catalog-info location reachable by the catalog -- **THEN** the catalog ingests it without requiring AIResource-specific registration logic +- **WHEN** an AiResource entity is declared in a standard catalog-info location reachable by the catalog +- **THEN** the catalog ingests it without requiring AiResource-specific registration logic --- ### Requirement: Git/HTTPS-backed content via source-location -AIResource entities whose content lives in git/HTTPS SHALL declare `metadata.annotations["backstage.io/source-location"]` with a normal `url:https://…` (or other UrlReader-compatible) location-ref. Entity YAML ingestion continues to use the existing `UrlReaderProcessor` for catalog locations. +AiResource entities whose content lives in git/HTTPS SHALL declare `metadata.annotations["backstage.io/source-location"]` with a normal `url:https://…` (or other UrlReader-compatible) location-ref. Entity YAML ingestion continues to use the existing `UrlReaderProcessor` for catalog locations. #### Scenario: Git entity with source-location annotation ingested -- **WHEN** an AIResource entity declares `backstage.io/source-location: url:https://github.com/my-org/my-skills` +- **WHEN** an AiResource entity declares `backstage.io/source-location: url:https://github.com/my-org/my-skills` - **THEN** the catalog ingests the entity without errors #### Scenario: Missing source-location annotation produces warning -- **WHEN** an AIResource entity omits the `backstage.io/source-location` annotation +- **WHEN** an AiResource entity omits the `backstage.io/source-location` annotation - **THEN** the catalog ingests the entity but emits a processor warning indicating the annotation is missing (standard catalog behavior) --- ### Requirement: OCI-backed source-location format validation -When an AIResource entity's `backstage.io/source-location` target uses the `oci://` scheme, an RHDH catalog processor SHALL validate URI/location-ref format only. The processor SHALL NOT make any network calls to a registry. +When an AiResource entity's `backstage.io/source-location` target uses the `oci://` scheme, an RHDH catalog processor SHALL validate URI/location-ref format only. The processor SHALL NOT make any network calls to a registry. #### Scenario: Valid OCI source-location accepted -- **WHEN** an AIResource entity declares `backstage.io/source-location: url:oci://quay.io/org/skills:latest` +- **WHEN** an AiResource entity declares `backstage.io/source-location: url:oci://quay.io/org/skills:latest` - **THEN** the entity passes processor validation and is ingested without registry calls #### Scenario: Malformed OCI source-location rejected -- **WHEN** an AIResource entity declares a malformed OCI `backstage.io/source-location` (including bare `oci://…` without the `url:` prefix) +- **WHEN** an AiResource entity declares a malformed OCI `backstage.io/source-location` (including bare `oci://…` without the `url:` prefix) - **THEN** the processor rejects it with a validation error describing the expected `url:oci://…` format and makes no registry call #### Scenario: Zero registry I/O during OCI validation @@ -55,19 +55,19 @@ When an AIResource entity's `backstage.io/source-location` target uses the `oci: ### Requirement: Schema validation with actionable error messages -The catalog processor SHALL validate every AIResource entity against the applicable schema before storing it. Validation errors SHALL include the field path (or annotation name), invalid value when applicable, and a human-readable description of the violated constraint. +The catalog processor SHALL validate every AiResource entity against the applicable schema before storing it. Validation errors SHALL include the field path (or annotation name), invalid value when applicable, and a human-readable description of the violated constraint. #### Scenario: Missing required upstream field produces field-specific error -- **WHEN** an AIResource entity is missing a required upstream field such as `spec.owner` +- **WHEN** an AiResource entity is missing a required upstream field such as `spec.owner` - **THEN** the validation error identifies the exact field path and states that it is required #### Scenario: Invalid `spec.scope` produces enumeration error -- **WHEN** an AIResource entity declares an invalid `spec.scope` +- **WHEN** an AiResource entity declares an invalid `spec.scope` - **THEN** the validation error states the received value and lists the accepted values `organization`, `product`, and `team` #### Scenario: Multiple validation errors reported together -- **WHEN** an AIResource entity has multiple invalid fields or annotations +- **WHEN** an AiResource entity has multiple invalid fields or annotations - **THEN** all validation errors are reported in a single response diff --git a/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-discovery/spec.md b/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-discovery/spec.md index 49aa8274dfb..0cbe89d4465 100644 --- a/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-discovery/spec.md +++ b/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-discovery/spec.md @@ -1,79 +1,79 @@ ## ADDED Requirements -### Requirement: AIResource entities returned by standard catalog entity API +### Requirement: AiResource entities returned by standard catalog entity API -The catalog SHALL return AIResource entities via the standard `GET /api/catalog/entities` endpoint. AIResource entities SHALL be retrievable using the `kind=AIResource` filter parameter. +The catalog SHALL return AiResource entities via the standard `GET /api/catalog/entities` endpoint. AiResource entities SHALL be retrievable using the `kind=AiResource` filter parameter. -#### Scenario: Listing AIResource entities by kind +#### Scenario: Listing AiResource entities by kind -- **WHEN** a client calls `GET /api/catalog/entities?filter=kind=AIResource` -- **THEN** the response contains all AIResource entities the caller can read +- **WHEN** a client calls `GET /api/catalog/entities?filter=kind=AiResource` +- **THEN** the response contains all AiResource entities the caller can read -#### Scenario: Retrieving a single AIResource entity by ref +#### Scenario: Retrieving a single AiResource entity by ref -- **WHEN** a client calls the standard by-name entity endpoint for an AIResource entity -- **THEN** the response contains the full AIResource entity if it exists and the caller has access +- **WHEN** a client calls the standard by-name entity endpoint for an AiResource entity +- **THEN** the response contains the full AiResource entity if it exists and the caller has access --- -### Requirement: AIResource entities filterable by content type +### Requirement: AiResource entities filterable by content type -The catalog SHALL support filtering AIResource entities by `spec.type`. +The catalog SHALL support filtering AiResource entities by `spec.type`. #### Scenario: Filter by content type -- **WHEN** a client calls `GET /api/catalog/entities?filter=kind=AIResource,spec.type=skill` -- **THEN** the response contains only AIResource entities that declare `spec.type: skill` +- **WHEN** a client calls `GET /api/catalog/entities?filter=kind=AiResource,spec.type=skill` +- **THEN** the response contains only AiResource entities that declare `spec.type: skill` #### Scenario: Filter returns entities matching exact type value - **WHEN** a client filters on `spec.type=agent` -- **THEN** only AIResource entities with `spec.type: agent` are returned +- **THEN** only AiResource entities with `spec.type: agent` are returned --- -### Requirement: AIResource entities filterable by scope +### Requirement: AiResource entities filterable by scope -The catalog SHALL support filtering AIResource entities by `spec.scope`. +The catalog SHALL support filtering AiResource entities by `spec.scope`. #### Scenario: Filter by scope -- **WHEN** a client calls `GET /api/catalog/entities?filter=kind=AIResource,spec.scope=team` -- **THEN** only AIResource entities with `spec.scope: team` are returned +- **WHEN** a client calls `GET /api/catalog/entities?filter=kind=AiResource,spec.scope=team` +- **THEN** only AiResource entities with `spec.scope: team` are returned #### Scenario: Entities without scope excluded from scope filter -- **WHEN** a client filters on `spec.scope=organization` and an AIResource entity omits `spec.scope` +- **WHEN** a client filters on `spec.scope=organization` and an AiResource entity omits `spec.scope` - **THEN** that entity does not appear in the filtered results --- -### Requirement: AIResource entities filterable by owner and lifecycle +### Requirement: AiResource entities filterable by owner and lifecycle -The catalog SHALL support filtering AIResource entities by `spec.owner` and `spec.lifecycle`. +The catalog SHALL support filtering AiResource entities by `spec.owner` and `spec.lifecycle`. #### Scenario: Filter by owner - **WHEN** a client filters on `spec.owner` -- **THEN** only AIResource entities with the matching owner are returned +- **THEN** only AiResource entities with the matching owner are returned #### Scenario: Filter by lifecycle - **WHEN** a client filters on `spec.lifecycle=production` -- **THEN** only AIResource entities with `spec.lifecycle: production` are returned +- **THEN** only AiResource entities with `spec.lifecycle: production` are returned --- -### Requirement: AIResource entities appear in catalog full-text search +### Requirement: AiResource entities appear in catalog full-text search -AIResource entities SHALL be indexed in catalog full-text search. Indexed fields SHALL include `metadata.name`, `metadata.title`, `metadata.description`, and `spec.type`. +AiResource entities SHALL be indexed in catalog full-text search. Indexed fields SHALL include `metadata.name`, `metadata.title`, `metadata.description`, and `spec.type`. #### Scenario: Search by entity name -- **WHEN** a user searches for a term that matches an AIResource entity's `metadata.name` +- **WHEN** a user searches for a term that matches an AiResource entity's `metadata.name` - **THEN** that entity appears in search results #### Scenario: Search by content type term - **WHEN** a user searches for a term such as `skills` -- **THEN** AIResource entities with matching `spec.type` values appear in results +- **THEN** AiResource entities with matching `spec.type` values appear in results diff --git a/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-entity-page/spec.md b/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-entity-page/spec.md index 52313a2b2a5..69f140ca440 100644 --- a/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-entity-page/spec.md +++ b/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-entity-page/spec.md @@ -1,17 +1,17 @@ ## ADDED Requirements -### Requirement: AIResource entity detail page rendered via standard catalog layout +### Requirement: AiResource entity detail page rendered via standard catalog layout -The system SHALL render a detail page for AIResource entities using the existing Backstage catalog `EntityPage` layout. +The system SHALL render a detail page for AiResource entities using the existing Backstage catalog `EntityPage` layout. -#### Scenario: Entity detail page loads for AIResource kind +#### Scenario: Entity detail page loads for AiResource kind -- **WHEN** a user navigates to the catalog URL of an AIResource entity +- **WHEN** a user navigates to the catalog URL of an AiResource entity - **THEN** the standard entity detail page renders with the entity metadata visible #### Scenario: Entity page displays for both git/HTTPS and OCI sourced entities -- **WHEN** an AIResource entity has a git/HTTPS or OCI `backstage.io/source-location` +- **WHEN** an AiResource entity has a git/HTTPS or OCI `backstage.io/source-location` - **THEN** the same entity page layout renders for both and the location display adapts to the source type --- @@ -22,12 +22,12 @@ The entity detail page SHALL display the asset location from `backstage.io/sourc #### Scenario: Git/HTTPS location displayed as clickable link -- **WHEN** an AIResource entity has `backstage.io/source-location: url:https://…` +- **WHEN** an AiResource entity has `backstage.io/source-location: url:https://…` - **THEN** the entity page shows the URL as a clickable link #### Scenario: OCI location displayed as copyable text -- **WHEN** an AIResource entity has `backstage.io/source-location: url:oci://…` +- **WHEN** an AiResource entity has `backstage.io/source-location: url:oci://…` - **THEN** the entity page displays the OCI URI as copyable text with a copy-to-clipboard affordance (not as a normal browser navigation link) #### Scenario: Location label indicates source type @@ -39,30 +39,30 @@ The entity detail page SHALL display the asset location from `backstage.io/sourc ### Requirement: Relationships graph displayed via standard catalog graph viewer -The entity detail page SHALL include the standard catalog relationships graph showing any declared relations between the AIResource entity and other catalog entities. +The entity detail page SHALL include the standard catalog relationships graph showing any declared relations between the AiResource entity and other catalog entities. #### Scenario: Graph card rendered on entity page -- **WHEN** a user views an AIResource entity detail page +- **WHEN** a user views an AiResource entity detail page - **THEN** the relationships graph card is present #### Scenario: Graph empty when no relations declared -- **WHEN** an AIResource entity declares no relations +- **WHEN** an AiResource entity declares no relations - **THEN** the graph card renders an empty graph rather than disappearing from the page --- ### Requirement: TechDocs displayed when `techdocs-ref` annotation is present -If an AIResource entity declares `metadata.annotations["backstage.io/techdocs-ref"]`, the entity detail page SHALL include a TechDocs tab that renders the referenced documentation. +If an AiResource entity declares `metadata.annotations["backstage.io/techdocs-ref"]`, the entity detail page SHALL include a TechDocs tab that renders the referenced documentation. #### Scenario: TechDocs tab present when annotation is set -- **WHEN** an AIResource entity declares `backstage.io/techdocs-ref` +- **WHEN** an AiResource entity declares `backstage.io/techdocs-ref` - **THEN** the entity page includes a Docs tab and renders the TechDocs content #### Scenario: TechDocs tab absent when annotation is not set -- **WHEN** an AIResource entity does not declare `backstage.io/techdocs-ref` +- **WHEN** an AiResource entity does not declare `backstage.io/techdocs-ref` - **THEN** no Docs tab is shown diff --git a/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-entity-schema/spec.md b/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-entity-schema/spec.md index ac2be999f1c..9fedf3f0890 100644 --- a/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-entity-schema/spec.md +++ b/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/specs/ai-resource-entity-schema/spec.md @@ -1,6 +1,6 @@ -## RHDH Extension Requirements for AIResource +## RHDH Extension Requirements for AiResource -RHDH extends the upstream `AiResource` / `AIResource` entity kind with RHDH-specific validation behavior. This spec covers only what this workspace adds on top of the upstream kind definition. +RHDH extends the upstream `AiResource` entity kind with RHDH-specific validation behavior. This spec covers only what this workspace adds on top of the upstream kind definition. Upstream has no `spec.location` field. Asset content location uses the standard `backstage.io/source-location` annotation. @@ -8,78 +8,78 @@ Upstream has no `spec.location` field. Asset content location uses the standard ### Requirement: `spec.scope` field -RHDH AIResource entities MAY declare `spec.scope` to indicate the intended audience of the AI asset. Valid values are `organization`, `product`, and `team`. The field is optional. +RHDH AiResource entities MAY declare `spec.scope` to indicate the intended audience of the AI asset. Valid values are `organization`, `product`, and `team`. The field is optional. #### Scenario: Valid scope accepted -- **WHEN** an AIResource entity declares `spec.scope: organization` +- **WHEN** an AiResource entity declares `spec.scope: organization` - **THEN** the catalog ingests it without error #### Scenario: Additional valid scope values accepted -- **WHEN** an AIResource entity declares `spec.scope: product` or `spec.scope: team` +- **WHEN** an AiResource entity declares `spec.scope: product` or `spec.scope: team` - **THEN** the catalog ingests it without error #### Scenario: Omitted scope accepted -- **WHEN** an AIResource entity omits `spec.scope` +- **WHEN** an AiResource entity omits `spec.scope` - **THEN** the catalog ingests it without error #### Scenario: Invalid scope rejected with actionable error -- **WHEN** an AIResource entity declares `spec.scope` with a value other than `organization`, `product`, or `team` +- **WHEN** an AiResource entity declares `spec.scope` with a value other than `organization`, `product`, or `team` - **THEN** the catalog rejects it with a validation error that names `spec.scope`, the received value, and the accepted values --- ### Requirement: No RHDH `spec.location` field -RHDH SHALL NOT require or validate a `spec.location` struct for AIResource asset location. Git/HTTPS and OCI content references use `backstage.io/source-location` only. +RHDH SHALL NOT require or validate a `spec.location` struct for AiResource asset location. Git/HTTPS and OCI content references use `backstage.io/source-location` only. #### Scenario: Entity without `spec.location` accepted -- **WHEN** an AIResource entity omits `spec.location` and provides a valid `backstage.io/source-location` +- **WHEN** an AiResource entity omits `spec.location` and provides a valid `backstage.io/source-location` - **THEN** the entity passes RHDH extension validation for location concerns #### Scenario: `spec.location` is not the OCI validation input -- **WHEN** an AIResource entity includes a legacy `spec.location` field +- **WHEN** an AiResource entity includes a legacy `spec.location` field - **THEN** RHDH OCI format validation does not treat `spec.location.target` as the source of truth (validation is based on `backstage.io/source-location`) --- ### Requirement: OCI source-location URI scheme -When an AIResource entity declares `backstage.io/source-location` whose location-ref target uses the `oci://` scheme, the annotation SHALL use the Backstage location-ref form `url:oci://…`, and the target SHALL be a well-formed OCI reference. +When an AiResource entity declares `backstage.io/source-location` whose location-ref target uses the `oci://` scheme, the annotation SHALL use the Backstage location-ref form `url:oci://…`, and the target SHALL be a well-formed OCI reference. #### Scenario: Valid OCI source-location accepted -- **WHEN** an AIResource entity declares `backstage.io/source-location: url:oci://quay.io/myorg/my-skills:latest` +- **WHEN** an AiResource entity declares `backstage.io/source-location: url:oci://quay.io/myorg/my-skills:latest` - **THEN** the entity passes OCI format validation #### Scenario: Valid OCI digest source-location accepted -- **WHEN** an AIResource entity declares `backstage.io/source-location: url:oci://quay.io/myorg/my-skills@sha256:` +- **WHEN** an AiResource entity declares `backstage.io/source-location: url:oci://quay.io/myorg/my-skills@sha256:` - **THEN** the entity passes OCI format validation #### Scenario: OCI target without `oci://` scheme rejected -- **WHEN** an AIResource entity declares `backstage.io/source-location: url:quay.io/myorg/my-skills:latest` intending an OCI asset +- **WHEN** an AiResource entity declares `backstage.io/source-location: url:quay.io/myorg/my-skills:latest` intending an OCI asset - **THEN** the catalog does not treat it as a valid OCI source-location (no `oci://` target scheme) #### Scenario: Bare `oci://` annotation without `url:` prefix rejected -- **WHEN** an AIResource entity declares `backstage.io/source-location: oci://quay.io/myorg/my-skills:latest` +- **WHEN** an AiResource entity declares `backstage.io/source-location: oci://quay.io/myorg/my-skills:latest` - **THEN** the catalog rejects it with an actionable error stating that the location-ref form `url:oci://…` is required #### Scenario: Malformed OCI target rejected -- **WHEN** an AIResource entity declares `backstage.io/source-location: url:oci://` or another malformed OCI reference +- **WHEN** an AiResource entity declares `backstage.io/source-location: url:oci://` or another malformed OCI reference - **THEN** the catalog rejects it with an error describing the expected `oci://registry/repository[:tag|@digest]` format #### Scenario: Non-OCI source-location is not subject to OCI validation -- **WHEN** an AIResource entity declares `backstage.io/source-location: url:https://github.com/myorg/my-skills` +- **WHEN** an AiResource entity declares `backstage.io/source-location: url:https://github.com/myorg/my-skills` - **THEN** the OCI format rules do not apply --- @@ -90,15 +90,15 @@ RHDH-specific validation errors SHALL include the field path (or annotation name #### Scenario: `spec.scope` error is actionable -- **WHEN** an AIResource entity declares an invalid `spec.scope` +- **WHEN** an AiResource entity declares an invalid `spec.scope` - **THEN** the error identifies `spec.scope`, the received value, and the accepted values #### Scenario: OCI source-location error is actionable -- **WHEN** an AIResource entity provides a malformed OCI `backstage.io/source-location` +- **WHEN** an AiResource entity provides a malformed OCI `backstage.io/source-location` - **THEN** the error states the expected `url:oci://…` format without exposing internal implementation details #### Scenario: Multiple extension errors reported together -- **WHEN** an AIResource entity violates multiple RHDH extension constraints +- **WHEN** an AiResource entity violates multiple RHDH extension constraints - **THEN** all errors are reported in a single response rather than stopping at the first error diff --git a/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/tasks.md b/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/tasks.md index 50385c67b5a..c4d0e83dfc0 100644 --- a/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/tasks.md +++ b/workspaces/ai-integrations/openspec/changes/ai-resource-catalog-entity-kind/tasks.md @@ -1,59 +1,63 @@ -# Tasks: AIResource Catalog Entity Kind +# Tasks: AiResource Catalog Entity Kind Tracking note: schema/processor work originally landed against a `spec.location` model (RHIDP-14553/14554/14556). RHIDP-15739 retools OCI support onto upstream `backstage.io/source-location` (`url:oci://…`) and removes `spec.location`. ## 1. Upstream Investigation & Schema Foundation - [x] 1.1 Investigate the upstream `AiResource` kind to confirm the merged model, schema shape, and extension points used by this workspace -- [x] 1.2 Extend or wrap the AIResource schema to support optional `spec.scope` with allowed values `organization`, `product`, and `team` +- [x] 1.2 Extend or wrap the AiResource schema to support optional `spec.scope` with allowed values `organization`, `product`, and `team` - [x] 1.3 Add validation for `spec.scope` that reports field path, received value, and accepted values -- [ ] 1.4 Remove RHDH `spec.location` (types, schema, docs, examples) from AIResource OCI/git asset location support +- [ ] 1.4 Remove RHDH `spec.location` (types, schema, docs, examples) from AiResource OCI/git asset location support - [ ] 1.5 Retool OCI validation to enforce `backstage.io/source-location` location-ref form `url:oci://…` (type `url`, target starts with `oci://` and is a well-formed OCI reference) -- [ ] 1.6 Ensure AIResource validation errors are actionable and can report multiple errors in one response +- [ ] 1.6 Ensure AiResource validation errors are actionable and can report multiple errors in one response - [ ] 1.7 Add/update unit tests covering valid and invalid `spec.scope`, valid and invalid OCI source-location values (including bare `oci://…` without `url:`), and multi-error reporting -## 2. Catalog Ingestion - OCI Processor +## 2. Catalog Ingestion - OCI / Extensions Processor -- [x] 2.1 Implement `AIResourceOciProcessor` as a `CatalogProcessor` with format-only validation and zero network calls _(initial `spec.location` version)_ -- [ ] 2.2 Retool `AIResourceOciProcessor` to validate OCI `backstage.io/source-location` targets instead of `spec.location.target` -- [ ] 2.3 Ensure the OCI processor makes zero outbound HTTP or network calls -- [ ] 2.4 Register/keep `AIResourceOciProcessor` in the catalog backend module under the updated contract -- [ ] 2.5 Add/update unit tests covering valid `url:oci://…` annotations, malformed targets, missing/`url:`-prefix cases, and zero-network behavior +OCI format validation originally lived in a standalone `AiResourceOciProcessor` +and was later consolidated into `AiResourceExtensionsProcessor` (alongside +`spec.scope`). Use that name going forward. + +- [x] 2.1 Implement OCI format-only validation with zero network calls _(initial `spec.location` version; historically `AiResourceOciProcessor`)_ +- [x] 2.2 Retool `AiResourceExtensionsProcessor` to validate OCI `backstage.io/source-location` targets instead of `spec.location.target` +- [x] 2.3 Ensure OCI validation makes zero outbound HTTP or network calls +- [x] 2.4 Register/keep `AiResourceExtensionsProcessor` in the catalog backend module under the updated contract +- [x] 2.5 Add/update unit tests covering valid `url:oci://…` annotations, malformed targets, missing/`url:`-prefix cases, and zero-network behavior ## 3. Catalog Ingestion - Git Path & Registration -- [x] 3.1 Verify git-backed AIResource entities ingest correctly through the existing `UrlReaderProcessor` +- [x] 3.1 Verify git-backed AiResource entities ingest correctly through the existing `UrlReaderProcessor` - [x] 3.2 Verify a missing `backstage.io/source-location` annotation on a git entity produces a warning rather than an error -- [x] 3.3 Verify AIResource entities are registrable through standard catalog registration and discovery flows +- [x] 3.3 Verify AiResource entities are registrable through standard catalog registration and discovery flows - [ ] 3.4 Add/update integration tests covering git/HTTPS source-location entities and OCI `url:oci://…` source-location entities (registration still via normal catalog locations) ## 4. Entity Detail Page -- [ ] 4.1 Add AIResource kind support to the workspace entity page routing +- [ ] 4.1 Add AiResource kind support to the workspace entity page routing - [ ] 4.2 Display asset location from `backstage.io/source-location`: HTTPS/git targets as links; `oci://` targets as copyable text -- [ ] 4.3 Ensure the standard relationships graph is present for AIResource entities +- [ ] 4.3 Ensure the standard relationships graph is present for AiResource entities - [ ] 4.4 Show a TechDocs tab when `backstage.io/techdocs-ref` is present and hide it when absent - [ ] 4.5 Add frontend tests covering location rendering, graph presence, and conditional TechDocs display ## 5. Discovery & Filtering -- [ ] 5.1 Verify AIResource entities are returned by `GET /api/catalog/entities?filter=kind=AIResource` (or upstream kind spelling used by the workspace) -- [ ] 5.2 Verify by-name lookup for AIResource entities +- [ ] 5.1 Verify AiResource entities are returned by `GET /api/catalog/entities?filter=kind=AiResource` (or upstream kind spelling used by the workspace) +- [ ] 5.2 Verify by-name lookup for AiResource entities - [ ] 5.3 Verify filtering by `spec.type`, `spec.scope`, `spec.owner`, and `spec.lifecycle` - [ ] 5.4 Verify entities without `spec.scope` are excluded when a scope filter is applied -- [ ] 5.5 Verify AIResource entities appear in catalog full-text search +- [ ] 5.5 Verify AiResource entities appear in catalog full-text search - [ ] 5.6 Add integration tests for filter and search behavior ## 6. Documentation & Examples -- [x] 6.1 Update the example git-backed AIResource `catalog-info.yaml` (source-location only; no `spec.location`) -- [x] 6.2 Update the example OCI-backed AIResource `catalog-info.yaml` to use `backstage.io/source-location: url:oci://…` +- [x] 6.1 Update the example git-backed AiResource `catalog-info.yaml` (source-location only; no `spec.location`) +- [x] 6.2 Update the example OCI-backed AiResource `catalog-info.yaml` to use `backstage.io/source-location: url:oci://…` - [ ] 6.3 Document schema fields and registration guidance in the workspace (including why `url:` prefix is required) - [ ] 6.4 Document the OCI validation-only behavior, air-gap rationale, and that an OCI UrlReader is deferred ## 7. Final Verification -- [ ] 7.1 Walk through each scenario in the local AIResource specs against the implementation +- [ ] 7.1 Walk through each scenario in the local AiResource specs against the implementation - [ ] 7.2 Update release-facing documentation if the feature ships from this workspace ## Explicitly out of scope (do not implement in this change) diff --git a/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/design.md b/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/design.md index aa5ae843e37..4427577401e 100644 --- a/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/design.md +++ b/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/design.md @@ -70,7 +70,7 @@ The “Research reference” column records where the catalog field was derived | Research reference (SDK `AgentConfiguration`) | AiResource | Required | Notes | | --------------------------------------------- | -------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | | `name` | `metadata.name` | Yes | No `spec.name`; optional `metadata.title` for display | -| `instructions` | `spec.instructions` | Yes | Non-empty string only (no function form in YAML) | +| `instructions` | `spec.instructions` | No | Optional string (no function form in YAML). Omit when the agent image/runtime already bakes in a default prompt. | | `handoffDescription` | `spec.handoffDescription` | No | string | | `model` | `spec.model` | No | string model id | | `handoffs` | `spec.handoffs` | No | `string[]`, opaque (no entity-ref format check) | @@ -94,9 +94,9 @@ The “Research reference” column records where the catalog field was derived ### D5 — Schema + processor in this OpenSpec; agent-only processor rules -**Choice**: Deliver types, examples, schema tests, **and** catalog processor validation for agent-specific fields. Processor does **not** re-validate core entity fields (`owner`, `lifecycle`, etc.) beyond existing catalog behavior. +**Choice**: Deliver types, examples, schema tests, **and** catalog processor validation for agent-specific fields. The agent processor (`AiResourceAgentProcessor`) lives in `catalog-backend-module-ai-resource-agent`, not in `AiResourceExtensionsProcessor`. The extensions processor remains scope/OCI only. Processor does **not** re-validate core entity fields (`owner`, `lifecycle`, etc.) beyond existing catalog behavior. -**Rationale**: Epic cohesion (15867 + 15868 share one field set). Keep processor focused on agent fields. +**Rationale**: Epic cohesion (15867 + 15868 share one field set). Keep processor focused on agent fields. Agent validation belongs with the agent packages, matching the packaging approach from #4128. ### D6 — Dual-track documentation (rhdh-plugins + upstream) @@ -108,7 +108,7 @@ The “Research reference” column records where the catalog field was derived **Choice**: Provide at least one example (or fixture set) with a router-style agent (`handoffs` + `handoffDescription` on specialists) using only D3 fields—not Augment-only keys. -**Rationale**: Exercises required `instructions` and optional handoff/`modelSettings` fields in a realistic catalog authoring scenario. +**Rationale**: Exercises optional `instructions` and handoff/`modelSettings` fields in a realistic catalog authoring scenario. ### D8 — Correct plural type examples in sibling discovery OpenSpec @@ -132,14 +132,14 @@ The “Research reference” column records where the catalog field was derived - Per-type validators / guards: `skillAiResourceEntityV1alpha1Validator`, `ruleAiResourceEntityV1alpha1Validator`, `isSkillAiResourceEntity`, `isRuleAiResourceEntity` - Kind registration via `aiResourceEntityModel` / `catalogModuleAiResourceEntityModel` -Agent should follow that pattern: add an `AgentAiResourceEntity…` (name TBD) member of the AiResource union (or an RHDH-local extension layer that mirrors it until upstream accepts agent), with a `KindValidator` + type guard keyed on `spec.type: 'agent'`. +Agent should follow that pattern: add an `AgentAiResourceEntity…` (name TBD) member of the AiResource union (or an RHDH-local extension layer that mirrors it until upstream accepts agent), with a `KindValidator` + type guard keyed on `spec.type: ‘agent’`. **Secondary reference — MCP server API discriminated extension** (same catalog-model alpha surface): -- `McpServerApiEntity` with `spec.type: 'mcp-server'`, `mcpServerApiEntityValidator`, `isMcpServerApiEntity`, `mcpServerApiEntityModel` +- `McpServerApiEntity` with `spec.type: ‘mcp-server’`, `mcpServerApiEntityValidator`, `isMcpServerApiEntity`, `mcpServerApiEntityModel` - Shows how Backstage extends an existing kind with a typed `spec.type` branch (useful precedent for dual-track / upstream PR work) -**Local RHDH extension precedent**: this workspace’s `AIResourceExtensionsProcessor` for `spec.scope` / OCI checks—agent **field** validation (RHIDP-15868) should extend that processor path for agent-specific rules, while the **typed schema** itself follows the catalog-model validator pattern above. +**Agent packaging**: Agent schema, `KindValidator`, type guard, and `CatalogModelLayer` live in `catalog-model-ai-resource-agent`. Agent-specific field validation (RHIDP-15868) lives in `catalog-backend-module-ai-resource-agent` as `AiResourceAgentProcessor`, registered alongside the model source in the same backend module. The `AiResourceExtensionsProcessor` in `catalog-backend-module-ai-resource-extensions` remains scope/OCI only and does not contain agent-specific rules. Kind spelling is `AiResource` (matching #4128 / the agent model packages). **Rationale**: Without these pointers, implementers (human or coding agent) will invent ad-hoc types that diverge from skill/rule and force manual rework. Gabe’s review feedback on this PR. diff --git a/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/proposal.md b/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/proposal.md index 5c5e4de758f..86ffd939271 100644 --- a/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/proposal.md +++ b/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/proposal.md @@ -7,9 +7,9 @@ Platform engineers need a first-class way to register AI agents in the Software ## What Changes - Add a typed agent schema for `kind: AiResource` with `spec.type: agent` (TypeScript types / schema-style validation; no OpenAI Agents SDK package dependency). -- Encode the decided field mapping (RHIDP-15866) in OpenSpec design; only agent-specific required field is non-empty `spec.instructions`. +- Encode the decided field mapping (RHIDP-15866) in OpenSpec design; `spec.instructions` is optional (agents may bake in a default prompt). - Add example `catalog-info.yaml` and/or test fixtures covering a representative agent (required + optional fields). -- Extend catalog processor validation for agent entities (RHIDP-15868): reject missing/invalid agent-specific fields with actionable errors. +- Add agent-specific catalog processor validation in the agent backend module (`catalog-backend-module-ai-resource-agent`) via `AiResourceAgentProcessor` (RHIDP-15868): reject missing/invalid agent-specific fields with actionable errors. `AiResourceExtensionsProcessor` remains scope/OCI only. - Update in-repo OpenSpec/design docs for `AiResource` + agent ownership under RHDHPLAN-1507; remove “pending 1113” language for the agent type where it appears. - Add unit/schema and processor tests for accept/reject paths. - Align naming with upstream skill/rule style: singular `spec.type: agent` (not `agents`); correct sibling discovery examples accordingly. @@ -49,7 +49,7 @@ _(none promoted under `openspec/specs/` yet. Sibling change-local discovery exam ## Impact - **Schema / types**: New or extended TypeScript for agent-shaped `AiResource` in this workspace. -- **Catalog processor**: Agent-specific validation in the AiResource extensions processor path. +- **Catalog processor**: Agent-specific validation in the agent backend module (`AiResourceAgentProcessor`), not the extensions processor. - **Examples / fixtures**: New catalog YAML under `examples/` (and/or test fixtures). - **Tests**: Schema/unit and processor accept/reject coverage. - **Docs / OpenSpec**: Dual-track (rhdh-plugins + upstream) and singular type discriminator docs. diff --git a/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/specs/ai-resource-agent-ingestion/spec.md b/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/specs/ai-resource-agent-ingestion/spec.md index 5d4f1811784..c5a8c783a25 100644 --- a/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/specs/ai-resource-agent-ingestion/spec.md +++ b/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/specs/ai-resource-agent-ingestion/spec.md @@ -6,13 +6,13 @@ When an AiResource entity declares `spec.type: agent`, the catalog processor MUS #### Scenario: Valid agent entity ingests -- **WHEN** an AiResource entity with `spec.type: agent` and non-empty `spec.instructions` (and correctly typed optional agent fields, if present) is ingested +- **WHEN** an AiResource entity with `spec.type: agent` (and correctly typed optional agent fields, if present) is ingested - **THEN** the catalog processor accepts it -#### Scenario: Missing instructions rejected at ingestion +#### Scenario: Missing instructions accepted at ingestion -- **WHEN** an AiResource entity with `spec.type: agent` omits `spec.instructions` or sets it to an empty string -- **THEN** the catalog processor rejects ingestion with an actionable error that names `spec.instructions` +- **WHEN** an AiResource entity with `spec.type: agent` omits `spec.instructions` +- **THEN** the catalog processor accepts the entity (agents may bake in a default prompt at runtime) #### Scenario: Wrong-type instructions rejected at ingestion @@ -39,7 +39,7 @@ For `spec.type: agent`, the processor MUST accept `spec.handoffs` and `spec.tool ### Requirement: Actionable processor errors -Agent validation errors MUST identify the field path, the problem (missing/empty/wrong type), and MUST NOT expose internal class names or stack traces to catalog authors. +Agent validation errors MUST identify the field path, the problem (wrong type / invalid shape), and MUST NOT expose internal class names or stack traces to catalog authors. #### Scenario: Error names the field @@ -61,7 +61,7 @@ AiResource entities that are not `spec.type: agent` MUST continue to follow exis ### Requirement: Processor automated tests -Automated tests MUST cover processor accept and reject paths for agent entities, including missing `spec.instructions` and at least one invalid optional field shape. +Automated tests MUST cover processor accept and reject paths for agent entities, including an agent that omits `spec.instructions` and at least one invalid optional field shape. #### Scenario: Accept path covered @@ -70,5 +70,5 @@ Automated tests MUST cover processor accept and reject paths for agent entities, #### Scenario: Reject path covered -- **WHEN** the processor test suite runs against an agent entity missing `spec.instructions` +- **WHEN** the processor test suite runs against an agent entity with a wrong-type optional field (for example `spec.handoffs` as a non-array) - **THEN** the suite asserts a validation failure diff --git a/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/specs/ai-resource-agent-schema/spec.md b/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/specs/ai-resource-agent-schema/spec.md index 3be99468819..74e029950fd 100644 --- a/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/specs/ai-resource-agent-schema/spec.md +++ b/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/specs/ai-resource-agent-schema/spec.md @@ -16,21 +16,21 @@ AiResource entities that represent agents MUST declare `spec.type` with the exac --- -### Requirement: Required agent fields +### Requirement: Agent instructions field -An agent-shaped AiResource (`spec.type: agent`) MUST include `spec.instructions` as a non-empty string. Entity identity uses `metadata.name` (there is no separate `spec.name`). Standard catalog fields such as `spec.owner` and `spec.lifecycle` remain normal AiResource/entity expectations and are not redefined by this agent schema capability. +An agent-shaped AiResource (`spec.type: agent`) MAY omit `spec.instructions` when the agent image/runtime already provides a default prompt. When present, `spec.instructions` MUST be a string. Entity identity uses `metadata.name` (there is no separate `spec.name`). Standard catalog fields such as `spec.owner` and `spec.lifecycle` remain normal AiResource/entity expectations and are not redefined by this agent schema capability. These are schema-layer requirements for agent authoring and tests; catalog processor behavior for agent-specific fields is specified under `ai-resource-agent-ingestion`. #### Scenario: Minimal valid agent accepted -- **WHEN** an AiResource entity declares `spec.type: agent`, non-empty `spec.instructions`, and a valid `metadata.name` +- **WHEN** an AiResource entity declares `spec.type: agent` and a valid `metadata.name` (with or without `spec.instructions`) - **THEN** the agent schema accepts the entity -#### Scenario: Missing instructions rejected +#### Scenario: Missing instructions accepted -- **WHEN** an AiResource entity declares `spec.type: agent` but omits `spec.instructions` or sets it to an empty string -- **THEN** the agent schema rejects the entity with an error that names `spec.instructions` +- **WHEN** an AiResource entity declares `spec.type: agent` but omits `spec.instructions` +- **THEN** the agent schema accepts the entity #### Scenario: Wrong-type instructions rejected @@ -43,7 +43,7 @@ These are schema-layer requirements for agent authoring and tests; catalog proce The agent schema MUST allow the optional agent fields defined in this change’s `design.md` mapping table (under `spec` / `metadata`, not agent-specific annotations). Optional field membership and shapes SHALL follow that mapping rather than a divergent inventory in code. -At minimum, the mapping’s optional catalog fields include: `spec.handoffDescription`, `spec.model`, `spec.handoffs` (`string[]`), `spec.tools` (`string[]`), `spec.toolUseBehavior`, `spec.resetToolChoice`, `spec.modelSettings` (`temperature`, `maxTokens`, `toolChoice` only for v1), and `spec.outputSchema`. +At minimum, the mapping’s optional catalog fields include: `spec.instructions`, `spec.handoffDescription`, `spec.model`, `spec.handoffs` (`string[]`), `spec.tools` (`string[]`), `spec.toolUseBehavior`, `spec.resetToolChoice`, `spec.modelSettings` (`temperature`, `maxTokens`, `toolChoice` only for v1), and `spec.outputSchema`. #### Scenario: Optional fields from mapping accepted @@ -91,23 +91,23 @@ The rhdh-plugins agent schema implementation MUST NOT import `@openai/agents-cor ### Requirement: Examples and fixtures for agent entities -The workspace MUST provide example catalog YAML and/or test fixtures that demonstrate a representative agent entity covering required fields and a non-empty subset of optional fields from the design mapping (including at least one of `handoffs` or `modelSettings`). +The workspace MUST provide example catalog YAML and/or test fixtures that demonstrate a representative agent entity covering required core fields and a non-empty subset of optional fields from the design mapping (including at least one of `handoffs` or `modelSettings`). #### Scenario: Example catalog YAML exists - **WHEN** a developer opens the workspace examples (or equivalent fixtures) for agent AiResources -- **THEN** at least one `kind: AiResource` document with `spec.type: agent` and non-empty `spec.instructions` is present +- **THEN** at least one `kind: AiResource` document with `spec.type: agent` is present #### Scenario: Fixture usable by schema tests - **WHEN** schema/unit tests run -- **THEN** they load a representative agent fixture (or inline equivalent) that exercises required and optional fields +- **THEN** they load a representative agent fixture (or inline equivalent) that exercises core and optional fields --- ### Requirement: Schema-layer validation tests -Unit or schema tests MUST cover acceptance of valid agent entities and rejection of clearly invalid shapes at the schema layer (missing required agent fields, wrong `spec.type` for agent validation, wrong types on optional fields). +Unit or schema tests MUST cover acceptance of valid agent entities and rejection of clearly invalid shapes at the schema layer (wrong `spec.type` for agent validation, wrong types on optional fields). #### Scenario: Valid agent test passes @@ -116,7 +116,7 @@ Unit or schema tests MUST cover acceptance of valid agent entities and rejection #### Scenario: Invalid agent test fails closed -- **WHEN** the agent schema test suite runs against an agent entity missing `spec.instructions` +- **WHEN** the agent schema test suite runs against an agent entity with a wrong-type optional field (for example `spec.handoffs` as a non-array) - **THEN** the suite asserts a schema validation failure --- diff --git a/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/tasks.md b/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/tasks.md index 2d41bdc9fbc..e64f69004f7 100644 --- a/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/tasks.md +++ b/workspaces/ai-integrations/openspec/changes/airesource-agent-typed-schema/tasks.md @@ -7,13 +7,13 @@ ## 2. Agent TypeScript schema -- [ ] 2.1 Add an agent AiResource typed variant (union member / local mirror) with `spec.type: 'agent'`, required `instructions`, and optional fields from design D3—matching the skill/rule discriminated-type style (not an ad-hoc standalone interface) +- [ ] 2.1 Add an agent AiResource typed variant (union member / local mirror) with `spec.type: 'agent'`, optional `instructions`, and optional fields from design D3—matching the skill/rule discriminated-type style (not an ad-hoc standalone interface) - [ ] 2.2 Add `KindValidator` + type guard for agent (same shape as `skillAiResourceEntityV1alpha1Validator` / `isSkillAiResourceEntity`); no OpenAI Agents SDK import; opaque `string[]` for handoffs/tools - [ ] 2.3 Wire validator into catalog model / backend module registration the way skill/rule (and MCP `mcp-server`) validators are registered; export public types from the chosen package entrypoint / API report as needed ## 3. Examples and fixtures -- [ ] 3.1 Add example `catalog-info.yaml` (under `examples/`) with at least one `kind: AiResource`, `spec.type: agent`, non-empty `spec.instructions` +- [ ] 3.1 Add example `catalog-info.yaml` (under `examples/`) with at least one `kind: AiResource`, `spec.type: agent` (include `spec.instructions` in at least one example) - [ ] 3.2 Include optional fields in the example/fixture set (at least `handoffs` or `modelSettings`; prefer a small router + specialist handoff shape) - [ ] 3.3 Wire the example into local catalog locations or document how to register it (follow existing skill example pattern) @@ -21,15 +21,16 @@ - [ ] 4.1 Add tests that accept a minimal valid agent entity - [ ] 4.2 Add tests that accept a valid agent with optional mapped fields populated -- [ ] 4.3 Add tests that reject missing/empty `spec.instructions`, wrong type discriminator (`agents`), and clearly wrong optional field types +- [ ] 4.3 Add tests that accept omitted `spec.instructions`, reject wrong type discriminator (`agents`), and clearly wrong optional field types - [ ] 4.4 Assert schema sources do not depend on OpenAI Agents SDK packages ## 5. Catalog processor validation (RHIDP-15868) -- [ ] 5.1 Extend the AiResource extensions processor to validate agent-specific fields when `spec.type: agent` -- [ ] 5.2 Reject missing/empty `spec.instructions` and wrong optional agent field shapes with actionable errors +- [ ] 5.1 Add `AiResourceAgentProcessor` in `catalog-backend-module-ai-resource-agent` to validate agent-specific fields when `spec.type: agent`; do **not** extend `AiResourceExtensionsProcessor` (which remains scope/OCI only) +- [ ] 5.2 Accept omitted `spec.instructions`; reject wrong-type instructions and wrong optional agent field shapes with actionable errors - [ ] 5.3 Do not enforce entity-ref format on `handoffs` / `tools`; do not add new owner/lifecycle processor rules - [ ] 5.4 Add processor tests for accept and reject paths; ensure non-agent AiResources are unaffected +- [ ] 5.5 Register `AiResourceAgentProcessor` in the agent backend module alongside the model source ## 6. Docs and OpenSpec DoD diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/README.md b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/README.md new file mode 100644 index 00000000000..5966cb7a82b --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/README.md @@ -0,0 +1,65 @@ +# @red-hat-developer-hub/backstage-plugin-catalog-backend-module-ai-resource-agent + +A Backstage catalog backend module that registers the agent-shaped +`AiResource` catalog model and validates agent-specific fields at +ingestion. + +This package is intentionally separate from +`catalog-backend-module-ai-resource-extensions`, which owns shared RHDH +extensions such as `spec.scope` and OCI `source-location` checks. + +## What it registers + +On init the module: + +1. Adds the agent catalog model source (`agentAiResourceEntityModel`) for + `kind: AiResource`, `spec.type: agent` +2. Registers `AiResourceAgentProcessor` for agent field validation during + catalog processing + +## Validation + +### `AiResourceAgentProcessor` + +Runs only for `kind: AiResource`. When `spec.type` is `'agent'`, validates +agent-specific fields and collects all violations into a single error. +Non-agent AiResource entities (`skill`, `rule`, `model`, …) are unaffected. + +| Field | Rule | +| ------------------------- | ------------------------------------------- | +| `spec.instructions` | Optional; must be a string if present | +| `spec.handoffs` | Optional; must be an array if present | +| `spec.tools` | Optional; must be an array if present | +| `spec.resetToolChoice` | Optional; must be a boolean if present | +| `spec.modelSettings` | Optional; must be a plain object if present | +| `spec.toolUseBehavior` | Optional; string or string array if present | +| `spec.outputSchema` | Optional; string or plain object if present | +| `spec.handoffDescription` | Optional; must be a string if present | +| `spec.model` | Optional; must be a string if present | + +`handoffs` / `tools` accept opaque string arrays without entity-ref format +enforcement. Core entity fields such as `spec.owner` and `spec.lifecycle` +are not re-validated here. + +Deeper schema typing (including array element types) is handled by the +companion `catalog-model-ai-resource-agent` package. + +## Related packages + +| Package | Responsibility | +| --------------------------------------------------------- | -------------------------------------------------- | +| `catalog-model-ai-resource-agent` | Types, JSON schema, KindValidator | +| `catalog-backend-module-ai-resource-agent` (this package) | Model registration + agent processor | +| `catalog-backend-module-ai-resource-extensions` | RHDH `spec.scope` + OCI source-location validation | + +## Examples + +See [`examples/ai-resource-agents.yaml`](../../examples/ai-resource-agents.yaml) +for router + specialist agent catalog entities. + +## Public API + +| Export | Description | +| ---------------------------------------- | ----------------------------------------------------------- | +| `catalogModuleAiResourceAgent` (default) | Backend module that registers the agent model and processor | +| `AiResourceAgentProcessor` | `CatalogProcessor` for agent-specific field validation | diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/package.json b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/package.json index e2931f635d2..60191d5dc62 100644 --- a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/package.json +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/package.json @@ -32,10 +32,12 @@ "dependencies": { "@backstage/backend-plugin-api": "^1.9.2", "@backstage/catalog-model": "^1.9.0", + "@backstage/plugin-catalog-common": "^1.1.10", "@backstage/plugin-catalog-node": "^2.2.2", "@red-hat-developer-hub/backstage-plugin-catalog-model-ai-resource-agent": "workspace:^" }, "devDependencies": { + "@backstage/backend-test-utils": "^1.11.4", "@backstage/cli": "^0.36.3" }, "files": [ diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/report.api.md b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/report.api.md index e3b7b93b814..f3f8942a23a 100644 --- a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/report.api.md +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/report.api.md @@ -4,6 +4,22 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { CatalogProcessor } from '@backstage/plugin-catalog-node'; +import { CatalogProcessorEmit } from '@backstage/plugin-catalog-node'; +import { Entity } from '@backstage/catalog-model'; +import { LocationSpec } from '@backstage/plugin-catalog-common'; + +// @public +export class AiResourceAgentProcessor implements CatalogProcessor { + // (undocumented) + getProcessorName(): string; + // (undocumented) + preProcessEntity( + entity: Entity, + _location: LocationSpec, + _emit: CatalogProcessorEmit, + ): Promise; +} // @public const catalogModuleAiResourceAgent: BackendFeature; diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/AiResourceAgentProcessor.test.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/AiResourceAgentProcessor.test.ts new file mode 100644 index 00000000000..b9ba96d140f --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/AiResourceAgentProcessor.test.ts @@ -0,0 +1,238 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { AiResourceAgentProcessor } from './AiResourceAgentProcessor'; + +function makeAiResource( + spec: Entity['spec'] = {}, + annotations?: Record, +): Entity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { + name: 'test-resource', + ...(annotations ? { annotations } : {}), + }, + spec, + }; +} + +describe('AiResourceAgentProcessor', () => { + let processor: AiResourceAgentProcessor; + const location = { type: 'url', target: 'https://example.com' }; + const emit = jest.fn(); + + beforeEach(() => { + processor = new AiResourceAgentProcessor(); + emit.mockClear(); + }); + + it('should return processor name', () => { + expect(processor.getProcessorName()).toBe('AiResourceAgentProcessor'); + }); + + describe('agent validation (spec.type: agent)', () => { + it('should accept a valid agent entity with required fields', async () => { + const entity = makeAiResource({ + type: 'agent', + lifecycle: 'production', + owner: 'ai-platform-team', + instructions: 'You are a test agent.', + }); + + const result = await processor.preProcessEntity(entity, location, emit); + + expect(result).toEqual(entity); + }); + + it('should accept a valid agent with all optional fields', async () => { + const entity = makeAiResource({ + type: 'agent', + lifecycle: 'production', + owner: 'ai-platform-team', + instructions: 'You are a fully configured agent.', + handoffDescription: 'Handles everything.', + model: 'gpt-4o', + handoffs: ['agent-a', 'agent-b'], + tools: ['tool-x', 'tool-y'], + toolUseBehavior: 'run_llm_again', + resetToolChoice: true, + modelSettings: { temperature: 0.5 }, + outputSchema: { type: 'object' }, + }); + + const result = await processor.preProcessEntity(entity, location, emit); + + expect(result).toEqual(entity); + }); + + it('should accept opaque handoffs and tools strings', async () => { + const entity = makeAiResource({ + type: 'agent', + instructions: 'Agent with opaque refs.', + handoffs: ['some-arbitrary-string', 'another-ref'], + tools: ['my-custom-tool'], + }); + + const result = await processor.preProcessEntity(entity, location, emit); + + expect(result).toEqual(entity); + }); + + it('should accept agent with missing instructions', async () => { + const entity = makeAiResource({ + type: 'agent', + lifecycle: 'production', + owner: 'team', + }); + + const result = await processor.preProcessEntity(entity, location, emit); + + expect(result).toEqual(entity); + }); + + it('should accept agent with empty instructions', async () => { + const entity = makeAiResource({ + type: 'agent', + instructions: '', + }); + + const result = await processor.preProcessEntity(entity, location, emit); + + expect(result).toEqual(entity); + }); + + it('should reject agent with wrong-type instructions', async () => { + const entity = makeAiResource({ + type: 'agent', + instructions: 42, + }); + + await expect( + processor.preProcessEntity(entity, location, emit), + ).rejects.toThrow('spec.instructions'); + }); + + it('should reject agent with handoffs as non-array', async () => { + const entity = makeAiResource({ + type: 'agent', + instructions: 'Valid instructions.', + handoffs: 'not-an-array', + }); + + await expect( + processor.preProcessEntity(entity, location, emit), + ).rejects.toThrow('spec.handoffs'); + }); + + it('should reject agent with resetToolChoice as non-boolean', async () => { + const entity = makeAiResource({ + type: 'agent', + instructions: 'Valid instructions.', + resetToolChoice: 'yes', + }); + + await expect( + processor.preProcessEntity(entity, location, emit), + ).rejects.toThrow('spec.resetToolChoice'); + }); + + it('should not apply agent instructions rule to skill entities', async () => { + const entity = makeAiResource({ + type: 'skill', + lifecycle: 'production', + owner: 'team', + }); + + const result = await processor.preProcessEntity(entity, location, emit); + + expect(result).toEqual(entity); + }); + + it('should not apply agent instructions rule to entities without spec.type', async () => { + const entity = makeAiResource({ + lifecycle: 'production', + owner: 'team', + }); + + const result = await processor.preProcessEntity(entity, location, emit); + + expect(result).toEqual(entity); + }); + + it('should report multiple agent errors together', async () => { + const entity = makeAiResource({ + type: 'agent', + instructions: 42, + handoffs: 'not-an-array', + }); + + const error = await processor + .preProcessEntity(entity, location, emit) + .catch((e: Error) => e); + + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain('spec.instructions'); + expect(message).toContain('spec.handoffs'); + }); + + it('should not expose internal class names in agent errors', async () => { + const entity = makeAiResource({ + type: 'agent', + instructions: 42, + }); + + const error = await processor + .preProcessEntity(entity, location, emit) + .catch((e: Error) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).not.toMatch(/AiResourceAgentProcessor/); + expect((error as Error).message).not.toMatch(/at\s+\w+\.\w+\s+\(/); + }); + }); + + describe('non-AiResource entities', () => { + it('should pass through Component entities unchanged', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'my-component' }, + spec: { type: 'service', lifecycle: 'production', owner: 'team-a' }, + }; + + const result = await processor.preProcessEntity(entity, location, emit); + + expect(result).toEqual(entity); + }); + + it('should not validate agent fields on non-AiResource kinds', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', + metadata: { name: 'my-resource' }, + spec: { type: 'agent', owner: 'team-a' }, + }; + + const result = await processor.preProcessEntity(entity, location, emit); + + expect(result).toEqual(entity); + }); + }); +}); diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/AiResourceAgentProcessor.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/AiResourceAgentProcessor.ts new file mode 100644 index 00000000000..1bedfcb0ec0 --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/AiResourceAgentProcessor.ts @@ -0,0 +1,68 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + CatalogProcessor, + CatalogProcessorEmit, +} from '@backstage/plugin-catalog-node'; +import { Entity } from '@backstage/catalog-model'; +import { LocationSpec } from '@backstage/plugin-catalog-common'; +import { collectAgentErrors } from './collectAgentErrors'; + +/** + * A CatalogProcessor that validates agent-specific fields on + * AiResource entities with `spec.type: 'agent'`. + * + * Validates: + * - `spec.instructions`: optional; must be a string if present + * - `spec.handoffs` / `spec.tools`: must be arrays if present + * - `spec.resetToolChoice`: must be boolean if present + * - `spec.modelSettings`: must be plain object if present + * - `spec.toolUseBehavior`: must be string or string array if present + * - `spec.outputSchema`: must be string or object if present + * - `spec.handoffDescription` / `spec.model`: must be strings if present + * + * Non-agent AiResource entities (skill, rule, model) are unaffected. + * All constraint violations are collected and reported in a single + * error rather than stopping at the first failure. + * + * @public + */ +export class AiResourceAgentProcessor implements CatalogProcessor { + getProcessorName(): string { + return 'AiResourceAgentProcessor'; + } + + async preProcessEntity( + entity: Entity, + _location: LocationSpec, + _emit: CatalogProcessorEmit, + ): Promise { + if (entity.kind !== 'AiResource') { + return entity; + } + + const errors = collectAgentErrors(entity); + + if (errors.length > 0) { + throw new Error( + `Validation failed for AiResource agent entity: ${errors.join('; ')}`, + ); + } + + return entity; + } +} diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/collectAgentErrors.test.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/collectAgentErrors.test.ts new file mode 100644 index 00000000000..1eb5872537d --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/collectAgentErrors.test.ts @@ -0,0 +1,417 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { collectAgentErrors } from './collectAgentErrors'; + +function makeAgent(spec: Entity['spec'] = {}): Entity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { name: 'test-agent' }, + spec: { + type: 'agent', + lifecycle: 'production', + owner: 'ai-platform-team', + instructions: 'You are a test agent.', + ...spec, + }, + }; +} + +describe('collectAgentErrors', () => { + describe('non-agent entities are skipped', () => { + it('returns no errors for spec.type: skill', () => { + const entity = makeAgent({ type: 'skill' }); + delete (entity as any).spec.instructions; + + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('returns no errors for spec.type: rule', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { name: 'test-rule' }, + spec: { type: 'rule', lifecycle: 'production', owner: 'team' }, + }; + + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('returns no errors for spec.type: model', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { name: 'test-model' }, + spec: { type: 'model', lifecycle: 'production', owner: 'team' }, + }; + + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('returns no errors when spec is undefined', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'AiResource', + metadata: { name: 'test' }, + }; + + expect(collectAgentErrors(entity)).toEqual([]); + }); + }); + + describe('spec.instructions (optional)', () => { + it('accepts valid non-empty instructions', () => { + const entity = makeAgent({ instructions: 'You are a helpful agent.' }); + + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('accepts missing instructions', () => { + const entity = makeAgent(); + delete (entity as any).spec.instructions; + + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('accepts empty string instructions', () => { + const entity = makeAgent({ instructions: '' }); + + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('rejects null instructions', () => { + const entity = makeAgent({ instructions: null }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.instructions'); + expect(errors[0]).toContain('string'); + }); + + it('rejects numeric instructions', () => { + const entity = makeAgent({ instructions: 42 }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.instructions'); + expect(errors[0]).toContain('string'); + }); + + it('rejects array instructions', () => { + const entity = makeAgent({ instructions: ['step 1', 'step 2'] }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.instructions'); + }); + }); + + describe('spec.handoffs (optional, must be array)', () => { + it('accepts undefined handoffs', () => { + const entity = makeAgent(); + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('accepts handoffs as string array', () => { + const entity = makeAgent({ + handoffs: ['agent-a', 'agent-b'], + }); + + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('accepts opaque handoff strings (no entity-ref format)', () => { + const entity = makeAgent({ + handoffs: ['some-arbitrary-string', 'another-ref'], + }); + + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('rejects handoffs as a string', () => { + const entity = makeAgent({ handoffs: 'not-an-array' }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.handoffs'); + expect(errors[0]).toContain('array'); + }); + + it('rejects handoffs as a number', () => { + const entity = makeAgent({ handoffs: 123 }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.handoffs'); + }); + + it('rejects handoffs as an object', () => { + const entity = makeAgent({ handoffs: { a: 'b' } }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.handoffs'); + }); + }); + + describe('spec.tools (optional, must be array)', () => { + it('accepts tools as string array', () => { + const entity = makeAgent({ + tools: ['tool-x', 'tool-y'], + }); + + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('accepts opaque tool strings (no entity-ref format)', () => { + const entity = makeAgent({ + tools: ['my-custom-tool'], + }); + + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('rejects tools as a string', () => { + const entity = makeAgent({ tools: 'not-an-array' }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.tools'); + expect(errors[0]).toContain('array'); + }); + }); + + describe('spec.resetToolChoice (optional, must be boolean)', () => { + it('accepts resetToolChoice as true', () => { + const entity = makeAgent({ resetToolChoice: true }); + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('accepts resetToolChoice as false', () => { + const entity = makeAgent({ resetToolChoice: false }); + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('rejects resetToolChoice as a string', () => { + const entity = makeAgent({ resetToolChoice: 'yes' }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.resetToolChoice'); + expect(errors[0]).toContain('boolean'); + }); + + it('rejects resetToolChoice as a number', () => { + const entity = makeAgent({ resetToolChoice: 1 }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.resetToolChoice'); + }); + }); + + describe('spec.modelSettings (optional, must be object)', () => { + it('accepts modelSettings as a plain object', () => { + const entity = makeAgent({ + modelSettings: { temperature: 0.5, maxTokens: 2048 }, + }); + + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('rejects modelSettings as an array', () => { + const entity = makeAgent({ modelSettings: [1, 2, 3] }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.modelSettings'); + expect(errors[0]).toContain('object'); + }); + + it('rejects modelSettings as a string', () => { + const entity = makeAgent({ modelSettings: 'high' }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.modelSettings'); + }); + + it('rejects modelSettings as null', () => { + const entity = makeAgent({ modelSettings: null }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.modelSettings'); + }); + }); + + describe('spec.toolUseBehavior (optional, string or string[])', () => { + it('accepts toolUseBehavior as a string', () => { + const entity = makeAgent({ toolUseBehavior: 'run_llm_again' }); + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('accepts toolUseBehavior as a string array', () => { + const entity = makeAgent({ + toolUseBehavior: ['tool-a', 'tool-b'], + }); + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('rejects toolUseBehavior as a number', () => { + const entity = makeAgent({ toolUseBehavior: 42 }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.toolUseBehavior'); + }); + + it('rejects toolUseBehavior as a boolean', () => { + const entity = makeAgent({ toolUseBehavior: true }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.toolUseBehavior'); + }); + }); + + describe('spec.outputSchema (optional, string or object)', () => { + it('accepts outputSchema as a string', () => { + const entity = makeAgent({ outputSchema: 'text' }); + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('accepts outputSchema as an object', () => { + const entity = makeAgent({ + outputSchema: { type: 'object', properties: {} }, + }); + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('rejects outputSchema as a number', () => { + const entity = makeAgent({ outputSchema: 123 }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.outputSchema'); + }); + + it('rejects outputSchema as an array', () => { + const entity = makeAgent({ outputSchema: ['a', 'b'] }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.outputSchema'); + }); + }); + + describe('spec.handoffDescription (optional, must be string)', () => { + it('accepts handoffDescription as a string', () => { + const entity = makeAgent({ + handoffDescription: 'Handles routing.', + }); + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('rejects handoffDescription as a number', () => { + const entity = makeAgent({ handoffDescription: 42 }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.handoffDescription'); + expect(errors[0]).toContain('string'); + }); + }); + + describe('spec.model (optional, must be string)', () => { + it('accepts model as a string', () => { + const entity = makeAgent({ model: 'gpt-4o' }); + expect(collectAgentErrors(entity)).toEqual([]); + }); + + it('rejects model as a number', () => { + const entity = makeAgent({ model: 42 }); + + const errors = collectAgentErrors(entity); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('spec.model'); + expect(errors[0]).toContain('string'); + }); + }); + + describe('multiple errors reported together', () => { + it('collects all agent field errors in a single array', () => { + const entity = makeAgent({ + instructions: 42, + handoffs: 'not-an-array', + resetToolChoice: 'yes', + }); + + const errors = collectAgentErrors(entity); + expect(errors.length).toBeGreaterThanOrEqual(3); + expect(errors.some(e => e.includes('spec.instructions'))).toBe(true); + expect(errors.some(e => e.includes('spec.handoffs'))).toBe(true); + expect(errors.some(e => e.includes('spec.resetToolChoice'))).toBe(true); + }); + }); + + describe('error quality', () => { + it('does not expose internal class names', () => { + const entity = makeAgent({ instructions: 42 }); + + const errors = collectAgentErrors(entity); + expect(errors.length).toBeGreaterThan(0); + for (const err of errors) { + expect(err).not.toMatch(/Processor/); + expect(err).not.toMatch(/at\s+\w+\.\w+\s+\(/); + } + }); + + it('names the field path in the error', () => { + const entity = makeAgent({ instructions: 42 }); + + const errors = collectAgentErrors(entity); + expect(errors[0]).toContain('spec.instructions'); + }); + }); + + describe('valid agent with all optional fields', () => { + it('returns no errors for a fully populated agent', () => { + const entity = makeAgent({ + instructions: 'You are a fully configured agent.', + handoffDescription: 'Handles everything.', + model: 'gpt-4o', + handoffs: ['agent-a', 'agent-b'], + tools: ['tool-x', 'tool-y'], + toolUseBehavior: 'run_llm_again', + resetToolChoice: true, + modelSettings: { + temperature: 0.5, + maxTokens: 2048, + toolChoice: 'auto', + }, + outputSchema: { + type: 'object', + properties: { result: { type: 'string' } }, + }, + }); + + expect(collectAgentErrors(entity)).toEqual([]); + }); + }); +}); diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/collectAgentErrors.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/collectAgentErrors.ts new file mode 100644 index 00000000000..be7519d8afe --- /dev/null +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/collectAgentErrors.ts @@ -0,0 +1,149 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; + +/** + * Collects agent-specific validation errors for an AiResource entity + * with `spec.type: 'agent'` without throwing. Returns an array of + * error messages (empty if valid or if `spec.type` is not `'agent'`). + * + * Validates agent-specific fields only; does NOT re-validate core + * entity fields such as `spec.owner` or `spec.lifecycle`. + * + * @internal + */ +export function collectAgentErrors(entity: Entity): string[] { + const spec = entity.spec; + if (spec?.type !== 'agent') { + return []; + } + + const errors: string[] = []; + + // Optional: spec.instructions — must be a string if present. + // Omitted when the agent image/runtime already bakes in a default prompt. + if ( + spec.instructions !== undefined && + typeof spec.instructions !== 'string' + ) { + errors.push( + `spec.instructions must be a string; got ${typeLabel(spec.instructions)}`, + ); + } + + // Optional: spec.handoffs — must be an array if present + if (spec.handoffs !== undefined && !Array.isArray(spec.handoffs)) { + errors.push( + `spec.handoffs must be an array; got ${typeLabel(spec.handoffs)}`, + ); + } + + // Optional: spec.tools — must be an array if present + if (spec.tools !== undefined && !Array.isArray(spec.tools)) { + errors.push(`spec.tools must be an array; got ${typeLabel(spec.tools)}`); + } + + // Optional: spec.resetToolChoice — must be a boolean if present + if ( + spec.resetToolChoice !== undefined && + typeof spec.resetToolChoice !== 'boolean' + ) { + errors.push( + `spec.resetToolChoice must be a boolean; got ${typeLabel( + spec.resetToolChoice, + )}`, + ); + } + + // Optional: spec.modelSettings — must be a plain object if present + if (spec.modelSettings !== undefined) { + if ( + typeof spec.modelSettings !== 'object' || + spec.modelSettings === null || + Array.isArray(spec.modelSettings) + ) { + errors.push( + `spec.modelSettings must be an object; got ${typeLabel( + spec.modelSettings, + )}`, + ); + } + } + + // Optional: spec.toolUseBehavior — must be a string or string array + if (spec.toolUseBehavior !== undefined) { + if ( + typeof spec.toolUseBehavior !== 'string' && + !Array.isArray(spec.toolUseBehavior) + ) { + errors.push( + `spec.toolUseBehavior must be a string or an array; got ${typeLabel( + spec.toolUseBehavior, + )}`, + ); + } + } + + // Optional: spec.outputSchema — must be a string or plain object + if (spec.outputSchema !== undefined) { + const isString = typeof spec.outputSchema === 'string'; + const isObject = + typeof spec.outputSchema === 'object' && + spec.outputSchema !== null && + !Array.isArray(spec.outputSchema); + if (!isString && !isObject) { + errors.push( + `spec.outputSchema must be a string or an object; got ${typeLabel( + spec.outputSchema, + )}`, + ); + } + } + + // Optional: spec.handoffDescription — must be a string if present + if ( + spec.handoffDescription !== undefined && + typeof spec.handoffDescription !== 'string' + ) { + errors.push( + `spec.handoffDescription must be a string; got ${typeLabel( + spec.handoffDescription, + )}`, + ); + } + + // Optional: spec.model — must be a string if present + if (spec.model !== undefined && typeof spec.model !== 'string') { + errors.push(`spec.model must be a string; got ${typeLabel(spec.model)}`); + } + + return errors; +} + +/** + * Returns a human-friendly type label for error messages. + * Distinguishes arrays and null from plain "object". + */ +function typeLabel(value: unknown): string { + if (value === null) { + return 'null'; + } + if (Array.isArray(value)) { + return 'array'; + } + return typeof value; +} diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/index.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/index.ts index 611d02c6ab5..4350efcc89e 100644 --- a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/index.ts +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/index.ts @@ -20,3 +20,4 @@ * @packageDocumentation */ export { catalogModuleAiResourceAgent as default } from './module'; +export { AiResourceAgentProcessor } from './AiResourceAgentProcessor'; diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/module.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/module.ts index 8fed9ce3214..092fd8f368a 100644 --- a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/module.ts +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-agent/src/module.ts @@ -16,8 +16,10 @@ import { createBackendModule } from '@backstage/backend-plugin-api'; import type { CatalogModelSource } from '@backstage/catalog-model/alpha'; +import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; import { catalogModelExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; import { agentAiResourceEntityModel } from '@red-hat-developer-hub/backstage-plugin-catalog-model-ai-resource-agent'; +import { AiResourceAgentProcessor } from './AiResourceAgentProcessor'; /** * Registers the agent specType for the AiResource kind in the catalog. @@ -35,14 +37,16 @@ export const catalogModuleAiResourceAgent = createBackendModule({ reg.registerInit({ deps: { model: catalogModelExtensionPoint, + catalog: catalogProcessingExtensionPoint, }, - async init({ model }) { + async init({ model, catalog }) { const source: CatalogModelSource = { async *read() { yield { data: [{ layer: agentAiResourceEntityModel }] }; }, }; model.addModelSource(source); + catalog.addProcessor(new AiResourceAgentProcessor()); }, }); }, diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/CHANGELOG.md b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/CHANGELOG.md index 3023a2a3089..7f938bb96eb 100644 --- a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/CHANGELOG.md +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/CHANGELOG.md @@ -12,14 +12,14 @@ ### Minor Changes -- 55088f9: Aggregate multiple AIResource extension errors into a single response. +- 55088f9: Aggregate multiple AiResource extension errors into a single response. Extract OCI validation into a shared `collectOciErrors` function called by - `AIResourceExtensionsProcessor` so that scope and OCI constraint violations + `AiResourceExtensionsProcessor` so that scope and OCI constraint violations are reported together instead of stopping at the first failure. -- 8317e6e: Consolidate OCI location validation into `AIResourceExtensionsProcessor`. - The standalone `AIResourceOciProcessor` class and its public export have been +- 8317e6e: Consolidate OCI location validation into `AiResourceExtensionsProcessor`. + The standalone `AiResourceOciProcessor` class and its public export have been removed; OCI format checks now run via the internal `collectOciErrors` helper - called by `AIResourceExtensionsProcessor`. + called by `AiResourceExtensionsProcessor`. - 93847ae: Migrate OCI asset location validation from `spec.location` to the standard `backstage.io/source-location` annotation using the Backstage location-ref form `url:oci://...`. The annotation is parsed with upstream `parseLocationRef` @@ -28,12 +28,12 @@ ### Patch Changes -- e168046: Add AIResource example YAML files and extend README with schema field +- e168046: Add AiResource example YAML files and extend README with schema field reference, registration guidance, and OCI validation-only documentation. ## 0.2.0 ### Minor Changes -- 6af0114: Add AIResource extension validation via AIResourceExtensionsProcessor, starting with spec.scope -- 67be6ce: Scaffold new AIResource catalog extension and add upstream module to workspace +- 6af0114: Add AiResource extension validation via AiResourceExtensionsProcessor, starting with spec.scope +- 67be6ce: Scaffold new AiResource catalog extension and add upstream module to workspace diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/README.md b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/README.md index 2fee58c9139..f93524cd3aa 100644 --- a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/README.md +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/README.md @@ -1,17 +1,17 @@ # @red-hat-developer-hub/backstage-plugin-catalog-backend-module-ai-resource-extensions A Backstage catalog backend module that validates RHDH-specific extension -fields on AIResource entities. +fields on AiResource entities. ## Validation The module registers a single catalog processor — -`AIResourceExtensionsProcessor` — for `AIResource` entities. -Non-AIResource entities pass through unchanged. +`AiResourceExtensionsProcessor` — for `AiResource` entities. +Non-AiResource entities pass through unchanged. -### `AIResourceExtensionsProcessor` +### `AiResourceExtensionsProcessor` -Validates RHDH extension fields on `AIResource` entities. All constraint +Validates RHDH extension fields on `AiResource` entities. All constraint violations are collected and reported in a single error rather than stopping at the first failure. @@ -43,8 +43,8 @@ format rules and pass through without validation. ## Schema Fields -An `AIResource` entity uses `apiVersion: backstage.io/v1beta1` and -`kind: AIResource`. The following fields are relevant: +An `AiResource` entity uses `apiVersion: backstage.io/v1beta1` and +`kind: AiResource`. The following fields are relevant: | Field | Enforced by this module | Description | | ------------------------------ | ----------------------- | ------------------------------------------------------------------------------------------------ | @@ -66,14 +66,14 @@ pass through without error. | `product` | The AI asset is scoped to a specific product | | `team` | The AI asset is scoped to a specific team | -## Registering an AIResource Entity +## Registering an AiResource Entity -AIResource entities are registered through the standard RHDH catalog -interfaces — no AIResource-specific registration flow is required. +AiResource entities are registered through the standard RHDH catalog +interfaces — no AiResource-specific registration flow is required. ### Git-backed entities -1. Create a `catalog-info.yaml` with `kind: AIResource`. +1. Create a `catalog-info.yaml` with `kind: AiResource`. 2. Set `backstage.io/source-location` to the repository URL using the standard location-ref form (e.g. `url:https://github.com/my-org/my-repo`) so consumers can locate the source repository. @@ -85,7 +85,7 @@ for a complete example. ### OCI-backed entities -1. Create a `catalog-info.yaml` with `kind: AIResource`. +1. Create a `catalog-info.yaml` with `kind: AiResource`. 2. Set `backstage.io/source-location` to an OCI reference using the location-ref form `url:oci://…` (e.g. `url:oci://quay.io/my-org/my-model:latest`). @@ -101,7 +101,7 @@ for a complete example. ## OCI Validation-Only Behavior -The `AIResourceExtensionsProcessor` validates the `backstage.io/source-location` +The `AiResourceExtensionsProcessor` validates the `backstage.io/source-location` annotation when the location-ref target uses the `oci://` scheme. It checks: - The annotation uses the `url:oci://…` location-ref form (not bare `oci://…`). @@ -123,6 +123,6 @@ resolve when registry access is available. | Export | Description | | ------------------------------- | ------------------------------------------------------- | -| `AIResourceExtensionsProcessor` | `CatalogProcessor` for RHDH AIResource extension fields | +| `AiResourceExtensionsProcessor` | `CatalogProcessor` for RHDH AiResource extension fields | | `VALID_AI_RESOURCE_SCOPES` | Readonly tuple of accepted scope values | -| `AIResourceScope` | Type union of accepted scope values | +| `AiResourceScope` | Type union of accepted scope values | diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/report.api.md b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/report.api.md index 63d7148b6a2..857daccc8cd 100644 --- a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/report.api.md +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/report.api.md @@ -10,7 +10,7 @@ import { Entity } from '@backstage/catalog-model'; import { LocationSpec } from '@backstage/plugin-catalog-common'; // @public -export class AIResourceExtensionsProcessor implements CatalogProcessor { +export class AiResourceExtensionsProcessor implements CatalogProcessor { // (undocumented) getProcessorName(): string; // (undocumented) @@ -22,7 +22,7 @@ export class AIResourceExtensionsProcessor implements CatalogProcessor { } // @public -export type AIResourceScope = (typeof VALID_AI_RESOURCE_SCOPES)[number]; +export type AiResourceScope = (typeof VALID_AI_RESOURCE_SCOPES)[number]; // @public const catalogModuleCatalogBackendModuleAiResourceExtensions: BackendFeature; diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/AIResourceExtensionsProcessor.test.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/AiResourceExtensionsProcessor.test.ts similarity index 86% rename from workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/AIResourceExtensionsProcessor.test.ts rename to workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/AiResourceExtensionsProcessor.test.ts index 13a83ee0879..7192462026a 100644 --- a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/AIResourceExtensionsProcessor.test.ts +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/AiResourceExtensionsProcessor.test.ts @@ -16,17 +16,17 @@ import { Entity } from '@backstage/catalog-model'; import { - AIResourceExtensionsProcessor, + AiResourceExtensionsProcessor, VALID_AI_RESOURCE_SCOPES, -} from './AIResourceExtensionsProcessor'; +} from './AiResourceExtensionsProcessor'; -function makeAIResource( +function makeAiResource( spec: Entity['spec'] = {}, annotations?: Record, ): Entity { return { apiVersion: 'backstage.io/v1beta1', - kind: 'AIResource', + kind: 'AiResource', metadata: { name: 'test-resource', ...(annotations ? { annotations } : {}), @@ -35,25 +35,25 @@ function makeAIResource( }; } -describe('AIResourceExtensionsProcessor', () => { - let processor: AIResourceExtensionsProcessor; +describe('AiResourceExtensionsProcessor', () => { + let processor: AiResourceExtensionsProcessor; const location = { type: 'url', target: 'https://example.com' }; const emit = jest.fn(); beforeEach(() => { - processor = new AIResourceExtensionsProcessor(); + processor = new AiResourceExtensionsProcessor(); emit.mockClear(); }); it('should return processor name', () => { - expect(processor.getProcessorName()).toBe('AIResourceExtensionsProcessor'); + expect(processor.getProcessorName()).toBe('AiResourceExtensionsProcessor'); }); describe('valid scope values', () => { it.each(VALID_AI_RESOURCE_SCOPES)( 'should accept spec.scope: %s', async scope => { - const entity = makeAIResource({ scope }); + const entity = makeAiResource({ scope }); const result = await processor.preProcessEntity(entity, location, emit); @@ -64,7 +64,7 @@ describe('AIResourceExtensionsProcessor', () => { describe('omitted scope', () => { it('should accept entity without spec.scope', async () => { - const entity = makeAIResource({}); + const entity = makeAiResource({}); const result = await processor.preProcessEntity(entity, location, emit); @@ -72,7 +72,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should accept entity with undefined spec.scope', async () => { - const entity = makeAIResource({ scope: undefined }); + const entity = makeAiResource({ scope: undefined }); const result = await processor.preProcessEntity(entity, location, emit); @@ -82,7 +82,7 @@ describe('AIResourceExtensionsProcessor', () => { it('should accept entity with no spec property', async () => { const entity: Entity = { apiVersion: 'backstage.io/v1beta1', - kind: 'AIResource', + kind: 'AiResource', metadata: { name: 'test-resource' }, }; @@ -94,15 +94,15 @@ describe('AIResourceExtensionsProcessor', () => { describe('invalid scope', () => { it('should reject invalid spec.scope value', async () => { - const entity = makeAIResource({ scope: 'enterprise' }); + const entity = makeAiResource({ scope: 'enterprise' }); await expect( processor.preProcessEntity(entity, location, emit), - ).rejects.toThrow('Validation failed for AIResource entity'); + ).rejects.toThrow('Validation failed for AiResource entity'); }); it('should include field path in error', async () => { - const entity = makeAIResource({ scope: 'enterprise' }); + const entity = makeAiResource({ scope: 'enterprise' }); await expect( processor.preProcessEntity(entity, location, emit), @@ -110,7 +110,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should include received value in error', async () => { - const entity = makeAIResource({ scope: 'enterprise' }); + const entity = makeAiResource({ scope: 'enterprise' }); await expect( processor.preProcessEntity(entity, location, emit), @@ -118,7 +118,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should include accepted values in error', async () => { - const entity = makeAIResource({ scope: 'enterprise' }); + const entity = makeAiResource({ scope: 'enterprise' }); await expect( processor.preProcessEntity(entity, location, emit), @@ -126,7 +126,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should reject empty string scope', async () => { - const entity = makeAIResource({ scope: '' }); + const entity = makeAiResource({ scope: '' }); await expect( processor.preProcessEntity(entity, location, emit), @@ -134,7 +134,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should reject numeric scope value', async () => { - const entity = makeAIResource({ scope: 42 }); + const entity = makeAiResource({ scope: 42 }); await expect( processor.preProcessEntity(entity, location, emit), @@ -142,7 +142,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should reject null scope value', async () => { - const entity = makeAIResource({ scope: null }); + const entity = makeAiResource({ scope: null }); await expect( processor.preProcessEntity(entity, location, emit), @@ -152,7 +152,7 @@ describe('AIResourceExtensionsProcessor', () => { describe('error quality', () => { it('should not expose internal class names in error', async () => { - const entity = makeAIResource({ scope: 'invalid' }); + const entity = makeAiResource({ scope: 'invalid' }); const error = await processor .preProcessEntity(entity, location, emit) @@ -160,12 +160,12 @@ describe('AIResourceExtensionsProcessor', () => { expect(error).toBeInstanceOf(Error); expect((error as Error).message).not.toMatch( - /AIResourceExtensionsProcessor/, + /AiResourceExtensionsProcessor/, ); }); it('should not expose stack trace patterns in error message', async () => { - const entity = makeAIResource({ scope: 'invalid' }); + const entity = makeAiResource({ scope: 'invalid' }); const error = await processor .preProcessEntity(entity, location, emit) @@ -178,7 +178,7 @@ describe('AIResourceExtensionsProcessor', () => { describe('OCI source-location validation', () => { it('should accept valid url:oci:// source-location', async () => { - const entity = makeAIResource( + const entity = makeAiResource( { scope: 'team' }, { 'backstage.io/source-location': 'url:oci://quay.io/org/skills:latest', @@ -191,7 +191,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should accept valid url:oci:// with digest reference', async () => { - const entity = makeAIResource( + const entity = makeAiResource( {}, { 'backstage.io/source-location': @@ -205,7 +205,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should accept valid url:oci:// with deeply nested path', async () => { - const entity = makeAIResource( + const entity = makeAiResource( {}, { 'backstage.io/source-location': @@ -219,7 +219,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should reject bare oci:// without url: prefix', async () => { - const entity = makeAIResource( + const entity = makeAiResource( {}, { 'backstage.io/source-location': 'oci://quay.io/org/skills:latest', @@ -232,7 +232,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should include bare oci:// value in error', async () => { - const entity = makeAIResource( + const entity = makeAiResource( {}, { 'backstage.io/source-location': 'oci://quay.io/org/skills:latest', @@ -245,7 +245,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should reject bare oci:// with multiple leading spaces', async () => { - const entity = makeAIResource( + const entity = makeAiResource( {}, { 'backstage.io/source-location': ' oci://quay.io/org/skills:latest', @@ -258,7 +258,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should reject malformed url:oci:// with empty target', async () => { - const entity = makeAIResource( + const entity = makeAiResource( {}, { 'backstage.io/source-location': 'url:oci://', @@ -271,7 +271,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should reject url:oci:// with only registry (no repo)', async () => { - const entity = makeAIResource( + const entity = makeAiResource( {}, { 'backstage.io/source-location': 'url:oci://quay.io', @@ -284,7 +284,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should reject url:oci:// with trailing slash', async () => { - const entity = makeAIResource( + const entity = makeAiResource( {}, { 'backstage.io/source-location': 'url:oci://quay.io/org/model/', @@ -297,7 +297,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should reject url:oci:// with whitespace in target', async () => { - const entity = makeAIResource( + const entity = makeAiResource( {}, { 'backstage.io/source-location': 'url:oci:// quay.io/org/model:latest', @@ -310,7 +310,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should validate url:oci:// target with whitespace after colon', async () => { - const entity = makeAIResource( + const entity = makeAiResource( {}, { 'backstage.io/source-location': 'url: oci://quay.io/org/model:latest', @@ -324,7 +324,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should accept url:oci:// with trailing whitespace (trimmed by parseLocationRef)', async () => { - const entity = makeAIResource( + const entity = makeAiResource( {}, { 'backstage.io/source-location': 'url:oci://quay.io/org/model:latest ', @@ -337,7 +337,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should validate url:oci:// with leading whitespace on annotation', async () => { - const entity = makeAIResource( + const entity = makeAiResource( {}, { 'backstage.io/source-location': ' url:oci://quay.io/org/model:latest', @@ -351,7 +351,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should pass through non-OCI source-location (url:https://)', async () => { - const entity = makeAIResource( + const entity = makeAiResource( {}, { 'backstage.io/source-location': @@ -365,7 +365,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should pass through entity without source-location annotation', async () => { - const entity = makeAIResource({}); + const entity = makeAiResource({}); const result = await processor.preProcessEntity(entity, location, emit); @@ -375,7 +375,7 @@ describe('AIResourceExtensionsProcessor', () => { it('should pass through entity without any annotations', async () => { const entity: Entity = { apiVersion: 'backstage.io/v1beta1', - kind: 'AIResource', + kind: 'AiResource', metadata: { name: 'test-resource' }, spec: { type: 'model' }, }; @@ -386,7 +386,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should reference annotation name in error messages', async () => { - const entity = makeAIResource( + const entity = makeAiResource( {}, { 'backstage.io/source-location': 'url:oci://', @@ -401,7 +401,7 @@ describe('AIResourceExtensionsProcessor', () => { describe('multiple extension errors reported together', () => { it('should report both scope and OCI errors in a single response', async () => { - const entity = makeAIResource( + const entity = makeAiResource( { scope: 'invalid' }, { 'backstage.io/source-location': 'oci://quay.io/myorg/skills:latest', @@ -419,7 +419,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should include field path and value for scope error', async () => { - const entity = makeAIResource( + const entity = makeAiResource( { scope: 'invalid' }, { 'backstage.io/source-location': 'oci://quay.io/myorg/skills:latest', @@ -436,7 +436,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should include annotation name and constraint for OCI error', async () => { - const entity = makeAIResource( + const entity = makeAiResource( { scope: 'invalid' }, { 'backstage.io/source-location': 'oci://quay.io/myorg/skills:latest', @@ -452,7 +452,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should not expose internal class names in multi-error response', async () => { - const entity = makeAIResource( + const entity = makeAiResource( { scope: 'invalid' }, { 'backstage.io/source-location': 'oci://quay.io/myorg/skills:latest', @@ -465,13 +465,13 @@ describe('AIResourceExtensionsProcessor', () => { expect(error).toBeInstanceOf(Error); expect((error as Error).message).not.toMatch( - /AIResourceExtensionsProcessor/, + /AiResourceExtensionsProcessor/, ); - expect((error as Error).message).not.toMatch(/AIResourceOciProcessor/); + expect((error as Error).message).not.toMatch(/AiResourceOciProcessor/); }); it('should not expose stack traces in multi-error response', async () => { - const entity = makeAIResource( + const entity = makeAiResource( { scope: 'invalid' }, { 'backstage.io/source-location': 'oci://quay.io/myorg/skills:latest', @@ -487,7 +487,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should return single error when only scope is invalid', async () => { - const entity = makeAIResource({ scope: 'enterprise' }); + const entity = makeAiResource({ scope: 'enterprise' }); const error = await processor .preProcessEntity(entity, location, emit) @@ -499,7 +499,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should return single error when only OCI annotation is invalid', async () => { - const entity = makeAIResource( + const entity = makeAiResource( { scope: 'organization' }, { 'backstage.io/source-location': 'oci://quay.io/myorg/skills:latest', @@ -516,7 +516,7 @@ describe('AIResourceExtensionsProcessor', () => { }); it('should pass valid entity without errors', async () => { - const entity = makeAIResource( + const entity = makeAiResource( { scope: 'team' }, { 'backstage.io/source-location': @@ -530,7 +530,7 @@ describe('AIResourceExtensionsProcessor', () => { }); }); - describe('non-AIResource entities', () => { + describe('non-AiResource entities', () => { it('should pass through Component entities unchanged', async () => { const entity: Entity = { apiVersion: 'backstage.io/v1alpha1', @@ -544,7 +544,7 @@ describe('AIResourceExtensionsProcessor', () => { expect(result).toEqual(entity); }); - it('should not validate scope on non-AIResource kinds', async () => { + it('should not validate scope on non-AiResource kinds', async () => { const entity: Entity = { apiVersion: 'backstage.io/v1alpha1', kind: 'Resource', diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/AIResourceExtensionsProcessor.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/AiResourceExtensionsProcessor.ts similarity index 82% rename from workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/AIResourceExtensionsProcessor.ts rename to workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/AiResourceExtensionsProcessor.ts index e16f267d086..4ecf3fad952 100644 --- a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/AIResourceExtensionsProcessor.ts +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/AiResourceExtensionsProcessor.ts @@ -23,7 +23,7 @@ import { LocationSpec } from '@backstage/plugin-catalog-common'; import { collectOciErrors } from './collectOciErrors'; /** - * Valid values for spec.scope on AIResource entities. + * Valid values for spec.scope on AiResource entities. * * @public */ @@ -34,15 +34,15 @@ export const VALID_AI_RESOURCE_SCOPES = [ ] as const; /** - * Type for valid AIResource scope values. + * Type for valid AiResource scope values. * * @public */ -export type AIResourceScope = (typeof VALID_AI_RESOURCE_SCOPES)[number]; +export type AiResourceScope = (typeof VALID_AI_RESOURCE_SCOPES)[number]; /** * A CatalogProcessor that validates RHDH-specific extension - * fields on AIResource entities. + * fields on AiResource entities. * * Validates: * - `spec.scope`: optional field restricted to 'organization', @@ -55,9 +55,9 @@ export type AIResourceScope = (typeof VALID_AI_RESOURCE_SCOPES)[number]; * * @public */ -export class AIResourceExtensionsProcessor implements CatalogProcessor { +export class AiResourceExtensionsProcessor implements CatalogProcessor { getProcessorName(): string { - return 'AIResourceExtensionsProcessor'; + return 'AiResourceExtensionsProcessor'; } async preProcessEntity( @@ -65,7 +65,7 @@ export class AIResourceExtensionsProcessor implements CatalogProcessor { _location: LocationSpec, _emit: CatalogProcessorEmit, ): Promise { - if (entity.kind !== 'AIResource') { + if (entity.kind !== 'AiResource') { return entity; } @@ -74,7 +74,7 @@ export class AIResourceExtensionsProcessor implements CatalogProcessor { const scope = entity.spec?.scope; if ( scope !== undefined && - !VALID_AI_RESOURCE_SCOPES.includes(scope as AIResourceScope) + !VALID_AI_RESOURCE_SCOPES.includes(scope as AiResourceScope) ) { const accepted = VALID_AI_RESOURCE_SCOPES.map(v => `'${v}'`).join(', '); const sanitized = Array.from(String(scope)) @@ -90,7 +90,7 @@ export class AIResourceExtensionsProcessor implements CatalogProcessor { if (errors.length > 0) { throw new Error( - `Validation failed for AIResource entity: ${errors.join('; ')}`, + `Validation failed for AiResource entity: ${errors.join('; ')}`, ); } diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/collectOciErrors.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/collectOciErrors.ts index 62a990b1e05..542b691fb06 100644 --- a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/collectOciErrors.ts +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/collectOciErrors.ts @@ -21,7 +21,7 @@ import { } from '@backstage/catalog-model'; /** - * Collects OCI-related validation errors for an AIResource entity + * Collects OCI-related validation errors for an AiResource entity * without throwing. Returns an array of error messages (empty if valid). * * Validates the `backstage.io/source-location` annotation when its diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/index.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/index.ts index 94ca38b838a..fcc102761a3 100644 --- a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/index.ts +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/index.ts @@ -21,7 +21,7 @@ */ export { catalogModuleCatalogBackendModuleAiResourceExtensions as default } from './module'; export { - AIResourceExtensionsProcessor, + AiResourceExtensionsProcessor, VALID_AI_RESOURCE_SCOPES, -} from './AIResourceExtensionsProcessor'; -export type { AIResourceScope } from './AIResourceExtensionsProcessor'; +} from './AiResourceExtensionsProcessor'; +export type { AiResourceScope } from './AiResourceExtensionsProcessor'; diff --git a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/module.ts b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/module.ts index 349c6942ef4..215e4067c82 100644 --- a/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/module.ts +++ b/workspaces/ai-integrations/plugins/catalog-backend-module-ai-resource-extensions/src/module.ts @@ -16,12 +16,12 @@ import { createBackendModule } from '@backstage/backend-plugin-api'; import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; -import { AIResourceExtensionsProcessor } from './AIResourceExtensionsProcessor'; +import { AiResourceExtensionsProcessor } from './AiResourceExtensionsProcessor'; /** * catalogModuleCatalogBackendModuleAiResourceExtensions is the catalog-backend-module-ai-resource-extensions backend module for the catalog plugin. * - * Registers {@link AIResourceExtensionsProcessor}, which validates all + * Registers {@link AiResourceExtensionsProcessor}, which validates all * RHDH extension constraints (spec.scope and OCI source-location) and * aggregates errors into a single response. * @@ -37,7 +37,7 @@ export const catalogModuleCatalogBackendModuleAiResourceExtensions = catalog: catalogProcessingExtensionPoint, }, async init({ catalog }) { - catalog.addProcessor(new AIResourceExtensionsProcessor()); + catalog.addProcessor(new AiResourceExtensionsProcessor()); }, }); }, diff --git a/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/report.api.md b/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/report.api.md index 178c30dbc61..6f2b3c2fec7 100644 --- a/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/report.api.md +++ b/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/report.api.md @@ -23,7 +23,7 @@ export interface AgentAiResourceEntityV1alpha1 extends Entity { lifecycle: string; owner: string; system?: string; - instructions: string; + instructions?: string; handoffDescription?: string; model?: string; handoffs?: string[]; diff --git a/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/src/AgentAiResourceEntityV1alpha1.test.ts b/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/src/AgentAiResourceEntityV1alpha1.test.ts index b18ccfca2fd..592f4553d88 100644 --- a/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/src/AgentAiResourceEntityV1alpha1.test.ts +++ b/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/src/AgentAiResourceEntityV1alpha1.test.ts @@ -143,20 +143,18 @@ describe('agentAiResourceEntityV1alpha1Validator', () => { }); describe('reject paths', () => { - it('rejects missing spec.instructions', async () => { + it('accepts missing spec.instructions', async () => { const entity = makeMinimalAgent(); delete (entity as any).spec.instructions; - await expect( - agentAiResourceEntityV1alpha1Validator.check(entity), - ).rejects.toThrow(); + const result = await agentAiResourceEntityV1alpha1Validator.check(entity); + expect(result).toBe(true); }); - it('rejects empty string spec.instructions', async () => { - await expect( - agentAiResourceEntityV1alpha1Validator.check( - makeMinimalAgent({ instructions: '' }), - ), - ).rejects.toThrow(); + it('accepts empty string spec.instructions', async () => { + const result = await agentAiResourceEntityV1alpha1Validator.check( + makeMinimalAgent({ instructions: '' }), + ); + expect(result).toBe(true); }); it('rejects wrong type for spec.instructions (number)', async () => { diff --git a/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/src/AiResource.v1alpha1.agent.schema.json b/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/src/AiResource.v1alpha1.agent.schema.json index b700aa1452c..4d1e4e7266b 100644 --- a/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/src/AiResource.v1alpha1.agent.schema.json +++ b/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/src/AiResource.v1alpha1.agent.schema.json @@ -34,7 +34,7 @@ }, "spec": { "type": "object", - "required": ["type", "lifecycle", "owner", "instructions"], + "required": ["type", "lifecycle", "owner"], "properties": { "type": { "type": "string", @@ -60,8 +60,7 @@ }, "instructions": { "type": "string", - "description": "The agent's instructions (system prompt). Must be a non-empty string.", - "minLength": 1 + "description": "The agent's instructions (system prompt). Optional — omit when the agent image/runtime already provides a default prompt." }, "handoffDescription": { "type": "string", diff --git a/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/src/types.ts b/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/src/types.ts index 9e74e82dc14..949ab111490 100644 --- a/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/src/types.ts +++ b/workspaces/ai-integrations/plugins/catalog-model-ai-resource-agent/src/types.ts @@ -58,8 +58,11 @@ export interface AgentAiResourceEntityV1alpha1 extends Entity { owner: string; /** An entity reference to the system that the AI resource belongs to. */ system?: string; - /** The agent's instructions (system prompt). Required, non-empty. */ - instructions: string; + /** + * The agent's instructions (system prompt). Optional — omit when the + * agent image/runtime already provides a default prompt. + */ + instructions?: string; /** A description used when this agent is listed as a handoff target. */ handoffDescription?: string; /** The model identifier for this agent. */ diff --git a/workspaces/ai-integrations/yarn.lock b/workspaces/ai-integrations/yarn.lock index d8fe6334775..ddef760ee11 100644 --- a/workspaces/ai-integrations/yarn.lock +++ b/workspaces/ai-integrations/yarn.lock @@ -10986,8 +10986,10 @@ __metadata: resolution: "@red-hat-developer-hub/backstage-plugin-catalog-backend-module-ai-resource-agent@workspace:plugins/catalog-backend-module-ai-resource-agent" dependencies: "@backstage/backend-plugin-api": "npm:^1.9.2" + "@backstage/backend-test-utils": "npm:^1.11.4" "@backstage/catalog-model": "npm:^1.9.0" "@backstage/cli": "npm:^0.36.3" + "@backstage/plugin-catalog-common": "npm:^1.1.10" "@backstage/plugin-catalog-node": "npm:^2.2.2" "@red-hat-developer-hub/backstage-plugin-catalog-model-ai-resource-agent": "workspace:^" languageName: unknown