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
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"')
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -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__":
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+82
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user