feat(resource): add resource type inheritance and polymorphic tool matching

Implement ADR-042 single-inheritance for resource types with polymorphic
tool and handler resolution.

Core changes:
- Add `inherits` field to ResourceTypeConfigSchema, ResourceTypeSpec, and
  ResourceTypeModel with chain depth validation (max 5), circular
  inheritance detection, and built-in-from-custom guard
- New inheritance.py module (390 lines): resolve_inheritance_chain(),
  validate_chain(), is_subtype_of(), resolve_fields(), find_subtypes()
- Wire inheritance into ResourceRegistryService: chain validation on
  register_type(), resolve_type_inheritance_chain(), is_subtype_of()
- Add find_tools_for_resource() to ToolRegistry with polymorphic matching
  that walks the inheritance chain
- Add resolve_handler_polymorphic() to handler resolver
- Alembic migration m6_004_resource_type_inherits adds inherits column
  and index to resource_types table

CLI changes:
- `agents resource type list` shows Inherits column
- `agents resource type show` displays inheritance chain

Tests:
- 30 BDD scenarios in resource_type_inheritance.feature (all pass)
- 18 Robot Framework test cases
- 14 ASV benchmark timing methods across 4 suites

Docs:
- docs/reference/resource_type_inheritance.md (full reference)
- Updated docs/schema/resource_type.schema.yaml
- CHANGELOG entry for #513

ISSUES CLOSED: #513
This commit is contained in:
2026-03-06 21:17:24 +00:00
parent fcdf80f342
commit bb8175aa11
20 changed files with 2991 additions and 125 deletions
+8
View File
@@ -12,6 +12,14 @@
datetimes. Wired into the DI container. Includes 28 Behave BDD scenarios,
3 Robot Framework integration tests, ASV benchmarks (5 time + 2 track), and
reference documentation. (#195)
### Added
- Resource type single-inheritance via `inherits` field (ADR-042) (#513)
- Inheritance chain resolution, field merging, and polymorphic type matching
- `ToolRegistry.find_tools_for_resource()` for polymorphic tool binding
- Polymorphic handler resolution with ancestor-type fallback
- CLI: `agents resource type list` shows Inherits column; `type show` displays inheritance chain
- Alembic migration `m6_004_resource_type_inherits` adds `inherits` column to `resource_types`
- Fixed `agents project show` not finding a project immediately after creation.
Extended the `session.commit()` fix from #589 to also cover `update()` and
`delete()` in `NamespacedProjectRepository`, and updated the class docstring
@@ -0,0 +1,37 @@
"""Add inherits column to resource_types for type inheritance (ADR-042).
Revision ID: m6_004_resource_type_inherits
Revises: m6_003_async_jobs_table
Create Date: 2026-03-06 00:00:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "m6_004_resource_type_inherits"
down_revision: str | Sequence[str] | None = "m6_003_async_jobs_table"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add inherits column and index to resource_types."""
op.add_column(
"resource_types",
sa.Column("inherits", sa.String(255), nullable=True),
)
op.create_index(
"ix_resource_types_inherits",
"resource_types",
["inherits"],
)
def downgrade() -> None:
"""Remove inherits column and index from resource_types."""
op.drop_index("ix_resource_types_inherits", table_name="resource_types")
op.drop_column("resource_types", "inherits")
@@ -0,0 +1,157 @@
"""ASV benchmarks for resource type inheritance resolution engine."""
from __future__ import annotations
from typing import Any
from cleveragents.resource.inheritance import (
find_subtypes,
is_subtype_of,
resolve_fields,
resolve_inheritance_chain,
)
def _build_linear_registry(depth: int) -> tuple[dict[str, Any], str]:
"""Build a linear inheritance chain of *depth* levels.
Returns ``(registry, leaf_name)`` where the chain is::
leaf -> level-(depth-2) -> ... -> level-0 -> root
"""
registry: dict[str, Any] = {"root": {}}
prev = "root"
for i in range(depth - 1):
name = f"level-{i}"
registry[name] = {"inherits": prev}
prev = name
return registry, prev
def _build_wide_registry(width: int) -> dict[str, Any]:
"""Build a registry with *width* direct children of a single ancestor."""
registry: dict[str, Any] = {"ancestor": {}}
for i in range(width):
registry[f"child-{i}"] = {"inherits": "ancestor"}
return registry
def _build_field_registry(depth: int) -> tuple[dict[str, Any], str]:
"""Build a registry with mergeable fields at each level."""
registry: dict[str, Any] = {
"root": {
"sandbox_strategy": "copy_on_write",
"resource_kind": "physical",
"cli_args": [{"name": "base-arg", "type": "string"}],
"child_types": ["root-child"],
},
}
prev = "root"
for i in range(depth - 1):
name = f"level-{i}"
registry[name] = {
"inherits": prev,
"resource_kind": f"kind-{i}",
"cli_args": [{"name": f"arg-{i}", "type": "string"}],
"child_types": [f"ct-{i}"],
}
prev = name
return registry, prev
# ---------------------------------------------------------------------------
# Chain resolution benchmarks
# ---------------------------------------------------------------------------
class InheritanceChainResolutionSuite:
"""Benchmark resolve_inheritance_chain at various depths."""
def setup(self) -> None:
self.reg_1, self.leaf_1 = _build_linear_registry(1)
self.reg_3, self.leaf_3 = _build_linear_registry(3)
self.reg_5, self.leaf_5 = _build_linear_registry(5)
def time_resolve_chain_1_level(self) -> None:
resolve_inheritance_chain(self.leaf_1, self.reg_1)
def time_resolve_chain_3_level(self) -> None:
resolve_inheritance_chain(self.leaf_3, self.reg_3)
def time_resolve_chain_5_level(self) -> None:
resolve_inheritance_chain(self.leaf_5, self.reg_5)
# ---------------------------------------------------------------------------
# Polymorphic subtype check benchmarks
# ---------------------------------------------------------------------------
class IsSubtypeOfSuite:
"""Benchmark is_subtype_of at various depths."""
def setup(self) -> None:
self.reg_1, self.leaf_1 = _build_linear_registry(1)
self.reg_3, self.leaf_3 = _build_linear_registry(3)
self.reg_5, self.leaf_5 = _build_linear_registry(5)
def time_is_subtype_identity(self) -> None:
is_subtype_of("root", "root", self.reg_1)
def time_is_subtype_1_level(self) -> None:
is_subtype_of(self.leaf_1, "root", self.reg_1)
def time_is_subtype_3_level(self) -> None:
is_subtype_of(self.leaf_3, "root", self.reg_3)
def time_is_subtype_5_level(self) -> None:
is_subtype_of(self.leaf_5, "root", self.reg_5)
def time_is_subtype_negative(self) -> None:
is_subtype_of("root", self.leaf_5, self.reg_5)
# ---------------------------------------------------------------------------
# Field resolution benchmarks
# ---------------------------------------------------------------------------
class ResolveFieldsSuite:
"""Benchmark resolve_fields with field merging at various depths."""
def setup(self) -> None:
self.reg_1, self.leaf_1 = _build_field_registry(1)
self.reg_3, self.leaf_3 = _build_field_registry(3)
self.reg_5, self.leaf_5 = _build_field_registry(5)
def time_resolve_fields_1_level(self) -> None:
resolve_fields(self.leaf_1, self.reg_1)
def time_resolve_fields_3_level(self) -> None:
resolve_fields(self.leaf_3, self.reg_3)
def time_resolve_fields_5_level(self) -> None:
resolve_fields(self.leaf_5, self.reg_5)
# ---------------------------------------------------------------------------
# find_subtypes benchmarks
# ---------------------------------------------------------------------------
class FindSubtypesSuite:
"""Benchmark find_subtypes with varying registry widths."""
def setup(self) -> None:
self.reg_10 = _build_wide_registry(10)
self.reg_50 = _build_wide_registry(50)
self.reg_200 = _build_wide_registry(200)
def time_find_subtypes_10(self) -> None:
find_subtypes("ancestor", self.reg_10)
def time_find_subtypes_50(self) -> None:
find_subtypes("ancestor", self.reg_50)
def time_find_subtypes_200(self) -> None:
find_subtypes("ancestor", self.reg_200)
+233
View File
@@ -0,0 +1,233 @@
# Resource Type Inheritance
## Overview
Resource type inheritance allows custom resource types to derive from existing
types, inheriting their field definitions, CLI arguments, DAG constraints, and
tool bindings. A child type can override or extend its parent's configuration
while remaining compatible with tools and handlers that target the parent type.
This feature was introduced in [ADR-042](../adr/ADR-042-resource-type-inheritance.md)
to support polymorphic tool matching and reduce configuration duplication across
related resource types.
**Key benefits:**
- Define specialised resource types without repeating shared configuration.
- Tools bound to a parent type automatically match resources of any subtype.
- Handler resolution falls back through the inheritance chain when a
type-specific handler is not registered.
- DAG queries respect the type hierarchy, returning resources whose type is a
subtype of the requested ancestor.
## The `inherits` Field
A resource type declares its parent by setting the `inherits` field to the name
of an existing resource type:
```yaml
name: myorg/python-repo
inherits: git-checkout
resource_kind: physical
sandbox_strategy: git_worktree
description: A Git checkout containing a Python project.
cli_args:
- name: python-version
type: string
required: false
description: Expected Python version.
```
In this example `myorg/python-repo` inherits all fields from `git-checkout` and
adds a `python-version` CLI argument.
The `inherits` field is optional. When omitted the type is a root type with no
parent.
## Inheritance Chain Rules
The following five rules govern inheritance chains. Violations are reported by
`validate_chain()` and prevent the type from being registered.
### 1. Single Inheritance Only
A resource type may declare at most one parent via `inherits`. Multiple
inheritance is not supported.
### 2. Maximum Depth of 5 Levels
The inheritance chain from a type to its root ancestor must not exceed 5 levels
(i.e., at most 4 `inherits` hops). Deeper chains raise
`ResourceTypeInheritanceDepthError`.
```
root -> A -> B -> C -> D # depth 5 — OK
root -> A -> B -> C -> D -> E # depth 6 — rejected
```
### 3. No Circular Inheritance
An inheritance chain must not contain cycles. If registering type `A` with
`inherits: B` would create a cycle (e.g., `B` already inherits from `A`,
directly or transitively), `ResourceTypeCircularInheritanceError` is raised.
### 4. Built-in Types May Not Inherit from Custom Types
Built-in resource types (`built_in: true`) are not permitted to set `inherits`
to a custom (namespaced) type. Custom types may inherit from built-in types or
from other custom types.
### 5. Cannot Remove a Parent While Subtypes Exist
A resource type's `inherits` field cannot be cleared or changed to a different
parent while other registered types list it as their parent. Attempting to do so
raises `ResourceTypeParentRemovalError`. Remove or re-parent all subtypes first.
## Field Resolution
When a type declares `inherits`, its effective configuration is computed by
overlaying the child's explicitly declared scalar fields on top of the parent's
resolved fields (which may themselves be inherited). This is a simple
**last-writer-wins** override for scalar values:
| Field | Resolution |
|--------------------|---------------------------------------------------|
| `description` | Child value wins if set, otherwise parent's value. |
| `resource_kind` | Child value wins if set, otherwise parent's value. |
| `sandbox_strategy` | Child value wins if set, otherwise parent's value. |
| `user_addable` | Child value wins if set, otherwise parent's value. |
| `handler` | Child value wins if set, otherwise parent's value. |
| `capabilities` | Child value wins if set, otherwise parent's value. |
| `auto_discovery` | Child value wins if set, otherwise parent's value. |
| `equivalence` | Child value wins if set, otherwise parent's value. |
The `name`, `built_in`, and `inherits` fields are never inherited — they are
intrinsic to the declaring type.
## Collection Field Merging
Collection fields use **additive merging** by default: the child's entries are
appended to the parent's resolved entries (duplicates are deduplicated). To
replace the parent's collection entirely, set the corresponding
`<field>_replace` flag to `true`.
### `cli_args`
Parent CLI arguments are included in the child type. The child may add new
arguments or override a parent argument by declaring an argument with the same
`name`. To discard all parent arguments and use only the child's definitions:
```yaml
cli_args_replace: true
cli_args:
- name: custom-arg
type: string
```
### `child_types`
The child type's allowed `child_types` list is the union of the parent's and
the child's declarations. To replace:
```yaml
child_types_replace: true
child_types:
- myorg/sub-resource
```
### `parent_types`
Behaves identically to `child_types` — additive merge with
`parent_types_replace: true` for full replacement.
## Polymorphism Guarantees
### Tool Binding
`ToolRegistry.find_tools_for_resource()` returns tools bound to the resource's
own type **and** every ancestor type in its inheritance chain. A tool registered
for `git-checkout` will match resources of type `myorg/python-repo` if
`myorg/python-repo` inherits from `git-checkout`.
### Handler Resolution
When resolving a handler for a resource, the system walks the inheritance chain
from the most specific type to the root. The first type in the chain that has a
registered handler is used. This allows specialised types to override behaviour
while falling back to the parent handler when no override exists.
### DAG Queries
Resource DAG queries that filter by type respect the inheritance hierarchy. A
query for all resources of type `git-checkout` also returns resources whose type
is a subtype of `git-checkout`. Use `is_subtype_of()` for explicit type checks.
## API Reference
Public functions exported from `cleveragents.resource.inheritance`:
| Function | Signature | Description |
|----------|-----------|-------------|
| `resolve_inheritance_chain` | `(type_name: str, type_registry: dict) -> list[str]` | Returns the ordered inheritance chain from `type_name` to the root ancestor (inclusive). Raises `ResourceTypeCircularInheritanceError` on cycles and `ResourceTypeParentNotFoundError` if an ancestor is missing. |
| `validate_chain` | `(type_name: str, inherits: str \| None, type_registry: dict, is_built_in: bool) -> list[str]` | Validates the proposed inheritance chain and returns a list of error messages (empty on success). Checks all five chain rules. |
| `is_subtype_of` | `(type_name: str, ancestor_name: str, type_registry: dict) -> bool` | Returns `True` if `type_name` is equal to or a subtype of `ancestor_name`. |
| `resolve_fields` | `(type_name: str, type_registry: dict) -> dict` | Returns the fully resolved field dictionary for `type_name` after applying inheritance chain field resolution and collection merging. |
| `find_subtypes` | `(ancestor_name: str, type_registry: dict) -> list[str]` | Returns all registered type names that are subtypes of `ancestor_name` (direct and transitive). |
## Exceptions
The following exceptions are defined in `cleveragents.resource.inheritance` and
raised when inheritance rules are violated:
| Exception | Raised When |
|-----------|-------------|
| `ResourceTypeInheritanceDepthError` | The inheritance chain exceeds the maximum depth of 5 levels. |
| `ResourceTypeCircularInheritanceError` | A circular inheritance chain is detected. |
| `ResourceTypeParentNotFoundError` | The type named in `inherits` is not present in the type registry. |
| `ResourceTypeParentRemovalError` | An attempt is made to remove or change a type's parent while subtypes still reference it. |
All exceptions inherit from `CleverAgentsError` and include the type name and
chain details in their message for diagnostics.
## CLI
### `agents resource type list`
The tabular output includes an **Inherits** column showing the direct parent
type name (blank for root types):
```
Name Kind Sandbox Inherits
────────────────────── ───────── ──────────────── ──────────────
git-checkout physical git_worktree
fs-directory physical copy_on_write
myorg/python-repo physical git_worktree git-checkout
```
### `agents resource type show <name>`
Displays the full resolved configuration for the type, including an
**Inheritance Chain** section:
```
Name: myorg/python-repo
Inherits: git-checkout
Inheritance Chain: myorg/python-repo -> git-checkout
Resource Kind: physical
Sandbox Strategy: git_worktree
...
```
## Database
The `resource_types` table includes an `inherits` column:
| Column | Type | Nullable | Description |
|------------|---------------|----------|------------------------------------------|
| `inherits` | `String(255)` | Yes | Name of the parent resource type, or `NULL` for root types. |
This column is added by the Alembic migration
`m6_004_resource_type_inherits`. The column is indexed to support efficient
subtype lookups. Foreign-key integrity is enforced at the application layer
(not via a database constraint) because type registration order during
migrations may not satisfy FK ordering requirements.
+7
View File
@@ -34,6 +34,13 @@ fields:
description: >
Human-readable description of the resource type.
# ─── Inheritance ──────────────────────────────────────────────
inherits:
type: string
required: false
description: >
Parent resource type name. See ADR-042.
# ─── Classification ────────────────────────────────────────────
resource_kind:
type: string
+7 -7
View File
@@ -34,21 +34,21 @@ Feature: Resource CLI coverage boost for remaining uncovered lines
Then the CliRunner exit code should be non-zero
And the CliRunner output should contain "Aborted"
# ---- type_remove row is None (lines 260-262) ----
# ---- type_remove NotFoundError from service (lines 263-265) ----
Scenario: type_remove aborts when DB row is None after query
Given a mock resource service whose session returns no row for the type
Scenario: type_remove aborts when service.remove_type raises NotFoundError
Given a mock resource service whose remove_type raises NotFoundError
When I invoke type remove with --yes via CliRunner for the phantom type
Then the CliRunner exit code should be non-zero
And the CliRunner output should contain "Resource type not found"
# ---- type_remove generic exception triggers rollback (lines 267-269) ----
# ---- type_remove ValidationError from service (lines 269-271) ----
Scenario: type_remove rolls back session on unexpected exception
Given a mock resource service whose session delete raises a generic exception
Scenario: type_remove aborts when service.remove_type raises ValidationError
Given a mock resource service whose remove_type raises ValidationError
When I invoke type remove with --yes via CliRunner for the failing type
Then the CliRunner exit code should be non-zero
And the mock session rollback should have been called for type remove
And the CliRunner output should contain "Error:"
# ---- resource_remove edge_count > 0 (lines 653-658) ----
+332
View File
@@ -0,0 +1,332 @@
Feature: Resource type inheritance and polymorphic matching
ADR-042 defines single-inheritance for resource types.
Types may declare an `inherits` field pointing to one parent type.
The inheritance engine resolves chains, merges fields, detects cycles,
enforces a depth limit, and enables polymorphic tool matching.
# 1. Schema validation
Scenario: YAML with inherits field parses correctly
Given a type-inherit YAML string with inherits "container-instance"
When I parse the type-inherit YAML via ResourceTypeConfigSchema
Then the type-inherit schema inherits field is "container-instance"
Scenario: YAML with inherits set to null parses as root type
Given a type-inherit YAML string with no inherits
When I parse the type-inherit YAML via ResourceTypeConfigSchema
Then the type-inherit schema inherits field is null
Scenario: Schema rejects self-inheritance
Given a type-inherit YAML string where name equals inherits "acme/looper"
When I attempt to parse the type-inherit YAML expecting an error
Then the type-inherit parse error mentions "inherit from itself"
Scenario: Schema rejects built-in inheriting from custom type
Given a type-inherit built-in YAML inheriting from custom "acme/custom-rt"
When I attempt to parse the type-inherit YAML expecting an error
Then the type-inherit parse error mentions "cannot inherit from custom"
Scenario: YAML with replace flags parses correctly
Given a type-inherit YAML string with cli_args_replace true
When I parse the type-inherit YAML via ResourceTypeConfigSchema
Then the type-inherit schema cli_args_replace is true
# ── 2. Field resolution ───────────────────────────────────
Scenario: Scalar fields in subtype override parent
Given a type-inherit registry with parent "container-instance" description "Parent desc"
And a type-inherit child "acme/my-container" inheriting from "container-instance" with description "Child desc"
When I resolve type-inherit fields for "acme/my-container"
Then the type-inherit resolved description is "Child desc"
Scenario: Scalar fields fall through from parent when absent in child
Given a type-inherit registry with parent "container-instance" having handler "parent.Handler"
And a type-inherit child "acme/my-container" inheriting from "container-instance" without handler
When I resolve type-inherit fields for "acme/my-container"
Then the type-inherit resolved handler is "parent.Handler"
# ── 3. Collection merging ─────────────────────────────────
Scenario: cli_args additive merge — parent args preserved, child appended
Given a type-inherit parent "container-instance" with cli_args "path,port"
And a type-inherit child "acme/my-container" inheriting from "container-instance" with cli_args "env"
When I resolve type-inherit fields for "acme/my-container"
Then the type-inherit resolved cli_args names are "path, port, env"
Scenario: cli_args same-name replacement during merge
Given a type-inherit parent "container-instance" with cli_args "path,port"
And a type-inherit child "acme/my-container" inheriting from "container-instance" with cli_args "port"
When I resolve type-inherit fields for "acme/my-container"
Then the type-inherit resolved cli_args count is 2
And the type-inherit resolved cli_args names are "path, port"
Scenario: cli_args replace mode replaces parent entirely
Given a type-inherit parent "container-instance" with cli_args "path,port"
And a type-inherit child "acme/my-container" inheriting from "container-instance" with cli_args "env" and cli_args_replace
When I resolve type-inherit fields for "acme/my-container"
Then the type-inherit resolved cli_args names are "env"
Scenario: child_types additive merge — union deduplication
Given a type-inherit parent "container-instance" with child_types "fs-directory,fs-file"
And a type-inherit child "acme/my-container" inheriting from "container-instance" with child_types "fs-file,fs-mount"
When I resolve type-inherit fields for "acme/my-container"
Then the type-inherit resolved child_types are "fs-directory, fs-file, fs-mount"
Scenario: child_types replace mode replaces parent entirely
Given a type-inherit parent "container-instance" with child_types "fs-directory,fs-file"
And a type-inherit child "acme/my-container" inheriting from "container-instance" with child_types "fs-mount" and child_types_replace
When I resolve type-inherit fields for "acme/my-container"
Then the type-inherit resolved child_types are "fs-mount"
Scenario: parent_types additive merge
Given a type-inherit parent "container-instance" with parent_types "git-checkout"
And a type-inherit child "acme/my-container" inheriting from "container-instance" with parent_types "fs-mount"
When I resolve type-inherit fields for "acme/my-container"
Then the type-inherit resolved parent_types are "git-checkout, fs-mount"
Scenario: parent_types replace mode
Given a type-inherit parent "container-instance" with parent_types "git-checkout"
And a type-inherit child "acme/my-container" inheriting from "container-instance" with parent_types "fs-mount" and parent_types_replace
When I resolve type-inherit fields for "acme/my-container"
Then the type-inherit resolved parent_types are "fs-mount"
# ── 4. Chain resolution ───────────────────────────────────
Scenario: Single-level inheritance chain resolution
Given a type-inherit registry with "container-instance" as root
And a type-inherit type "acme/devcontainer" inheriting from "container-instance"
When I resolve the type-inherit chain for "acme/devcontainer"
Then the type-inherit chain is "acme/devcontainer, container-instance"
Scenario: Multi-level inheritance chain resolution
Given a type-inherit registry with "container-instance" as root
And a type-inherit type "acme/app-container" inheriting from "container-instance"
And a type-inherit type "acme/web-container" inheriting from "acme/app-container"
When I resolve the type-inherit chain for "acme/web-container"
Then the type-inherit chain is "acme/web-container, acme/app-container, container-instance"
Scenario: Root type chain is just itself
Given a type-inherit registry with "container-instance" as root
When I resolve the type-inherit chain for "container-instance"
Then the type-inherit chain is "container-instance"
Scenario: validate_chain succeeds for valid chain
Given a type-inherit registry with "container-instance" as root
When I validate type-inherit chain for "acme/devcontainer" inheriting "container-instance"
Then the type-inherit validated chain is "acme/devcontainer, container-instance"
Scenario: validate_chain rejects missing parent
Given a type-inherit registry with "container-instance" as root
When I validate type-inherit chain for "acme/devcontainer" inheriting "nonexistent" expecting error
Then the type-inherit validation error is ResourceTypeParentNotFoundError
# ── 5. Polymorphism ───────────────────────────────────────
Scenario: is_subtype_of returns true for direct child
Given a type-inherit registry with "container-instance" as root
And a type-inherit type "acme/devcontainer" inheriting from "container-instance"
When I check type-inherit is_subtype_of "acme/devcontainer" and "container-instance"
Then the type-inherit subtype check is true
Scenario: is_subtype_of returns true for same type
Given a type-inherit registry with "container-instance" as root
When I check type-inherit is_subtype_of "container-instance" and "container-instance"
Then the type-inherit subtype check is true
Scenario: is_subtype_of returns false for unrelated types
Given a type-inherit registry with "container-instance" as root
And a type-inherit registry also has "git-checkout" as root
When I check type-inherit is_subtype_of "container-instance" and "git-checkout"
Then the type-inherit subtype check is false
Scenario: is_subtype_of returns true for transitive ancestor
Given a type-inherit registry with "container-instance" as root
And a type-inherit type "acme/app-container" inheriting from "container-instance"
And a type-inherit type "acme/web-container" inheriting from "acme/app-container"
When I check type-inherit is_subtype_of "acme/web-container" and "container-instance"
Then the type-inherit subtype check is true
Scenario: Tool bound to parent matches subtype resource
Given a type-inherit registry with "container-instance" as root
And a type-inherit type "acme/devcontainer" inheriting from "container-instance"
And a type-inherit tool "ns/deploy" bound to resource type "container-instance"
When I find type-inherit tools for resource type "acme/devcontainer"
Then the type-inherit matched tools include "ns/deploy"
Scenario: Tool bound to child does not match parent resource
Given a type-inherit registry with "container-instance" as root
And a type-inherit type "acme/devcontainer" inheriting from "container-instance"
And a type-inherit tool "ns/special" bound to resource type "acme/devcontainer"
When I find type-inherit tools for resource type "container-instance"
Then the type-inherit matched tools list is empty
Scenario: find_subtypes returns all descendants
Given a type-inherit registry with "container-instance" as root
And a type-inherit type "acme/app-container" inheriting from "container-instance"
And a type-inherit type "acme/web-container" inheriting from "acme/app-container"
When I find type-inherit subtypes of "container-instance"
Then the type-inherit subtypes include "acme/app-container"
And the type-inherit subtypes include "acme/web-container"
# ── 6. Cycle detection ────────────────────────────────────
Scenario: Circular two-type cycle is rejected
Given a type-inherit registry with "acme/alpha" inheriting from "acme/beta"
And a type-inherit registry with "acme/beta" inheriting from "acme/alpha"
When I resolve the type-inherit chain for "acme/alpha" expecting error
Then the type-inherit chain error is ResourceTypeCircularInheritanceError
# ── 7. Depth limit ────────────────────────────────────────
Scenario: Chain exceeding MAX_CHAIN_DEPTH is rejected
Given a type-inherit registry with a chain of depth 6
When I resolve the type-inherit chain for the deepest type expecting error
Then the type-inherit chain error is ResourceTypeInheritanceDepthError
Scenario: Chain at exactly MAX_CHAIN_DEPTH succeeds
Given a type-inherit registry with a chain of depth 5
When I resolve the type-inherit chain for the deepest type
Then the type-inherit chain length is 5
# ── 8. Parent removal guard ───────────────────────────────
Scenario: Parent type with subtypes cannot be removed
Given a type-inherit registry with "container-instance" as root
And a type-inherit type "acme/devcontainer" inheriting from "container-instance"
When I attempt to type-inherit remove "container-instance"
Then the type-inherit removal error is ResourceTypeParentRemovalError
# ── 9. Additional coverage ────────────────────────────────
Scenario: Circular three-type cycle is rejected
Given a type-inherit registry with "acme/alpha" inheriting from "acme/beta"
And a type-inherit registry with "acme/beta" inheriting from "acme/gamma"
And a type-inherit registry with "acme/gamma" inheriting from "acme/alpha"
When I resolve the type-inherit chain for "acme/alpha" expecting error
Then the type-inherit chain error is ResourceTypeCircularInheritanceError
Scenario: Three-level field merge — grandchild overrides child and inherits root
Given a type-inherit parent "container-instance" with cli_args "path,port"
And a type-inherit child "acme/app-container" inheriting from "container-instance" with cli_args "env"
And a type-inherit grandchild "acme/web-container" inheriting from "acme/app-container" with cli_args "host"
When I resolve type-inherit fields for "acme/web-container"
Then the type-inherit resolved cli_args names are "path, port, env, host"
Scenario: resolve_handler_polymorphic falls back to ancestor handler
Given a type-inherit registry with "container-instance" as root with handler "cleveragents.resource.handlers.fs_directory:FsDirectoryHandler"
And a type-inherit type "acme/devcontainer" inheriting from "container-instance"
When I resolve type-inherit handler polymorphic for "acme/devcontainer"
Then the type-inherit resolved handler is not None
Scenario: validate_chain rejects built-in type inheriting custom parent
Given a type-inherit registry with "acme/custom-parent" as root
When I validate type-inherit chain for built-in "my-builtin" inheriting "acme/custom-parent" expecting error
Then the type-inherit validation error is ValueError
# ── 10. Coverage: edge cases ──────────────────────────────
Scenario: Chain resolution raises ParentNotFoundError for missing parent
Given a type-inherit registry with "acme/child" inheriting from "acme/missing"
When I resolve the type-inherit chain for "acme/child" expecting error
Then the type-inherit chain error is ResourceTypeParentNotFoundError
Scenario: Chain resolution returns singleton for unregistered type
When I resolve the type-inherit chain for "acme/never-registered"
Then the type-inherit chain is "acme/never-registered"
Scenario: validate_chain returns singleton when inherits is None
Given a type-inherit registry with "container-instance" as root
When I validate type-inherit chain for "acme/standalone" inheriting nothing
Then the type-inherit validated chain is "acme/standalone"
Scenario: is_subtype_of returns false when chain has errors
Given a type-inherit registry with "acme/broken" inheriting from "acme/missing"
When I check type-inherit is_subtype_of "acme/broken" and "acme/missing"
Then the type-inherit subtype check is false
Scenario: resolve_fields with root-only type returns its own fields
Given a type-inherit registry with parent "container-instance" description "Root container type"
When I resolve type-inherit fields for "container-instance"
Then the type-inherit resolved description is "Root container type"
Scenario: _get_inherits works with object-style entries
Given a type-inherit registry with object-style entry "acme/obj-child" inheriting "container-instance"
And a type-inherit registry with "container-instance" as root
When I resolve the type-inherit chain for "acme/obj-child"
Then the type-inherit chain is "acme/obj-child, container-instance"
Scenario: _to_dict works with Pydantic model_dump entries
Given a type-inherit registry with pydantic-style entry "acme/pydantic-type" having description "Pydantic type"
When I resolve type-inherit fields for "acme/pydantic-type"
Then the type-inherit resolved description is "Pydantic type"
Scenario: _merge_cli_args returns child when parent has no args
Given a type-inherit parent "empty-parent" with no cli_args
And a type-inherit child "acme/child-only" inheriting from "empty-parent" with cli_args "path"
When I resolve type-inherit fields for "acme/child-only"
Then the type-inherit resolved cli_args names are "path"
Scenario: _merge_cli_args returns parent when child has no args
Given a type-inherit parent "container-instance" with cli_args "path,port"
And a type-inherit child "acme/bare-child" inheriting from "container-instance" with no cli_args
When I resolve type-inherit fields for "acme/bare-child"
Then the type-inherit resolved cli_args names are "path, port"
Scenario: resolve_handler_polymorphic raises when no handler in chain
Given a type-inherit registry with "container-instance" as root
And a type-inherit type "acme/no-handler" inheriting from "container-instance"
When I resolve type-inherit handler polymorphic for "acme/no-handler" expecting error
Then the type-inherit handler error is HandlerResolutionError
Scenario: Tool registry duplicate add raises ToolError
Given a type-inherit tool registry with tool "ns/dup-tool" bound to "container-instance"
When I add type-inherit tool "ns/dup-tool" again expecting error
Then the type-inherit tool error is ToolError
Scenario: Tool registry get returns None for unknown tool
Given a type-inherit empty tool registry
When I get type-inherit tool "ns/unknown"
Then the type-inherit tool result is None
Scenario: Tool registry list_tools with namespace filter
Given a type-inherit tool registry with tool "ns/tool-a" bound to "container-instance"
And a type-inherit tool registry with tool "other/tool-b" bound to "git-checkout"
When I list type-inherit tools with namespace "ns"
Then the type-inherit tools list has 1 entry
Scenario: Tool registry remove returns true for existing tool
Given a type-inherit tool registry with tool "ns/to-remove" bound to "container-instance"
When I remove type-inherit tool "ns/to-remove"
Then the type-inherit tool remove result is true
Scenario: Tool registry remove returns false for missing tool
Given a type-inherit empty tool registry
When I remove type-inherit tool "ns/nonexistent"
Then the type-inherit tool remove result is false
Scenario: find_tools_for_resource skips tools with no bindings
Given a type-inherit registry with "container-instance" as root
And a type-inherit tool registry with unbound tool "ns/no-bindings"
When I find type-inherit tools for resource type "container-instance"
Then the type-inherit matched tools list is empty
Scenario: Tool registry list_tools with source filter
Given a type-inherit tool registry with tool "ns/tool-x" bound to "container-instance"
When I list type-inherit tools with source "test"
Then the type-inherit tools list has 1 entry
Scenario: resolve_handler raises on empty reference
When I resolve type-inherit handler with empty reference expecting error
Then the type-inherit handler error is HandlerResolutionError
Scenario: resolve_handler raises on missing module
When I resolve type-inherit handler with reference "nonexistent.module:Cls" expecting error
Then the type-inherit handler error is HandlerResolutionError
Scenario: resolve_handler raises on missing class
When I resolve type-inherit handler with reference "cleveragents.resource.handlers.fs_directory:NonexistentClass" expecting error
Then the type-inherit handler error is HandlerResolutionError
Scenario: resolve_handler raises on malformed colon-only reference
When I resolve type-inherit handler with reference ":ClassName" expecting error
Then the type-inherit handler error is HandlerResolutionError
@@ -6,8 +6,8 @@ Covers the remaining uncovered lines and partial branches in
- Lines 79-81: ``_get_registry_service()``
- Lines 194-195: ``type_add`` FileNotFoundError handler
- Lines 234-236: ``type_remove`` user declines confirmation
- Lines 260-262: ``type_remove`` DB row is None
- Lines 267-269: ``type_remove`` generic Exception → rollback
- Lines 263-265: ``type_remove`` NotFoundError from service
- Lines 269-271: ``type_remove`` ValidationError from service
- Lines 653-658: ``resource_remove`` edge_count > 0
- Lines 668-672: ``resource_remove`` generic Exception → rollback
"""
@@ -215,39 +215,25 @@ def step_invoke_type_remove_no(context: Context) -> None:
# ---------------------------------------------------------------------------
# Scenario: type_remove aborts when DB row is None after query
# Scenario: type_remove aborts when service.remove_type raises NotFoundError
# ---------------------------------------------------------------------------
@given("a mock resource service whose session returns no row for the type")
def step_mock_service_type_row_none(context: Context) -> None:
@given("a mock resource service whose remove_type raises NotFoundError")
def step_mock_service_remove_type_not_found(context: Context) -> None:
from cleveragents.core.exceptions import NotFoundError
svc = MagicMock()
svc.show_type.return_value = _make_mock_type_spec(built_in=False)
# Build the mock session with smart query dispatch
mock_session = MagicMock()
# ResourceModel query → count returns 0 (no resources reference this type)
resource_model_query = MagicMock()
resource_model_query.filter.return_value.count.return_value = 0
# ResourceTypeModel query → first returns None (row not found)
type_model_query = MagicMock()
type_model_query.filter_by.return_value.first.return_value = None
mock_session.query.side_effect = _smart_query_side_effect(
{
"ResourceModel": resource_model_query,
"ResourceTypeModel": type_model_query,
}
svc.remove_type.side_effect = NotFoundError(
resource_type="resource_type",
resource_id="test/mock-type",
)
svc._session.return_value = mock_session
context.rcb_mock_service = svc
@when("I invoke type remove with --yes via CliRunner for the phantom type")
def step_invoke_type_remove_row_none(context: Context) -> None:
def step_invoke_type_remove_not_found(context: Context) -> None:
from cleveragents.cli.commands.resource import app
runner = CliRunner()
@@ -262,43 +248,23 @@ def step_invoke_type_remove_row_none(context: Context) -> None:
# ---------------------------------------------------------------------------
# Scenario: type_remove rolls back session on unexpected exception
# Scenario: type_remove aborts when service.remove_type raises ValidationError
# ---------------------------------------------------------------------------
@given("a mock resource service whose session delete raises a generic exception")
def step_mock_service_type_delete_exception(context: Context) -> None:
@given("a mock resource service whose remove_type raises ValidationError")
def step_mock_service_remove_type_validation_error(context: Context) -> None:
svc = MagicMock()
svc.show_type.return_value = _make_mock_type_spec(built_in=False)
mock_session = MagicMock()
# ResourceModel query → count returns 0
resource_model_query = MagicMock()
resource_model_query.filter.return_value.count.return_value = 0
# ResourceTypeModel query → first returns a mock row
mock_row = MagicMock()
type_model_query = MagicMock()
type_model_query.filter_by.return_value.first.return_value = mock_row
mock_session.query.side_effect = _smart_query_side_effect(
{
"ResourceModel": resource_model_query,
"ResourceTypeModel": type_model_query,
}
svc.remove_type.side_effect = ValidationError(
message="Cannot remove type: 5 resource(s) still reference it.",
details={"name": "test/mock-type"},
)
# session.delete raises a generic exception
mock_session.delete.side_effect = RuntimeError("unexpected DB failure")
svc._session.return_value = mock_session
context.rcb_mock_service = svc
context.rcb_mock_session = mock_session
@when("I invoke type remove with --yes via CliRunner for the failing type")
def step_invoke_type_remove_exception(context: Context) -> None:
def step_invoke_type_remove_validation_error(context: Context) -> None:
from cleveragents.cli.commands.resource import app
runner = CliRunner()
@@ -312,11 +278,6 @@ def step_invoke_type_remove_exception(context: Context) -> None:
)
@then("the mock session rollback should have been called for type remove")
def step_verify_type_rollback(context: Context) -> None:
context.rcb_mock_session.rollback.assert_called()
# ---------------------------------------------------------------------------
# Scenario: resource_remove aborts when resource has edges
# ---------------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
"""Robot Framework helper for resource type inheritance tests.
Each public function is exposed as a Robot keyword. When run standalone
via ``python robot/helper_resource_type_inheritance.py <subcommand>``
each command prints ``<subcommand>-ok`` on success.
"""
from __future__ import annotations
import sys
from typing import Any
from cleveragents.resource.inheritance import (
MAX_CHAIN_DEPTH,
ResourceTypeCircularInheritanceError,
ResourceTypeInheritanceDepthError,
find_subtypes,
is_subtype_of,
resolve_fields,
resolve_inheritance_chain,
validate_chain,
)
# ---------------------------------------------------------------------------
# Shared test registries
# ---------------------------------------------------------------------------
_SIMPLE_REGISTRY: dict[str, Any] = {
"resource": {},
"container-instance": {"inherits": "resource", "sandbox_strategy": "docker"},
"devcontainer-instance": {
"inherits": "container-instance",
"sandbox_strategy": "devcontainer",
"cli_args": [{"name": "profile", "type": "string"}],
},
}
_DEEP_REGISTRY: dict[str, Any] = {
"root": {},
"level-1": {"inherits": "root"},
"level-2": {"inherits": "level-1"},
"level-3": {"inherits": "level-2"},
"level-4": {"inherits": "level-3"},
}
_FIELD_REGISTRY: dict[str, Any] = {
"base": {
"sandbox_strategy": "copy_on_write",
"resource_kind": "physical",
"cli_args": [{"name": "path", "type": "string"}],
"child_types": ["alpha"],
},
"mid": {
"inherits": "base",
"resource_kind": "virtual",
"cli_args": [{"name": "tag", "type": "string"}],
"child_types": ["beta"],
},
"leaf": {
"inherits": "mid",
"sandbox_strategy": "none",
"cli_args": [
{"name": "path", "type": "int"}, # overrides base's "path"
],
},
}
_WIDE_REGISTRY: dict[str, Any] = {
"ancestor": {},
}
for _i in range(20):
_WIDE_REGISTRY[f"child-{_i}"] = {"inherits": "ancestor"}
# ---------------------------------------------------------------------------
# Robot keywords
# ---------------------------------------------------------------------------
def resolve_inheritance_chain_keyword(
type_name: str,
) -> list[str]:
"""Resolve an inheritance chain using the simple registry."""
return resolve_inheritance_chain(type_name, _SIMPLE_REGISTRY)
def resolve_deep_chain(type_name: str) -> list[str]:
"""Resolve an inheritance chain using the deep (4-level) registry."""
return resolve_inheritance_chain(type_name, _DEEP_REGISTRY)
def check_is_subtype_of(
type_name: str,
ancestor_name: str,
) -> bool:
"""Return True if *type_name* is a subtype of *ancestor_name*."""
return is_subtype_of(type_name, ancestor_name, _SIMPLE_REGISTRY)
def check_is_subtype_deep(
type_name: str,
ancestor_name: str,
) -> bool:
"""Return True if *type_name* is a subtype of *ancestor_name* (deep)."""
return is_subtype_of(type_name, ancestor_name, _DEEP_REGISTRY)
def resolve_merged_fields(type_name: str) -> dict[str, Any]:
"""Resolve merged fields for *type_name* using the field registry."""
return resolve_fields(type_name, _FIELD_REGISTRY)
def find_all_subtypes(ancestor_name: str) -> list[str]:
"""Find all subtypes of *ancestor_name* in the wide registry."""
return find_subtypes(ancestor_name, _WIDE_REGISTRY)
def validate_chain_keyword(
type_name: str,
inherits: str | None,
) -> list[str]:
"""Validate a chain declaration against the simple registry."""
return validate_chain(type_name, inherits, _SIMPLE_REGISTRY)
def get_max_chain_depth() -> int:
"""Return the configured MAX_CHAIN_DEPTH constant."""
return MAX_CHAIN_DEPTH
def detect_circular_inheritance() -> str:
"""Attempt to resolve a circular chain; return error class name."""
circular: dict[str, Any] = {
"a": {"inherits": "b"},
"b": {"inherits": "a"},
}
try:
resolve_inheritance_chain("a", circular)
return "no-error"
except ResourceTypeCircularInheritanceError:
return "ResourceTypeCircularInheritanceError"
def detect_depth_exceeded() -> str:
"""Attempt to resolve an overly-deep chain; return error class name."""
deep: dict[str, Any] = {"t0": {}}
for i in range(1, MAX_CHAIN_DEPTH + 2):
deep[f"t{i}"] = {"inherits": f"t{i - 1}"}
leaf = f"t{MAX_CHAIN_DEPTH + 1}"
try:
resolve_inheritance_chain(leaf, deep)
return "no-error"
except ResourceTypeInheritanceDepthError:
return "ResourceTypeInheritanceDepthError"
# ---------------------------------------------------------------------------
# Standalone CLI
# ---------------------------------------------------------------------------
def _chain_resolve() -> None:
chain = resolve_inheritance_chain("devcontainer-instance", _SIMPLE_REGISTRY)
assert chain == ["devcontainer-instance", "container-instance", "resource"], (
f"unexpected chain: {chain}"
)
print("chain-resolve-ok")
def _subtype_check() -> None:
assert is_subtype_of("devcontainer-instance", "resource", _SIMPLE_REGISTRY)
assert not is_subtype_of("resource", "devcontainer-instance", _SIMPLE_REGISTRY)
print("subtype-check-ok")
def _field_merge() -> None:
fields = resolve_fields("leaf", _FIELD_REGISTRY)
assert fields["sandbox_strategy"] == "none", f"got {fields['sandbox_strategy']}"
assert fields["resource_kind"] == "virtual", f"got {fields['resource_kind']}"
# cli_args: "path" from leaf overrides base, "tag" from mid kept
names = [a["name"] for a in fields["cli_args"]]
assert "tag" in names, f"tag missing from {names}"
assert "path" in names, f"path missing from {names}"
print("field-merge-ok")
def _find_subtypes_cmd() -> None:
subtypes = find_subtypes("ancestor", _WIDE_REGISTRY)
assert len(subtypes) == 20, f"expected 20 subtypes, got {len(subtypes)}"
print("find-subtypes-ok")
def _circular_detect() -> None:
result = detect_circular_inheritance()
assert result == "ResourceTypeCircularInheritanceError", f"got {result}"
print("circular-detect-ok")
_COMMANDS = {
"chain-resolve": _chain_resolve,
"subtype-check": _subtype_check,
"field-merge": _field_merge,
"find-subtypes": _find_subtypes_cmd,
"circular-detect": _circular_detect,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(1)
_COMMANDS[sys.argv[1]]()
+114
View File
@@ -0,0 +1,114 @@
*** Settings ***
Documentation Resource type inheritance chain resolution and polymorphic type matching
Library Process
Library OperatingSystem
Library Collections
Library helper_resource_type_inheritance.py
*** Variables ***
${PYTHON} python
*** Test Cases ***
Single Inheritance Chain Resolution
[Documentation] Resolve a two-level chain: devcontainer-instance -> container-instance -> resource
${chain}= Resolve Inheritance Chain Keyword devcontainer-instance
Length Should Be ${chain} 3
Should Be Equal ${chain}[0] devcontainer-instance
Should Be Equal ${chain}[1] container-instance
Should Be Equal ${chain}[2] resource
Deep Chain Resolution
[Documentation] Resolve a four-level chain through the deep registry
${chain}= Resolve Deep Chain level-4
Length Should Be ${chain} 5
Should Be Equal ${chain}[0] level-4
Should Be Equal ${chain}[4] root
Root Type Returns Single Element Chain
[Documentation] A root type with no parent returns a one-element chain
${chain}= Resolve Inheritance Chain Keyword resource
Length Should Be ${chain} 1
Should Be Equal ${chain}[0] resource
Polymorphic Subtype Check Positive
[Documentation] devcontainer-instance is a subtype of resource (transitive)
${result}= Check Is Subtype Of devcontainer-instance resource
Should Be True ${result}
Polymorphic Subtype Check Negative
[Documentation] resource is NOT a subtype of devcontainer-instance
${result}= Check Is Subtype Of resource devcontainer-instance
Should Not Be True ${result}
Polymorphic Identity Check
[Documentation] A type is always a subtype of itself
${result}= Check Is Subtype Of container-instance container-instance
Should Be True ${result}
Deep Polymorphic Subtype Check
[Documentation] level-4 is a subtype of root through four levels
${result}= Check Is Subtype Deep level-4 root
Should Be True ${result}
Field Merging Scalars Override
[Documentation] Scalar fields from child override parent values
${fields}= Resolve Merged Fields leaf
Should Be Equal ${fields}[sandbox_strategy] none
Should Be Equal ${fields}[resource_kind] virtual
Field Merging Collections Additive
[Documentation] Collection fields merge additively by default
${fields}= Resolve Merged Fields mid
${child_types}= Get From Dictionary ${fields} child_types
Should Contain ${child_types} alpha
Should Contain ${child_types} beta
Field Merging CLI Args Same Name Replacement
[Documentation] cli_args with matching name from child replace parent
${fields}= Resolve Merged Fields leaf
${cli_args}= Get From Dictionary ${fields} cli_args
# "path" from leaf replaces "path" from base; "tag" from mid preserved
${names}= Evaluate [a["name"] for a in $cli_args]
Should Contain ${names} path
Should Contain ${names} tag
Find All Subtypes Of Ancestor
[Documentation] find_subtypes returns all direct children of a wide registry
${subtypes}= Find All Subtypes ancestor
Length Should Be ${subtypes} 20
Circular Inheritance Detected
[Documentation] Circular chain raises ResourceTypeCircularInheritanceError
${err}= Detect Circular Inheritance
Should Be Equal ${err} ResourceTypeCircularInheritanceError
Depth Limit Exceeded Detected
[Documentation] Chain exceeding MAX_CHAIN_DEPTH raises depth error
${err}= Detect Depth Exceeded
Should Be Equal ${err} ResourceTypeInheritanceDepthError
Max Chain Depth Constant
[Documentation] MAX_CHAIN_DEPTH is exposed and equals 5
${depth}= Get Max Chain Depth
Should Be Equal As Integers ${depth} 5
Standalone Helper Chain Resolve
[Documentation] Run helper as standalone script: chain-resolve
${result}= Run Process ${PYTHON} robot/helper_resource_type_inheritance.py chain-resolve
... env:PYTHONPATH=src
Should Be Equal As Integers ${result.rc} 0 helper failed: ${result.stderr}
Should Contain ${result.stdout} chain-resolve-ok
Standalone Helper Subtype Check
[Documentation] Run helper as standalone script: subtype-check
${result}= Run Process ${PYTHON} robot/helper_resource_type_inheritance.py subtype-check
... env:PYTHONPATH=src
Should Be Equal As Integers ${result.rc} 0 helper failed: ${result.stderr}
Should Contain ${result.stdout} subtype-check-ok
Standalone Helper Field Merge
[Documentation] Run helper as standalone script: field-merge
${result}= Run Process ${PYTHON} robot/helper_resource_type_inheritance.py field-merge
... env:PYTHONPATH=src
Should Be Equal As Integers ${result.rc} 0 helper failed: ${result.stderr}
Should Contain ${result.stdout} field-merge-ok
@@ -55,6 +55,14 @@ from cleveragents.infrastructure.database.models import (
ResourceModel,
ResourceTypeModel,
)
from cleveragents.resource.inheritance import (
ResourceTypeParentNotFoundError,
ResourceTypeParentRemovalError,
find_subtypes,
is_subtype_of,
resolve_inheritance_chain,
validate_chain,
)
logger = logging.getLogger(__name__)
@@ -295,6 +303,9 @@ class ResourceRegistryService:
def register_type(self, config_path: str | Path) -> ResourceTypeSpec:
"""Load a YAML resource type config, validate, and persist.
If the type declares ``inherits``, the parent type must already
be registered and the resulting chain is validated (ADR-042).
Args:
config_path: Path to the YAML configuration file.
@@ -302,7 +313,8 @@ class ResourceRegistryService:
The validated ``ResourceTypeSpec``.
Raises:
ValidationError: If the config is invalid.
ValidationError: If the config is invalid or chain rules
are violated.
"""
path = Path(config_path)
if not path.exists():
@@ -326,6 +338,27 @@ class ResourceRegistryService:
details={"name": spec.name},
)
# Validate inheritance chain (ADR-042 rules 1-4).
# NOTE: ADR-042 steps 3 (pre-compute merged fields) and 5
# (validate handler loadability) are deferred to runtime.
if spec.inherits is not None:
registry = self._load_type_registry()
try:
validate_chain(
spec.name,
spec.inherits,
registry,
is_built_in=spec.built_in,
)
except (
ResourceTypeParentNotFoundError,
ValueError,
) as exc:
raise ValidationError(
message=str(exc),
details={"name": spec.name, "inherits": spec.inherits},
) from exc
db_model = _spec_to_db(spec, source=str(path))
session.add(db_model)
session.flush()
@@ -385,6 +418,143 @@ class ResourceRegistryService:
finally:
session.close()
def remove_type(self, name: str) -> None:
"""Remove a custom resource type after safety checks.
Enforces ADR-042 Rule 5: a type that is a parent of registered
subtypes cannot be removed until the subtypes are removed first.
Args:
name: Name of the resource type to remove.
Raises:
NotFoundError: If the type is not registered.
ValidationError: If the type is built-in or has resources.
ResourceTypeParentRemovalError: If subtypes still reference
this type via ``inherits``.
"""
session = self._session()
try:
row = session.query(ResourceTypeModel).filter_by(name=name).first()
if row is None:
raise NotFoundError(
resource_type="resource_type",
resource_id=name,
)
spec = _db_to_spec(row)
if spec.built_in:
raise ValidationError(
message=f"Cannot remove built-in resource type: {name}",
details={"name": name},
)
# Guard: reject if resources still reference this type
resource_count: int = (
session.query(ResourceModel)
.filter(ResourceModel.type_name == name)
.count()
)
if resource_count > 0:
raise ValidationError(
message=(
f"Cannot remove type '{name}': "
f"{resource_count} resource(s) still reference it."
),
details={"name": name, "resource_count": resource_count},
)
# Guard: ADR-042 Rule 5 — reject if subtypes exist
registry = self._load_type_registry()
subtypes = find_subtypes(name, registry)
if subtypes:
raise ResourceTypeParentRemovalError(
f"Cannot remove '{name}': subtypes exist: {subtypes}"
)
session.delete(row)
session.commit()
except (NotFoundError, ValidationError, ResourceTypeParentRemovalError):
session.rollback()
raise
except Exception:
session.rollback()
raise
finally:
session.close()
def resolve_type_inheritance_chain(self, type_name: str) -> list[str]:
"""Resolve the full inheritance chain for a resource type.
Walks the ``inherits`` links from *type_name* to the root ancestor.
Args:
type_name: Name of the resource type to resolve.
Returns:
List of type names from child to root, e.g.
``["devcontainer-instance", "container-instance"]``.
Raises:
NotFoundError: If the type is not registered.
ValidationError: If the chain is invalid (cycle, depth, etc.).
"""
registry = self._load_type_registry()
if type_name not in registry:
raise NotFoundError(
resource_type="resource_type",
resource_id=type_name,
)
try:
return resolve_inheritance_chain(type_name, registry)
except ValueError as exc:
raise ValidationError(
message=str(exc),
details={"type_name": type_name},
) from exc
def is_subtype_of(self, type_name: str, ancestor_name: str) -> bool:
"""Check whether *type_name* is the same as or inherits from *ancestor_name*.
Args:
type_name: The concrete type to check.
ancestor_name: The declared/expected type.
Returns:
``True`` if *type_name* equals *ancestor_name* or
transitively inherits from it.
"""
registry = self._load_type_registry()
return is_subtype_of(type_name, ancestor_name, registry)
def _load_type_registry(self) -> dict[str, dict[str, Any]]:
"""Load all registered types into an in-memory dict for chain ops.
.. note::
Returns a **minimal** registry for chain resolution and subtype
checks only. It does NOT include ``handler``, ``cli_args``,
``capabilities``, or other fields needed by
``resolve_fields()`` or ``resolve_handler_polymorphic()``.
Returns:
Dict mapping type name to its config dict (with ``inherits``).
"""
session = self._session()
try:
rows = session.query(ResourceTypeModel).all()
registry: dict[str, dict[str, Any]] = {}
for row in rows:
name = str(row.name)
raw_inherits = getattr(row, "inherits", None)
registry[name] = {
"inherits": str(raw_inherits) if raw_inherits else None,
"built_in": "/" not in name,
}
return registry
finally:
session.close()
# -- Resource operations -------------------------------------------------
def register_resource(
@@ -484,11 +654,20 @@ class ResourceRegistryService:
finally:
session.close()
def list_resources(self, type_name: str | None = None) -> list[Resource]:
def list_resources(
self,
type_name: str | None = None,
*,
exact: bool = False,
) -> list[Resource]:
"""List registered resources.
Args:
type_name: Optional type name filter.
exact: When ``False`` (default) and *type_name* is given,
include resources whose type is a **subtype** of
*type_name* (ADR-042 polymorphic listing). When
``True``, match only the exact type name.
Returns:
List of ``Resource`` domain objects.
@@ -497,7 +676,14 @@ class ResourceRegistryService:
try:
query = session.query(ResourceModel)
if type_name is not None:
query = query.filter_by(type_name=type_name)
if exact:
query = query.filter_by(type_name=type_name)
else:
# ADR-042: polymorphic listing — include subtypes
registry = self._load_type_registry()
subtypes = find_subtypes(type_name, registry)
all_types = [type_name, *subtypes]
query = query.filter(ResourceModel.type_name.in_(all_types))
query = query.order_by(ResourceModel.created_at)
rows = query.all()
return [_db_resource_to_domain(row) for row in rows]
@@ -572,20 +758,28 @@ class ResourceRegistryService:
allowed_children: list[str] = (
json.loads(str(allowed_raw)) if allowed_raw else []
)
if (
allowed_children
and child.resource_type_name not in allowed_children
):
raise ValidationError(
message=(
f"Type '{child.resource_type_name}' is not allowed "
f"as a child of '{parent.resource_type_name}'"
),
details={
"parent_type": parent.resource_type_name,
"child_type": child.resource_type_name,
},
if allowed_children:
# ADR-042: polymorphic matching — a child whose type
# is a subtype of any allowed type satisfies the
# constraint (e.g. allowed "container-instance"
# accepts "acme/my-container" that inherits from it).
registry = self._load_type_registry()
child_type = child.resource_type_name
match = any(
is_subtype_of(child_type, allowed, registry)
for allowed in allowed_children
)
if not match:
raise ValidationError(
message=(
f"Type '{child_type}' is not allowed "
f"as a child of '{parent.resource_type_name}'"
),
details={
"parent_type": parent.resource_type_name,
"child_type": child_type,
},
)
if parent.resource_id == child.resource_id:
raise ValidationError(
@@ -887,6 +1081,7 @@ def _spec_to_db(spec: ResourceTypeSpec, source: str) -> ResourceTypeModel:
auto_discover_json=auto_discover_json,
capabilities_json=capabilities_json,
equivalence_json=equivalence_json,
inherits=spec.inherits,
source=source,
created_at=now_iso,
updated_at=now_iso,
@@ -938,6 +1133,9 @@ def _db_to_spec(row: ResourceTypeModel) -> ResourceTypeSpec:
raw_handler = getattr(row, "handler_ref", None)
handler_str: str | None = str(raw_handler) if raw_handler is not None else None
raw_inherits = getattr(row, "inherits", None)
inherits_str: str | None = str(raw_inherits) if raw_inherits is not None else None
return ResourceTypeSpec.from_config(
{
"name": name_str,
@@ -953,6 +1151,7 @@ def _db_to_spec(row: ResourceTypeModel) -> ResourceTypeSpec:
"handler": handler_str,
"capabilities": capabilities,
"built_in": is_builtin,
"inherits": inherits_str,
}
)
+33 -37
View File
@@ -53,6 +53,7 @@ Based on ``implementation_plan.md`` -- Tasks B0.cli.resources, B1.cli.
from __future__ import annotations
import logging
from pathlib import Path
from typing import Annotated, Any
@@ -71,6 +72,9 @@ from cleveragents.core.exceptions import (
NotFoundError,
ValidationError,
)
from cleveragents.resource.inheritance import ResourceTypeParentRemovalError
logger = logging.getLogger(__name__)
# Main resource app
app = typer.Typer(
@@ -113,7 +117,8 @@ def _resource_type_dict(spec: Any) -> dict[str, object]:
"default": arg.default,
}
)
return {
inherits = getattr(spec, "inherits", None)
result: dict[str, object] = {
"name": spec.name,
"description": spec.description or "",
"resource_kind": str(spec.resource_kind),
@@ -126,6 +131,9 @@ def _resource_type_dict(spec: Any) -> dict[str, object]:
"handler": spec.handler,
"capabilities": spec.capabilities,
}
if inherits is not None:
result["inherits"] = inherits
return result
def _resource_dict(resource: Any) -> dict[str, object]:
@@ -235,7 +243,7 @@ def type_remove(
try:
service = _get_registry_service()
# Check if the type exists and whether it's built-in
# Pre-check so we can show a friendly message before prompting
spec = service.show_type(name)
if spec.built_in:
console.print(f"[red]Cannot remove built-in resource type:[/red] {name}")
@@ -249,46 +257,15 @@ def type_remove(
console.print("[yellow]Aborted.[/yellow]")
raise typer.Abort()
# Delete via direct session access (service doesn't expose delete yet)
session = service._session()
try:
from cleveragents.infrastructure.database.models import (
ResourceModel,
ResourceTypeModel,
)
# Check for resources referencing this type
resource_count: int = (
session.query(ResourceModel)
.filter(ResourceModel.type_name == name)
.count()
)
if resource_count > 0:
console.print(
f"[red]Cannot remove type '{name}': "
f"{resource_count} resource(s) still reference it.[/red]"
)
raise typer.Abort()
row = session.query(ResourceTypeModel).filter_by(name=name).first()
if row is None:
console.print(f"[red]Resource type not found:[/red] {name}")
raise typer.Abort()
session.delete(row)
session.commit()
except typer.Abort:
raise
except Exception:
session.rollback()
raise
finally:
session.close()
service.remove_type(name)
console.print(f"[green]Removed resource type:[/green] {name}")
except NotFoundError as exc:
console.print(f"[red]Resource type not found:[/red] {name}")
raise typer.Abort() from exc
except ResourceTypeParentRemovalError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Abort() from exc
except CleverAgentsError as exc:
console.print(f"[red]Error:[/red] {exc.message}")
raise typer.Abort() from exc
@@ -324,6 +301,7 @@ def type_list(
table.add_column("Name", style="cyan")
table.add_column("Kind", style="blue")
table.add_column("Sandbox", style="magenta")
table.add_column("Inherits", style="green")
table.add_column("Built-in", justify="center")
table.add_column("User-addable", justify="center")
table.add_column("Description", style="dim")
@@ -332,10 +310,12 @@ def type_list(
desc = spec.description or ""
if len(desc) > 50:
desc = desc[:47] + "..."
inherits = getattr(spec, "inherits", None) or ""
table.add_row(
spec.name,
str(spec.resource_kind),
str(spec.sandbox_strategy),
inherits,
"yes" if spec.built_in else "no",
"yes" if spec.user_addable else "no",
desc,
@@ -399,9 +379,25 @@ def _print_type_panel(spec: Any) -> None:
else:
args_display = " (none)"
inherits = getattr(spec, "inherits", None)
inherits_display = inherits or "(none)"
# Attempt to resolve the full inheritance chain for display.
chain_display = "(root type)"
if inherits is not None:
try:
service = _get_registry_service()
chain = service.resolve_type_inheritance_chain(spec.name)
chain_display = " -> ".join(chain)
except Exception: # CLI display; crash is worse than partial info
logger.debug("Failed to resolve chain for %s: %s", spec.name, "error")
chain_display = f"{spec.name} -> {inherits} -> ..."
details = (
f"[bold]Name:[/bold] {spec.name}\n"
f"[bold]Description:[/bold] {spec.description or '(none)'}\n"
f"[bold]Inherits:[/bold] {inherits_display}\n"
f"[bold]Inheritance Chain:[/bold] {chain_display}\n"
f"[bold]Kind:[/bold] {spec.resource_kind}\n"
f"[bold]Sandbox Strategy:[/bold] {spec.sandbox_strategy}\n"
f"[bold]Built-in:[/bold] {'yes' if spec.built_in else 'no'}\n"
@@ -307,6 +307,11 @@ class ResourceTypeSpec(BaseModel):
False,
description=("Whether this is a built-in type (allows unnamespaced names)."),
)
inherits: str | None = Field(
default=None,
max_length=255,
description="Parent resource type name (ADR-042 single inheritance).",
)
# -- Name validation -----------------------------------------------------
@@ -371,6 +376,15 @@ class ResourceTypeSpec(BaseModel):
"'equivalence' configuration for deduplication."
)
# ADR-042 rule 3: no cycles (self-inheritance)
if self.inherits is not None and self.inherits == self.name:
raise ValueError(f"'{self.name}' cannot inherit from itself.")
# ADR-042 rule 4: built-in must not inherit from custom
if self.built_in and self.inherits is not None and "/" in self.inherits:
raise ValueError(
f"Built-in '{self.name}' cannot inherit from custom '{self.inherits}'."
)
return self
# -- Factory methods ------------------------------------------------------
@@ -427,6 +441,7 @@ class ResourceTypeSpec(BaseModel):
},
),
built_in=config.get("built_in", False),
inherits=config.get("inherits"),
)
# -- CLI rendering --------------------------------------------------------
@@ -448,6 +463,9 @@ class ResourceTypeSpec(BaseModel):
result["user_addable"] = self.user_addable
result["built_in"] = self.built_in
if self.inherits is not None:
result["inherits"] = self.inherits
if self.cli_args:
result["cli_args"] = [
{
@@ -1320,6 +1320,9 @@ class ResourceTypeModel(Base): # type: ignore[misc]
capabilities_json = Column(Text, nullable=True)
equivalence_json = Column(Text, nullable=True)
# Single-inheritance parent type name (ADR-042)
inherits = Column(String(255), nullable=True)
# Source: "builtin" or path to YAML config file
source = Column(String(500), nullable=True)
@@ -1342,6 +1345,7 @@ class ResourceTypeModel(Base): # type: ignore[misc]
Index("ix_resource_types_namespace", "namespace"),
Index("ix_resource_types_kind", "resource_kind"),
Index("ix_resource_types_user_addable", "user_addable"),
Index("ix_resource_types_inherits", "inherits"),
)
+27 -1
View File
@@ -1 +1,27 @@
"""Resource type schema loading and validation."""
"""Resource type schema loading, validation, and inheritance resolution."""
from cleveragents.resource.inheritance import (
MAX_CHAIN_DEPTH,
ResourceTypeCircularInheritanceError,
ResourceTypeInheritanceDepthError,
ResourceTypeParentNotFoundError,
ResourceTypeParentRemovalError,
find_subtypes,
is_subtype_of,
resolve_fields,
resolve_inheritance_chain,
validate_chain,
)
__all__ = [
"MAX_CHAIN_DEPTH",
"ResourceTypeCircularInheritanceError",
"ResourceTypeInheritanceDepthError",
"ResourceTypeParentNotFoundError",
"ResourceTypeParentRemovalError",
"find_subtypes",
"is_subtype_of",
"resolve_fields",
"resolve_inheritance_chain",
"validate_chain",
]
@@ -26,6 +26,13 @@ from typing import Any
from cleveragents.resource.handlers.protocol import ResourceHandler
__all__ = [
"HandlerResolutionError",
"clear_handler_cache",
"resolve_handler",
"resolve_handler_polymorphic",
]
logger = logging.getLogger(__name__)
# Cache of already-resolved handler instances keyed by reference string
@@ -114,6 +121,49 @@ def resolve_handler(handler_ref: str) -> ResourceHandler:
return instance
def resolve_handler_polymorphic(
type_name: str,
type_registry: dict[str, Any],
) -> ResourceHandler:
"""Resolve a handler for *type_name*, falling back to ancestor types.
Implements ADR-042 §Handler Inheritance: if the type has no direct
handler, walk up the inheritance chain and use the first ancestor's
handler.
Args:
type_name: Resource type name.
type_registry: Dict mapping type names to definitions. Each
entry must support ``handler`` and ``inherits`` keys.
Returns:
An instantiated handler.
Raises:
HandlerResolutionError: If no handler is found in the chain.
"""
from cleveragents.resource.inheritance import resolve_inheritance_chain
chain = resolve_inheritance_chain(type_name, type_registry)
for ancestor in chain:
entry = type_registry.get(ancestor)
if entry is None:
continue
handler_ref = (
entry.get("handler")
if isinstance(entry, dict)
else getattr(entry, "handler", None)
)
if handler_ref:
return resolve_handler(str(handler_ref))
raise HandlerResolutionError(
f"No handler found for '{type_name}' or any of its ancestors: "
f"{' -> '.join(chain)}"
)
def clear_handler_cache() -> None:
"""Clear the handler instance cache (useful for testing)."""
with _cache_lock:
+398
View File
@@ -0,0 +1,398 @@
"""Resource type inheritance resolution engine.
Implements ADR-042: single-inheritance resource type specialization with
field resolution, collection merging, chain validation, and polymorphic
type checking.
Key functions:
| Function | Purpose |
|------------------------------|-----------------------------------------------|
| ``resolve_inheritance_chain``| Walk ``inherits`` links from child to root |
| ``resolve_fields`` | Merge fields along the chain (subtype wins) |
| ``is_subtype_of`` | Polymorphic check: does A inherit from B? |
| ``validate_chain`` | Registration-time chain validation |
Chain rules (ADR-042 §Inheritance Chain Rules):
1. Single inheritance only (at most one ``inherits`` value).
2. Maximum chain depth of 5 levels.
3. No circular inheritance.
4. Built-in types may not inherit from custom (namespaced) types.
5. Removing a parent type is prohibited while subtypes exist.
See Also:
- ``docs/adr/ADR-042-resource-type-inheritance.md``
- ``docs/specification.md`` lines 23460-23479
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
#: Maximum inheritance chain depth (ADR-042 rule 2).
MAX_CHAIN_DEPTH: int = 5
class ResourceTypeInheritanceDepthError(ValueError):
"""Raised when an inheritance chain exceeds the maximum depth."""
class ResourceTypeCircularInheritanceError(ValueError):
"""Raised when a circular inheritance chain is detected."""
class ResourceTypeParentNotFoundError(ValueError):
"""Raised when a parent type referenced by ``inherits`` is not registered."""
class ResourceTypeParentRemovalError(ValueError):
"""Raised when attempting to remove a type that has registered subtypes."""
def resolve_inheritance_chain(
type_name: str,
type_registry: dict[str, Any],
) -> list[str]:
"""Walk the ``inherits`` links from *type_name* to the root type.
Returns the full chain as a list starting with *type_name* and
ending with the root ancestor (the first type that has no
``inherits`` value).
Args:
type_name: Name of the type to resolve.
type_registry: Mapping of type name to type definition. Each
definition must support ``inherits`` as a key (dict) or
attribute (object with ``.inherits``).
Returns:
List of type names from child to root, e.g.
``["devcontainer-instance", "container-instance"]``.
Raises:
ResourceTypeParentNotFoundError: If a parent in the chain is
not in *type_registry*.
ResourceTypeCircularInheritanceError: If a cycle is detected.
ResourceTypeInheritanceDepthError: If the chain exceeds
:data:`MAX_CHAIN_DEPTH` levels.
"""
chain: list[str] = []
visited: set[str] = set()
current = type_name
while current is not None:
if current in visited:
raise ResourceTypeCircularInheritanceError(
f"Circular inheritance detected: {' -> '.join(chain)} -> {current}"
)
if len(chain) >= MAX_CHAIN_DEPTH:
raise ResourceTypeInheritanceDepthError(
f"Inheritance chain for '{type_name}' exceeds the maximum "
f"depth of {MAX_CHAIN_DEPTH}: {' -> '.join(chain)}"
)
visited.add(current)
chain.append(current)
entry = type_registry.get(current)
if entry is None and current != type_name:
raise ResourceTypeParentNotFoundError(
f"Parent type '{current}' (in chain for '{type_name}') "
"is not registered."
)
if entry is None:
# type_name itself is not in the registry. We return
# [type_name] instead of raising so that callers like
# is_subtype_of() can treat unregistered types as root
# types with no ancestors (returns False for any ancestor
# check). Service-level callers should verify existence
# before calling.
break
parent = _get_inherits(entry)
current = parent
return chain
def validate_chain(
type_name: str,
inherits: str | None,
type_registry: dict[str, Any],
*,
is_built_in: bool = False,
) -> list[str]:
"""Validate an inheritance declaration at registration time.
Checks all five ADR-042 chain rules and returns the resolved chain
on success.
Args:
type_name: Name of the type being registered.
inherits: Parent type name (or ``None`` for root types).
type_registry: Current registry contents (must not yet contain
*type_name*).
is_built_in: Whether the type being registered is built-in.
Returns:
The resolved inheritance chain (child to root).
Raises:
ResourceTypeParentNotFoundError: If *inherits* names a type
not in *type_registry*.
ResourceTypeCircularInheritanceError: If adding this type
would create a cycle.
ResourceTypeInheritanceDepthError: If the resulting chain
would exceed :data:`MAX_CHAIN_DEPTH`.
ValueError: If a built-in type tries to inherit from a custom
type.
"""
if inherits is None:
return [type_name]
# Rule 1: single inheritance is enforced by the schema (one field).
# Rule 4: built-in types must not inherit from custom types.
if is_built_in and "/" in inherits:
raise ValueError(
f"Built-in type '{type_name}' cannot inherit from custom type '{inherits}'."
)
# Parent must exist.
if inherits not in type_registry:
raise ResourceTypeParentNotFoundError(
f"Parent type '{inherits}' is not registered. "
f"Cannot register '{type_name}' with inherits='{inherits}'."
)
# Build a temporary registry entry to check the chain.
temp_registry = dict(type_registry)
temp_registry[type_name] = {"inherits": inherits}
# This will enforce rules 2 (depth) and 3 (cycles).
return resolve_inheritance_chain(type_name, temp_registry)
def is_subtype_of(
type_name: str,
ancestor_name: str,
type_registry: dict[str, Any],
) -> bool:
"""Check whether *type_name* is the same as or a subtype of *ancestor_name*.
This is the core polymorphism check used by:
- Tool binding (ADR-042 §Tool Binding Polymorphism)
- Auto-discovery child type matching
- DAG query filtering
Args:
type_name: The concrete type to check.
ancestor_name: The declared/expected type.
type_registry: Current registry contents.
Returns:
``True`` if *type_name* equals *ancestor_name* or transitively
inherits from it.
"""
if type_name == ancestor_name:
return True
try:
chain = resolve_inheritance_chain(type_name, type_registry)
except (
ResourceTypeParentNotFoundError,
ResourceTypeCircularInheritanceError,
ResourceTypeInheritanceDepthError,
):
return False
return ancestor_name in chain
def resolve_fields(
type_name: str,
type_registry: dict[str, Any],
) -> dict[str, Any]:
"""Resolve the merged fields for *type_name* along its inheritance chain.
Field resolution (ADR-042 §Field Resolution):
- Scalar fields: subtype's value overrides parent's.
- Collection fields (``cli_args``, ``child_types``, ``parent_types``):
additive merging by default; ``<field>_replace: true`` replaces.
Args:
type_name: Name of the type to resolve.
type_registry: Current registry contents.
Returns:
A dict with the fully resolved field values.
"""
chain = resolve_inheritance_chain(type_name, type_registry)
# Walk from root to child (so child overrides parent).
merged: dict[str, Any] = {}
for name in reversed(chain):
entry = type_registry.get(name)
if entry is None:
continue
fields = _to_dict(entry)
# Scalar fields: subtype wins.
for key, value in fields.items():
if key in _COLLECTION_FIELDS:
continue
if key.endswith("_replace"):
continue
if key == "inherits":
continue
merged[key] = value
# Collection fields: additive merge or replace.
for field_name in _COLLECTION_FIELDS:
replace_key = f"{field_name}_replace"
should_replace = fields.get(replace_key, False)
child_value = fields.get(field_name)
if child_value is None:
continue
if should_replace or field_name not in merged:
merged[field_name] = child_value
else:
merged[field_name] = _merge_collection(
field_name,
merged[field_name],
child_value,
)
# Preserve the inherits field from the original type.
original = type_registry.get(type_name)
if original is not None:
merged["inherits"] = _get_inherits(original)
return merged
def find_subtypes(
ancestor_name: str,
type_registry: dict[str, Any],
) -> list[str]:
"""Find all types that inherit from *ancestor_name* (directly or transitively).
Args:
ancestor_name: The ancestor type name.
type_registry: Current registry contents.
Returns:
List of subtype names (does NOT include *ancestor_name* itself).
"""
subtypes: list[str] = []
for name in type_registry:
if name == ancestor_name:
continue
if is_subtype_of(name, ancestor_name, type_registry):
subtypes.append(name)
return subtypes
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
#: Collection fields that use additive merging (ADR-042 §Collection Field Merging).
#: ``properties`` is also listed as additive in ADR-042; included here so
#: that parent properties are inherited and children can override individual
#: keys (dict merge) rather than replacing the entire mapping.
_COLLECTION_FIELDS: frozenset[str] = frozenset(
{"cli_args", "child_types", "parent_types", "properties"}
)
def _get_inherits(entry: Any) -> str | None:
"""Extract the ``inherits`` value from a registry entry (dict or object)."""
if isinstance(entry, dict):
return entry.get("inherits")
return getattr(entry, "inherits", None)
def _to_dict(entry: Any) -> dict[str, Any]:
"""Convert a registry entry to a dict for field merging."""
if isinstance(entry, dict):
return dict(entry)
if hasattr(entry, "model_dump"):
return entry.model_dump()
return dict(vars(entry))
def _merge_collection(
field_name: str,
parent_value: Any,
child_value: Any,
) -> Any:
"""Merge a collection field using additive semantics.
For ``cli_args``: child entries with the same ``name`` replace parent
entries (same-name replacement). For ``child_types`` and
``parent_types``: union of both lists (deduplication).
"""
if field_name == "cli_args":
return _merge_cli_args(parent_value, child_value)
# child_types and parent_types: union, preserving order, child last.
return _merge_string_list(parent_value, child_value)
def _merge_cli_args(
parent_args: list[Any],
child_args: list[Any],
) -> list[Any]:
"""Merge CLI argument lists with same-name replacement.
Child args with the same ``name`` as a parent arg replace the parent
entry. Otherwise child args are appended.
"""
if not parent_args:
return list(child_args)
if not child_args:
return list(parent_args)
# Build lookup of child arg names for O(1) checking.
child_names: set[str] = set()
for arg in child_args:
name = arg.get("name") if isinstance(arg, dict) else getattr(arg, "name", None)
if name:
child_names.add(name)
# Keep parent args that are NOT overridden by child.
merged: list[Any] = []
for arg in parent_args:
name = arg.get("name") if isinstance(arg, dict) else getattr(arg, "name", None)
if name not in child_names:
merged.append(arg)
# Append all child args.
merged.extend(child_args)
return merged
def _merge_string_list(
parent: list[str],
child: list[str],
) -> list[str]:
"""Merge two string lists with deduplication, preserving order."""
seen: set[str] = set()
merged: list[str] = []
for item in parent:
if item not in seen:
seen.add(item)
merged.append(item)
for item in child:
if item not in seen:
seen.add(item)
merged.append(item)
return merged
+47
View File
@@ -211,6 +211,26 @@ class ResourceTypeConfigSchema(BaseModel):
False,
description="Whether this is a built-in type.",
)
inherits: str | None = Field(
default=None,
max_length=255,
description=(
"Parent resource type name to inherit from (single inheritance). "
"See ADR-042 for full inheritance semantics."
),
)
cli_args_replace: bool = Field(
False,
description="Replace parent cli_args entirely instead of merging.",
)
child_types_replace: bool = Field(
False,
description="Replace parent child_types entirely instead of merging.",
)
parent_types_replace: bool = Field(
False,
description="Replace parent parent_types entirely instead of merging.",
)
model_config = ConfigDict(
str_strip_whitespace=True,
@@ -245,6 +265,21 @@ class ResourceTypeConfigSchema(BaseModel):
)
return v
@field_validator("inherits")
@classmethod
def validate_inherits(cls, v: str | None) -> str | None:
"""Validate parent type name format if provided."""
if v is None:
return v
if not _BUILTIN_NAME_RE.match(v) and not _NAMESPACED_RE.match(v):
raise ValueError(
f"Invalid inherits type name '{v}'. "
"Names must start with a letter and contain only "
"alphanumeric characters, hyphens, or underscores. "
"Custom types must follow namespace/name format."
)
return v
@field_validator("resource_kind")
@classmethod
def validate_resource_kind(cls, v: str) -> str:
@@ -290,6 +325,18 @@ class ResourceTypeConfigSchema(BaseModel):
"'equivalence' configuration."
)
# Cannot inherit from self (ADR-042 rule 3: no cycles)
if self.inherits is not None and self.inherits == self.name:
raise ValueError(f"Resource type '{self.name}' cannot inherit from itself.")
# Built-in types must not inherit from custom types (ADR-042 rule 4)
if self.built_in and self.inherits is not None and "/" in self.inherits:
raise ValueError(
f"Built-in resource type '{self.name}' cannot inherit from "
f"custom type '{self.inherits}'. Built-in types may only "
"inherit from other built-in types."
)
return self
# ────────────────────────────────────────────────────────
+51 -7
View File
@@ -3,24 +3,30 @@
Thread-safe registry that stores
[ToolSpec][cleveragents.tool.runtime.ToolSpec] instances keyed by their
namespaced name. Supports registration, lookup, listing with optional
filters, and removal.
filters, removal, and polymorphic resource-type matching.
## Operations
| Method | Description |
|---------------------|---------------------------------------------------|
| ``register(spec)`` | Add a tool; raises ``ToolError`` on name collision|
| ``get(name)`` | Lookup by namespaced name; returns ``None`` if missing |
| ``list_tools(...)`` | List with optional namespace/type filters |
| ``remove(name)`` | Unregister; returns ``bool`` |
| Method | Description |
|-----------------------------|---------------------------------------------------|
| ``register(spec)`` | Add a tool; raises ``ToolError`` on name collision|
| ``get(name)`` | Lookup by namespaced name; returns ``None`` if missing |
| ``list_tools(...)`` | List with optional namespace/type filters |
| ``find_tools_for_resource`` | Polymorphic tool matching by resource type (ADR-042) |
| ``remove(name)`` | Unregister; returns ``bool`` |
All mutating operations are protected by ``threading.RLock``.
See Also:
- ADR-042: Resource Type Inheritance (§Tool Binding Polymorphism)
"""
from __future__ import annotations
import threading
from typing import Any
from cleveragents.resource.inheritance import is_subtype_of
from cleveragents.tool.runtime import ToolError, ToolSpec
@@ -90,6 +96,44 @@ class ToolRegistry:
return specs
def find_tools_for_resource(
self,
resource_type: str,
type_registry: dict[str, Any],
) -> list[ToolSpec]:
"""Find tools whose resource bindings accept *resource_type*.
Implements ADR-042 §Tool Binding Polymorphism: a tool bound to
a parent type (e.g., ``container-instance``) also matches any
subtype (e.g., ``devcontainer-instance``).
Resource bindings are stored in ``tool.source_metadata`` under
the key ``"resource_bindings"`` as a list of dicts with a
``"resource_type"`` field.
Args:
resource_type: The concrete resource type name to match.
type_registry: Dict mapping type names to their definitions
(must contain ``inherits`` keys for chain walking).
Returns:
List of matching ``ToolSpec`` instances.
"""
with self._lock:
specs = list(self._tools.values())
matched: list[ToolSpec] = []
for spec in specs:
bindings = spec.source_metadata.get("resource_bindings", [])
if not bindings:
continue
for binding in bindings:
slot_type = binding.get("resource_type", "")
if is_subtype_of(resource_type, slot_type, type_registry):
matched.append(spec)
break # One matching binding is enough
return matched
def remove(self, name: str) -> bool:
"""Remove a tool from the registry.