Files
cleveragents-core/features/steps/actor_registry_steps.py
T
hurui200320 8dc55655e9
CI / push-validation (push) Successful in 41s
CI / helm (push) Successful in 42s
CI / benchmark-publish (push) Failing after 56s
CI / build (push) Successful in 1m6s
CI / lint (push) Successful in 1m13s
CI / quality (push) Successful in 1m34s
CI / typecheck (push) Successful in 2m12s
CI / security (push) Successful in 2m13s
CI / e2e_tests (push) Successful in 3m45s
CI / integration_tests (push) Successful in 3m57s
CI / unit_tests (push) Successful in 4m53s
CI / docker (push) Successful in 1m34s
CI / coverage (push) Successful in 11m27s
CI / status-check (push) Successful in 3s
CI / benchmark-publish (pull_request) Has been skipped
CI / helm (pull_request) Successful in 32s
CI / push-validation (pull_request) Successful in 33s
CI / build (pull_request) Successful in 53s
CI / lint (pull_request) Successful in 59s
CI / quality (pull_request) Successful in 1m28s
CI / security (pull_request) Successful in 1m29s
CI / typecheck (pull_request) Successful in 1m32s
CI / e2e_tests (pull_request) Successful in 4m1s
CI / integration_tests (pull_request) Successful in 5m4s
CI / unit_tests (pull_request) Successful in 5m51s
CI / docker (pull_request) Successful in 1m27s
CI / coverage (pull_request) Successful in 11m51s
CI / status-check (pull_request) Successful in 3s
feat(actor): make built-in actors virtual, resolved on-demand from provider registry
Replace the DB-persistence approach for built-in actors with in-memory virtual
resolution. Built-in actors (e.g. openai/gpt-4o, anthropic/claude-sonnet) are
now resolved on-demand from ProviderRegistry at query time and merged with
persisted custom actors — no database writes occur for built-in actors.

Key changes:
- Add ActorRegistry._resolve_virtual_builtin_actors(): generates virtual Actor
  objects in-memory from configured providers (is_built_in=True, id=None)
- ActorRegistry.list()/list_actors(): merges virtual built-ins with custom DB
  actors; custom actors win on name collision; result sorted alphabetically
- ActorRegistry.get()/get_actor(): DB-first, virtual built-in fallback,
  NotFoundError if neither
- ActorRegistry.remove()/remove_actor(): rejects virtual built-in names with
  ValidationError
- ActorRegistry.set_default_actor(): stores only the actor name string via new
  actor_preferences singleton table; no actor row created for virtual built-ins
- ActorRegistry.get_default_actor(): reads preference name, resolves via
  DB→virtual chain, returns actor with is_default=True
- Remove ensure_built_in_actors() entirely — 20+ call sites cleaned up including
  plan.py
- Remove ActorRepository.upsert_built_in() — no longer needed
- Remove is_built_in from ActorModel DB column (kept on Actor domain model for
  virtual actors)
- New Alembic migration m10_001_virtual_builtin_actors: drops is_built_in column,
  adds actor_preferences singleton table
- Add ActorService.set_default_actor_name() and get_default_actor_name() for
  preference storage without requiring a DB actor row
- Update 15+ Behave step files and 5 feature files; add new
  features/virtual_builtin_actors.feature with 8 scenarios covering list, show,
  remove, set-default, get-default, no-DB-writes guarantees
- Rewrite tests/actor/test_registry_builtin_yaml.py: TestEnsureBuiltInActorsWithYaml
  → TestResolveVirtualBuiltinActors plus new TestListActors, TestGetActor,
  TestRemoveActor, TestDefaultActor test classes

Quality gates: lint ✓, typecheck ✓, unit_tests ✓ (15674 scenarios), coverage ✓
(97.10%), integration_tests ✓ (1997 tests)

ISSUES CLOSED: #10923
2026-04-30 10:56:21 +00:00

349 lines
12 KiB
Python

"""Behave steps covering actor registry behaviors."""
from __future__ import annotations
import ast
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 (
ProviderCapabilities,
ProviderInfo,
ProviderType,
)
class _StubSettings:
def __init__(self, defaults: ProviderDefaults) -> None:
self._defaults = defaults
def resolve_provider_defaults(self) -> ProviderDefaults:
return self._defaults
class _StubProviderRegistry:
def __init__(self, providers: list[ProviderInfo]) -> None:
self._providers = providers
self.calls = 0
def get_configured_providers(self) -> list[ProviderInfo]:
self.calls += 1
return list(self._providers)
class _StubActorService:
def __init__(self) -> None:
self.actors: dict[str, Actor] = {}
self.default_actor_name: str | None = None
self.upsert_payloads: list[dict[str, Any]] = []
def upsert_actor(
self,
*,
name: str,
provider: str,
model: str,
config_blob: dict[str, Any],
graph_descriptor: dict[str, Any] | None,
unsafe: bool,
set_default: bool,
yaml_text: str | None = None,
schema_version: str | None = None,
compiled_metadata: dict[str, Any] | None = None,
) -> Actor:
actor = Actor(
id=None,
name=name,
provider=provider,
model=model,
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=False,
is_default=False,
)
self.actors[name] = actor
if set_default:
self.set_default_actor(name)
self.upsert_payloads.append(
{
"name": name,
"provider": provider,
"model": model,
"config_blob": dict(config_blob),
"graph_descriptor": graph_descriptor,
"unsafe": unsafe,
"set_default": set_default,
}
)
return actor
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 get_default_actor_name(self) -> str | None:
return self.default_actor_name
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
for value in self.actors.values():
value.is_default = value.name == name
return actor
def set_default_actor_name(self, name: str) -> None:
self.default_actor_name = name
def get_actor(self, name: str) -> Actor | None:
from cleveragents.core.exceptions import NotFoundError
actor = self.actors.get(name)
if actor is None:
raise NotFoundError(resource_type="actor", resource_id=name)
return actor
def list_actors(self) -> list[Actor]:
return list(self.actors.values())
def remove_actor(self, name: str) -> None:
self.actors.pop(name, None)
if self.default_actor_name == name:
self.default_actor_name = None
def _default_defaults() -> ProviderDefaults:
return ProviderDefaults(
provider=None, provider_source="test", model=None, model_source="test"
)
def _build_registry(
context: Context,
providers: list[ProviderInfo],
defaults: ProviderDefaults | None = None,
) -> None:
context.actor_service = _StubActorService()
context.provider_registry = _StubProviderRegistry(providers)
context.settings = _StubSettings(defaults or _default_defaults())
context.registry = ActorRegistry(
actor_service=context.actor_service,
provider_registry=context.provider_registry,
settings=context.settings,
)
@given('provider defaults are provider "{provider}" model "{model}"')
def step_provider_defaults(context: Context, provider: str, model: str) -> None:
normalized_provider = provider or None
normalized_model = model or None
context.default_values = ProviderDefaults(
provider=normalized_provider,
provider_source="scenario",
model=normalized_model,
model_source="scenario",
)
@given("an actor registry with configured providers:")
def step_registry_with_providers(context: Context) -> None:
providers: list[ProviderInfo] = []
for row in context.table:
provider_type = ProviderType(row["type"])
providers.append(
ProviderInfo(
provider_type=provider_type,
name=row["name"],
api_key_env_var="ENV",
default_model=row["model"],
capabilities=ProviderCapabilities(),
is_configured=True,
)
)
defaults = getattr(context, "default_values", _default_defaults())
_build_registry(context, providers, defaults)
@given("an actor registry with no configured providers")
def step_registry_with_no_providers(context: Context) -> None:
defaults = getattr(context, "default_values", _default_defaults())
_build_registry(context, [], defaults)
@when("I ensure built-in actors are generated")
def step_ensure_built_ins(context: Context) -> None:
"""Resolve virtual built-in actors (replaces old ensure_built_in_actors)."""
context.generated_actors = context.registry._resolve_virtual_builtin_actors()
@then("no actors should be created")
def step_no_actors_created(context: Context) -> None:
# Virtual built-in resolution: no DB writes, but virtual actors may still
# be in context.generated_actors. Check the registry list is empty.
assert context.generated_actors == []
@then("no default actor should be set")
def step_no_default_set(context: Context) -> None:
assert context.actor_service.get_default_actor() is None
@then("the registry should list actors {expected}")
def step_registry_should_list(context: Context, expected: str) -> None:
expected_list = ast.literal_eval(expected)
actual_names = [actor.name for actor in context.registry.list_actors()]
assert actual_names == expected_list, actual_names
@then('the default actor should be "{expected}"')
def step_default_actor(context: Context, expected: str) -> None:
actor = context.actor_service.get_default_actor()
assert actor is not None, "No default actor was set"
assert actor.name == expected
@when('I remove actor "{name}"')
def step_remove_actor(context: Context, name: str) -> None:
context.registry.remove_actor(name)
@then("the stored actors should be {expected}")
def step_stored_actors(context: Context, expected: str) -> None:
expected_list = ast.literal_eval(expected)
assert sorted(context.actor_service.actors.keys()) == sorted(expected_list)
@when('I attempt to upsert actor "{name}" with unsafe config')
def step_upsert_unsafe_actor(context: Context, name: str) -> None:
try:
context.registry.upsert_actor(
name=name,
config_blob={"provider": "local", "model": "unsafe", "unsafe": True},
)
context.error = None
except Exception as exc:
context.error = exc
@then("a registry validation error should be raised for unsafe actor")
def step_expect_validation_error(context: Context) -> None:
assert isinstance(context.error, ValidationError), type(context.error)
@when(
'I upsert actor "{name}" with provider "{provider}" model "{model}" graph descriptor {graph_descriptor} and options {options_blob}'
)
def step_upsert_actor_with_graph(
context: Context,
name: str,
provider: str,
model: str,
graph_descriptor: str,
options_blob: str,
) -> None:
graph = ast.literal_eval(graph_descriptor)
options = ast.literal_eval(options_blob)
context.saved_actor = context.registry.upsert_actor(
name=name,
provider=provider,
model=model,
graph_descriptor=graph,
config_blob=options,
)
@when('I upsert actor "{name}" with raw config {config_blob}')
def step_upsert_actor_raw_config(context: Context, name: str, config_blob: str) -> None:
blob = ast.literal_eval(config_blob)
context.saved_actor = context.registry.upsert_actor(name=name, config_blob=blob)
@when('I retrieve actor "{name}" via the registry')
def step_get_actor_via_registry(context: Context, name: str) -> None:
context.retrieved_actor = context.registry.get_actor(name)
@when('I set the default actor to "{name}" via the registry')
def step_set_default_actor_via_registry(context: Context, name: str) -> None:
context.registry.set_default_actor(name)
@then(
'the stored actor config should include provider "{provider}" model "{model}" graph descriptor {graph_descriptor} unsafe false and option "{option_key}" "{option_value}"'
)
def step_assert_canonical_blob(
context: Context,
provider: str,
model: str,
graph_descriptor: str,
option_key: str,
option_value: str,
) -> None:
graph = ast.literal_eval(graph_descriptor)
assert context.actor_service.upsert_payloads, "No actor was upserted"
blob = context.actor_service.upsert_payloads[-1]["config_blob"]
assert blob["provider"] == provider
assert blob["model"] == model
assert blob["graph_descriptor"] == graph
assert blob.get("unsafe") is False
assert blob.get(option_key) == option_value
@then(
'the saved actor config should keep graph descriptor {graph_descriptor} and option "{option_key}" {option_value}'
)
def step_assert_preserved_graph_and_option(
context: Context,
graph_descriptor: str,
option_key: str,
option_value: str,
) -> None:
graph = ast.literal_eval(graph_descriptor)
parsed_value = ast.literal_eval(option_value)
assert context.actor_service.upsert_payloads, "No actor was upserted"
blob = context.actor_service.upsert_payloads[-1]["config_blob"]
assert blob["graph_descriptor"] == graph
assert blob.get("options", {}).get(option_key) == parsed_value
assert blob.get("unsafe") is False
@then('the registry default actor via wrapper should be "{expected}"')
def step_assert_wrapper_default_actor(context: Context, expected: str) -> None:
actor = context.registry.get_default_actor()
assert actor is not None, "No default actor set via registry"
assert actor.name == expected
@then(
'the built-in actor payload should include graph descriptor and capabilities for "{actor_name}"'
)
def step_assert_built_in_payload(context: Context, actor_name: str) -> None:
assert context.actor_service.upsert_payloads, "No actors were persisted"
payload = None
for item in context.actor_service.upsert_payloads:
if item.get("name") == actor_name:
payload = item
break
assert payload is not None, f"Actor {actor_name} not found in payloads"
blob = payload["config_blob"]
graph = blob.get("graph_descriptor")
assert graph is not None, "graph_descriptor missing on built-in actor"
assert graph.get("provider")
assert graph.get("model")
assert graph.get("source") == "provider-registry"
capabilities = blob.get("capabilities")
assert capabilities is not None, "capabilities missing on built-in actor config"