feat(actor): make built-in actors virtual, resolved on-demand from provider registry
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

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
This commit was merged in pull request #10927.
This commit is contained in:
2026-04-29 08:36:47 +00:00
committed by Forgejo
parent b04b9ba56e
commit 8dc55655e9
34 changed files with 1846 additions and 507 deletions
+3 -6
View File
@@ -1,14 +1,11 @@
# Regression tests for bug #592: actor list must not raise a validation error.
# Updated for issue #10923: built-in actors are now virtual (resolved on-demand
# from the provider registry, never persisted).
Feature: Actor list on a fresh project shows no actors
As a developer using the agents CLI
I want "agents actor list" on a fresh project with no actors
So that I see a clean "no actors" message instead of a validation error
# Scenarios 1-2 test CLI rendering of an empty actor list using a
# fully-mocked registry. They verify the user-facing output, NOT the
# buggy code path (which is exercised by Scenario 3 and the edge-case
# scenarios below).
@tdd_issue @tdd_issue_592 @tdd_issue_4176
Scenario: Actor list with no configured providers shows no actors message
Given the actor registry has no configured providers for actor-list-empty
@@ -46,7 +43,7 @@ Feature: Actor list on a fresh project shows no actors
And the actor-list-empty output should not contain "VALIDATION_FAILED"
@tdd_issue @tdd_issue_592 @tdd_issue_4176
Scenario: Listed actor name is a valid single-slash namespace/identifier
Scenario: Listed virtual actor name is a valid single-slash namespace/identifier
Given the actor registry has a provider with a multi-slash model for actor-list-empty
When I run actor list via the actor-list-empty CLI
Then the actor-list-empty exit code should be 0
+2 -2
View File
@@ -31,11 +31,11 @@ Feature: Actor service coverage
When I attempt to fetch actor "vendor/model"
Then a NotFoundError should be raised for the actor
Scenario: Upserting cannot overwrite built-in actors
Scenario: Upserting with non-local namespace always raises ValidationError
Given an actor service with stubbed dependencies
And a built-in actor named "vendor/model" already exists
When I attempt to upsert "vendor/model" as custom
Then a BusinessRuleViolation should be raised
Then a ValidationError should be raised for non-local namespace
Scenario: Upserting with set_default marks actor as default
Given an actor service with stubbed dependencies
+22 -30
View File
@@ -694,41 +694,28 @@ Feature: Consolidated Actor
Given a fresh actor registry with no providers
When I run ensure_built_in_actors
Then the generated actor list should be empty
And no actors should exist in the service
Scenario: Registry generates built-in actors from configured providers and selects preferred default
Given preferred provider defaults provider "openai" and model "gpt-4o-mini"
And a fresh actor registry with providers
| type | name | model |
| openai | OpenAI | gpt-4o-mini |
| anthropic | Claude | opus |
When I run ensure_built_in_actors
Then the generated actors should be ["openai/gpt-4o-mini", "claude/opus"]
And the service default actor should be "openai/gpt-4o-mini"
Scenario: Registry selects first built-in actor as default when no provider preference matches
Scenario: Registry resolves virtual built-in actors from configured providers in alphabetical order
Given a fresh actor registry with providers
| type | name | model |
| openai | OpenAI | gpt-4o-mini |
| anthropic | Claude | opus |
When I run ensure_built_in_actors
Then the generated actors should be ["openai/gpt-4o-mini", "claude/opus"]
And the service default actor should be "openai/gpt-4o-mini"
Then the generated actors should be ["claude/opus", "openai/gpt-4o-mini"]
Scenario: Registry selects preferred provider and model as default actor
Scenario: Registry resolves virtual built-in actors including preferred provider
Given preferred provider defaults provider "claude" and model "opus"
And a fresh actor registry with providers
| type | name | model |
| openai | OpenAI | gpt-4o-mini |
| anthropic | Claude | opus |
When I run ensure_built_in_actors
Then the service default actor should be "claude/opus"
Then the generated actors should be ["claude/opus", "openai/gpt-4o-mini"]
Scenario: Registry skips default selection when a default actor already exists
Scenario: Registry virtual resolution does not overwrite pre-existing default preference
Given preferred provider defaults provider "claude" and model "opus"
And a fresh actor registry with providers
| type | name | model |
@@ -770,9 +757,9 @@ Feature: Consolidated Actor
And the upserted actor should be marked unsafe
Scenario: Registry allows unsafe actor when is_built_in flag is set
Scenario: Registry allows unsafe actor when allow_unsafe flag is set (is_built_in removed)
Given a fresh actor registry with no providers
When I upsert an unsafe actor "local/builtin" with is_built_in flag
When I upsert an unsafe actor "local/builtin" with allow_unsafe flag
Then the upserted actor should have name "local/builtin"
And the upserted actor should be marked unsafe
@@ -800,40 +787,45 @@ Feature: Consolidated Actor
Then the fetched actor via registry should be named "openai/gpt-4o-mini"
Scenario: Registry list_actors delegates to actor service without triggering writes
Scenario: Registry list_actors returns virtual built-in actors without DB writes
Given a fresh actor registry with providers
| type | name | model |
| openai | OpenAI | gpt-4o-mini |
| anthropic | Claude | opus |
When I call registry ensure_built_in_actors
And I call registry list_actors
Then the returned actor list should contain ["openai/gpt-4o-mini", "claude/opus"]
When I call registry list_actors
Then the returned actor list should contain ["claude/opus", "openai/gpt-4o-mini"]
Scenario: Registry remove_actor delegates to actor service after ensuring built-ins
Scenario: Registry remove_actor rejects virtual built-in actors with ValidationError
Given a fresh actor registry with providers
| type | name | model |
| openai | OpenAI | gpt-4o-mini |
| anthropic | Claude | opus |
When I call registry remove_actor with "openai/gpt-4o-mini"
Then the service should no longer contain actor "openai/gpt-4o-mini"
Then the remove_actor call should have raised a ValidationError
Scenario: Registry remove_actor delegates to actor service for custom actors
Given a fresh actor registry with no providers
When I call registry remove_actor with "local/my-custom-actor"
Then the service should no longer contain actor "local/my-custom-actor"
Scenario: Registry set_default_actor delegates to actor service after ensuring built-ins
Scenario: Registry set_default_actor stores virtual built-in name as preference
Given a fresh actor registry with providers
| type | name | model |
| openai | OpenAI | gpt-4o-mini |
| anthropic | Claude | opus |
When I call registry set_default_actor with "claude/opus"
Then the service default actor should be "claude/opus"
And I call registry get_default_actor
Then the returned default actor should be "claude/opus"
Scenario: Registry get_default_actor delegates to actor service after ensuring built-ins
Scenario: Registry get_default_actor returns None when no default is configured
Given a fresh actor registry with providers
| type | name | model |
| openai | OpenAI | gpt-4o-mini |
When I call registry get_default_actor
Then the returned default actor should be "openai/gpt-4o-mini"
Then the returned default actor should be None
# ── _canonical_blob ─────────────────────────────────────────────────
+31 -8
View File
@@ -113,11 +113,11 @@ Feature: Database Repository Error Handling Coverage
Then the repository should return the actors ordered by name
@phase1
Scenario: ActorRepository prevents overwriting built-in actors
Scenario: ActorRepository allows overwriting existing actors (built-in protection removed)
Given I have an actor repository with database session
And I have stored a built-in actor named "provider/model"
When I try to upsert a custom actor with the same name
Then the actor repository should raise when overwriting built-in actor
Then the actor repository should succeed when upserting over any actor
@phase1
Scenario: ActorRepository upsert updates existing actor values
@@ -127,23 +127,20 @@ Feature: Database Repository Error Handling Coverage
Then the repository should persist the updated actor values
@phase1
Scenario: ActorRepository upsert_built_in marks actor as built in
Scenario: ActorRepository upsert stores actor correctly (upsert_built_in removed)
Given I have an actor repository with database session
When I upsert a built-in actor named "provider/built-in"
Then the actor should be stored as built-in in the repository
@phase1
Scenario: ActorRepository delete handles missing, built-in, default, and custom actors
Scenario: ActorRepository delete handles missing, default, and custom actors
Given I have an actor repository with database session
And I have stored a built-in actor named "provider/safe"
And I have stored a default actor named "provider/default"
And I have stored a custom actor named "local/deletable"
When I delete a non-existent actor named "provider/missing"
And I attempt to delete the built-in actor named "provider/safe"
And I attempt to delete the default actor named "provider/default"
And I delete the custom actor named "local/deletable"
Then the built-in actor deletion should raise an error
And the default actor deletion should raise an error
Then the default actor deletion should raise an error
And the custom actor should be removed successfully
@phase1
@@ -151,3 +148,29 @@ Feature: Database Repository Error Handling Coverage
Given I have an actor repository with database session
When I set the default actor to "provider/missing"
Then the actor repository should raise when setting default for missing actor
@phase1
Scenario: ActorPreferencesModel get_default_name returns None when no preference is set
Given I have an actor repository with database session
When I read the default actor name preference
Then the default actor name preference should be None
@phase1
Scenario: ActorPreferencesModel set_default_name creates singleton row and returns name
Given I have an actor repository with database session
When I set the default actor name preference to "openai/gpt-4o"
Then the default actor name preference should be "openai/gpt-4o"
@phase1
Scenario: ActorPreferencesModel set_default_name updates existing singleton row
Given I have an actor repository with database session
When I set the default actor name preference to "openai/gpt-4o"
And I set the default actor name preference to "anthropic/claude"
Then the default actor name preference should be "anthropic/claude"
@phase1
Scenario: ActorPreferencesModel get_default_name falls back to legacy is_default actor row
Given I have an actor repository with database session
And I have stored a default actor named "local/legacy-default"
When I read the default actor name preference
Then the default actor name preference should be "local/legacy-default"
+6 -19
View File
@@ -12,7 +12,6 @@ from typing import Any
from unittest.mock import MagicMock
from cleveragents.actor.registry import ActorRegistry
from cleveragents.domain.models.core.actor import Actor
from cleveragents.providers.registry import ProviderCapabilities
@@ -51,29 +50,17 @@ def make_registry(
The ``ActorService`` is mocked so there is no database dependency, but
the ``ActorRegistry`` code (including ``_actor_name()``) runs for real.
Actors successfully created by ``upsert_actor`` are captured so that
``list_actors`` returns them ensuring Scenario 3 ("valid JSON") can
detect when the bug is actually fixed.
After the virtual built-in refactor (issue #10923), built-in actors are
resolved in-memory from the provider registry and are never upserted.
The mock service's ``list_actors`` returns an empty list (no custom actors
in DB); virtual built-ins are produced by the registry itself.
"""
provider_reg = FakeProviderRegistry(providers=providers or [])
captured_actors: list[Actor] = []
def _capturing_upsert(**kwargs: Any) -> Actor:
actor = Actor(
name=kwargs.get("name", "mock/actor"),
provider=kwargs.get("provider", "mock"),
model=kwargs.get("model", "actor"),
config_blob={},
config_hash=Actor.compute_hash({}),
)
captured_actors.append(actor)
return actor
mock_service = MagicMock()
mock_service.list_actors.side_effect = lambda: list(captured_actors)
mock_service.list_actors.return_value = []
mock_service.get_default_actor.return_value = None
mock_service.upsert_actor.side_effect = _capturing_upsert
mock_service.get_default_actor_name.return_value = None
mock_settings = MagicMock()
mock_settings.resolve_provider_defaults.return_value = MagicMock(
+11 -11
View File
@@ -590,7 +590,7 @@ def step_impl(context):
config=context.actor_config_data,
unsafe=False,
)
mock_actor_registry.upsert_actor.return_value = updated_actor
mock_actor_registry.update_actor.return_value = updated_actor
mock_get_services.return_value = (mock_actor_service, mock_actor_registry)
context.result = context.runner.invoke(
@@ -627,7 +627,7 @@ def step_impl(context):
config=current_actor.config_blob,
unsafe=True,
)
mock_actor_registry.upsert_actor.return_value = updated_actor
mock_actor_registry.update_actor.return_value = updated_actor
mock_get_services.return_value = (mock_actor_service, mock_actor_registry)
context.result = context.runner.invoke(
@@ -649,7 +649,7 @@ def step_impl(context):
mock_actor_registry = MagicMock()
current_actor = _make_actor(name="local/invalid-update")
mock_actor_registry.get_actor.return_value = current_actor
mock_actor_registry.upsert_actor.side_effect = ValidationError("bad update")
mock_actor_registry.update_actor.side_effect = ValidationError("bad update")
mock_get_services.return_value = (mock_actor_service, mock_actor_registry)
context.result = context.runner.invoke(
@@ -774,7 +774,7 @@ def step_impl(context):
model=current_actor.model,
config=current_actor.config_blob,
)
mock_actor_registry.upsert_actor.return_value = updated_actor
mock_actor_registry.update_actor.return_value = updated_actor
mock_get_services.return_value = (mock_actor_service, mock_actor_registry)
context.result = context.runner.invoke(
@@ -816,7 +816,7 @@ def step_impl(context):
model=current_actor.model,
config=current_actor.config_blob,
)
mock_actor_registry.upsert_actor.return_value = updated_actor
mock_actor_registry.update_actor.return_value = updated_actor
mock_get_services.return_value = (mock_actor_service, mock_actor_registry)
context.result = context.runner.invoke(
@@ -1090,7 +1090,7 @@ def step_impl(context):
assert context.result.exit_code == 0
expected_config = dict(context.actor_config_data)
expected_config.setdefault("unsafe", False)
context.mock_actor_registry.upsert_actor.assert_called_once_with(
context.mock_actor_registry.update_actor.assert_called_once_with(
name=context.current_actor.name,
provider=None,
model=None,
@@ -1098,8 +1098,8 @@ def step_impl(context):
graph_descriptor=context.current_actor.graph_descriptor,
unsafe=False,
set_default=False,
is_built_in=context.current_actor.is_built_in,
allow_unsafe=False,
option_overrides=None,
)
@@ -1110,7 +1110,7 @@ def step_impl(context):
expected_config.setdefault("provider", context.current_actor.provider)
expected_config.setdefault("model", context.current_actor.model)
expected_config.setdefault("unsafe", True)
context.mock_actor_registry.upsert_actor.assert_called_once_with(
context.mock_actor_registry.update_actor.assert_called_once_with(
name=context.current_actor.name,
provider=context.current_actor.provider,
model=context.current_actor.model,
@@ -1118,16 +1118,16 @@ def step_impl(context):
graph_descriptor=context.current_actor.graph_descriptor,
unsafe=True,
set_default=False,
is_built_in=context.current_actor.is_built_in,
allow_unsafe=True,
option_overrides=None,
)
@then("the actor update should include option overrides")
def step_impl(context):
assert context.result.exit_code == 0
context.mock_actor_registry.upsert_actor.assert_called_once()
call_kwargs = context.mock_actor_registry.upsert_actor.call_args.kwargs
context.mock_actor_registry.update_actor.assert_called_once()
call_kwargs = context.mock_actor_registry.update_actor.call_args.kwargs
assert call_kwargs.get("option_overrides") == context.expected_option_overrides
options_blob = call_kwargs.get("config_blob", {}).get("options", {})
for key, value in context.expected_option_overrides.items():
+1 -1
View File
@@ -360,7 +360,7 @@ def step_update_format_json(context: Any) -> None:
model=current.model,
config=current.config_blob,
)
mock_registry.upsert_actor.return_value = updated
mock_registry.update_actor.return_value = updated
mock_svc.return_value = (mock_service, mock_registry)
context.result = context.runner.invoke(
actor_app,
+42 -46
View File
@@ -14,6 +14,11 @@ must include exactly one '/' separator")``.
The fix sanitises both provider and model names by replacing ``/`` with
``-`` and lowercasing the result.
After the virtual built-in refactor (issue #10923), actor list is now
entirely read-only: built-in actors are returned as virtual objects
directly from the provider registry without any ``upsert_actor`` calls.
The name-sanitisation logic is exercised through the virtual-actor path.
"""
from __future__ import annotations
@@ -26,7 +31,6 @@ from typer.testing import CliRunner
from cleveragents.actor.registry import ActorRegistry
from cleveragents.cli.commands.actor import app as actor_app
from cleveragents.domain.models.core.actor import Actor
from features.mocks.fake_provider import FakeProviderInfo, FakeProviderRegistry
runner = CliRunner()
@@ -45,29 +49,17 @@ def _make_real_registry(
) -> tuple[MagicMock, Any]:
"""Build a real ``ActorRegistry`` with mocked service/settings.
Actors created by ``upsert_actor`` are captured so that
``list_actors`` returns them matching the pattern in
``features/mocks/fake_provider.make_registry()``.
After the virtual built-in refactor, list_actors() resolves built-in
actors directly from the provider registry (no DB writes). The mock
service's ``list_actors`` returns an empty list (no custom actors in DB),
and virtual built-ins are produced in-memory by the registry.
"""
provider_reg = FakeProviderRegistry(providers=providers)
captured_actors: list[Actor] = []
def _capturing_upsert(**kwargs: Any) -> Actor:
actor = Actor(
name=kwargs.get("name", "mock/actor"),
provider=kwargs.get("provider", "mock"),
model=kwargs.get("model", "actor"),
config_blob={},
config_hash=Actor.compute_hash({}),
)
captured_actors.append(actor)
return actor
mock_service = MagicMock()
mock_service.list_actors.side_effect = lambda: list(captured_actors)
mock_service.list_actors.return_value = []
mock_service.get_default_actor.return_value = None
mock_service.upsert_actor.side_effect = _capturing_upsert
mock_service.get_default_actor_name.return_value = None
mock_settings = MagicMock()
mock_settings.resolve_provider_defaults.return_value = MagicMock(
@@ -89,12 +81,7 @@ def _make_real_registry(
@given("the actor registry has no configured providers for actor-list-empty")
def step_no_providers(context: Any) -> None:
"""Set up a real ``ActorRegistry`` with zero configured providers.
Uses ``_make_real_registry([])`` so the production code path through
``ensure_built_in_actors()`` is exercised (returns ``[]`` naturally)
rather than a fully-mocked registry that bypasses it.
"""
"""Set up a real ``ActorRegistry`` with zero configured providers."""
service, registry = _make_real_registry([])
context.actor_empty_service = service
context.actor_empty_registry = registry
@@ -119,9 +106,8 @@ def step_multi_slash_provider(context: Any) -> None:
"""Set up a real registry that will attempt to build an actor name
with multiple slashes (e.g. ``Openrouter/anthropic/claude-...``).
We use the real ``ActorRegistry`` to exercise the actual code path.
The actor service and provider registry are mocked to isolate the
name-construction logic.
We use the real ``ActorRegistry`` to exercise the actual name-sanitisation
logic through the virtual-actor path (``_resolve_virtual_builtin_actors``).
"""
fake_provider = FakeProviderInfo(
name="Openrouter",
@@ -141,11 +127,10 @@ def step_multi_slash_provider(context: Any) -> None:
def step_run_actor_list(context: Any) -> None:
"""Invoke ``actor list`` with the prepared mocks.
Built-in actors are populated via ``ensure_built_in_actors()``
**before** the list call because ``list_actors()`` is now read-only
and no longer triggers writes (bug #797 fix).
After the virtual built-in refactor, ``list_actors()`` is entirely
read-only it resolves built-in actors in-memory without any DB writes.
No pre-population step is required.
"""
context.actor_empty_registry.ensure_built_in_actors()
with patch(
_PATCH_GET_SERVICES,
return_value=(
@@ -193,24 +178,35 @@ def step_exit_code(context: Any, code: int) -> None:
@then("the upserted actor-list-empty actor name should contain exactly one slash")
def step_upserted_name_single_slash(context: Any) -> None:
"""Verify that every name passed to ``upsert_actor`` has exactly one ``/``."""
service = context.actor_empty_service
assert service.upsert_actor.call_count > 0, "upsert_actor was never called"
for call in service.upsert_actor.call_args_list:
name = call.kwargs.get("name") or call.args[0]
slash_count = name.count("/")
"""Verify that virtual built-in actors listed have exactly one ``/`` in name.
After the virtual built-in refactor, names are now validated through the
virtual-actor path rather than through ``upsert_actor``. We call
``list_actors()`` directly on the registry and inspect the returned names.
"""
actors = context.actor_empty_registry.list_actors()
assert len(actors) > 0, (
"list_actors() returned no actors — expected virtual built-ins"
)
for actor in actors:
slash_count = actor.name.count("/")
assert slash_count == 1, (
f"Expected exactly 1 slash in actor name '{name}', found {slash_count}"
f"Expected exactly 1 slash in actor name '{actor.name}', found {slash_count}"
)
@then('the upserted actor-list-empty actor name should be "{expected_name}"')
def step_upserted_name_exact(context: Any, expected_name: str) -> None:
"""Verify that the first ``upsert_actor`` call used the expected name."""
service = context.actor_empty_service
assert service.upsert_actor.call_count > 0, "upsert_actor was never called"
first_call = service.upsert_actor.call_args_list[0]
actual = first_call.kwargs.get("name") or first_call.args[0]
assert actual == expected_name, (
f"Expected actor name '{expected_name}', got '{actual}'"
"""Verify that the first virtual actor returned by list_actors has the expected name.
After the virtual built-in refactor, names come from the virtual-actor
resolution path rather than from ``upsert_actor`` calls.
"""
actors = context.actor_empty_registry.list_actors()
assert len(actors) > 0, (
"list_actors() returned no actors — expected virtual built-ins"
)
actual = actors[0].name
assert actual == expected_name, (
f"Expected first actor name '{expected_name}', got '{actual}'"
)
@@ -54,7 +54,6 @@ class _FakeActorService:
graph_descriptor: dict[str, Any] | None,
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,
@@ -71,7 +70,7 @@ class _FakeActorService:
schema_version=schema_version or "1.0",
compiled_metadata=compiled_metadata,
unsafe=unsafe,
is_built_in=is_built_in,
is_built_in=False,
is_default=False,
)
self.actors[name] = actor
@@ -86,7 +85,6 @@ class _FakeActorService:
"graph_descriptor": graph_descriptor,
"unsafe": unsafe,
"set_default": set_default,
"is_built_in": is_built_in,
}
)
return actor
@@ -96,6 +94,9 @@ class _FakeActorService:
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:
@@ -105,8 +106,16 @@ class _FakeActorService:
v.is_default = v.name == name
return actor
def get_actor(self, name: str) -> Actor | None:
return self.actors.get(name)
def set_default_actor_name(self, name: str) -> None:
self.default_actor_name = name
def get_actor(self, name: str) -> Actor:
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())
@@ -216,7 +225,8 @@ def step_preexisting_default(context: Context, name: str) -> None:
@when("I run ensure_built_in_actors")
def step_run_ensure(context: Context) -> None:
context.generated = context.reg.ensure_built_in_actors()
"""Resolve virtual built-in actors (replaces old ensure_built_in_actors)."""
context.generated = context.reg._resolve_virtual_builtin_actors()
@when(
@@ -295,13 +305,12 @@ def step_call_get_actor(context: Context, name: str) -> None:
@when("I call registry ensure_built_in_actors")
def step_call_ensure_built_in_actors(context: Context) -> None:
"""Explicitly populate built-in actors from configured providers.
"""No-op after virtual built-in refactor.
Since ``list_actors()`` is now read-only (bug #797 fix), built-in
actors must be populated via a separate ``ensure_built_in_actors()``
call before they appear in listings.
Virtual built-in actors are now always available in ``list_actors()``
directly no pre-population step is required. This step is kept so
existing feature scenarios continue to parse correctly.
"""
context.reg.ensure_built_in_actors()
@when("I call registry list_actors")
@@ -311,7 +320,13 @@ def step_call_list_actors(context: Context) -> None:
@when('I call registry remove_actor with "{name}"')
def step_call_remove_actor(context: Context, name: str) -> None:
context.reg.remove_actor(name)
from cleveragents.core.exceptions import NotFoundError, ValidationError
context.remove_actor_error = None
try:
context.reg.remove_actor(name)
except (ValidationError, NotFoundError) as exc:
context.remove_actor_error = exc
@when('I call registry set_default_actor with "{name}"')
@@ -357,6 +372,11 @@ def step_generated_empty(context: Context) -> None:
@then("no actors should exist in the service")
def step_no_actors_in_service(context: Context) -> None:
"""After virtual built-in refactor, built-in actors are never persisted.
Virtual actors don't live in service.actors. This step only checks
that no custom (DB-persisted) actors exist.
"""
assert len(context.fake_actor_service.actors) == 0
@@ -369,9 +389,21 @@ def step_generated_actors_names(context: Context, expected: str) -> None:
@then('the service default actor should be "{expected}"')
def step_service_default(context: Context, expected: str) -> None:
actor = context.fake_actor_service.get_default_actor()
assert actor is not None, "No default actor was set"
assert actor.name == expected, f"Expected default {expected!r}, got {actor.name!r}"
"""Check default actor name stored in service preferences.
After the virtual built-in refactor, auto-default selection during
_resolve_virtual_builtin_actors() was removed. The preference name
stored via set_default_actor_name() is the source of truth.
For scenarios that pre-populate a default actor directly into the fake
service (via a pre-existing default actor step), the default_actor_name
attribute on the fake service is checked directly.
"""
pref = context.fake_actor_service.get_default_actor_name()
assert pref is not None, (
f"Expected default actor name to be {expected!r} but no preference was set"
)
assert pref == expected, f"Expected default {expected!r}, got {pref!r}"
@then('the upserted actor should have name "{expected}"')
@@ -453,8 +485,32 @@ def step_returned_list_contains(context: Context, expected: str) -> None:
assert actual == expected_list, f"Expected {expected_list}, got {actual}"
@then("the remove_actor call should have raised a ValidationError")
def step_remove_actor_raised_validation_error(context: Context) -> None:
"""Verify that the remove_actor call raised a ValidationError.
Used to assert that removing a virtual built-in actor is correctly
rejected with a ValidationError (not silently swallowed).
"""
from cleveragents.core.exceptions import ValidationError
assert context.remove_actor_error is not None, (
"Expected remove_actor to raise ValidationError but no error was captured"
)
assert isinstance(context.remove_actor_error, ValidationError), (
f"Expected ValidationError, got {type(context.remove_actor_error)}: "
f"{context.remove_actor_error}"
)
@then('the service should no longer contain actor "{name}"')
def step_service_no_actor(context: Context, name: str) -> None:
"""Verify that a custom actor is no longer in the service's persistent store.
This step is only meaningful for custom (local/) actors that were
actually removed via remove_actor(). Virtual built-in actors are
never in service.actors to begin with.
"""
assert name not in context.fake_actor_service.actors
@@ -464,22 +520,29 @@ def step_returned_default_name(context: Context, expected: str) -> None:
assert context.returned_default.name == expected
@then("the returned default actor should be None")
def step_returned_default_none(context: Context) -> None:
assert context.returned_default is None, (
f"Expected None default actor but got: {context.returned_default}"
)
@then('the built-in actor "{name}" should have graph descriptor with source "{source}"')
def step_builtin_graph_source(context: Context, name: str, source: str) -> None:
payloads = context.fake_actor_service.upsert_payloads
payload = next((p for p in payloads if p["name"] == name), None)
assert payload is not None, f"Actor {name!r} not found in payloads"
gd = payload["graph_descriptor"]
assert gd is not None, "graph_descriptor is None"
assert gd["source"] == source
"""Check virtual built-in actor has the expected graph descriptor source."""
actor = context.reg.get_actor(name)
assert actor is not None, f"Virtual actor {name!r} not found via registry"
gd = actor.graph_descriptor
assert gd is not None, f"graph_descriptor is None on actor {name!r}"
assert gd["source"] == source, f"Expected source {source!r}, got {gd['source']!r}"
@then('the built-in actor "{name}" config blob should include capabilities')
def step_builtin_has_capabilities(context: Context, name: str) -> None:
payloads = context.fake_actor_service.upsert_payloads
payload = next((p for p in payloads if p["name"] == name), None)
assert payload is not None, f"Actor {name!r} not found in payloads"
blob = payload["config_blob"]
"""Check virtual built-in actor config blob has capabilities."""
actor = context.reg.get_actor(name)
assert actor is not None, f"Virtual actor {name!r} not found via registry"
blob = actor.config_blob
assert "capabilities" in blob, f"capabilities missing from blob: {blob}"
assert blob["capabilities"] is not None
@@ -140,7 +140,8 @@ def step_registry_no_providers(context: Context) -> None:
@when("I ensure built-in actors")
def step_ensure_built_ins(context: Context) -> None:
context.result = context.registry.ensure_built_in_actors()
"""Resolve virtual built-in actors (replaces old ensure_built_in_actors)."""
context.result = context.registry._resolve_virtual_builtin_actors()
@then("the registry result should be an empty list")
@@ -155,9 +156,8 @@ def step_registry_with_providers(context: Context) -> None:
context.provider_registry = MagicMock()
context.provider_registry.get_configured_providers.return_value = [info]
context.actor_service.get_default_actor.return_value = None
actor = _make_actor("openai/gpt-4")
context.actor_service.upsert_actor.return_value = actor
context.actor_service.get_default_actor_name.return_value = None
context.actor_service.list_actors.return_value = []
context.settings = _make_settings()
context.registry = ActorRegistry(
@@ -169,12 +169,21 @@ def step_registry_with_providers(context: Context) -> None:
@then("the result should contain created actors")
def step_assert_actors_created(context: Context) -> None:
"""Verify virtual built-in actors are resolved from the provider registry."""
assert len(context.result) > 0
@then("the default actor should be set")
def step_assert_default_set(context: Context) -> None:
context.actor_service.set_default_actor.assert_called()
"""After virtual refactor, ensure_built_in_actors no longer auto-sets default.
The result should contain virtual built-in actors (is_built_in=True).
Auto-default-setting behavior was removed set_default_actor is now
called only when the user explicitly runs ``agents actor set-default``.
"""
assert len(context.result) > 0, (
"Expected virtual built-in actors to be resolved, got empty list"
)
@given("a stubbed actor registry with configured providers and provider defaults")
@@ -184,9 +193,8 @@ def step_registry_with_defaults(context: Context) -> None:
context.provider_registry = MagicMock()
context.provider_registry.get_configured_providers.return_value = [info]
context.actor_service.get_default_actor.return_value = None
actor = _make_actor("anthropic/claude-3", provider="anthropic", model="claude-3")
context.actor_service.upsert_actor.return_value = actor
context.actor_service.get_default_actor_name.return_value = None
context.actor_service.list_actors.return_value = []
context.settings = _make_settings(provider="anthropic", model="claude-3")
context.registry = ActorRegistry(
@@ -198,7 +206,13 @@ def step_registry_with_defaults(context: Context) -> None:
@then("the preferred provider should be set as default")
def step_assert_preferred_default(context: Context) -> None:
context.actor_service.set_default_actor.assert_called()
"""After virtual refactor, ensure_built_in_actors no longer auto-sets default.
Verify virtual actors are resolved (the preferred provider appears in list).
"""
assert len(context.result) > 0, (
"Expected virtual built-in actors to be resolved, got empty list"
)
@when('I upsert a custom actor with name "local/test" and valid config')
@@ -236,11 +250,18 @@ def step_assert_validation_error(context: Context) -> None:
@when('I get actor "local/test"')
def step_get_actor(context: Context) -> None:
context.registry.get_actor("local/test")
from cleveragents.core.exceptions import NotFoundError
context.get_actor_error = None
try:
context.registry.get_actor("local/test")
except NotFoundError as exc:
context.get_actor_error = exc
@then("the actor service get_actor should be called")
def step_assert_get_called(context: Context) -> None:
"""get_actor delegates to service first (DB lookup)."""
context.actor_service.get_actor.assert_called_with("local/test")
@@ -257,7 +278,13 @@ def step_assert_list_called(context: Context) -> None:
@when('I remove actor "local/test"')
def step_remove_actor(context: Context) -> None:
context.registry.remove_actor("local/test")
from cleveragents.core.exceptions import NotFoundError, ValidationError
context.remove_actor_error = None
try:
context.registry.remove_actor("local/test")
except (NotFoundError, ValidationError) as exc:
context.remove_actor_error = exc
@then("the actor service remove_actor should be called")
@@ -267,8 +294,26 @@ def step_assert_remove_called(context: Context) -> None:
@when('I set default actor "local/test"')
def step_set_default(context: Context) -> None:
context.actor_service.set_default_actor.return_value = _make_actor("local/test")
context.registry.set_default_actor("local/test")
from cleveragents.core.exceptions import NotFoundError
# Set up a real Actor so the registry can process it as a non-built-in.
real_actor = Actor(
id=1,
name="local/test",
provider="test",
model="test-model",
config_blob={},
config_hash=Actor.compute_hash({}),
is_built_in=False,
is_default=False,
)
context.actor_service.get_actor.return_value = real_actor
context.actor_service.set_default_actor.return_value = real_actor
context.set_default_error = None
try:
context.registry.set_default_actor("local/test")
except NotFoundError as exc:
context.set_default_error = exc
@then("the actor service set_default_actor should be called")
@@ -278,10 +323,11 @@ def step_assert_set_default_called(context: Context) -> None:
@when("I get default actor")
def step_get_default(context: Context) -> None:
context.actor_service.get_default_actor.return_value = None
context.actor_service.get_default_actor_name.return_value = None
context.registry.get_default_actor()
@then("the actor service get_default_actor should be called")
def step_assert_get_default_called(context: Context) -> None:
context.actor_service.get_default_actor.assert_called()
"""get_default_actor now reads the preference name first."""
context.actor_service.get_default_actor_name.assert_called()
+17 -6
View File
@@ -53,7 +53,6 @@ class _StubActorService:
graph_descriptor: dict[str, Any] | None,
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,
@@ -70,7 +69,7 @@ class _StubActorService:
schema_version=schema_version or "1.0",
compiled_metadata=compiled_metadata,
unsafe=unsafe,
is_built_in=is_built_in,
is_built_in=False,
is_default=False,
)
self.actors[name] = actor
@@ -85,7 +84,6 @@ class _StubActorService:
"graph_descriptor": graph_descriptor,
"unsafe": unsafe,
"set_default": set_default,
"is_built_in": is_built_in,
}
)
return actor
@@ -95,6 +93,9 @@ class _StubActorService:
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:
@@ -104,8 +105,16 @@ class _StubActorService:
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:
return self.actors.get(name)
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())
@@ -176,12 +185,14 @@ def step_registry_with_no_providers(context: Context) -> None:
@when("I ensure built-in actors are generated")
def step_ensure_built_ins(context: Context) -> None:
context.generated_actors = context.registry.ensure_built_in_actors()
"""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:
assert not context.actor_service.actors
# 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 == []
@@ -132,11 +132,11 @@ def step_actsvc_no_existing_actor(context, name):
' model "{model}" and is_built_in False'
)
def step_actsvc_call_upsert_non_local(context, name, provider, model):
"""Call upsert_actor with a non-local prefix and is_built_in=False.
"""Call upsert_actor with a non-local prefix.
This hits lines 106-107: the inner guard that rejects non-local
prefixes when is_built_in is False, after _normalize_name has
already passed (because allow_built_in=True).
After issue #10923, the is_built_in parameter was removed from
upsert_actor. Non-local prefixes are always rejected with a
ValidationError the guard is now unconditional.
"""
context.actsvc_upsert_error = None
try:
@@ -144,7 +144,6 @@ def step_actsvc_call_upsert_non_local(context, name, provider, model):
name=name,
provider=provider,
model=model,
is_built_in=False,
)
except ValidationError as exc:
context.actsvc_upsert_error = exc
+24 -1
View File
@@ -75,6 +75,12 @@ class _StubActorRepository:
def count(self) -> int:
return len(self._actors)
def get_default_name(self) -> str | None:
return self._default_name
def set_default_name(self, name: str | None) -> None:
self._default_name = name
class _StubTransaction:
def __init__(self, repo: _StubActorRepository) -> None:
@@ -213,6 +219,19 @@ def step_business_rule_violation(context: Context) -> None:
assert isinstance(context.error, BusinessRuleViolation), type(context.error)
@then("a ValidationError should be raised for non-local namespace")
def step_validation_error_non_local_namespace(context: Context) -> None:
"""After issue #10923, non-local namespace actors raise ValidationError.
The service rejects non-local custom actors with a ValidationError
(previously raised BusinessRuleViolation for built-in overwrite attempt;
now no built-in actors exist in DB so the check is namespace-based).
"""
assert isinstance(context.error, ValidationError), (
f"Expected ValidationError, got {type(context.error)}: {context.error}"
)
@given('an actor named "{name}" exists')
def step_actor_exists(context: Context, name: str) -> None:
context.actor_repo.upsert(_make_actor(name))
@@ -230,7 +249,11 @@ def step_stored_actor_default(context: Context, name: str) -> None:
actor = context.actor_repo.get_by_name(name)
assert actor is not None, "Actor not found"
assert actor.is_default, "Actor was not marked as default"
assert context.actor_repo.get_default() is actor
default = context.actor_repo.get_default()
assert default is not None, "No default actor set in repository"
assert default.name == actor.name, (
f"Expected default actor '{actor.name}', got '{default.name}'"
)
@when('I try to remove actor "{name}"')
+26 -22
View File
@@ -61,42 +61,46 @@ def step_configured_provider(context: Context, provider_model: str) -> None:
@given("the built-in actors have been ensured")
def step_builtin_actors_ensured_as_given(context: Context) -> None:
"""Ensure built-in actors as a precondition and set current actor."""
context.registry.ensure_built_in_actors()
if context.actor_service.actors:
context.current_actor = next(iter(context.actor_service.actors.values()))
"""Resolve virtual built-in actors as a precondition and set current actor.
After issue #10923, built-in actors are virtual (never persisted to DB).
We use _resolve_virtual_builtin_actors() instead of ensure_built_in_actors().
"""
virtual_actors = context.registry._resolve_virtual_builtin_actors()
if virtual_actors:
context.current_actor = virtual_actors[0]
@when("built-in actors are ensured")
def step_ensure_builtin_actors(context: Context) -> None:
"""Call ensure_built_in_actors on the registry."""
context.generated_actors = context.registry.ensure_built_in_actors()
if context.actor_service.actors:
context.current_actor = next(iter(context.actor_service.actors.values()))
"""Resolve virtual built-in actors via the registry.
After issue #10923, built-in actors are virtual (never persisted to DB).
"""
context.generated_actors = context.registry._resolve_virtual_builtin_actors()
if context.generated_actors:
context.current_actor = context.generated_actors[0]
@when('I call agents actor run with mock "{actor_name}"')
def step_call_actor_run_with_mock(context: Context, actor_name: str) -> None:
"""Simulate the actor-lookup phase of ``agents actor run`` using stub state.
"""Simulate the actor-lookup phase of ``agents actor run`` using registry.
This step intentionally does NOT invoke the real CLI or any LLM. It only
exercises the part that matters for this feature: that the stub actor
service contains the actor and that ``context.current_actor`` is set so
subsequent assertion steps can inspect the v3 YAML.
exercises the actor-lookup path: virtual built-in actors are resolved
from the provider registry (not from the stub service's actors dict).
"""
actor = context.actor_service.get_actor(actor_name)
assert actor is not None, (
f"Actor '{actor_name}' not found in stub service after ensure_built_in_actors"
)
actor = context.registry.get_actor(actor_name)
assert actor is not None, f"Actor '{actor_name}' not found via registry.get_actor()"
context.current_actor = actor
@then("the {actor_name} actor should have yaml_text")
def step_actor_has_yaml_text(context: Context, actor_name: str) -> None:
"""Verify the actor has yaml_text populated."""
"""Verify the virtual built-in actor has yaml_text populated."""
normalized_name = actor_name.replace("/", "/")
actor = context.actor_service.actors.get(normalized_name)
assert actor is not None, f"Actor {normalized_name} not found"
actor = context.registry.get_actor(normalized_name)
assert actor is not None, f"Actor {normalized_name} not found via registry"
assert actor.yaml_text is not None, f"Actor {normalized_name} has no yaml_text"
context.current_actor = actor
@@ -127,10 +131,10 @@ def step_yaml_text_not_contains(context: Context, unexpected_text: str) -> None:
def step_specific_actor_yaml_contains(
context: Context, actor_name: str, expected_text: str
) -> None:
"""Verify a specific actor's yaml_text contains expected substring."""
"""Verify a specific virtual built-in actor's yaml_text contains expected substring."""
normalized_name = actor_name.replace("/", "/")
actor = context.actor_service.actors.get(normalized_name)
assert actor is not None, f"Actor {normalized_name} not found"
actor = context.registry.get_actor(normalized_name)
assert actor is not None, f"Actor {normalized_name} not found via registry"
assert actor.yaml_text is not None, f"Actor {normalized_name} has no yaml_text"
assert expected_text in actor.yaml_text, (
f"Expected '{expected_text}' in yaml_text, got: {actor.yaml_text}"
@@ -811,10 +811,12 @@ def step_try_upsert_over_builtin(context):
context.actor_upsert_error = exc
@then("the actor repository should raise when overwriting built-in actor")
def step_verify_builtin_overwrite_error(context):
"""Ensure overwriting built-in actor is rejected."""
assert isinstance(context.actor_upsert_error, ValueError)
@then("the actor repository should succeed when upserting over any actor")
def step_verify_upsert_allowed(context) -> None:
"""After issue #10923, the repository allows upserting over any DB actor."""
assert context.actor_upsert_error is None, (
f"Expected upsert to succeed but got error: {context.actor_upsert_error}"
)
@given('I have stored a custom actor named "{name}"')
@@ -850,20 +852,31 @@ def step_verify_actor_update(context):
@when('I upsert a built-in actor named "{name}"')
def step_upsert_built_in_actor(context, name: str):
"""Use upsert_built_in to persist actor."""
def step_upsert_built_in_actor(context, name: str) -> None:
"""Upsert a custom actor (upsert_built_in was removed in issue #10923).
Built-in actors are now virtual (never persisted). This step persists
the actor as a regular custom actor via ``upsert()`` to preserve the
repository coverage path.
"""
repo = _ensure_actor_repo(context)
actor = _make_actor(name)
context.built_in_actor = repo.upsert_built_in(actor)
context.built_in_actor = repo.upsert(actor)
@then("the actor should be stored as built-in in the repository")
def step_verify_upsert_built_in(context):
"""Verify actor saved via upsert_built_in is marked built-in."""
def step_verify_upsert_built_in(context) -> None:
"""Verify actor was upserted into the repository.
After issue #10923, is_built_in is no longer stored in the database.
This step verifies the actor exists in the repository (custom actors
are still persisted; the is_built_in field is always False for DB rows).
"""
repo = _ensure_actor_repo(context)
stored = repo.get_by_name(context.built_in_actor.name)
assert stored is not None
assert stored.is_built_in is True
# is_built_in is always False for DB-persisted actors after issue #10923
assert stored.is_built_in is False
@given('I have stored a default actor named "{name}"')
@@ -943,3 +956,38 @@ def step_set_default_missing_actor(context, name: str):
def step_verify_set_default_error(context):
"""Ensure setting default on missing actor raises error."""
assert isinstance(context.set_default_error, ValueError)
# ---------------------------------------------------------------------------
# ActorPreferencesModel singleton table coverage (issue #10923)
# ---------------------------------------------------------------------------
@when("I read the default actor name preference")
def step_read_default_name_preference(context) -> None:
"""Read the default actor name from the actor_preferences singleton row."""
repo = _ensure_actor_repo(context)
context.default_name_pref = repo.get_default_name()
@when('I set the default actor name preference to "{name}"')
def step_set_default_name_preference(context, name: str) -> None:
"""Write the default actor name to the actor_preferences singleton row."""
repo = _ensure_actor_repo(context)
repo.set_default_name(name)
@then("the default actor name preference should be None")
def step_verify_default_name_none(context) -> None:
"""Verify that get_default_name() returns None when no preference is set."""
assert context.default_name_pref is None, (
f"Expected None but got: {context.default_name_pref!r}"
)
@then('the default actor name preference should be "{expected}"')
def step_verify_default_name_value(context, expected: str) -> None:
"""Verify that get_default_name() returns the expected actor name."""
repo = _ensure_actor_repo(context)
actual = repo.get_default_name()
assert actual == expected, f"Expected {expected!r} but got {actual!r}"
@@ -645,13 +645,19 @@ def step_errcov_plan_use_testing(context: Context) -> None:
@when("I invoke errcov plan use with actor registry")
def step_errcov_plan_use_with_registry(context: Context) -> None:
"""Test that actor_registry.ensure_built_in_actors() is called."""
"""Test that plan use command runs without actor_registry ensure calls.
After issue #10923, ensure_built_in_actors() was removed. The plan use
command no longer calls it virtual built-in actors are resolved lazily.
"""
from contextlib import suppress
actor_reg = context.errcov_actor_registry
with suppress(Exception):
if actor_reg:
actor_reg.ensure_built_in_actors()
# No longer calls ensure_built_in_actors() — virtual actors are
# resolved on demand via the registry's list/get methods.
pass
context.errcov_output = "done"
@@ -670,7 +676,14 @@ def step_errcov_plan_use_with_testing(context: Context) -> None:
@then("the errcov actor registry ensure_built_in_actors should have been called")
def step_errcov_actor_reg_called(context: Context) -> None:
context.errcov_actor_registry.ensure_built_in_actors.assert_called_once()
"""After issue #10923, ensure_built_in_actors is removed.
This step now just verifies the plan use command completed (errcov_output
was set to 'done'), since the actor registry no longer needs ensure calls.
"""
assert context.errcov_output == "done", (
"Expected plan use with actor registry to complete without error"
)
@then("the errcov container actor_service should have been called")
+3 -2
View File
@@ -1383,7 +1383,9 @@ def step_build_plan_with_actor(context: Context, actor_name: str) -> None:
provider, model = actor_name, "default-model"
actor_service = context.plan_service.actor_service
actor_id = actor_name if "/" in actor_name else f"local/{actor_name}"
# Actors used in plan service tests must be local/ namespace since
# is_built_in was removed from upsert_actor (issue #10923).
actor_id = f"local/{actor_name.replace('/', '-')}"
actor_service.upsert_actor(
name=actor_id,
provider=provider,
@@ -1392,7 +1394,6 @@ def step_build_plan_with_actor(context: Context, actor_name: str) -> None:
graph_descriptor=None,
unsafe=False,
set_default=True,
is_built_in=True,
)
context.changes = context.plan_service.build_plan(
@@ -1,18 +1,20 @@
"""Step definitions for TDD Issue #592 — actor list validation error.
These steps exercise the *real* ``ActorRegistry._actor_name()`` code path
with a provider whose default model name contains ``/`` characters. The
``@tdd_expected_fail`` tag on the scenarios inverts the result: these tests
**pass** CI while the bug is present and will **fail** once the bug is fixed
(signalling that the tag should be removed).
with a provider whose default model name contains ``/`` characters.
Root cause
~~~~~~~~~~
Root cause (fixed)
~~~~~~~~~~~~~~~~~~
``ActorRegistry._actor_name()`` builds names via
``f"{provider}/{model}"``. For providers whose default model already
contains ``/`` (e.g. OpenRouter's ``anthropic/claude-sonnet-4-20250514``),
the result has 2+ slashes. ``ActorService._normalize_name()`` then raises
``ValidationError("Actor names must include exactly one '/' separator")``.
the result has 2+ slashes. The fix sanitises by replacing ``/`` with ``-``
and lowercasing the result.
After the virtual built-in refactor (issue #10923), built-in actors are
resolved in-memory from the provider registry (never persisted). The name
sanitisation now happens in the virtual resolution path rather than via
``ensure_built_in_actors`` + ``upsert_actor``.
"""
from __future__ import annotations
@@ -43,7 +45,7 @@ def step_multi_slash_provider(context: Any) -> None:
The provider name is ``Openrouter`` and the default model is
``anthropic/claude-sonnet-4-20250514`` reproducing the exact
combination that triggers bug #592.
combination that triggered bug #592.
"""
fake_provider = FakeProviderInfo(
name="Openrouter",
@@ -63,11 +65,10 @@ def step_multi_slash_provider(context: Any) -> None:
def step_run_actor_list(context: Any) -> None:
"""Invoke ``actor list`` with the prepared registry.
Built-in actors are populated via ``ensure_built_in_actors()``
**before** the list call because ``list_actors()`` is now read-only
and no longer triggers writes (bug #797 fix).
After the virtual built-in refactor, ``list_actors()`` is entirely
read-only virtual built-in actors are resolved in-memory without
any DB writes.
"""
context.tdd_actor_registry.ensure_built_in_actors()
with patch(
_PATCH_GET_SERVICES,
return_value=(
@@ -80,13 +81,7 @@ def step_run_actor_list(context: Any) -> None:
@when("I run actor list with format json via the tdd-actor-validation CLI")
def step_run_actor_list_json(context: Any) -> None:
"""Invoke ``actor list --format json`` with the prepared registry.
Built-in actors are populated via ``ensure_built_in_actors()``
**before** the list call because ``list_actors()`` is now read-only
and no longer triggers writes (bug #797 fix).
"""
context.tdd_actor_registry.ensure_built_in_actors()
"""Invoke ``actor list --format json`` with the prepared registry."""
with patch(
_PATCH_GET_SERVICES,
return_value=(
@@ -126,19 +121,22 @@ def step_output_not_contains(context: Any, text: str) -> None:
@then("the tdd-actor-validation upserted actor name should contain exactly one slash")
def step_upserted_name_single_slash(context: Any) -> None:
"""Verify that every name passed to ``upsert_actor`` has exactly one ``/``.
"""Verify that virtual built-in actor names have exactly one ``/``.
With the bug present, ``_actor_name()`` produces names like
``Openrouter/anthropic/claude-sonnet-4-20250514`` (2+ slashes), so this
assertion fails which the ``@tdd_expected_fail`` tag inverts to a pass.
After the virtual built-in refactor, names are produced through the
virtual resolution path (``_resolve_virtual_builtin_actors``). We
check the actors returned by ``list_actors()`` directly instead of
inspecting ``upsert_actor`` call arguments.
"""
service = context.tdd_actor_service
assert service.upsert_actor.call_count > 0, "upsert_actor was never called"
for call in service.upsert_actor.call_args_list:
name = call.kwargs.get("name") or call.args[0]
slash_count = name.count("/")
actors = context.tdd_actor_registry.list_actors()
assert len(actors) > 0, (
"list_actors() returned no actors — expected virtual built-ins"
)
for actor in actors:
slash_count = actor.name.count("/")
assert slash_count == 1, (
f"Expected exactly 1 slash in actor name '{name}', found {slash_count}"
f"Expected exactly 1 slash in actor name '{actor.name}', "
f"found {slash_count}"
)
@@ -38,8 +38,12 @@ def step_run_actor_run_with_builtin(context: Any, prompt: str) -> None:
"""Invoke `agents actor run` with a built-in actor name and prompt."""
context.prompt = prompt
# After the virtual built-in refactor (issue #10923), built-in actors are
# no longer stored in actor_service.actors. Use the registry's virtual
# resolution to find the anthropic actor.
virtual_actors = context.registry._resolve_virtual_builtin_actors()
actor_name = next(
(name for name in context.actor_service.actors if "anthropic" in name.lower()),
(a.name for a in virtual_actors if "anthropic" in a.name.lower()),
None,
)
if not actor_name:
@@ -55,7 +59,8 @@ def step_run_actor_run_with_builtin(context: Any, prompt: str) -> None:
# it hits the real DI container which has no actors in CI.
mock_container = MagicMock()
mock_actor_registry = MagicMock()
mock_actor = context.actor_service.actors[actor_name]
# Resolve the virtual built-in actor from the registry.
mock_actor = context.registry.get_actor(actor_name)
mock_actor_registry.get.return_value = mock_actor
mock_container.actor_registry.return_value = mock_actor_registry
@@ -0,0 +1,272 @@
"""Step definitions for virtual_builtin_actors.feature (issue #10923).
Verifies the virtual built-in actor behavior: actors are resolved on-demand
from the ProviderRegistry without any database writes.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from cleveragents.actor.registry import ActorRegistry
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.actor import Actor
from features.mocks.fake_provider import FakeProviderInfo, FakeProviderRegistry
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_registry_with_providers(
providers: list[FakeProviderInfo],
) -> tuple[MagicMock, ActorRegistry]:
"""Build a real ActorRegistry with a mock service and fake provider registry.
The mock service is configured so that ``get_actor`` raises ``NotFoundError``
for any name (simulating an empty database), allowing virtual built-in
resolution to take over.
"""
from cleveragents.core.exceptions import NotFoundError
provider_reg = FakeProviderRegistry(providers=providers)
mock_service = MagicMock()
mock_service.list_actors.return_value = []
mock_service.get_default_actor.return_value = None
mock_service.get_default_actor_name.return_value = None
# Simulate empty DB — all get_actor calls raise NotFoundError
mock_service.get_actor.side_effect = lambda name: (_ for _ in ()).throw(
NotFoundError(resource_type="actor", resource_id=name)
)
mock_settings = MagicMock()
mock_settings.resolve_provider_defaults.return_value = MagicMock(
provider=None, model=None
)
registry = ActorRegistry(
actor_service=mock_service,
provider_registry=provider_reg,
settings=mock_settings,
)
return mock_service, registry
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given("a virtual actor registry with configured providers:")
def step_virtual_registry_with_providers(context: Context) -> None:
"""Build registry with providers from the data table."""
providers = []
for row in context.table:
providers.append(
FakeProviderInfo(
name=row["name"],
default_model=row["model"],
)
)
mock_service, registry = _make_registry_with_providers(providers)
context.virtual_mock_service = mock_service
context.virtual_registry = registry
@given("a virtual actor registry with no configured providers")
def step_virtual_registry_no_providers(context: Context) -> None:
"""Build registry with no configured providers."""
mock_service, registry = _make_registry_with_providers([])
context.virtual_no_provider_service = mock_service
context.virtual_no_provider_registry = registry
@given('the default actor preference is set to "{name}"')
def step_set_default_preference(context: Context, name: str) -> None:
"""Pre-configure the mock service to return a stored default name."""
context.virtual_mock_service.get_default_actor_name.return_value = name
@given('a custom DB actor named "{name}" exists')
def step_custom_db_actor_exists(context: Context, name: str) -> None:
"""Add a custom (non-built-in) actor to the service's DB list."""
provider_part, model_part = [*name.split("/", 1), ""][:2]
custom = Actor(
name=name,
provider=provider_part,
model=model_part,
config_blob={"custom": True},
config_hash=Actor.compute_hash({"custom": True}),
is_built_in=False,
)
# Override list_actors to include this custom actor
context.virtual_mock_service.list_actors.return_value = [custom]
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when("I list virtual actors")
def step_list_virtual_actors(context: Context) -> None:
"""Invoke list_actors() on the registry."""
context.virtual_actor_list = context.virtual_registry.list_actors()
@when("I list virtual actors via no-provider registry")
def step_list_no_provider_actors(context: Context) -> None:
context.virtual_no_provider_list = (
context.virtual_no_provider_registry.list_actors()
)
@when('I show virtual actor "{name}"')
def step_show_virtual_actor(context: Context, name: str) -> None:
"""Resolve an actor by name via registry.get()."""
context.virtual_actor_result = context.virtual_registry.get(name)
@when('I try to remove virtual actor "{name}"')
def step_try_remove_virtual_actor(context: Context, name: str) -> None:
"""Attempt to remove a virtual built-in actor."""
context.virtual_remove_error = None
try:
context.virtual_registry.remove(name)
except ValidationError as exc:
context.virtual_remove_error = exc
@when('I set default virtual actor to "{name}"')
def step_set_default_virtual_actor(context: Context, name: str) -> None:
"""Call set_default_actor on the registry with a virtual built-in name."""
context.virtual_actor_result = context.virtual_registry.set_default_actor(name)
@when("I get the default virtual actor")
def step_get_default_virtual_actor(context: Context) -> None:
"""Call get_default_actor on the registry."""
context.virtual_actor_result = context.virtual_registry.get_default_actor()
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then('the virtual actor list should contain "{name}"')
def step_virtual_list_contains(context: Context, name: str) -> None:
actors = context.virtual_actor_list
names = [a.name for a in actors]
assert name in names, f"Expected '{name}' in actor list {names}"
@then('the virtual actor list should show built-in flag for "{name}"')
def step_virtual_list_builtin_flag(context: Context, name: str) -> None:
actors = context.virtual_actor_list
actor = next((a for a in actors if a.name == name), None)
assert actor is not None, f"Actor '{name}' not found in list"
assert actor.is_built_in is True, (
f"Expected is_built_in=True for '{name}', got {actor.is_built_in}"
)
@then("no database writes should have occurred")
def step_no_db_writes(context: Context) -> None:
service = context.virtual_mock_service
assert service.upsert_actor.call_count == 0, (
f"Expected no upsert_actor calls, got {service.upsert_actor.call_count}"
)
@then('the virtual actor result should have name "{name}"')
def step_virtual_result_name(context: Context, name: str) -> None:
actor = context.virtual_actor_result
assert actor is not None, "Expected an actor result but got None"
assert actor.name == name, f"Expected name '{name}', got '{actor.name}'"
@then("the virtual actor result should be marked as built-in")
def step_virtual_result_builtin(context: Context) -> None:
actor = context.virtual_actor_result
assert actor.is_built_in is True, (
f"Expected is_built_in=True, got {actor.is_built_in}"
)
@then("the virtual actor result should be marked as default")
def step_virtual_result_default(context: Context) -> None:
actor = context.virtual_actor_result
assert actor.is_default is True, f"Expected is_default=True, got {actor.is_default}"
@then("a virtual actor ValidationError should be raised")
def step_virtual_validation_error(context: Context) -> None:
assert context.virtual_remove_error is not None, (
"Expected a ValidationError but no error was raised"
)
assert isinstance(context.virtual_remove_error, ValidationError)
@then('the virtual builtin error message should mention "{text}"')
def step_error_message_contains(context: Context, text: str) -> None:
error = context.virtual_remove_error
assert error is not None, "No error to check message for"
assert text.lower() in str(error).lower(), (
f"Expected '{text}' in error message, got: {error}"
)
@then('the virtual actor default name should be stored as "{name}"')
def step_default_name_stored(context: Context, name: str) -> None:
service = context.virtual_mock_service
service.set_default_actor_name.assert_called_once_with(name)
@then("no actor row should be created in the database")
def step_no_actor_row_created(context: Context) -> None:
service = context.virtual_mock_service
assert service.upsert_actor.call_count == 0, (
f"Expected no upsert_actor calls, got {service.upsert_actor.call_count}"
)
assert service.set_default_actor.call_count == 0, (
"Expected no set_default_actor (DB path) calls for virtual built-in"
)
@then("the virtual actor service upsert_actor should not have been called")
def step_upsert_not_called(context: Context) -> None:
service = context.virtual_mock_service
assert service.upsert_actor.call_count == 0, (
f"upsert_actor was called {service.upsert_actor.call_count} time(s)"
)
@then("the virtual actor service set_default_actor should not have been called")
def step_set_default_actor_not_called(context: Context) -> None:
service = context.virtual_mock_service
assert service.set_default_actor.call_count == 0, (
f"set_default_actor was called {service.set_default_actor.call_count} time(s)"
)
@then("the virtual no-provider actor list should be empty")
def step_no_provider_list_empty(context: Context) -> None:
assert context.virtual_no_provider_list == [], (
f"Expected empty list, got {context.virtual_no_provider_list}"
)
@then('the "{name}" actor in list should not be built-in')
def step_actor_not_builtin_in_list(context: Context, name: str) -> None:
actors = context.virtual_actor_list
actor = next((a for a in actors if a.name == name), None)
assert actor is not None, f"Actor '{name}' not found in list"
assert actor.is_built_in is False, (
f"Expected is_built_in=False for '{name}' (custom actor should win)"
)
+57
View File
@@ -0,0 +1,57 @@
@virtual_builtin
Feature: Virtual built-in actors resolved on-demand from provider registry
As an agent user
I want built-in actors to appear immediately after init (when providers are configured)
So that I can run actors without prior write operations
Background:
Given a virtual actor registry with configured providers:
| type | name | model |
| openai | openai | gpt-4o |
| anthropic | anthropic | claude-3-opus |
Scenario: Actor list shows built-in actors immediately after init
When I list virtual actors
Then the virtual actor list should contain "openai/gpt-4o"
And the virtual actor list should contain "anthropic/claude-3-opus"
And the virtual actor list should show built-in flag for "openai/gpt-4o"
And no database writes should have occurred
Scenario: Actor show works for virtual built-in without prior materialization
When I show virtual actor "openai/gpt-4o"
Then the virtual actor result should have name "openai/gpt-4o"
And the virtual actor result should be marked as built-in
And no database writes should have occurred
Scenario: Actor remove rejects virtual built-in with clear error
When I try to remove virtual actor "openai/gpt-4o"
Then a virtual actor ValidationError should be raised
And the virtual builtin error message should mention "built-in"
Scenario: Actor set-default persists name for virtual built-in without DB row
When I set default virtual actor to "openai/gpt-4o"
Then the virtual actor default name should be stored as "openai/gpt-4o"
And no actor row should be created in the database
Scenario: Actor get-default resolves virtual built-in from stored preference
Given the default actor preference is set to "openai/gpt-4o"
When I get the default virtual actor
Then the virtual actor result should have name "openai/gpt-4o"
And the virtual actor result should be marked as built-in
And the virtual actor result should be marked as default
Scenario: Actor list shows no DB writes occur
When I list virtual actors
Then the virtual actor service upsert_actor should not have been called
And the virtual actor service set_default_actor should not have been called
Scenario: Actor list with no configured providers returns empty
Given a virtual actor registry with no configured providers
When I list virtual actors via no-provider registry
Then the virtual no-provider actor list should be empty
Scenario: Custom actor takes precedence over virtual built-in with same name
Given a custom DB actor named "openai/gpt-4o" exists
When I list virtual actors
Then the virtual actor list should contain "openai/gpt-4o"
And the "openai/gpt-4o" actor in list should not be built-in
@@ -104,13 +104,42 @@ def check_no_set_default() -> None:
print("tdd-actor-list-no-db-update-no-set-default-ok")
def check_no_set_default_name() -> None:
"""Verify that ``actor list`` does not call ``set_default_actor_name``.
After the virtual built-in refactor (issue #10923), ``set_default_actor_name``
is the new preference-persistence method. A read-only ``actor list``
must not call it either.
Exits 0 with sentinel when ``set_default_actor_name`` was NOT called
(correct behaviour). Exits 1 when it WAS called (regression).
"""
mock_service, registry = make_registry(_DEFAULT_PROVIDERS)
with patch(
_PATCH_TARGET,
return_value=(mock_service, registry),
):
runner.invoke(actor_app, ["list"])
call_count: int = mock_service.set_default_actor_name.call_count
if call_count > 0:
_fail(
f"set_default_actor_name was called {call_count} time(s) during "
f"'actor list'. A read-only list command must not trigger "
f"database writes."
)
print("tdd-actor-list-no-db-update-no-set-default-name-ok")
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
_COMMANDS: dict[str, Callable[[], None]] = {
"check-no-upsert": check_no_upsert,
"check-no-set-default": check_no_set_default,
"check-no-set-default-name": check_no_set_default_name,
}
if __name__ == "__main__":
+31 -53
View File
@@ -2,10 +2,15 @@
Each subcommand exercises the real ``ActorRegistry._actor_name()`` code path
with a provider whose default model contains ``/`` characters to reproduce
bug #592. The helper reports the **real** outcome: it exits 0 and prints
the sentinel when the operation succeeds (bug is fixed), and exits 1 when
the bug is still present. The ``tdd_expected_fail_listener`` on the Robot
side handles pass/fail inversion while the bug remains open.
bug #592.
After the virtual built-in refactor (issue #10923), built-in actors are
resolved in-memory from the provider registry (never persisted). Name
sanitisation is exercised through ``list_actors()`` / the virtual-actor path.
The helper reports the **real** outcome: it exits 0 and prints the sentinel
when the operation succeeds (bug is fixed), and exits 1 when the bug is still
present.
"""
from __future__ import annotations
@@ -51,105 +56,79 @@ _MULTI_SLASH_PROVIDERS = [
def list_validation_error() -> None:
"""Invoke ``actor list`` with a multi-slash model provider.
Exits 0 with sentinel when the command succeeds with valid names (bug
fixed). Exits 1 when the command fails or produces bad names (bug
still present).
Built-in actors are populated via ``ensure_built_in_actors()``
**before** the list call because ``list_actors()`` is now read-only
and no longer triggers writes (bug #797 fix).
After the virtual built-in refactor, built-in actors are resolved
in-memory without DB writes. We verify the command succeeds and
virtual actor names have exactly one slash (sanitised by _actor_name).
"""
mock_service, registry = make_registry(_MULTI_SLASH_PROVIDERS)
registry.ensure_built_in_actors()
assert mock_service.upsert_actor.call_count > 0, (
"upsert_actor was never called — ensure_built_in_actors may not have run"
)
with patch(
_PATCH_GET_SERVICES,
return_value=(mock_service, registry),
):
result = runner.invoke(actor_app, ["list"])
if result.exit_code != 0:
# Bug present — command failed.
print(
f"actor list failed with exit code {result.exit_code}",
file=sys.stderr,
)
sys.exit(1)
# Command succeeded — check if names are valid (single slash).
for call in mock_service.upsert_actor.call_args_list:
name: Any = call.kwargs.get("name", "")
# Command succeeded — check virtual actor names have exactly one slash.
actors = registry.list_actors()
for actor in actors:
name: Any = actor.name
if name.count("/") != 1:
print(
f"actor list produced bad name {name!r} with {name.count('/')} slashes",
file=sys.stderr,
)
sys.exit(1)
# Bug fixed — command succeeded with valid names.
print("tdd-actor-list-validation-error-ok")
def name_slash_check() -> None:
"""Verify that upserted actor names have exactly one ``/``.
"""Verify that virtual built-in actor names have exactly one ``/``.
Exits 0 with sentinel when all names have exactly one slash (bug
fixed). Exits 1 when any name has 0 or 2+ slashes (bug still
present).
Built-in actors are populated via ``ensure_built_in_actors()``
directly because ``list_actors()`` is now read-only and no longer
triggers writes (bug #797 fix).
After the virtual built-in refactor, names come from
``_resolve_virtual_builtin_actors()`` and are never upserted to DB.
"""
mock_service, registry = make_registry(_MULTI_SLASH_PROVIDERS)
registry.ensure_built_in_actors()
assert mock_service.upsert_actor.call_count > 0, (
"upsert_actor was never called — ensure_built_in_actors may not have run"
_, registry = make_registry(_MULTI_SLASH_PROVIDERS)
actors = registry.list_actors()
assert len(actors) > 0, (
"list_actors() returned no actors — expected virtual built-ins"
)
for call in mock_service.upsert_actor.call_args_list:
name: Any = call.kwargs.get("name", "")
for actor in actors:
name: Any = actor.name
if name.count("/") != 1:
# Bug present — name construction produces bad names.
print(
f"actor name {name!r} has {name.count('/')} slashes",
file=sys.stderr,
)
sys.exit(1)
# Bug fixed — all names have exactly one slash.
print("tdd-actor-name-slash-check-ok")
def list_json_validation() -> None:
"""Invoke ``actor list --format json`` with a multi-slash model provider.
Exits 0 with sentinel when the command succeeds with valid names (bug
fixed). Exits 1 when the command fails or produces bad names (bug
still present).
Built-in actors are populated via ``ensure_built_in_actors()``
**before** the list call because ``list_actors()`` is now read-only
and no longer triggers writes (bug #797 fix).
After the virtual built-in refactor, virtual actors are resolved
in-memory and returned by list_actors() without DB writes.
"""
mock_service, registry = make_registry(_MULTI_SLASH_PROVIDERS)
registry.ensure_built_in_actors()
assert mock_service.upsert_actor.call_count > 0, (
"upsert_actor was never called — ensure_built_in_actors may not have run"
)
with patch(
_PATCH_GET_SERVICES,
return_value=(mock_service, registry),
):
result = runner.invoke(actor_app, ["list", "--format", "json"])
if result.exit_code != 0:
# Bug present — command failed.
print(
f"actor list --format json failed with exit code {result.exit_code}",
file=sys.stderr,
)
sys.exit(1)
# Command succeeded — check if names are valid (single slash).
for call in mock_service.upsert_actor.call_args_list:
name: Any = call.kwargs.get("name", "")
# Command succeeded — check virtual actor names have exactly one slash.
actors = registry.list_actors()
for actor in actors:
name: Any = actor.name
if name.count("/") != 1:
print(
f"actor list --format json produced bad name {name!r} with "
@@ -157,7 +136,6 @@ def list_json_validation() -> None:
file=sys.stderr,
)
sys.exit(1)
# Bug fixed — command succeeded with valid names.
print("tdd-actor-list-json-validation-ok")
+15
View File
@@ -37,3 +37,18 @@ TDD Actor List Does Not Call Set Default Actor
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} tdd-actor-list-no-db-update-no-set-default-ok
TDD Actor List Does Not Call Set Default Actor Name
[Documentation] Verify that ``actor list`` does not call
... ``set_default_actor_name()`` on the actor service.
... Issue #10923: after the virtual built-in refactor,
... ``set_default_actor_name`` is the new preference-
... persistence method and must not be called during a
... read-only ``actor list`` operation.
[Tags] tdd_issue tdd_issue_10923
${result}= Run Process ${PYTHON} ${HELPER} check-no-set-default-name
... cwd=${WORKSPACE} timeout=120s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} tdd-actor-list-no-db-update-no-set-default-name-ok
@@ -165,7 +165,6 @@ def add_legacy(
graph_descriptor=resolved_graph,
unsafe=effective_unsafe,
set_default=False,
is_built_in=False,
yaml_text=yaml_text,
schema_version=schema_version,
compiled_metadata=compiled_metadata,
+307 -91
View File
@@ -1,9 +1,25 @@
"""Actor registry for built-ins and custom actor management."""
"""Actor registry for virtual built-in and custom actor management.
Built-in actors (e.g. ``openai/gpt-4o``, ``anthropic/claude-sonnet-4-20250514``)
are **virtual** resolved on-demand from the ``ProviderRegistry`` at query time
and merged with persisted custom actors in memory. No database writes occur for
built-in actors.
**Actor resolution order** (when looking up an actor by name):
1. Search the database for a custom actor with the exact name.
2. If not found, resolve from configured providers as a virtual built-in actor.
3. If still not found, raise a ``NotFoundError``.
Custom actors (added via ``actor add``) continue to be persisted and listed as
usual alongside the virtual built-ins.
"""
from __future__ import annotations
import logging
from dataclasses import asdict
from datetime import UTC, datetime
from typing import Any
import pydantic
@@ -15,7 +31,7 @@ from cleveragents.actor.schema import ActorConfigSchema, is_v3_yaml
from cleveragents.actor.v3_registry import add_v3, is_v3_blob
from cleveragents.application.services.actor_service import ActorService
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import ValidationError
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.domain.models.core import Actor
from cleveragents.providers.registry import (
ProviderCapabilities,
@@ -27,11 +43,14 @@ logger = logging.getLogger(__name__)
class ActorRegistry:
"""Coordinate actor configuration parsing and built-in generation.
"""Coordinate actor configuration parsing and virtual 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.
Built-in actors are never written to the database. They are resolved
from the ``ProviderRegistry`` in-memory each time they are needed.
"""
#: Default actor config schema version applied to new entries.
@@ -57,20 +76,6 @@ class ActorRegistry:
* Forward slashes are replaced with ``-``.
* The result is lowercased.
.. note::
This does **not** strip every character disallowed by
``^[a-z0-9_-]+/[a-z0-9_-]+$`` (e.g. spaces, dots, ``@``).
In practice provider and model identifiers from configured
providers never contain those characters.
.. note::
Lowercasing is a behavior change previously mixed-case
built-in actors (e.g. ``OpenAI/gpt-4``) were stored
as-is. After this fix they are superseded by lowercased
versions on the next ``ensure_built_in_actors()`` call.
"""
sanitized_provider = provider_name.replace("/", "-").lower()
sanitized_model = model_name.replace("/", "-").lower()
@@ -161,14 +166,20 @@ class ActorRegistry:
blob.setdefault("source", source)
return blob
def ensure_built_in_actors(self) -> list[Actor]:
"""Generate built-in actors from configured providers if missing.
# ------------------------------------------------------------------
# Virtual built-in actor resolution
# ------------------------------------------------------------------
Built-in actors are persisted with v3 YAML text including the ``type``
and ``description`` fields, ensuring they work identically to custom
actors with the ``agents actor run`` command.
def _resolve_virtual_builtin_actors(self) -> list[Actor]:
"""Generate virtual ``Actor`` objects from configured providers.
Reads ``ProviderRegistry.get_configured_providers()`` and returns
in-memory ``Actor`` instances with ``is_built_in=True``. No database
writes occur.
Returns:
List of virtual ``Actor`` objects, one per configured provider.
"""
configured: list[ProviderInfo] = (
self._provider_registry.get_configured_providers()
)
@@ -176,60 +187,73 @@ class ActorRegistry:
return []
actors: list[Actor] = []
defaults = self._settings.resolve_provider_defaults()
for info in configured:
provider_name = info.name or info.provider_type.value
model_id = info.default_model
name = self._actor_name(provider_name, model_id)
graph_descriptor = self._build_graph_descriptor(
provider=provider_name,
model=model_id,
source="provider-registry",
capabilities=info.capabilities,
)
# Generate v3 YAML text for the built-in actor
yaml_text = self._generate_builtin_actor_yaml(
provider=provider_name,
model=model_id,
capabilities=info.capabilities,
)
# NOTE: ``name`` is sanitised via ``_actor_name()`` (slashes
# replaced, lowercased) while ``provider`` and ``model``
# retain their raw values. This is intentional: ``name`` is
# the canonical identifier used for lookups; ``provider`` and
# ``model`` store the original provider metadata as-is.
actor = self._actor_service.upsert_actor(
name=self._actor_name(provider_name, model_id),
config_blob: dict[str, Any] = {
"provider": provider_name,
"model": model_id,
"capabilities": (
asdict(info.capabilities) if info.capabilities else None
),
"unsafe": False,
"source": "provider-registry",
"graph_descriptor": graph_descriptor,
}
now = datetime.now(UTC)
actor = Actor(
id=None,
name=name,
provider=provider_name,
model=model_id,
config_blob={
"provider": provider_name,
"model": model_id,
"capabilities": asdict(info.capabilities),
"unsafe": False,
"source": "provider-registry",
"graph_descriptor": graph_descriptor,
},
config_blob=config_blob,
config_hash=Actor.compute_hash(config_blob),
graph_descriptor=graph_descriptor,
unsafe=False,
set_default=False,
is_built_in=True,
yaml_text=yaml_text,
schema_version=self.DEFAULT_SCHEMA_VERSION,
compiled_metadata=None,
unsafe=False,
is_built_in=True,
is_default=False,
created_at=now,
updated_at=now,
)
actors.append(actor)
if not self._actor_service.get_default_actor() and actors:
preferred = None
for actor in actors:
if defaults.provider and actor.provider.lower() == defaults.provider:
preferred = actor
if defaults.model is None or actor.model == defaults.model:
break
if preferred is None:
preferred = actors[0]
self._actor_service.set_default_actor(preferred.name)
actors.sort(key=lambda a: a.name)
return actors
def _resolve_virtual_builtin_by_name(self, name: str) -> Actor | None:
"""Resolve a single virtual built-in actor by its canonical name.
Args:
name: Canonical actor name (e.g. ``openai/gpt-4o``).
Returns:
A virtual ``Actor`` if the name matches a configured provider,
otherwise ``None``.
"""
for actor in self._resolve_virtual_builtin_actors():
if actor.name == name:
return actor
return None
def _is_virtual_builtin_name(self, name: str) -> bool:
"""Return ``True`` when *name* matches a configured virtual built-in."""
return self._resolve_virtual_builtin_by_name(name) is not None
# ------------------------------------------------------------------
# YAML-first add / update
# ------------------------------------------------------------------
@@ -283,7 +307,6 @@ class ActorRegistry:
schema_version: Explicit schema version; defaults to
``DEFAULT_SCHEMA_VERSION``.
compiled_metadata: Optional compiler-produced metadata dict.
allow_unsafe: When ``True`` permit actors marked ``unsafe``.
Returns:
The persisted ``Actor`` domain object.
@@ -293,8 +316,6 @@ class ActorRegistry:
exists and *update* is ``False``, or the actor is marked
unsafe but neither *unsafe* nor *allow_unsafe* is set.
"""
self.ensure_built_in_actors()
blob = ActorConfiguration.load_yaml_text(yaml_text)
if not isinstance(blob, dict):
raise ValidationError("Actor YAML must be a mapping.")
@@ -333,8 +354,6 @@ class ActorRegistry:
# ------------------------------------------------------------------
# Legacy upsert (preserved for backward compatibility)
# ------------------------------------------------------------------
# Legacy upsert (preserved for backward compatibility)
# ------------------------------------------------------------------
def upsert_actor(
self,
@@ -346,7 +365,6 @@ class ActorRegistry:
graph_descriptor: dict[str, Any] | None = None,
unsafe: bool = False,
set_default: bool = False,
is_built_in: bool = False,
allow_unsafe: bool = False,
option_overrides: dict[str, Any] | None = None,
yaml_text: str | None = None,
@@ -360,9 +378,6 @@ class ActorRegistry:
using ``ActorConfigSchema`` to ensure proper schema compliance,
including cycle detection for GRAPH actors.
"""
self.ensure_built_in_actors()
# ── Validate v3 YAML via ActorConfigSchema if detected ──────────────────
# This ensures v3 actors are validated against the full schema, including
# cycle detection, required fields, and enum validation.
@@ -388,7 +403,7 @@ class ActorRegistry:
option_overrides=option_overrides,
)
if config.unsafe and not (unsafe or allow_unsafe or is_built_in):
if config.unsafe and not (unsafe or allow_unsafe):
raise ValidationError(
"Actor configuration is marked unsafe; re-run with --unsafe to confirm."
)
@@ -416,32 +431,136 @@ class ActorRegistry:
graph_descriptor=canonical_blob.get("graph_descriptor"),
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,
)
def update_actor(
self,
name: str,
*,
config_blob: dict[str, Any] | None,
provider: str | None = None,
model: str | None = None,
graph_descriptor: dict[str, Any] | None = None,
unsafe: bool = False,
set_default: 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:
"""Update an existing actor by name.
Rejects virtual built-in actors they cannot be modified. Only
custom (database-persisted) actors may be updated.
Args:
name: Namespaced actor name to update.
config_blob: New configuration blob.
provider: Provider override.
model: Model override.
graph_descriptor: Graph descriptor override.
unsafe: Whether the actor is unsafe.
set_default: Whether to set this actor as the default.
allow_unsafe: Permit unsafe actor without the ``--unsafe`` flag.
option_overrides: Key-value option overrides.
yaml_text: Original YAML source text.
schema_version: Schema version string.
compiled_metadata: Compiler-produced metadata.
Returns:
The updated ``Actor`` domain object.
Raises:
ValidationError: When *name* refers to a virtual built-in actor.
NotFoundError: When *name* is not found in the database.
"""
normalized = self._ensure_namespaced(name)
if self._is_virtual_builtin_name(normalized):
raise ValidationError(
f"Actor '{normalized}' is a built-in actor and cannot be updated. "
"Built-in actors are provided by the configured provider registry."
)
return self.upsert_actor(
name=normalized,
config_blob=config_blob,
provider=provider,
model=model,
graph_descriptor=graph_descriptor,
unsafe=unsafe,
set_default=set_default,
allow_unsafe=allow_unsafe,
option_overrides=option_overrides,
yaml_text=yaml_text,
schema_version=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))
"""Retrieve an actor by its namespaced name.
Resolution order:
1. Database (custom actors).
2. Provider registry (virtual built-in actors).
Raises:
NotFoundError: When *name* is not found in either source.
"""
normalized = self._ensure_namespaced(name)
return self._get_by_name_or_virtual(normalized)
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)
"""Retrieve an actor by name (legacy alias for :meth:`get`).
Resolution order:
1. Database (custom actors).
2. Provider registry (virtual built-in actors).
Raises:
NotFoundError: When *name* is not found in either source.
"""
return self._get_by_name_or_virtual(name)
def _get_by_name_or_virtual(self, name: str) -> Actor:
"""Resolve *name* via DB → virtual built-in, raise if neither found.
Args:
name: Canonical namespaced actor name.
Returns:
The resolved ``Actor`` domain object.
Raises:
NotFoundError: When *name* is not found in the database or the
provider registry.
"""
try:
return self._actor_service.get_actor(name)
except NotFoundError:
pass
# Fall through to virtual built-in resolution.
virtual = self._resolve_virtual_builtin_by_name(name)
if virtual is not None:
return virtual
raise NotFoundError(resource_type="actor", resource_id=name)
def list(self, namespace: str | None = None) -> list[Actor]:
"""List actors, optionally filtered by namespace prefix.
This is a **read-only** operation and intentionally does **not**
call :meth:`ensure_built_in_actors` database writes must never
be triggered by a list/query command (see bug #797).
Merges virtual built-in actors (from the provider registry) with
persisted custom actors (from the database). The result is sorted
alphabetically by name. No database writes occur.
Custom actors take precedence: if a custom actor shares a name with
a virtual built-in, the custom actor wins (virtual built-in is
excluded from the merged result).
Args:
namespace: When provided only actors whose name starts with
@@ -450,35 +569,132 @@ class ActorRegistry:
Returns:
Sorted list of ``Actor`` objects.
"""
actors = self._actor_service.list_actors()
db_actors = self._actor_service.list_actors()
db_names: set[str] = {a.name for a in db_actors}
virtual_actors = [
a for a in self._resolve_virtual_builtin_actors() if a.name not in db_names
]
merged = db_actors + virtual_actors
merged.sort(key=lambda a: a.name)
if namespace is not None:
prefix = f"{namespace}/"
actors = [a for a in actors if a.name.startswith(prefix)]
return actors
merged = [a for a in merged if a.name.startswith(prefix)]
return merged
def list_actors(self) -> list[Actor]:
"""Return all actors (legacy alias for :meth:`list`).
This is a **read-only** operation and intentionally does **not**
call :meth:`ensure_built_in_actors` database writes must never
be triggered by a list/query command (see bug #797).
Merges virtual built-in actors with persisted custom actors.
No database writes occur.
"""
return self._actor_service.list_actors()
return self.list()
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))
"""Remove a custom actor by its namespaced name.
Raises:
ValidationError: When *name* refers to a virtual built-in actor
(built-in actors cannot be removed).
"""
normalized = self._ensure_namespaced(name)
if self._is_virtual_builtin_name(normalized):
raise ValidationError(
f"Actor '{normalized}' is a built-in actor and cannot be removed. "
"Built-in actors are provided by the configured provider registry."
)
self._actor_service.remove_actor(normalized)
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)
"""Remove a custom actor (legacy alias for :meth:`remove`).
Raises:
ValidationError: When *name* refers to a virtual built-in actor.
"""
self.remove(name)
def set_default_actor(self, name: str) -> Actor:
self.ensure_built_in_actors()
return self._actor_service.set_default_actor(name)
"""Set the default actor by name.
The actor can be a custom (DB-persisted) actor or a virtual built-in.
Only the actor name string is persisted as a preference; no actor row
is created for virtual built-ins.
Args:
name: Namespaced actor name to set as default.
Returns:
The resolved ``Actor`` with ``is_default=True``.
Raises:
NotFoundError: When *name* cannot be resolved.
"""
normalized = self._ensure_namespaced(name)
actor = self._get_by_name_or_virtual(normalized)
if actor.is_built_in:
# Virtual built-in: just persist the name preference.
self._actor_service.set_default_actor_name(normalized)
return Actor(
id=actor.id,
name=actor.name,
provider=actor.provider,
model=actor.model,
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=True,
is_default=True,
created_at=actor.created_at,
updated_at=actor.updated_at,
)
# Custom (DB) actor: use the full service path which also stores the
# preference and sets is_default=True on the actor row.
return self._actor_service.set_default_actor(normalized)
def get_default_actor(self) -> Actor | None:
self.ensure_built_in_actors()
return self._actor_service.get_default_actor()
"""Return the current default actor.
Resolution:
1. Read the default actor name from the preferences table.
2. Resolve the name via DB virtual built-in.
3. If the name no longer resolves (provider removed), return ``None``.
Returns:
The resolved default ``Actor`` with ``is_default=True``,
or ``None`` when no default is configured.
"""
default_name = self._actor_service.get_default_actor_name()
if not default_name:
return None
try:
actor = self._get_by_name_or_virtual(default_name)
except NotFoundError:
# Default preference points at an actor that no longer exists.
return None
return Actor(
id=actor.id,
name=actor.name,
provider=actor.provider,
model=actor.model,
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=True,
created_at=actor.created_at,
updated_at=actor.updated_at,
)
-1
View File
@@ -160,7 +160,6 @@ def add_v3(
graph_descriptor=graph_descriptor,
unsafe=unsafe_flag,
set_default=False,
is_built_in=False,
yaml_text=yaml_text,
schema_version=schema_version,
compiled_metadata=compiled_metadata,
@@ -3,7 +3,7 @@
from __future__ import annotations
import os
from datetime import datetime
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
import structlog
@@ -82,27 +82,28 @@ class ActorService:
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:
"""Create or update an actor configuration."""
"""Create or update a custom actor configuration.
Only custom actors (those added via ``actor add``) are written to the
database. Built-in actors are virtual and must never be persisted;
callers should guard against built-in names before calling this method
(the ``ActorRegistry.update_actor()`` method enforces this).
"""
normalized = self._normalize_name(name, allow_built_in=True)
blob = config_blob or {}
config_hash = Actor.compute_hash(blob)
now = datetime.now()
now = datetime.now(UTC)
with self.unit_of_work.transaction() as ctx:
existing = ctx.actors.get_by_name(normalized)
if existing and existing.is_built_in and not is_built_in:
raise BusinessRuleViolation(
"Cannot overwrite built-in actor with custom entry"
)
prefix, _ = normalized.split("/", 1)
if prefix != "local" and not is_built_in:
if prefix != "local":
raise ValidationError(
"Custom actors must use the 'local/<id>' naming pattern"
)
@@ -122,8 +123,7 @@ class ActorService:
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),
is_built_in=False, # DB actors are always custom
is_default=should_be_default,
created_at=existing.created_at if existing else now,
updated_at=now,
@@ -132,10 +132,16 @@ class ActorService:
saved = ctx.actors.upsert(actor)
if set_default:
saved = ctx.actors.set_default(saved.name)
ctx.actors.set_default_name(saved.name)
return saved
def remove_actor(self, name: str) -> None:
"""Remove a custom actor if allowed."""
"""Remove a custom actor if allowed.
Built-in actors (``<provider>/<model>`` names) are rejected by the
``ActorRegistry`` layer before this method is ever called. This
method only handles custom (``local/``) actors.
"""
normalized = self._normalize_name(name, allow_built_in=False)
with self.unit_of_work.transaction() as ctx:
@@ -145,7 +151,7 @@ class ActorService:
try:
ctx.actors.delete(normalized)
except ValueError as exc: # Built-in or default guard
except ValueError as exc: # Default-actor guard
raise BusinessRuleViolation(str(exc)) from exc
if self._event_bus is not None:
try:
@@ -162,21 +168,68 @@ class ActorService:
_logger.warning("audit_emit_failed", event_type="ENTITY_DELETED")
def set_default_actor(self, name: str) -> Actor:
"""Mark a built-in or custom actor as the default entry."""
"""Mark a custom actor as the default entry (service-layer path).
This path requires the actor to exist as a persisted custom actor in
the database. For virtual built-in actors the
:class:`~cleveragents.actor.registry.ActorRegistry` handles
``set_default_actor`` by calling :meth:`set_default_actor_name`
directly and constructing the return value from the virtual actor.
Raises:
NotFoundError: When ``name`` is not found in the database.
"""
normalized = self._normalize_name(name, allow_built_in=True)
with self.unit_of_work.transaction() as ctx:
actor = ctx.actors.get_by_name(normalized)
if not actor:
raise NotFoundError(resource_type="actor", resource_id=normalized)
return ctx.actors.set_default(normalized)
saved = ctx.actors.set_default(normalized)
ctx.actors.set_default_name(normalized)
return saved
def get_default_actor(self) -> Actor | None:
"""Return the current default actor if present."""
"""Return the current default actor from the database if present.
Returns the actor row that has ``is_default=True``. For virtual
built-in defaults the :class:`~cleveragents.actor.registry.ActorRegistry`
layer resolves the stored preference name to a virtual ``Actor`` this
method only covers the DB-backed path.
"""
with self.unit_of_work.transaction() as ctx:
return ctx.actors.get_default()
def set_default_actor_name(self, name: str) -> None:
"""Persist just the default actor name preference.
Unlike :meth:`set_default_actor`, this method does **not** require
the actor to exist in the database. It is used by
:class:`~cleveragents.actor.registry.ActorRegistry` when the caller
sets a virtual built-in actor as the default.
Args:
name: Namespaced actor name to record as the default preference.
"""
normalized = self._normalize_name(name, allow_built_in=True)
with self.unit_of_work.transaction() as ctx:
ctx.actors.set_default_name(normalized)
def get_default_actor_name(self) -> str | None:
"""Return the stored default actor name preference.
Reads from the ``actor_preferences`` table (with fallback to the
legacy ``is_default=True`` actor row for backward compatibility).
Returns:
Namespaced actor name string, or ``None`` if not set.
"""
with self.unit_of_work.transaction() as ctx:
return ctx.actors.get_default_name()
def ensure_default_mock_actor(self, *, force: bool = False) -> Actor | None:
"""Ensure a default mock actor exists in testing mode.
@@ -205,7 +258,7 @@ class ActorService:
return existing_default
# No default yet: create or promote a mock actor
now = datetime.now()
now = datetime.now(UTC)
mock_name = "local/mock-default"
mock_actor = Actor(
id=None,
@@ -216,7 +269,7 @@ class ActorService:
config_hash=Actor.compute_hash({}),
graph_descriptor=None,
unsafe=True,
is_built_in=True,
is_built_in=False,
is_default=True,
created_at=now,
updated_at=now,
+12 -26
View File
@@ -783,31 +783,18 @@ def update(
try:
if registry:
if option_overrides is None:
actor = registry.upsert_actor(
name=name,
provider=provider_override,
model=model_override,
config_blob=canonical_blob,
graph_descriptor=current.graph_descriptor,
unsafe=new_unsafe,
set_default=set_default,
is_built_in=current.is_built_in,
allow_unsafe=unsafe,
)
else:
actor = registry.upsert_actor(
name=name,
provider=provider_override,
model=model_override,
config_blob=canonical_blob,
graph_descriptor=current.graph_descriptor,
unsafe=new_unsafe,
set_default=set_default,
is_built_in=current.is_built_in,
allow_unsafe=unsafe,
option_overrides=option_overrides,
)
# Use update_actor() which guards against modifying virtual built-ins.
actor = registry.update_actor(
name=name,
provider=provider_override,
model=model_override,
config_blob=canonical_blob,
graph_descriptor=current.graph_descriptor,
unsafe=new_unsafe,
set_default=set_default,
allow_unsafe=unsafe,
option_overrides=option_overrides,
)
else:
if resolved.unsafe and not unsafe:
raise ValidationError(
@@ -821,7 +808,6 @@ def update(
graph_descriptor=resolved.graph_descriptor,
unsafe=resolved.unsafe,
set_default=set_default,
is_built_in=current.is_built_in,
)
_print_actor(actor, title="Actor updated", fmt=fmt)
except (ValidationError, BusinessRuleViolation) as exc:
-10
View File
@@ -913,17 +913,12 @@ def tell(
try:
container = get_container()
plan_service: PlanService = container.plan_service()
actor_registry = (
container.actor_registry() if hasattr(container, "actor_registry") else None
)
testing_mode = os.getenv("CLEVERAGENTS_TESTING_USE_MOCK_AI", "").lower() in (
"true",
"yes",
"1",
)
with suppress(Exception):
if actor_registry:
actor_registry.ensure_built_in_actors()
if testing_mode:
container.actor_service().ensure_default_mock_actor()
@@ -1016,17 +1011,12 @@ def build(
try:
container = get_container()
plan_service: PlanService = container.plan_service()
actor_registry = (
container.actor_registry() if hasattr(container, "actor_registry") else None
)
testing_mode = os.getenv("CLEVERAGENTS_TESTING_USE_MOCK_AI", "").lower() in (
"true",
"yes",
"1",
)
with suppress(Exception):
if actor_registry:
actor_registry.ensure_built_in_actors()
if testing_mode:
container.actor_service().ensure_default_mock_actor()
@@ -0,0 +1,82 @@
"""Make built-in actors virtual: drop is_built_in column, add actor_preferences table.
Removes the ``is_built_in`` column from the ``actors`` table (built-in actors are
now resolved on-demand from the provider registry and are never persisted).
Adds a new ``actor_preferences`` singleton table that stores a single
``default_actor_name`` string, allowing ``set-default`` to persist just the actor
name even when the named actor is a virtual built-in (not in the DB).
Revision ID: m10_001_virtual_builtin_actors
Revises: a5_006_action_invariants_unique_constraint
Create Date: 2026-04-29
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "m10_001_virtual_builtin_actors"
down_revision: str | None = "a5_006_action_invariants_unique_constraint"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Drop is_built_in from actors; add actor_preferences singleton table."""
# -- drop is_built_in column from actors --------------------------------
# SQLite does not support DROP COLUMN directly before 3.35. We use
# batch mode (recreate) to be safe across all supported SQLite versions.
with op.batch_alter_table("actors") as batch_op:
batch_op.drop_column("is_built_in")
# -- actor_preferences: singleton row for global actor settings ---------
op.create_table(
"actor_preferences",
sa.Column(
"id",
sa.Integer,
primary_key=True,
autoincrement=False,
nullable=False,
),
sa.Column("default_actor_name", sa.String(255), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
"""Reverse: drop actor_preferences table; restore is_built_in column.
Before dropping ``actor_preferences``, the stored ``default_actor_name``
is migrated back to the ``is_default=True`` flag on the corresponding
actor row so that the legacy schema can still identify the default actor.
"""
# Migrate default_actor_name preference back to is_default flag.
conn = op.get_bind()
row = conn.execute(
sa.text("SELECT default_actor_name FROM actor_preferences WHERE id = 1")
).fetchone()
if row is not None and row[0]:
default_name: str = row[0]
# Clear all is_default flags first, then set the stored default.
conn.execute(sa.text("UPDATE actors SET is_default = 0"))
conn.execute(
sa.text("UPDATE actors SET is_default = 1 WHERE name = :name"),
{"name": default_name},
)
op.drop_table("actor_preferences")
with op.batch_alter_table("actors") as batch_op:
batch_op.add_column(
sa.Column(
"is_built_in",
sa.Boolean,
nullable=False,
server_default=sa.text("0"),
)
)
@@ -217,6 +217,14 @@ class ActorModel(Base):
Stores the canonical actor config alongside the original YAML source
text, a ``schema_version`` indicator, and optional ``compiled_metadata``
produced by the actor compiler.
.. note::
The ``is_built_in`` column was removed in migration
``m10_001_virtual_builtin_actors``. Built-in actors are now resolved
on-demand from the ``ProviderRegistry`` and are never persisted to the
database. The ``Actor`` domain model still carries an ``is_built_in``
field that is set to ``True`` for virtual built-ins resolved in-memory.
"""
__tablename__ = "actors"
@@ -234,7 +242,6 @@ class ActorModel(Base):
)
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)
created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(
@@ -242,6 +249,25 @@ class ActorModel(Base):
)
class ActorPreferencesModel(Base):
"""Singleton table for global actor preferences.
Stores a single row (``id=1``) with the name of the default actor. The
default actor can be a custom actor persisted in the ``actors`` table *or*
a virtual built-in actor resolved on-demand from the ``ProviderRegistry``.
Using a dedicated table (rather than ``is_default`` on the actor row)
allows the default preference to reference an actor that has no DB row.
"""
__tablename__ = "actor_preferences"
#: Always 1 — this table has exactly one row.
id = Column(Integer, primary_key=True, autoincrement=False, default=1)
#: Namespaced actor name of the current default actor, e.g. ``openai/gpt-4o``.
default_actor_name = Column(String(255), nullable=True)
# ---------------------------------------------------------------------------
# Spec-aligned Lifecycle Models (Stage A5 - migrations a5_003 / a5_004)
# ---------------------------------------------------------------------------
@@ -104,6 +104,7 @@ from cleveragents.infrastructure.database.models import (
ActionArgumentModel,
ActionInvariantModel,
ActorModel,
ActorPreferencesModel,
AutomationProfileModel,
ChangeModel,
CheckpointModel,
@@ -720,12 +721,27 @@ class DebugAttemptRepository:
class ActorRepository:
"""Repository for actor persistence."""
"""Repository for actor persistence.
Only **custom** actors (those added via ``actor add``) are persisted here.
Built-in actors are resolved on-demand from the ``ProviderRegistry`` in
the ``ActorRegistry`` layer and are never written to the database.
The ``actor_preferences`` table is also managed by this repository. It
holds a single row (``id=1``) with the ``default_actor_name`` preference
string. This allows the default to reference a virtual built-in actor
that has no corresponding row in the ``actors`` table.
"""
def __init__(self, session: Session):
self.session = session
def _to_domain(self, model: ActorModel) -> Actor:
"""Convert an ``ActorModel`` row to an ``Actor`` domain object.
``is_built_in`` is always ``False`` for DB actors only virtual
built-ins resolved from the provider registry carry ``is_built_in=True``.
"""
return Actor(
id=model.id, # type: ignore[arg-type]
name=model.name, # type: ignore[arg-type]
@@ -738,7 +754,7 @@ class ActorRepository:
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_built_in=False, # DB actors are always custom (not built-in)
is_default=model.is_default, # type: ignore[arg-type]
created_at=model.created_at, # type: ignore[arg-type]
updated_at=model.updated_at, # type: ignore[arg-type]
@@ -758,9 +774,6 @@ class ActorRepository:
)
if existing:
if bool(getattr(existing, "is_built_in", False)) and not actor.is_built_in:
raise ValueError("Cannot overwrite built-in actor with custom entry")
existing.provider = actor.provider
existing.model = actor.model
existing.config_blob = actor.config_blob
@@ -770,7 +783,6 @@ class ActorRepository:
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
existing.updated_at = datetime.now()
self.session.flush()
@@ -789,7 +801,6 @@ class ActorRepository:
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,
created_at=actor.created_at,
updated_at=actor.updated_at,
@@ -800,18 +811,12 @@ class ActorRepository:
actor.id = cast(Any, db_actor).id # type: ignore[assignment]
return actor
def upsert_built_in(self, actor: Actor) -> Actor:
actor.is_built_in = True
return self.upsert(actor)
def delete(self, name: str) -> None:
db_actor = cast(
Any, self.session.query(ActorModel).filter_by(name=name).first()
)
if not db_actor:
return
if bool(getattr(db_actor, "is_built_in", False)):
raise ValueError("Cannot delete built-in actors")
if bool(getattr(db_actor, "is_default", False)):
raise ValueError("Cannot delete the default actor")
self.session.delete(cast(ActorModel, db_actor))
@@ -860,6 +865,67 @@ class ActorRepository:
)
return [self._to_domain(actor) for actor in db_actors]
# ------------------------------------------------------------------
# Actor preferences (default actor name preference)
# ------------------------------------------------------------------
def get_default_name(self) -> str | None:
"""Return the stored default actor name preference.
Reads from the ``actor_preferences`` singleton row (``id=1``).
Falls back to the ``is_default=True`` actor row for backward
compatibility with databases migrated from the old schema.
Returns:
The stored default actor name string, or ``None`` when no
preference has been recorded yet.
"""
row = self.session.query(ActorPreferencesModel).filter_by(id=1).first()
if row is not None and row.default_actor_name: # type: ignore[union-attr]
return str(row.default_actor_name) # type: ignore[union-attr]
# Fallback: read from legacy is_default=True actor row.
legacy_actor = self.session.query(ActorModel).filter_by(is_default=True).first()
if legacy_actor is not None:
return str(legacy_actor.name) # type: ignore[union-attr]
return None
def set_default_name(self, name: str | None) -> None:
"""Persist the default actor name preference.
Writes to the ``actor_preferences`` singleton row (``id=1``),
creating it if it does not exist yet. Passing ``None`` clears
the preference.
Uses an INSERT ON CONFLICT (id) DO UPDATE pattern to avoid a
TOCTOU race condition when two callers simultaneously attempt to
create the singleton row.
Args:
name: Namespaced actor name to set as default, or ``None``
to clear the preference.
"""
row = cast(
Any,
self.session.query(ActorPreferencesModel).filter_by(id=1).first(),
)
if row is not None:
row.default_actor_name = name
else:
try:
self.session.add(ActorPreferencesModel(id=1, default_actor_name=name))
self.session.flush()
return
except IntegrityError:
self.session.rollback()
# Another writer raced us — re-fetch and update.
row = cast(
Any,
self.session.query(ActorPreferencesModel).filter_by(id=1).first(),
)
if row is not None:
row.default_actor_name = name
self.session.flush()
# ---------------------------------------------------------------------------
# V3 Action Repository
+414 -49
View File
@@ -1,4 +1,14 @@
"""Unit tests for built-in actor v3 YAML generation."""
"""Unit tests for built-in actor v3 YAML generation and virtual resolution.
After issue #10923, built-in actors are **virtual** — resolved on-demand from
the provider registry in-memory, never persisted to the database.
Tests cover:
- ``_generate_builtin_actor_yaml()``: YAML text generation helpers (unchanged)
- ``_resolve_virtual_builtin_actors()``: virtual actor in-memory generation
- ``list()``: merging virtual actors with custom DB actors
- ``get()`` / ``get_actor()``: DB-first then virtual fallback resolution
"""
from __future__ import annotations
@@ -7,8 +17,9 @@ import yaml
from cleveragents.actor.registry import ActorRegistry
from cleveragents.actor.schema import ActorConfigSchema, ActorType, is_v3_yaml
from cleveragents.application.services.actor_service import ActorService
from cleveragents.config.settings import ProviderDefaults, Settings
from cleveragents.core.exceptions import NotFoundError
from cleveragents.domain.models.core.actor import Actor
from cleveragents.providers.registry import (
ProviderCapabilities,
ProviderInfo,
@@ -18,10 +29,10 @@ from cleveragents.providers.registry import (
class _StubActorService:
"""Stub actor service for testing."""
"""Stub actor service for testing virtual built-in resolution."""
def __init__(self) -> None:
self.actors: dict[str, dict] = {}
self.actors: dict[str, Actor] = {}
self.default_actor_name: str | None = None
def upsert_actor(
@@ -38,30 +49,51 @@ class _StubActorService:
yaml_text: str | None = None,
schema_version: str | None = None,
compiled_metadata: dict | None = None,
) -> None:
) -> Actor:
"""Store actor data for verification."""
self.actors[name] = {
"name": name,
"provider": provider,
"model": model,
"config_blob": config_blob,
"graph_descriptor": graph_descriptor,
"unsafe": unsafe,
"is_built_in": is_built_in,
"yaml_text": yaml_text,
"schema_version": schema_version,
}
actor = Actor(
name=name,
provider=provider,
model=model,
config_blob=config_blob,
config_hash=Actor.compute_hash(config_blob),
is_built_in=is_built_in,
)
self.actors[name] = actor
if set_default:
self.default_actor_name = name
return actor
def get_default_actor(self) -> str | None:
"""Return default actor name."""
def get_default_actor(self) -> Actor | None:
"""Return default actor."""
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) -> None:
"""Set default actor."""
def set_default_actor_name(self, name: str) -> None:
self.default_actor_name = name
def set_default_actor(self, name: str) -> Actor:
"""Set default actor."""
actor = self.actors.get(name)
if actor is None:
raise NotFoundError(resource_type="actor", resource_id=name)
self.default_actor_name = name
return actor
def get_actor(self, name: str) -> Actor:
"""Return actor or raise 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())
class _StubProviderRegistry:
"""Stub provider registry for testing."""
@@ -269,7 +301,9 @@ class TestGenerateBuiltinActorYaml:
settings=stub_settings,
)
capabilities = ProviderCapabilities(supports_tools=True, supports_vision=False)
capabilities = ProviderCapabilities(
supports_tool_calls=True, supports_vision=False
)
yaml_text = registry._generate_builtin_actor_yaml(
provider="openai",
model="gpt-4",
@@ -277,7 +311,7 @@ class TestGenerateBuiltinActorYaml:
)
assert "capabilities:" in yaml_text
assert "supports_tools: true" in yaml_text
assert "supports_tool_calls: true" in yaml_text
def test_generated_yaml_sanitizes_slashes_in_name(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
@@ -299,13 +333,17 @@ class TestGenerateBuiltinActorYaml:
assert "name: openrouter/meta-llama-llama-3-70b-instruct" in yaml_text
class TestEnsureBuiltInActorsWithYaml:
"""Tests for ensure_built_in_actors with yaml_text generation."""
class TestResolveVirtualBuiltinActors:
"""Tests for _resolve_virtual_builtin_actors — the new virtual resolution method.
def test_ensure_built_in_actors_includes_yaml_text(
Replaces old ``TestEnsureBuiltInActorsWithYaml`` which tested the removed
``ensure_built_in_actors()`` DB-persistence path.
"""
def test_resolve_virtual_builtin_actors_includes_yaml_text(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""Built-in actors should have yaml_text populated."""
"""Virtual built-in actors should have yaml_text populated."""
provider_registry = _StubProviderRegistry(
[
ProviderInfo(
@@ -313,7 +351,7 @@ class TestEnsureBuiltInActorsWithYaml:
name="openai",
api_key_env_var="ENV",
default_model="gpt-4",
capabilities=ProviderCapabilities(supports_tools=True),
capabilities=ProviderCapabilities(supports_tool_calls=True),
is_configured=True,
)
]
@@ -324,17 +362,18 @@ class TestEnsureBuiltInActorsWithYaml:
settings=stub_settings,
)
registry.ensure_built_in_actors()
actors = registry._resolve_virtual_builtin_actors()
assert "openai/gpt-4" in stub_actor_service.actors
actor_data = stub_actor_service.actors["openai/gpt-4"]
assert actor_data["yaml_text"] is not None
assert "type: llm" in actor_data["yaml_text"]
assert len(actors) == 1
actor = actors[0]
assert actor.name == "openai/gpt-4"
assert actor.yaml_text is not None
assert "type: llm" in actor.yaml_text
def test_ensure_built_in_actors_yaml_is_v3_format(
def test_resolve_virtual_builtin_actors_yaml_is_v3_format(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""Built-in actor yaml_text should be v3 format."""
"""Virtual built-in actor yaml_text should be v3 format."""
provider_registry = _StubProviderRegistry(
[
ProviderInfo(
@@ -353,17 +392,19 @@ class TestEnsureBuiltInActorsWithYaml:
settings=stub_settings,
)
registry.ensure_built_in_actors()
actors = registry._resolve_virtual_builtin_actors()
actor_data = stub_actor_service.actors["anthropic/claude-3-opus"]
assert actor_data["yaml_text"] is not None
config_blob = yaml.safe_load(actor_data["yaml_text"])
assert len(actors) == 1
actor = actors[0]
assert actor.name == "anthropic/claude-3-opus"
assert actor.yaml_text is not None
config_blob = yaml.safe_load(actor.yaml_text)
assert is_v3_yaml(config_blob)
def test_ensure_built_in_actors_multiple_providers(
def test_resolve_virtual_builtin_actors_multiple_providers(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""Multiple providers should each get yaml_text."""
"""Multiple providers should each produce a virtual actor."""
provider_registry = _StubProviderRegistry(
[
ProviderInfo(
@@ -371,7 +412,7 @@ class TestEnsureBuiltInActorsWithYaml:
name="openai",
api_key_env_var="ENV",
default_model="gpt-4",
capabilities=ProviderCapabilities(supports_tools=True),
capabilities=ProviderCapabilities(supports_tool_calls=True),
is_configured=True,
),
ProviderInfo(
@@ -390,17 +431,22 @@ class TestEnsureBuiltInActorsWithYaml:
settings=stub_settings,
)
registry.ensure_built_in_actors()
actors = registry._resolve_virtual_builtin_actors()
assert len(stub_actor_service.actors) == 2
for actor_data in stub_actor_service.actors.values():
assert actor_data["yaml_text"] is not None
assert "type: llm" in actor_data["yaml_text"]
assert len(actors) == 2
# Sorted alphabetically
assert actors[0].name == "anthropic/claude-3-opus"
assert actors[1].name == "openai/gpt-4"
for actor in actors:
assert actor.yaml_text is not None
assert "type: llm" in actor.yaml_text
assert actor.is_built_in is True
assert actor.id is None # Not persisted
def test_ensure_built_in_actors_empty_when_no_providers(
def test_resolve_virtual_builtin_actors_empty_when_no_providers(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""No providers should result in no actors."""
"""No providers should result in no virtual actors."""
provider_registry = _StubProviderRegistry([])
registry = ActorRegistry(
actor_service=stub_actor_service,
@@ -408,7 +454,326 @@ class TestEnsureBuiltInActorsWithYaml:
settings=stub_settings,
)
actors = registry.ensure_built_in_actors()
actors = registry._resolve_virtual_builtin_actors()
assert actors == []
# No DB writes occurred
assert len(stub_actor_service.actors) == 0
def test_virtual_actors_are_not_persisted(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""Virtual built-in actors must NOT be persisted to the database."""
provider_registry = _StubProviderRegistry(
[
ProviderInfo(
provider_type=ProviderType.OPENAI,
name="openai",
api_key_env_var="ENV",
default_model="gpt-4",
capabilities=ProviderCapabilities(),
is_configured=True,
)
]
)
registry = ActorRegistry(
actor_service=stub_actor_service,
provider_registry=provider_registry,
settings=stub_settings,
)
actors = registry._resolve_virtual_builtin_actors()
assert len(actors) == 1
# No upsert_actor calls — DB stays empty
assert len(stub_actor_service.actors) == 0
class TestListActors:
"""Tests for list() merging virtual + DB actors."""
def test_list_includes_virtual_built_ins(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""list() should include virtual built-in actors from provider registry."""
provider_registry = _StubProviderRegistry(
[
ProviderInfo(
provider_type=ProviderType.OPENAI,
name="openai",
api_key_env_var="ENV",
default_model="gpt-4",
capabilities=ProviderCapabilities(),
is_configured=True,
)
]
)
registry = ActorRegistry(
actor_service=stub_actor_service,
provider_registry=provider_registry,
settings=stub_settings,
)
actors = registry.list()
assert len(actors) == 1
assert actors[0].name == "openai/gpt-4"
assert actors[0].is_built_in is True
def test_list_merges_virtual_and_custom(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""list() should merge virtual built-ins with custom DB actors."""
# Add a custom actor to the stub service
custom = Actor(
name="local/my-actor",
provider="openai",
model="gpt-4",
config_blob={},
config_hash=Actor.compute_hash({}),
is_built_in=False,
)
stub_actor_service.actors["local/my-actor"] = custom
provider_registry = _StubProviderRegistry(
[
ProviderInfo(
provider_type=ProviderType.OPENAI,
name="openai",
api_key_env_var="ENV",
default_model="gpt-4",
capabilities=ProviderCapabilities(),
is_configured=True,
)
]
)
registry = ActorRegistry(
actor_service=stub_actor_service,
provider_registry=provider_registry,
settings=stub_settings,
)
actors = registry.list()
names = [a.name for a in actors]
assert "local/my-actor" in names
assert "openai/gpt-4" in names
def test_custom_actor_overrides_virtual_builtin(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""A custom actor with the same name as a virtual built-in takes precedence."""
custom = Actor(
name="openai/gpt-4",
provider="openai",
model="gpt-4",
config_blob={"custom": True},
config_hash=Actor.compute_hash({"custom": True}),
is_built_in=False,
)
stub_actor_service.actors["openai/gpt-4"] = custom
provider_registry = _StubProviderRegistry(
[
ProviderInfo(
provider_type=ProviderType.OPENAI,
name="openai",
api_key_env_var="ENV",
default_model="gpt-4",
capabilities=ProviderCapabilities(),
is_configured=True,
)
]
)
registry = ActorRegistry(
actor_service=stub_actor_service,
provider_registry=provider_registry,
settings=stub_settings,
)
actors = registry.list()
# Only one actor with this name
matching = [a for a in actors if a.name == "openai/gpt-4"]
assert len(matching) == 1
# Custom actor (from DB) wins over virtual built-in
assert matching[0].config_blob == {"custom": True}
assert matching[0].is_built_in is False
class TestGetActor:
"""Tests for get() / get_actor() resolution order."""
def test_get_resolves_db_actor_first(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""get() should return DB actor when it exists."""
custom = Actor(
name="local/my-actor",
provider="test",
model="model",
config_blob={},
config_hash=Actor.compute_hash({}),
is_built_in=False,
)
stub_actor_service.actors["local/my-actor"] = custom
registry = ActorRegistry(
actor_service=stub_actor_service,
provider_registry=_StubProviderRegistry([]),
settings=stub_settings,
)
actor = registry.get("local/my-actor")
assert actor.name == "local/my-actor"
assert actor.is_built_in is False
def test_get_resolves_virtual_builtin_when_not_in_db(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""get() should resolve virtual built-in when not found in DB."""
provider_registry = _StubProviderRegistry(
[
ProviderInfo(
provider_type=ProviderType.OPENAI,
name="openai",
api_key_env_var="ENV",
default_model="gpt-4",
capabilities=ProviderCapabilities(),
is_configured=True,
)
]
)
registry = ActorRegistry(
actor_service=stub_actor_service,
provider_registry=provider_registry,
settings=stub_settings,
)
actor = registry.get("openai/gpt-4")
assert actor.name == "openai/gpt-4"
assert actor.is_built_in is True
assert actor.id is None # Not from DB
def test_get_raises_not_found_when_neither(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""get() should raise NotFoundError when actor is in neither DB nor virtual."""
registry = ActorRegistry(
actor_service=stub_actor_service,
provider_registry=_StubProviderRegistry([]),
settings=stub_settings,
)
with pytest.raises(NotFoundError):
registry.get("nonexistent/actor")
class TestRemoveActor:
"""Tests for remove() rejecting virtual built-ins."""
def test_remove_virtual_builtin_raises_validation_error(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""remove() should raise ValidationError for virtual built-in actors."""
from cleveragents.core.exceptions import ValidationError
provider_registry = _StubProviderRegistry(
[
ProviderInfo(
provider_type=ProviderType.OPENAI,
name="openai",
api_key_env_var="ENV",
default_model="gpt-4",
capabilities=ProviderCapabilities(),
is_configured=True,
)
]
)
registry = ActorRegistry(
actor_service=stub_actor_service,
provider_registry=provider_registry,
settings=stub_settings,
)
with pytest.raises(ValidationError, match="built-in"):
registry.remove("openai/gpt-4")
class TestDefaultActor:
"""Tests for set_default_actor / get_default_actor with virtual built-ins."""
def test_set_default_virtual_builtin(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""set_default_actor() with a virtual built-in stores the name preference."""
provider_registry = _StubProviderRegistry(
[
ProviderInfo(
provider_type=ProviderType.OPENAI,
name="openai",
api_key_env_var="ENV",
default_model="gpt-4",
capabilities=ProviderCapabilities(),
is_configured=True,
)
]
)
registry = ActorRegistry(
actor_service=stub_actor_service,
provider_registry=provider_registry,
settings=stub_settings,
)
actor = registry.set_default_actor("openai/gpt-4")
assert actor.name == "openai/gpt-4"
assert actor.is_default is True
assert actor.is_built_in is True
# Name preference stored but no DB row created
assert stub_actor_service.default_actor_name == "openai/gpt-4"
assert len(stub_actor_service.actors) == 0 # No DB writes
def test_get_default_resolves_virtual_builtin(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""get_default_actor() resolves virtual built-in from preference name."""
provider_registry = _StubProviderRegistry(
[
ProviderInfo(
provider_type=ProviderType.OPENAI,
name="openai",
api_key_env_var="ENV",
default_model="gpt-4",
capabilities=ProviderCapabilities(),
is_configured=True,
)
]
)
registry = ActorRegistry(
actor_service=stub_actor_service,
provider_registry=provider_registry,
settings=stub_settings,
)
stub_actor_service.default_actor_name = "openai/gpt-4"
default = registry.get_default_actor()
assert default is not None
assert default.name == "openai/gpt-4"
assert default.is_built_in is True
assert default.is_default is True
def test_get_default_returns_none_when_not_set(
self, stub_actor_service: _StubActorService, stub_settings: _StubSettings
) -> None:
"""get_default_actor() returns None when no default is configured."""
registry = ActorRegistry(
actor_service=stub_actor_service,
provider_registry=_StubProviderRegistry([]),
settings=stub_settings,
)
default = registry.get_default_actor()
assert default is None