feat(actor): align actor registry persistence

This commit is contained in:
2026-02-18 11:05:43 +00:00
parent e8704249d0
commit 0ad18d4306
19 changed files with 1003 additions and 36 deletions
@@ -0,0 +1,52 @@
"""Add yaml_text, schema_version, compiled_metadata to actors table.
Extends the ``actors`` table with columns needed by the aligned actor
registry persistence layer:
- ``yaml_text``: Original YAML source text for the actor definition.
- ``schema_version``: Version indicator for the persisted schema
(defaults to ``'1.0'``).
- ``compiled_metadata``: JSON blob of metadata produced by the actor
compiler pipeline.
Revision ID: c3_001_actor_registry
Revises: a7_002_merge_heads
Create Date: 2026-02-18 00:00:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c3_001_actor_registry"
down_revision: str | Sequence[str] | None = "a7_002_merge_heads"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add registry persistence columns to actors table."""
with op.batch_alter_table("actors") as batch_op:
batch_op.add_column(sa.Column("yaml_text", sa.Text(), nullable=True))
batch_op.add_column(
sa.Column(
"schema_version",
sa.String(length=20),
nullable=False,
server_default="1.0",
)
)
batch_op.add_column(sa.Column("compiled_metadata", sa.JSON(), nullable=True))
def downgrade() -> None:
"""Remove registry persistence columns from actors table."""
with op.batch_alter_table("actors") as batch_op:
batch_op.drop_column("compiled_metadata")
batch_op.drop_column("schema_version")
batch_op.drop_column("yaml_text")
+110
View File
@@ -0,0 +1,110 @@
"""ASV benchmarks for actor registry list performance.
Measures the performance of:
- Actor creation with YAML text and metadata
- Actor listing with and without namespace filters
- Schema version tracking overhead
"""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
# Ensure the local *source* tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
# Force-reload the top-level package
import cleveragents # noqa: E402
importlib.reload(cleveragents)
from cleveragents.domain.models.core.actor import Actor # noqa: E402
def _make_actor(
name: str,
*,
yaml_text: str | None = None,
schema_version: str = "1.0",
compiled_metadata: dict | None = None,
) -> Actor:
"""Create a test actor with registry-aligned fields."""
blob: dict = {"provider": "openai", "model": "gpt-4"}
return Actor(
id=None,
name=name,
provider="openai",
model="gpt-4",
config_blob=blob,
config_hash=Actor.compute_hash(blob),
yaml_text=yaml_text,
schema_version=schema_version,
compiled_metadata=compiled_metadata,
)
class TimeActorCreation:
"""Benchmark actor creation with YAML text and metadata."""
_YAML = "name: bench/actor\nprovider: openai\nmodel: gpt-4\n"
_META = {"graph_nodes": ["a", "b"], "tool_count": 5}
def time_create_minimal(self) -> None:
_make_actor("bench/minimal")
def time_create_with_yaml(self) -> None:
_make_actor("bench/yaml", yaml_text=self._YAML)
def time_create_with_metadata(self) -> None:
_make_actor("bench/meta", yaml_text=self._YAML, compiled_metadata=self._META)
def time_create_with_schema_version(self) -> None:
_make_actor("bench/versioned", schema_version="2.0")
class TimeActorListing:
"""Benchmark actor listing and namespace filtering."""
def setup(self) -> None:
self.actors: list[Actor] = []
for i in range(100):
ns = "local" if i % 2 == 0 else "remote"
self.actors.append(
_make_actor(
f"{ns}/actor-{i}",
yaml_text=f"name: {ns}/actor-{i}\nprovider: openai\nmodel: gpt-4\n",
schema_version="1.0",
)
)
def time_list_all(self) -> None:
_ = list(self.actors)
def time_list_with_namespace_filter(self) -> None:
prefix = "local/"
_ = [a for a in self.actors if a.name.startswith(prefix)]
def time_list_schema_version_filter(self) -> None:
_ = [a for a in self.actors if a.schema_version == "1.0"]
class TimeComputeHash:
"""Benchmark config hash computation."""
_SMALL_BLOB: dict = {"provider": "openai", "model": "gpt-4"}
_LARGE_BLOB: dict = {
"provider": "openai",
"model": "gpt-4",
"options": {f"key_{i}": f"value_{i}" for i in range(100)},
"graph_descriptor": {"nodes": list(range(50))},
}
def time_hash_small_blob(self) -> None:
Actor.compute_hash(self._SMALL_BLOB)
def time_hash_large_blob(self) -> None:
Actor.compute_hash(self._LARGE_BLOB)
+14 -10
View File
@@ -82,9 +82,13 @@ class LinkRepositoryCRUD:
conn.execute(text("PRAGMA foreign_keys = ON"))
conn.commit()
Base.metadata.create_all(self.engine)
# Use a single session for all setup operations so FK
# relationships are visible within the same transaction.
self._setup_session = sessionmaker(bind=self.engine)()
self.sf = sessionmaker(bind=self.engine)
session = self.sf()
session = self._setup_session
# Resource type
rt = ResourceTypeModel()
rt.name = "bench/git-checkout" # type: ignore[assignment]
@@ -96,14 +100,12 @@ class LinkRepositoryCRUD:
session.add(rt)
session.commit()
# Project
proj = NamespacedProjectModel()
proj.namespaced_name = "bench/link-bench" # type: ignore[assignment]
proj.namespace = "bench" # type: ignore[assignment]
proj.tags_json = "[]" # type: ignore[assignment]
proj.created_at = _now_iso() # type: ignore[assignment]
proj.updated_at = _now_iso() # type: ignore[assignment]
session.add(proj)
# Project — use the same session so the commit is on the same
# connection that created the resource type.
project_repo = NamespacedProjectRepository(
session_factory=lambda: session,
)
project_repo.create(NamespacedProject(name="link-bench", namespace="bench"))
session.commit()
# Resources
@@ -121,8 +123,9 @@ class LinkRepositoryCRUD:
self.engine.dispose()
def time_create_and_list_30_links(self) -> None:
link_repo = ProjectResourceLinkRepository(session_factory=self.sf)
# Use a scoped session so create_link and remove_link share state.
session = self.sf()
link_repo = ProjectResourceLinkRepository(session_factory=lambda: session)
link_ids: list[str] = []
for i in range(30):
link = link_repo.create_link(
@@ -135,3 +138,4 @@ class LinkRepositoryCRUD:
for lid in link_ids:
link_repo.remove_link(lid)
session.commit()
session.close()
+36 -7
View File
@@ -35,7 +35,7 @@ def _bench_ulid() -> str:
for _ in range(7):
suffix = _CB32[n % 32] + suffix
n //= 32
return f"01HDAGBNCH0AQDYTR4B{suffix:>7s}"[:26]
return f"01HDAGBNCH0AQDYTR4B{suffix}"[:26]
def _seed_types(
@@ -101,6 +101,40 @@ def _seed_types(
session_factory().commit()
def _seed_single_type(
rt_repo: Any,
type_name: str = "bench/chain-type",
) -> None:
"""Seed a single self-linking resource type for DAG chain benchmarks."""
from cleveragents.domain.models.core.resource_type import (
ResourceKind,
ResourceTypeSpec,
SandboxStrategy,
)
spec = ResourceTypeSpec(
name=type_name,
description="Bench chain type",
resource_kind=ResourceKind.PHYSICAL,
sandbox_strategy=SandboxStrategy.NONE,
user_addable=True,
cli_args=[],
parent_types=[],
child_types=[type_name],
auto_discovery=None,
equivalence=None,
handler=None,
capabilities={
"read": True,
"write": True,
"sandbox": True,
"checkpoint": False,
},
built_in=False,
)
rt_repo.create(spec)
def _make_resource(
res_repo: Any,
type_name: str,
@@ -213,12 +247,7 @@ class TimeCycleDetection:
self.factory = _setup_db()
self.rt_repo = ResourceTypeRepository(self.factory)
self.res_repo = ResourceRepository(self.factory)
_seed_types(
self.rt_repo,
self.factory,
"bench/chain-type",
"bench/chain-type",
)
_seed_single_type(self.rt_repo, "bench/chain-type")
# Build a chain: r0 -> r1 -> ... -> rN
self.chain_ids: list[str] = []
+38
View File
@@ -192,6 +192,44 @@ resources 1 ──< validation_attachments (resource_id FK, CASCADE delete)
validation_attachments.validation_name → tools.name (logical, not enforced by FK)
```
---
### `actors`
Stores actor configurations with YAML text retention, schema version
tracking, and optional compiled metadata.
| Column | Type | Constraints | Description |
|---------------------|----------|-----------------------------|------------------------------------------------|
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Auto-generated row ID |
| `name` | TEXT(255)| NOT NULL, UNIQUE | Namespaced name (`namespace/identifier`) |
| `provider` | TEXT(255)| NOT NULL | Provider identifier (e.g. `openai`) |
| `model` | TEXT(255)| NOT NULL | Model identifier (e.g. `gpt-4`) |
| `config_blob` | JSON | NOT NULL, DEFAULT `{}` | Canonical actor configuration blob |
| `config_hash` | TEXT(128)| NOT NULL | SHA-256 hash of `config_blob` |
| `graph_descriptor` | JSON | | Adapter-produced graph descriptor |
| `yaml_text` | TEXT | | Original YAML source text for the actor config |
| `schema_version` | TEXT(20) | NOT NULL, DEFAULT `'1.0'` | Actor config schema version |
| `compiled_metadata` | JSON | | Compiler-produced metadata (graph topology etc)|
| `unsafe` | BOOLEAN | NOT NULL, DEFAULT FALSE | True when actor is marked unsafe |
| `is_built_in` | BOOLEAN | NOT NULL, DEFAULT FALSE | Generated from provider registry |
| `is_default` | BOOLEAN | NOT NULL, DEFAULT FALSE | Designated default actor |
| `created_at` | DATETIME | NOT NULL | Row creation timestamp |
| `updated_at` | DATETIME | NOT NULL | Last update timestamp |
**Notes:**
- `yaml_text` preserves the original YAML source so that actors can be
reconstructed or exported without loss.
- `schema_version` tracks which version of the actor YAML schema was
used to create the configuration. Defaults to `1.0`.
- `compiled_metadata` stores JSON output from the actor compiler
(graph topology, resolved tools, etc.).
- `name` follows the `namespace/identifier` convention enforced by the
domain model validator (e.g. `local/my-actor`, `openai/gpt-4`).
---
## Migration
- **Revision**: `c1_001_tool_registry`
+140
View File
@@ -0,0 +1,140 @@
Feature: Actor registry persistence with YAML text retention
As a developer
I want actor registry persistence to store YAML text alongside compiled metadata
So that original actor configs are retained and schema versions are tracked
Scenario: Add actor from YAML text retains original source
Given an actor registry with no configured providers for persistence
When I add actor from YAML text:
"""
name: local/test-actor
provider: openai
model: gpt-4
"""
Then the actor "local/test-actor" should have yaml_text containing "provider: openai"
And the actor "local/test-actor" should have schema_version "1.0"
Scenario: Add actor with explicit schema version
Given an actor registry with no configured providers for persistence
When I add actor from YAML text with schema_version "2.0":
"""
name: local/versioned
provider: anthropic
model: claude-3
"""
Then the actor "local/versioned" should have schema_version "2.0"
Scenario: Add actor with compiled metadata
Given an actor registry with no configured providers for persistence
When I add actor from YAML text with compiled_metadata:
"""
name: local/compiled
provider: openai
model: gpt-4o
"""
Then the actor "local/compiled" should have compiled_metadata key "graph_nodes"
Scenario: Update existing actor preserves YAML text
Given an actor registry with no configured providers for persistence
When I add actor from YAML text:
"""
name: local/updatable
provider: openai
model: gpt-4
"""
And I update actor from YAML text:
"""
name: local/updatable
provider: openai
model: gpt-4-turbo
"""
Then the actor "local/updatable" should have yaml_text containing "gpt-4-turbo"
Scenario: Remove actor by namespaced name
Given an actor registry with no configured providers for persistence
When I add actor from YAML text:
"""
name: local/removable
provider: openai
model: gpt-4
"""
And I remove actor "local/removable" via registry
Then the actor "local/removable" should not exist
Scenario: List actors with namespace filter
Given an actor registry with no configured providers for persistence
When I add actor from YAML text:
"""
name: local/alpha
provider: openai
model: gpt-4
"""
And I add actor from YAML text with update:
"""
name: local/beta
provider: anthropic
model: claude-3
"""
Then listing actors with namespace "local" should return 2 actors
And listing actors with namespace "nonexistent" should return 0 actors
Scenario: Namespaced name auto-prefixing
Given an actor registry with no configured providers for persistence
When I add actor from YAML text:
"""
name: local/auto-prefixed
provider: openai
model: gpt-4
"""
Then the actor "local/auto-prefixed" should exist
Scenario: Get actor by namespaced name
Given an actor registry with no configured providers for persistence
When I add actor from YAML text:
"""
name: local/fetchable
provider: openai
model: gpt-4
"""
Then getting actor "local/fetchable" via registry should return provider "openai"
Scenario: Add actor without name raises validation error
Given an actor registry with no configured providers for persistence
When I attempt to add actor from YAML text without name:
"""
provider: openai
model: gpt-4
"""
Then a validation error should be raised for missing name
Scenario: Add duplicate actor without update raises validation error
Given an actor registry with no configured providers for persistence
When I add actor from YAML text:
"""
name: local/duplicate
provider: openai
model: gpt-4
"""
And I attempt to add duplicate actor from YAML text:
"""
name: local/duplicate
provider: openai
model: gpt-4-turbo
"""
Then a validation error should be raised for duplicate actor
Scenario: Upsert actor via legacy API with yaml_text and schema_version
Given an actor registry with no configured providers for persistence
When I upsert actor "local/legacy-yaml" with yaml_text and schema_version "1.5"
Then the actor "local/legacy-yaml" should have yaml_text containing "legacy yaml content"
And the actor "local/legacy-yaml" should have schema_version "1.5"
Scenario: Default schema version is applied when not specified
Given an actor registry with no configured providers for persistence
When I add actor from YAML text:
"""
name: local/default-version
provider: openai
model: gpt-4
"""
Then the actor "local/default-version" should have schema_version "1.0"
@@ -55,6 +55,9 @@ class _FakeActorService:
unsafe: bool,
set_default: bool,
is_built_in: bool,
yaml_text: str | None = None,
schema_version: str | None = None,
compiled_metadata: dict[str, Any] | None = None,
) -> Actor:
actor = Actor(
id=None,
@@ -64,6 +67,9 @@ class _FakeActorService:
config_blob=config_blob,
config_hash=Actor.compute_hash(config_blob),
graph_descriptor=graph_descriptor,
yaml_text=yaml_text,
schema_version=schema_version or "1.0",
compiled_metadata=compiled_metadata,
unsafe=unsafe,
is_built_in=is_built_in,
is_default=False,
@@ -0,0 +1,254 @@
"""Behave steps for actor registry persistence with YAML text retention."""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from behave.runner import Context
from cleveragents.actor.registry import ActorRegistry
from cleveragents.config.settings import ProviderDefaults
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.actor import Actor
from cleveragents.providers.registry import ProviderInfo
class _StubSettings:
def __init__(self) -> None:
self._defaults = ProviderDefaults(
provider=None, provider_source="test", model=None, model_source="test"
)
def resolve_provider_defaults(self) -> ProviderDefaults:
return self._defaults
class _StubProviderRegistry:
def get_configured_providers(self) -> list[ProviderInfo]:
return []
class _StubActorService:
"""In-memory actor service stub for persistence tests."""
def __init__(self) -> None:
self.actors: dict[str, Actor] = {}
self.default_actor_name: str | None = None
def upsert_actor(
self,
*,
name: str,
provider: str,
model: str,
config_blob: dict[str, Any] | None = None,
graph_descriptor: dict[str, Any] | None = None,
unsafe: bool = False,
set_default: bool = False,
is_built_in: bool = False,
yaml_text: str | None = None,
schema_version: str | None = None,
compiled_metadata: dict[str, Any] | None = None,
) -> Actor:
blob = config_blob or {}
actor = Actor(
id=len(self.actors) + 1,
name=name,
provider=provider,
model=model,
config_blob=blob,
config_hash=Actor.compute_hash(blob),
graph_descriptor=graph_descriptor,
yaml_text=yaml_text,
schema_version=schema_version or "1.0",
compiled_metadata=compiled_metadata,
unsafe=unsafe,
is_built_in=is_built_in,
is_default=False,
)
self.actors[name] = actor
if set_default:
self.default_actor_name = name
return actor
def get_actor(self, name: str) -> Actor:
if name not in self.actors:
raise ValueError(f"Actor '{name}' not found")
return self.actors[name]
def list_actors(self) -> list[Actor]:
return list(self.actors.values())
def remove_actor(self, name: str) -> None:
if name not in self.actors:
raise ValueError(f"Actor '{name}' not found")
del self.actors[name]
if self.default_actor_name == name:
self.default_actor_name = None
def get_default_actor(self) -> Actor | None:
if self.default_actor_name and self.default_actor_name in self.actors:
return self.actors[self.default_actor_name]
return None
def set_default_actor(self, name: str) -> Actor:
actor = self.actors.get(name)
if actor is None:
raise ValueError("Actor does not exist")
self.default_actor_name = name
return actor
def _build_persistence_registry(context: Context) -> None:
context.actor_service = _StubActorService()
context.provider_registry = _StubProviderRegistry()
context.settings = _StubSettings()
context.registry = ActorRegistry(
actor_service=context.actor_service,
provider_registry=context.provider_registry,
settings=context.settings,
)
@given("an actor registry with no configured providers for persistence")
def step_registry_no_providers_persistence(context: Context) -> None:
_build_persistence_registry(context)
@when("I add actor from YAML text:")
def step_add_actor_yaml(context: Context) -> None:
yaml_text = context.text
context.added_actor = context.registry.add(yaml_text)
@when('I add actor from YAML text with schema_version "{version}":')
def step_add_actor_yaml_version(context: Context, version: str) -> None:
yaml_text = context.text
context.added_actor = context.registry.add(yaml_text, schema_version=version)
@when("I add actor from YAML text with compiled_metadata:")
def step_add_actor_yaml_compiled(context: Context) -> None:
yaml_text = context.text
metadata = {"graph_nodes": ["node1", "node2"], "tool_count": 3}
context.added_actor = context.registry.add(yaml_text, compiled_metadata=metadata)
@when("I update actor from YAML text:")
def step_update_actor_yaml(context: Context) -> None:
yaml_text = context.text
context.updated_actor = context.registry.add(yaml_text, update=True)
@when("I add actor from YAML text with update:")
def step_add_actor_yaml_with_update(context: Context) -> None:
yaml_text = context.text
context.added_actor = context.registry.add(yaml_text, update=True)
@when('I remove actor "{name}" via registry')
def step_remove_actor_via_registry(context: Context, name: str) -> None:
context.registry.remove(name)
@when("I attempt to add actor from YAML text without name:")
def step_attempt_add_no_name(context: Context) -> None:
yaml_text = context.text
try:
context.registry.add(yaml_text)
context.error = None
except (ValidationError, ValueError) as exc:
context.error = exc
@when("I attempt to add duplicate actor from YAML text:")
def step_attempt_add_duplicate(context: Context) -> None:
yaml_text = context.text
try:
context.registry.add(yaml_text, update=False)
context.error = None
except (ValidationError, ValueError) as exc:
context.error = exc
@when('I upsert actor "{name}" with yaml_text and schema_version "{version}"')
def step_upsert_legacy_yaml(context: Context, name: str, version: str) -> None:
context.saved_actor = context.registry.upsert_actor(
name=name,
config_blob={"provider": "openai", "model": "gpt-4"},
provider="openai",
model="gpt-4",
yaml_text="legacy yaml content",
schema_version=version,
)
@then('the actor "{name}" should have yaml_text containing "{substring}"')
def step_assert_yaml_text(context: Context, name: str, substring: str) -> None:
actor = context.actor_service.actors[name]
assert actor.yaml_text is not None, f"Actor {name} has no yaml_text"
assert substring in actor.yaml_text, (
f"Expected '{substring}' in yaml_text, got: {actor.yaml_text}"
)
@then('the actor "{name}" should have schema_version "{version}"')
def step_assert_schema_version(context: Context, name: str, version: str) -> None:
actor = context.actor_service.actors[name]
assert actor.schema_version == version, (
f"Expected schema_version '{version}', got: {actor.schema_version}"
)
@then('the actor "{name}" should have compiled_metadata key "{key}"')
def step_assert_compiled_metadata(context: Context, name: str, key: str) -> None:
actor = context.actor_service.actors[name]
assert actor.compiled_metadata is not None, f"Actor {name} has no compiled_metadata"
assert key in actor.compiled_metadata, (
f"Expected key '{key}' in compiled_metadata, "
f"got keys: {list(actor.compiled_metadata.keys())}"
)
@then('the actor "{name}" should not exist')
def step_assert_actor_not_exist(context: Context, name: str) -> None:
assert name not in context.actor_service.actors, f"Actor {name} still exists"
@then('the actor "{name}" should exist')
def step_assert_actor_exists(context: Context, name: str) -> None:
assert name in context.actor_service.actors, f"Actor {name} does not exist"
@then('listing actors with namespace "{namespace}" should return {count:d} actors')
def step_assert_namespace_filter(context: Context, namespace: str, count: int) -> None:
actors = context.registry.list(namespace=namespace)
assert len(actors) == count, (
f"Expected {count} actors in namespace '{namespace}', "
f"got {len(actors)}: {[a.name for a in actors]}"
)
@then('getting actor "{name}" via registry should return provider "{provider}"')
def step_assert_get_actor(context: Context, name: str, provider: str) -> None:
actor = context.registry.get(name)
assert actor.provider == provider, (
f"Expected provider '{provider}', got: {actor.provider}"
)
@then("a validation error should be raised for missing name")
def step_assert_missing_name_error(context: Context) -> None:
assert context.error is not None, "Expected a validation error"
assert "name" in str(context.error).lower(), (
f"Expected error about name, got: {context.error}"
)
@then("a validation error should be raised for duplicate actor")
def step_assert_duplicate_error(context: Context) -> None:
assert context.error is not None, "Expected a validation error"
assert "already exists" in str(context.error).lower(), (
f"Expected error about duplicate, got: {context.error}"
)
+6
View File
@@ -54,6 +54,9 @@ class _StubActorService:
unsafe: bool,
set_default: bool,
is_built_in: bool,
yaml_text: str | None = None,
schema_version: str | None = None,
compiled_metadata: dict[str, Any] | None = None,
) -> Actor:
actor = Actor(
id=None,
@@ -63,6 +66,9 @@ class _StubActorService:
config_blob=config_blob,
config_hash=Actor.compute_hash(config_blob),
graph_descriptor=graph_descriptor,
yaml_text=yaml_text,
schema_version=schema_version or "1.0",
compiled_metadata=compiled_metadata,
unsafe=unsafe,
is_built_in=is_built_in,
is_default=False,
+16 -16
View File
@@ -3140,22 +3140,22 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
- [ ] Git [Jeff]: `git branch -d feature/m3-actor-compiler`
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] **COMMIT (Owner: Jeff | Group: C3.registry | Branch: feature/m3-actor-registry | Planned: Day 15 | Expected: Day 20) - Commit message: "feat(actor): align actor registry persistence"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m3-actor-registry`
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master`
- [ ] Code [Jeff]: Update actor persistence to store YAML text, compiled metadata, and namespaced names with schema_version tracking.
- [ ] Docs [Jeff]: Update `docs/reference/database_schema.md` for actor YAML fields.
- [ ] Tests (Behave) [Jeff]: Add scenarios for actor registry add/update/remove with YAML text retention.
- [ ] Tests (Robot) [Jeff]: Add Robot test that lists actors and shows YAML metadata.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/actor_registry_bench.py` for registry list performance.
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [ ] Git [Jeff]: `git commit -m "feat(actor): align actor registry persistence"`
- [ ] Git [Jeff]: `git push -u origin feature/m3-actor-registry`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m3-actor-registry` to `master` with description "Align actor registry persistence with YAML text + metadata.".
- [X] **COMMIT (Owner: Jeff | Group: C3.registry | Branch: feature/m3-actor-registry | Planned: Day 15 | Expected: Day 21) - Commit message: "feat(actor): align actor registry persistence"**
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git pull origin master`
- [X] Git [Jeff]: `git checkout -b feature/m3-actor-registry`
- [X] Git [Jeff]: `git fetch origin && git merge origin/master`
- [X] Code [Jeff]: Update actor persistence to store YAML text, compiled metadata, and namespaced names with schema_version tracking.
- [X] Docs [Jeff]: Update `docs/reference/database_schema.md` for actor YAML fields.
- [X] Tests (Behave) [Jeff]: Add scenarios for actor registry add/update/remove with YAML text retention.
- [X] Tests (Robot) [Jeff]: Add Robot test that lists actors and shows YAML metadata.
- [X] Tests (ASV) [Jeff]: Add `benchmarks/actor_registry_bench.py` for registry list performance.
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [X] Git [Jeff]: `git commit -m "feat(actor): align actor registry persistence"`
- [X] Git [Jeff]: `git push -u origin feature/m3-actor-registry`
- [X] Forgejo PR [Jeff]: Open PR from `feature/m3-actor-registry` to `master` with description "Align actor registry persistence with YAML text + metadata.".
- [ ] **COMMIT (Owner: Jeff | Group: C3.cli | Branch: feature/m3-actor-cli | Planned: Day 16 | Expected: Day 20) - Commit message: "feat(cli): align actor commands to YAML"**
- [ ] Git [Jeff]: `git checkout master`
+27
View File
@@ -0,0 +1,27 @@
*** Settings ***
Library OperatingSystem
Library Collections
Library Process
*** Variables ***
${PYTHON} python
*** Test Cases ***
Actor Registry Lists Actors With YAML Metadata
${result}= Run Process ${PYTHON} robot/helper_actor_registry_persistence.py list_yaml_metadata stdout=PIPE stderr=PIPE
Should Be Equal As Integers ${result.rc} 0
${payload}= Evaluate __import__('json').loads('''${result.stdout.strip()}''')
Should Be Equal ${payload['name']} local/test
Should Be Equal ${payload['schema_version']} 1.0
Should Be True ${payload['has_yaml']}
Should Be True ${payload['has_meta']}
Actor Registry Schema Version Defaults To 1.0
${result}= Run Process ${PYTHON} robot/helper_actor_registry_persistence.py schema_version_default stdout=PIPE stderr=PIPE
Should Be Equal As Integers ${result.rc} 0
Should Be Equal ${result.stdout.strip()} 1.0
Actor Namespaced Name Validation
${result}= Run Process ${PYTHON} robot/helper_actor_registry_persistence.py namespaced_name stdout=PIPE stderr=PIPE
Should Be Equal As Integers ${result.rc} 0
Should Be Equal ${result.stdout.strip()} local/valid-name
+1 -1
View File
@@ -755,7 +755,7 @@ Run Python Script
# Write to a temporary file
${temp_file}= Evaluate __import__('tempfile').NamedTemporaryFile(mode='w', suffix='.py', delete=False, dir='/tmp').name
Create File ${temp_file} ${full_code}
${result}= Run Process ${PYTHON} ${temp_file} timeout=60s stderr=STDOUT env:PYTHONWARNINGS=ignore env:PYTHONDONTWRITEBYTECODE=1
${result}= Run Process ${PYTHON} ${temp_file} timeout=30s stderr=STDOUT env:PYTHONWARNINGS=ignore env:PYTHONDONTWRITEBYTECODE=1
Remove File ${temp_file}
# Check if process failed and log stderr if present
IF ${result.rc} != 0
@@ -0,0 +1,79 @@
"""Helper script for actor registry persistence Robot tests.
Invoked by Robot Framework via ``Run Process`` to verify domain-model
behaviour in a subprocess with the real package installed.
Usage::
python helper_actor_registry_persistence.py <test_name>
Where *test_name* is one of:
- ``list_yaml_metadata`` - verify yaml_text / compiled_metadata round-trip
- ``schema_version_default`` - verify default schema_version is ``"1.0"``
- ``namespaced_name`` - verify a namespaced name is preserved
"""
from __future__ import annotations
import json
import sys
def _test_list_yaml_metadata() -> None:
from cleveragents.domain.models.core.actor import Actor
actor = Actor(
name="local/test",
provider="openai",
model="gpt-4",
config_hash=Actor.compute_hash({}),
yaml_text="name: local/test\nprovider: openai\nmodel: gpt-4",
schema_version="1.0",
compiled_metadata={"nodes": 2},
)
print(
json.dumps(
{
"name": actor.name,
"schema_version": actor.schema_version,
"has_yaml": actor.yaml_text is not None,
"has_meta": actor.compiled_metadata is not None,
}
)
)
def _test_schema_version_default() -> None:
from cleveragents.domain.models.core.actor import Actor
actor = Actor(
name="local/default-ver",
provider="openai",
model="gpt-4",
config_hash=Actor.compute_hash({}),
)
print(actor.schema_version)
def _test_namespaced_name() -> None:
from cleveragents.domain.models.core.actor import Actor
actor = Actor(
name="local/valid-name",
provider="openai",
model="gpt-4",
config_hash=Actor.compute_hash({}),
)
print(actor.name)
_TESTS = {
"list_yaml_metadata": _test_list_yaml_metadata,
"schema_version_default": _test_schema_version_default,
"namespaced_name": _test_namespaced_name,
}
if __name__ == "__main__":
name = sys.argv[1]
_TESTS[name]()
+22
View File
@@ -32,6 +32,28 @@ class ActorConfiguration(BaseModel):
default=False, description="True when the actor config is marked unsafe"
)
@staticmethod
def load_yaml_text(text: str) -> dict[str, Any]:
"""Parse YAML text into a configuration blob.
Args:
text: Raw YAML string.
Returns:
Parsed configuration dictionary.
"""
try:
data = json.loads(text)
except json.JSONDecodeError:
data = ActorConfiguration._load_v2_yaml_content(text)
else:
if data is None:
return {}
if not isinstance(data, dict):
raise ValueError("Config must be a JSON or YAML object")
return cast(dict[str, Any], data)
return data
@staticmethod
def load_blob_from_file(config_path: Path) -> dict[str, Any]:
"""Load a configuration blob from a JSON or YAML file (v2 parity)."""
+143 -1
View File
@@ -18,7 +18,15 @@ from cleveragents.providers.registry import (
class ActorRegistry:
"""Coordinate actor configuration parsing and built-in generation."""
"""Coordinate actor configuration parsing and built-in generation.
Supports YAML-first actor persistence: each actor stores its original
YAML text, a ``schema_version``, and optional ``compiled_metadata``
alongside the canonical configuration blob.
"""
#: Default actor config schema version applied to new entries.
DEFAULT_SCHEMA_VERSION: str = "1.0"
def __init__(
self,
@@ -34,6 +42,16 @@ class ActorRegistry:
def _actor_name(self, provider_name: str, model_name: str) -> str:
return f"{provider_name}/{model_name}"
@staticmethod
def _ensure_namespaced(name: str) -> str:
"""Ensure *name* follows the ``namespace/identifier`` convention.
If the name contains no ``/`` it is prefixed with ``local/``.
"""
if "/" not in name:
return f"local/{name}"
return name
def _build_graph_descriptor(
self,
*,
@@ -124,6 +142,90 @@ class ActorRegistry:
return actors
# ------------------------------------------------------------------
# YAML-first add / update
# ------------------------------------------------------------------
def add(
self,
yaml_text: str,
*,
update: bool = False,
schema_version: str | None = None,
compiled_metadata: dict[str, Any] | None = None,
) -> Actor:
"""Add (or update) an actor from raw YAML text.
The YAML is parsed into an ``ActorConfiguration``, validated, and
persisted alongside the original *yaml_text* and *schema_version*.
Args:
yaml_text: The original actor YAML source.
update: When ``True`` allow overwriting an existing actor.
schema_version: Explicit schema version; defaults to
``DEFAULT_SCHEMA_VERSION``.
compiled_metadata: Optional compiler-produced metadata dict.
Returns:
The persisted ``Actor`` domain object.
Raises:
ValidationError: When the YAML is invalid or the actor already
exists and *update* is ``False``.
"""
self.ensure_built_in_actors()
blob = ActorConfiguration.load_yaml_text(yaml_text)
name_raw: str = blob.get("name", "")
if not name_raw:
raise ValidationError("Actor YAML must include a 'name' field.")
name = self._ensure_namespaced(name_raw)
provider_raw = blob.get("provider") or blob.get("provider_type", "")
model_raw = blob.get("model") or blob.get("model_id", "")
if not provider_raw or not model_raw:
raise ValidationError(
"Actor YAML must include 'provider' and 'model' fields."
)
provider = str(provider_raw)
model = str(model_raw)
# Check for existing actor when not updating
if not update:
try:
self._actor_service.get_actor(name)
raise ValidationError(
f"Actor '{name}' already exists. Pass update=True to overwrite."
)
except Exception as exc:
if "already exists" in str(exc):
raise
# NotFoundError is expected for new actors
version = schema_version or self.DEFAULT_SCHEMA_VERSION
config_blob: dict[str, Any] = dict(blob)
config_blob.setdefault("source", "yaml")
return self._actor_service.upsert_actor(
name=name,
provider=provider,
model=model,
config_blob=config_blob,
graph_descriptor=blob.get("graph_descriptor"),
unsafe=bool(blob.get("unsafe", False)),
set_default=False,
is_built_in=False,
yaml_text=yaml_text,
schema_version=version,
compiled_metadata=compiled_metadata,
)
# ------------------------------------------------------------------
# Legacy upsert (preserved for backward compatibility)
# ------------------------------------------------------------------
def upsert_actor(
self,
*,
@@ -137,6 +239,9 @@ class ActorRegistry:
is_built_in: bool = False,
allow_unsafe: bool = False,
option_overrides: dict[str, Any] | None = None,
yaml_text: str | None = None,
schema_version: str | None = None,
compiled_metadata: dict[str, Any] | None = None,
) -> Actor:
"""Parse, validate, and persist an actor configuration."""
@@ -180,17 +285,54 @@ class ActorRegistry:
unsafe=config.unsafe,
set_default=set_default,
is_built_in=is_built_in,
yaml_text=yaml_text,
schema_version=schema_version or self.DEFAULT_SCHEMA_VERSION,
compiled_metadata=compiled_metadata,
)
# ------------------------------------------------------------------
# CRUD helpers
# ------------------------------------------------------------------
def get(self, name: str) -> Actor:
"""Retrieve an actor by its namespaced name."""
self.ensure_built_in_actors()
return self._actor_service.get_actor(self._ensure_namespaced(name))
def get_actor(self, name: str) -> Actor:
"""Retrieve an actor by name (legacy alias for :meth:`get`)."""
self.ensure_built_in_actors()
return self._actor_service.get_actor(name)
def list(self, namespace: str | None = None) -> list[Actor]:
"""List actors, optionally filtered by namespace prefix.
Args:
namespace: When provided only actors whose name starts with
``namespace/`` are returned.
Returns:
Sorted list of ``Actor`` objects.
"""
self.ensure_built_in_actors()
actors = self._actor_service.list_actors()
if namespace is not None:
prefix = f"{namespace}/"
actors = [a for a in actors if a.name.startswith(prefix)]
return actors
def list_actors(self) -> list[Actor]:
"""Return all actors (legacy alias for :meth:`list`)."""
self.ensure_built_in_actors()
return self._actor_service.list_actors()
def remove(self, name: str) -> None:
"""Remove an actor by its namespaced name."""
self.ensure_built_in_actors()
self._actor_service.remove_actor(self._ensure_namespaced(name))
def remove_actor(self, name: str) -> None:
"""Remove a custom actor (legacy alias for :meth:`remove`)."""
self.ensure_built_in_actors()
self._actor_service.remove_actor(name)
@@ -68,6 +68,9 @@ class ActorService:
unsafe: bool = False,
set_default: bool = False,
is_built_in: bool = False,
yaml_text: str | None = None,
schema_version: str | None = None,
compiled_metadata: dict[str, Any] | None = None,
) -> Actor:
"""Create or update an actor configuration."""
@@ -100,6 +103,9 @@ class ActorService:
config_blob=blob,
config_hash=config_hash,
graph_descriptor=graph_descriptor,
yaml_text=yaml_text,
schema_version=schema_version or "1.0",
compiled_metadata=compiled_metadata,
unsafe=unsafe,
is_built_in=is_built_in
or (existing.is_built_in if existing else False),
@@ -20,6 +20,12 @@ class Actor(BaseModel):
Built-in actors are generated from the provider registry using the
``<provider>/<model>`` naming pattern. Custom actors must use the
``local/<id>`` prefix and persist a canonical configuration blob.
Attributes:
yaml_text: Original YAML source text for the actor configuration.
schema_version: Version of the actor config schema used.
compiled_metadata: JSON-serialisable metadata produced by the
actor compiler (graph topology, resolved tools, etc.).
"""
id: int | None = Field(None, description="Actor ID")
@@ -33,6 +39,13 @@ class Actor(BaseModel):
graph_descriptor: dict[str, Any] | None = Field(
default=None, description="Adapter-produced graph descriptor"
)
yaml_text: str | None = Field(default=None, description="Original YAML source text")
schema_version: str = Field(
default="1.0", description="Actor config schema version"
)
compiled_metadata: dict[str, Any] | None = Field(
default=None, description="Compiler-produced metadata (JSON)"
)
unsafe: bool = Field(False, description="True when actor is marked unsafe")
is_built_in: bool = Field(False, description="Generated from provider registry")
is_default: bool = Field(False, description="Designated default actor")
@@ -184,7 +184,12 @@ class DebugAttemptModel(Base):
class ActorModel(Base):
"""Database model for actor configurations."""
"""Database model for actor configurations.
Stores the canonical actor config alongside the original YAML source
text, a ``schema_version`` indicator, and optional ``compiled_metadata``
produced by the actor compiler.
"""
__tablename__ = "actors"
@@ -195,6 +200,11 @@ class ActorModel(Base):
config_blob = Column(JSON, nullable=False, default=dict)
config_hash = Column(String(128), nullable=False)
graph_descriptor = Column(JSON, nullable=True)
yaml_text = Column(Text, nullable=True)
schema_version = Column(
String(20), nullable=False, default="1.0", server_default="1.0"
)
compiled_metadata = Column(JSON, nullable=True)
unsafe = Column(Boolean, nullable=False, default=False)
is_built_in = Column(Boolean, nullable=False, default=False)
is_default = Column(Boolean, nullable=False, default=False)
@@ -686,6 +686,9 @@ class ActorRepository:
config_blob=model.config_blob or {}, # type: ignore[arg-type]
config_hash=model.config_hash, # type: ignore[arg-type]
graph_descriptor=model.graph_descriptor or None, # type: ignore[arg-type]
yaml_text=model.yaml_text, # type: ignore[arg-type]
schema_version=model.schema_version or "1.0", # type: ignore[arg-type]
compiled_metadata=model.compiled_metadata or None, # type: ignore[arg-type]
unsafe=model.unsafe, # type: ignore[arg-type]
is_built_in=model.is_built_in, # type: ignore[arg-type]
is_default=model.is_default, # type: ignore[arg-type]
@@ -715,6 +718,9 @@ class ActorRepository:
existing.config_blob = actor.config_blob
existing.config_hash = actor.config_hash
existing.graph_descriptor = actor.graph_descriptor
existing.yaml_text = actor.yaml_text
existing.schema_version = actor.schema_version
existing.compiled_metadata = actor.compiled_metadata
existing.unsafe = actor.unsafe
existing.is_built_in = actor.is_built_in
existing.is_default = actor.is_default
@@ -731,6 +737,9 @@ class ActorRepository:
config_blob=actor.config_blob,
config_hash=actor.config_hash,
graph_descriptor=actor.graph_descriptor,
yaml_text=actor.yaml_text,
schema_version=actor.schema_version,
compiled_metadata=actor.compiled_metadata,
unsafe=actor.unsafe,
is_built_in=actor.is_built_in,
is_default=actor.is_default,
@@ -783,6 +792,26 @@ class ActorRepository:
db_actor = self.session.query(ActorModel).filter_by(is_default=True).first()
return self._to_domain(db_actor) if db_actor else None
def list_by_namespace(self, namespace: str) -> list[Actor]:
"""List actors whose name starts with the given namespace prefix."""
db_actors = (
self.session.query(ActorModel)
.filter(ActorModel.name.startswith(f"{namespace}/"))
.order_by(ActorModel.name)
.all()
)
return [self._to_domain(actor) for actor in db_actors]
def list_by_schema_version(self, schema_version: str) -> list[Actor]:
"""List actors matching a specific schema version."""
db_actors = (
self.session.query(ActorModel)
.filter_by(schema_version=schema_version)
.order_by(ActorModel.name)
.all()
)
return [self._to_domain(actor) for actor in db_actors]
# ---------------------------------------------------------------------------
# V3 Action Repository