feat(actor): align actor registry persistence
This commit is contained in:
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
|
||||
@@ -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]()
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user